From f39c6a918c8e4a86f9e5accf2c8d8df2e6c5f433 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:08:48 -0400 Subject: [PATCH 01/51] feat(kernel-utils): add sheaf programming module Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.ts | 11 + packages/kernel-utils/src/sheaf/README.md | 103 +++ packages/kernel-utils/src/sheaf/guard.test.ts | 193 ++++++ packages/kernel-utils/src/sheaf/guard.ts | 137 ++++ .../src/sheaf/sheafify.e2e.test.ts | 386 ++++++++++++ .../kernel-utils/src/sheaf/sheafify.test.ts | 593 ++++++++++++++++++ packages/kernel-utils/src/sheaf/sheafify.ts | 298 +++++++++ packages/kernel-utils/src/sheaf/stalk.test.ts | 168 +++++ packages/kernel-utils/src/sheaf/stalk.ts | 80 +++ packages/kernel-utils/src/sheaf/types.ts | 79 +++ 10 files changed, 2048 insertions(+) create mode 100644 packages/kernel-utils/src/sheaf/README.md create mode 100644 packages/kernel-utils/src/sheaf/guard.test.ts create mode 100644 packages/kernel-utils/src/sheaf/guard.ts create mode 100644 packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts create mode 100644 packages/kernel-utils/src/sheaf/sheafify.test.ts create mode 100644 packages/kernel-utils/src/sheaf/sheafify.ts create mode 100644 packages/kernel-utils/src/sheaf/stalk.test.ts create mode 100644 packages/kernel-utils/src/sheaf/stalk.ts create mode 100644 packages/kernel-utils/src/sheaf/types.ts diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index bc895d4a1b..94fa2d7a76 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -44,3 +44,14 @@ export { DEFAULT_MAX_DELAY_MS, } from './retry.ts'; export type { RetryBackoffOptions, RetryOnRetryInfo } from './retry.ts'; +export type { + Section, + PresheafSection, + Lift, + LiftContext, + Presheaf, + Sheaf, +} from './sheaf/types.ts'; +export { sheafify } from './sheaf/sheafify.ts'; +export { collectSheafGuard } from './sheaf/guard.ts'; +export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/README.md b/packages/kernel-utils/src/sheaf/README.md new file mode 100644 index 0000000000..19b5f7d2a0 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/README.md @@ -0,0 +1,103 @@ +# Sheaf + +Runtime capability routing adapted from sheaf theory in algebraic topology. + +`sheafify({ name, sections })` produces a **sheaf** — an authority manager +over a presheaf of capabilities. The sheaf grants revocable dispatch sections +via `getSection`, tracks all delegated authority, and supports point-wise +revocation. + +## Concepts + +**Presheaf section** (`PresheafSection`) — The input data: a capability (exo) +paired with operational metadata, assigned over the open set defined by the +exo's guard. This is an element of the presheaf F = F_sem x F_op. + +> A `getBalance(string)` provider with `{ cost: 100 }` is one presheaf +> section. A `getBalance("alice")` provider with `{ cost: 1 }` is another, +> covering a narrower open set. + +**Germ** — An equivalence class of presheaf sections at an invocation point, +identified by metadata. At dispatch time, sections in the stalk with identical +metadata are collapsed into a single germ; the system picks an arbitrary +representative for dispatch. If two capabilities are indistinguishable by +metadata, the sheaf has no data to prefer one over the other. + +> Two `getBalance(string)` providers both with `{ cost: 1 }` collapse into +> one germ. The lift never sees both — it receives one representative. + +**Stalk** — The set of germs matching a specific `(method, args)` invocation, +computed at dispatch time by guard filtering and then collapsing equivalent +entries. + +> Stalk at `("getBalance", "alice")` might contain two germs (cost 1 vs 100); +> stalk at `("transfer", ...)` might contain one. + +**Lift** — An async function that selects one germ from a multi-germ stalk. +At dispatch time, metadata is decomposed into **constraints** (keys with the +same value across every germ — topologically determined, not a choice) and +**options** (the remaining keys — the lift's actual decision space). The lift +receives only options on each germ; constraints arrive separately in the +context. + +> `argmin` by cost, `argmin` by latency, or any custom selection logic. The +> lift is never invoked for single-germ stalks. + +**Sheaf** — The authority manager returned by `sheafify`. Holds the presheaf +data (captured at construction time) and a registry of all granted sections. + +``` +const sheaf = sheafify({ name: 'Wallet', sections }); +``` + +- `sheaf.getSection({ guard?, lift })` — produce a revocable dispatch exo +- `sheaf.revokePoint(method, ...args)` — revoke every granted section whose + guard covers the point +- `sheaf.getExported()` — union guard of all active (non-revoked) sections +- `sheaf.revokeAll()` — revoke every granted section + +## Dispatch pipeline + +At each invocation point `(method, args)` within a granted section: + +``` +getStalk(sections, method, args) presheaf → stalk (filter by guard) +collapseEquivalent(stalk) locality condition (quotient by metadata) +decomposeMetadata(collapsed) restriction map (constraints / options) +lift(stripped, { method, args, operational selection (extra-theoretic) + constraints }) +dispatch to collapsed[index].exo evaluation +``` + +## Design choices + +**Germ identity is metadata identity.** The collapse step quotients by +metadata: if two sections should be distinguishable, the caller must give them +distinguishable metadata. Sections with identical metadata are treated as +interchangeable. Under the sheaf condition (effect-equivalence), this recovers +the classical equivalence relation on germs. + +**Pseudosheafification.** The sheafification functor would precompute the full +etale space. This system defers to invocation time: compute the stalk, +collapse, decompose, lift. The trade-off is that global coherence (a lift +choosing consistently across points) is not guaranteed. + +**Restriction and gluing are implicit.** Guard restriction induces a +restriction map on metadata: restricting to a point filters the presheaf to +covering sections (`getStalk`), then `decomposeMetadata` strips the metadata +to distinguishing keys — the restricted metadata over that point. The join +works dually: the union of two sections has the join of their metadata, and +restriction at any point recovers the local distinguishing keys in O(n). +Gluing follows: compatible sections (equal metadata on their overlap) produce a +well-defined join. The dispatch pipeline computes all of this implicitly. The +remaining gap is `revokeSite` (revoking over an open set rather than a point), +which requires an `intersects` operator on guards not yet available. + +## Relationship to stacks + +This construction is more properly a **stack** in algebraic geometry. We call +it a sheaf because engineers already know "stack" as a LIFO data structure, and +the algebraic geometry term is unrelated. Within a germ, any representative +will do — authority-equivalence is asserted by constructor contract, not +verified at runtime. Between germs, metadata distinguishes them and the lift +resolves the choice. diff --git a/packages/kernel-utils/src/sheaf/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts new file mode 100644 index 0000000000..35e5b75dc4 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -0,0 +1,193 @@ +import { makeExo } from '@endo/exo'; +import { + M, + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { MethodGuard, Pattern } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { collectSheafGuard } from './guard.ts'; +import type { Section } from './types.ts'; + +const makeSection = ( + tag: string, + guards: Record, + methods: Record unknown>, +): Section => { + const interfaceGuard = M.interface(tag, guards); + return makeExo(tag, interfaceGuard, methods) as unknown as Section; +}; + +describe('collectSheafGuard', () => { + it('variable arity: add with 1, 2, and 3 args', () => { + const sections = [ + makeSection( + 'Calc:0', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + ), + makeSection( + 'Calc:1', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + ), + makeSection( + 'Calc:2', + { + add: M.call(M.number(), M.number(), M.number()).returns(M.number()), + }, + { add: (a: number, b: number, cc: number) => a + b + cc }, + ), + ]; + + const guard = collectSheafGuard('Calc', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.add) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + }; + + // 1 required arg (present in all), 2 optional (variable arity) + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(2); + }); + + it('return guard union', () => { + const sections = [ + makeSection( + 'S:0', + { f: M.call(M.eq(0)).returns(M.eq(0)) }, + { f: (_: number) => 0 }, + ), + makeSection( + 'S:1', + { f: M.call(M.eq(1)).returns(M.eq(1)) }, + { f: (_: number) => 1 }, + ), + ]; + + const guard = collectSheafGuard('S', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const { returnGuard } = getMethodGuardPayload( + methodGuards.f, + ) as unknown as { returnGuard: Pattern }; + + // Return guard is union of eq(0) and eq(1) + expect(matches(0, returnGuard)).toBe(true); + expect(matches(1, returnGuard)).toBe(true); + }); + + it('section with its own optional args: optional preserved in union', () => { + const sections = [ + makeSection( + 'Greeter', + { + greet: M.callWhen(M.string()) + .optional(M.string()) + .returns(M.string()), + }, + { greet: (name: string, _greeting?: string) => `hello ${name}` }, + ), + ]; + + const guard = collectSheafGuard('Greeter', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.greet) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + }; + + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(1); + }); + + it('rest arg guard preserved in collected union', () => { + const sections = [ + makeSection( + 'Logger', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('Logger', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const payload = getMethodGuardPayload(methodGuards.log) as unknown as { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + restArgGuard?: Pattern; + }; + + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards ?? []).toHaveLength(0); + expect(payload.restArgGuard).toBeDefined(); + }); + + it('rest arg guards unioned across sections', () => { + const sections = [ + makeSection( + 'A', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + ), + makeSection( + 'B', + { log: M.call(M.string()).rest(M.number()).returns(M.any()) }, + { log: (..._args: unknown[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('AB', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + const { restArgGuard } = getMethodGuardPayload( + methodGuards.log, + ) as unknown as { restArgGuard?: Pattern }; + + expect(matches('hello', restArgGuard)).toBe(true); + expect(matches(42, restArgGuard)).toBe(true); + }); + + it('multi-method guard collection', () => { + const sections = [ + makeSection( + 'Multi:0', + { + translate: M.call(M.string(), M.string()).returns(M.string()), + }, + { + translate: (from: string, to: string) => `${from}->${to}`, + }, + ), + makeSection( + 'Multi:1', + { + translate: M.call(M.string(), M.string()).returns(M.string()), + summarize: M.call(M.string()).returns(M.string()), + }, + { + translate: (from: string, to: string) => `${from}->${to}`, + summarize: (text: string) => `summary: ${text}`, + }, + ), + ]; + + const guard = collectSheafGuard('Multi', sections); + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + expect('translate' in methodGuards).toBe(true); + expect('summarize' in methodGuards).toBe(true); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/guard.ts b/packages/kernel-utils/src/sheaf/guard.ts new file mode 100644 index 0000000000..6666e9d52c --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -0,0 +1,137 @@ +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import type { Methods } from '@endo/exo'; +import { + M, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import type { Section } from './types.ts'; + +export type MethodGuardPayload = { + argGuards: Pattern[]; + optionalArgGuards?: Pattern[]; + restArgGuard?: Pattern; + returnGuard: Pattern; +}; + +/** + * Naive union of guards via M.or — no pattern canonicalization. + * + * @param guards - Guards to union. + * @returns A single guard representing the union. + */ +const unionGuard = (guards: Pattern[]): Pattern => { + if (guards.length === 1) { + const [first] = guards; + return first; + } + return M.or(...guards); +}; + +/** + * Compute the union of all section guards — the open set covered by the sheafified facade. + * + * For each method name across all sections, collects the arg guards at each + * position and produces a union via M.or. Sections with fewer args than + * the maximum contribute to required args; the remainder become optional. + * + * @param name - The name for the collected interface guard. + * @param sections - The sections whose guards are collected. + * @returns An interface guard covering all sections. + */ +export const collectSheafGuard = ( + name: string, + sections: Section[], +): InterfaceGuard => { + const payloadsByMethod = new Map(); + + for (const section of sections) { + const interfaceGuard = section[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + continue; + } + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { methodGuards: Record }; + for (const [methodName, methodGuard] of Object.entries(methodGuards)) { + const payload = getMethodGuardPayload( + methodGuard, + ) as unknown as MethodGuardPayload; + if (!payloadsByMethod.has(methodName)) { + payloadsByMethod.set(methodName, []); + } + const existing = payloadsByMethod.get(methodName); + existing?.push(payload); + } + } + + const getGuardAt = ( + payload: MethodGuardPayload, + idx: number, + ): Pattern | undefined => { + if (idx < payload.argGuards.length) { + return payload.argGuards[idx]; + } + return payload.optionalArgGuards?.[idx - payload.argGuards.length]; + }; + + const unionMethodGuards: Record = {}; + for (const [methodName, payloads] of payloadsByMethod) { + const minArity = Math.min( + ...payloads.map((payload) => payload.argGuards.length), + ); + const maxArity = Math.max( + ...payloads.map( + (payload) => + payload.argGuards.length + (payload.optionalArgGuards?.length ?? 0), + ), + ); + + const requiredArgGuards = []; + for (let idx = 0; idx < minArity; idx++) { + requiredArgGuards.push( + unionGuard(payloads.map((payload) => payload.argGuards[idx])), + ); + } + + const optionalArgGuards = []; + for (let idx = minArity; idx < maxArity; idx++) { + const guards = payloads + .map((payload) => getGuardAt(payload, idx)) + .filter((guard): guard is Pattern => guard !== undefined); + optionalArgGuards.push(unionGuard(guards)); + } + + const restArgGuards = payloads + .map((payload) => payload.restArgGuard) + .filter((restGuard): restGuard is Pattern => restGuard !== undefined); + const unionRestArgGuard = + restArgGuards.length > 0 ? unionGuard(restArgGuards) : undefined; + + const returnGuard = unionGuard( + payloads.map((payload) => payload.returnGuard), + ); + + const base = M.callWhen(...requiredArgGuards); + if (optionalArgGuards.length > 0 && unionRestArgGuard !== undefined) { + unionMethodGuards[methodName] = base + .optional(...optionalArgGuards) + .rest(unionRestArgGuard) + .returns(returnGuard); + } else if (optionalArgGuards.length > 0) { + unionMethodGuards[methodName] = base + .optional(...optionalArgGuards) + .returns(returnGuard); + } else if (unionRestArgGuard === undefined) { + unionMethodGuards[methodName] = base.returns(returnGuard); + } else { + unionMethodGuards[methodName] = base + .rest(unionRestArgGuard) + .returns(returnGuard); + } + } + + return M.interface(name, unionMethodGuards); +}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts new file mode 100644 index 0000000000..afee771b41 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -0,0 +1,386 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { sheafify } from './sheafify.ts'; +import type { Lift, PresheafSection, Section } from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// --------------------------------------------------------------------------- +// E2E: cost-optimal routing +// --------------------------------------------------------------------------- + +describe('e2e: cost-optimal routing', () => { + it('argmin picks cheapest section, re-sheafification expands landscape', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + // Remote: covers all accounts, expensive + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + { + // Local cache: covers only 'alice', cheap + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 1000 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + + // alice: both sections match, argmin picks local (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(1000); + + // bob: only remote matches (stalk=1, lift not invoked) + expect(await E(wallet).getBalance('bob')).toBe(500); + + // Expand with a broader local cache (cost=2), re-sheafify. + sections.push({ + exo: makeExo( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + ) as unknown as Section, + metadata: { cost: 2 }, + }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + + // bob: now remote (cost=100) and new local (cost=2) both match, argmin picks cost=2 + expect(await E(wallet).getBalance('bob')).toBe(500); + + // alice: three sections match, argmin still picks cost=1 + expect(await E(wallet).getBalance('alice')).toBe(1000); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: multi-tier capability routing +// --------------------------------------------------------------------------- + +describe('e2e: multi-tier capability routing', () => { + // A wallet integrates multiple data sources. Each declares its coverage + // via guards and carries latency metadata. The sheaf routes every call + // to the fastest matching source — no manual if/else, no strategy + // registration, just: + // guards (what can handle it) + metadata (how fast) + lift (pick best) + + type Tier = { latencyMs: number; label: string }; + + const fastest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.latencyMs ?? Infinity) < + (germs[bestIdx]!.metadata?.latencyMs ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes reads to the fastest matching tier and writes to the only capable section', async () => { + // Dispatch log — sections push their label on every call so we can + // observe which tier actually handled each request. + const log: string[] = []; + + // Shared ledger — all sections read from this, so the sheaf condition + // (effect-equivalence) holds by construction. + const ledger: Record = { + alice: 1000, + bob: 500, + carol: 250, + }; + + const sections: PresheafSection[] = []; + + // ── Tier 1: Network RPC ────────────────────────────────── + // Covers ALL accounts (M.string()), but slow (500ms). + sections.push({ + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { + getBalance: (acct: string) => { + log.push('network'); + return ledger[acct] ?? 0; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 500, label: 'network' }, + }); + + let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: fastest, + }); + + // Phase 1 — single backend: stalk is always 1, lift never fires. + expect(await E(wallet).getBalance('alice')).toBe(1000); + expect(await E(wallet).getBalance('bob')).toBe(500); + expect(await E(wallet).getBalance('dave')).toBe(0); + expect(log).toStrictEqual(['network', 'network', 'network']); + log.length = 0; + + // ── Tier 2: Local state for owned account ──────────────── + // Only covers 'alice' (M.eq), 1ms. + sections.push({ + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { + getBalance: (_acct: string) => { + log.push('local'); + return ledger.alice ?? 0; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 1, label: 'local' }, + }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: fastest, + }); + + // Phase 2 — alice routes to local (1ms < 500ms), bob still hits network. + expect(await E(wallet).getBalance('alice')).toBe(1000); + expect(await E(wallet).getBalance('bob')).toBe(500); + expect(log).toStrictEqual(['local', 'network']); + log.length = 0; + + // ── Tier 3: In-memory cache for specific accounts ──────── + // Covers bob and carol via M.or, instant (0ms). + sections.push({ + exo: makeExo( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.or(M.eq('bob'), M.eq('carol'))).returns( + M.number(), + ), + }), + { + getBalance: (acct: string) => { + log.push('cache'); + return ledger[acct] ?? 0; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 0, label: 'cache' }, + }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: fastest, + }); + + // Phase 3 — every known account hits its optimal tier. + expect(await E(wallet).getBalance('alice')).toBe(1000); // local (1ms) + expect(await E(wallet).getBalance('bob')).toBe(500); // cache (0ms) + expect(await E(wallet).getBalance('carol')).toBe(250); // cache (0ms) + expect(await E(wallet).getBalance('dave')).toBe(0); // network (only match) + expect(log).toStrictEqual(['local', 'cache', 'cache', 'network']); + log.length = 0; + + // ── Tier 4: Heterogeneous methods ──────────────────────── + // A write-capable section that declares `transfer`. None of the + // read-only tiers above declared it, so writes route here + // automatically — the guard algebra handles it, no config needed. + sections.push({ + exo: makeExo( + 'Wallet:3', + M.interface('Wallet:3', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }), + { + getBalance: (acct: string) => { + log.push('write-backend'); + return ledger[acct] ?? 0; + }, + transfer: (from: string, to: string, amt: number) => { + log.push('write-backend'); + const fromBal = ledger[from] ?? 0; + if (fromBal < amt) { + return false; + } + ledger[from] = fromBal - amt; + ledger[to] = (ledger[to] ?? 0) + amt; + return true; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 200, label: 'write-backend' }, + }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: fastest, + }); + + // transfer: only write-backend declares it → stalk=1, lift bypassed. + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'dave', 100)).toBe(true); + expect(log).toStrictEqual(['write-backend']); + log.length = 0; + + // The shared ledger is mutated. All tiers see the new state because + // they all close over the same ledger (sheaf condition by construction). + expect(await E(wallet).getBalance('alice')).toBe(900); // local (1ms), was 1000 + expect(await E(wallet).getBalance('dave')).toBe(100); // write-backend (200ms < 500ms) + expect(await E(wallet).getBalance('bob')).toBe(500); // cache, unchanged + expect(log).toStrictEqual(['local', 'write-backend', 'cache']); + }); + + it('same germ structure, different lifts, different routing', async () => { + // The lift is the operational policy — swap it and the same + // set of sections produces different routing behavior. + const ledger: Record = { alice: 1000, bob: 500 }; + + const build = (lift: Lift) => { + const log: string[] = []; + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { + getBalance: (acct: string) => { + log.push('network'); + return ledger[acct] ?? 0; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 500, label: 'network' }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { + getBalance: (acct: string) => { + log.push('mirror'); + return ledger[acct] ?? 0; + }, + }, + ) as unknown as Section, + metadata: { latencyMs: 50, label: 'mirror' }, + }, + ]; + + return { + wallet: sheafify({ name: 'Wallet', sections }).getSection({ lift }), + log, + }; + }; + + // Policy A: fastest wins (mirror at 50ms < network at 500ms). + const { wallet: walletA, log: logA } = build(fastest); + expect(await E(walletA).getBalance('alice')).toBe(1000); + expect(logA).toStrictEqual(['mirror']); + + // Policy B: highest latency wins (simulate "prefer-canonical-source"). + const slowest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.latencyMs ?? 0) > + (germs[bestIdx]!.metadata?.latencyMs ?? 0) + ? idx + : bestIdx, + 0, + ), + ); + const { wallet: walletB, log: logB } = build(slowest); + expect(await E(walletB).getBalance('alice')).toBe(1000); + expect(logB).toStrictEqual(['network']); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: preferAutonomous recovered as degenerate case +// --------------------------------------------------------------------------- + +describe('e2e: preferAutonomous recovered as degenerate case', () => { + it('binary push metadata recovers push-pull lift rule', async () => { + // Binary metadata: { push: true } = push section, { push: false } = pull + const preferPush: Lift<{ push: boolean }> = async (germs) => { + const pushIdx = germs.findIndex((entry) => entry.metadata?.push); + return Promise.resolve(pushIdx >= 0 ? pushIdx : 0); + }; + + const sections: PresheafSection<{ push: boolean }>[] = [ + { + // Pull section: M.any() guards, push=false + exo: makeExo( + 'PushPull:0', + M.interface('PushPull:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 999 }, + ) as unknown as Section, + metadata: { push: false }, + }, + { + // Push section: narrow guard, push=true + exo: makeExo( + 'PushPull:1', + M.interface('PushPull:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { push: true }, + }, + ]; + + const wallet = sheafify({ name: 'PushPull', sections }).getSection({ + lift: preferPush, + }); + + // alice: both match, preferPush picks push section + expect(await E(wallet).getBalance('alice')).toBe(42); + + // bob: only pull matches (stalk=1, lift bypassed) + expect(await E(wallet).getBalance('bob')).toBe(999); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts new file mode 100644 index 0000000000..8a0d268e4a --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -0,0 +1,593 @@ +import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { sheafify } from './sheafify.ts'; +import type { Lift, LiftContext, PresheafSection, Section } from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// --------------------------------------------------------------------------- +// Unit: sheafify +// --------------------------------------------------------------------------- + +describe('sheafify', () => { + it('single-section bypass: lift not invoked', async () => { + let liftCalled = false; + const lift: Lift<{ cost: number }> = async (_germs) => { + liftCalled = true; + return Promise.resolve(0); + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ lift }); + expect(await E(wallet).getBalance('alice')).toBe(42); + expect(liftCalled).toBe(false); + }); + + it('zero-coverage throws', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: async (_germs) => Promise.resolve(0), + }); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'No section covers', + ); + }); + + it('lift receives metadata and picks winner', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + // argmin picks cost=1 section which returns 42 + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + // eslint-disable-next-line vitest/prefer-lowercase-title + it('GET_INTERFACE_GUARD returns collected guard', () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('bob')).returns(M.number()), + }), + { getBalance: (_acct: string) => 50 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: async (_germs) => Promise.resolve(0), + }); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('re-sheafification picks up new sections and methods', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a cheaper section with a new method to the sections array, re-sheafify. + sections.push({ + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_from: string, _to: string, _amt: number) => true, + }, + ) as unknown as Section, + metadata: { cost: 1 }, + }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + + // argmin picks the cheaper section + expect(await E(wallet).getBalance('alice')).toBe(42); + // New method is available on the re-sheafified facade + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'bob', 10)).toBe(true); + }); + + it('pre-built exo dispatches correctly', async () => { + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo: exo as unknown as Section, metadata: { cost: 1 } }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: async (_germs) => Promise.resolve(0), + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + it('re-sheafification with pre-built exo picks up new methods', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a pre-built exo with a cheaper getBalance + new transfer method + const exo = makeExo( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.string(), M.number()).returns( + M.boolean(), + ), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_from: string, _to: string, _amt: number) => true, + }, + ); + sections.push({ exo: exo as unknown as Section, metadata: { cost: 1 } }); + wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + + // argmin picks the cheaper section + expect(await E(wallet).getBalance('alice')).toBe(42); + // New method is available on the re-sheafified facade + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + expect(await E(facade).transfer('alice', 'bob', 10)).toBe(true); + }); + + it('guard reflected in GET_INTERFACE_GUARD for pre-built exo', () => { + const exo = makeExo( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo: exo as unknown as Section, metadata: { cost: 1 } }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: async (_germs) => Promise.resolve(0), + }); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('lift receives constraints in context and only distinguishing metadata', async () => { + type Meta = { region: string; cost: number }; + let capturedGerms: PresheafSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async (germs, context) => { + capturedGerms = germs; + capturedContext = context; + return Promise.resolve(0); + }; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { region: 'us', cost: 100 }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { region: 'us', cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + expect(capturedContext).toStrictEqual({ + method: 'getBalance', + args: ['alice'], + constraints: { region: 'us' }, + }); + expect(capturedGerms.map((germ) => germ.metadata)).toStrictEqual([ + { cost: 100 }, + { cost: 1 }, + ]); + }); + + it('all-shared metadata yields empty distinguishing metadata', async () => { + type Meta = { region: string }; + let capturedGerms: PresheafSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async (germs, context) => { + capturedGerms = germs; + capturedContext = context; + return Promise.resolve(0); + }; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { region: 'us' }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { region: 'us' }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + // Both sections collapsed to one germ → lift not invoked + expect(capturedContext).toBeUndefined(); + expect(capturedGerms).toHaveLength(0); + }); + + it('collapses equivalent presheaf sections by metadata', async () => { + type Meta = { cost: number }; + let liftCalled = false; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: async (_germs) => { + liftCalled = true; + return Promise.resolve(0); + }, + }); + await E(wallet).getBalance('alice'); + + // Both sections have identical metadata → collapsed to one germ → lift bypassed + expect(liftCalled).toBe(false); + }); + + it('mixed sections participate in lift', async () => { + const argmin: Lift<{ cost: number }> = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + const exo = makeExo( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + metadata: { cost: 100 }, + }, + { exo: exo as unknown as Section, metadata: { cost: 1 } }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + lift: argmin, + }); + // argmin picks the exo section (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + // --------------------------------------------------------------------------- + // Revocation + // --------------------------------------------------------------------------- + + it('revokePoint revokes sections covering the point', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + const wallet = sheaf.getSection({ + lift: async () => Promise.resolve(0), + }); + + expect(await E(wallet).getBalance('alice')).toBe(42); + + sheaf.revokePoint('getBalance', 'alice'); + + // Entire section is revoked, not just the specific point + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'Section revoked', + ); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'Section revoked', + ); + }); + + it('revokeAll revokes all sections', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + const wallet = sheaf.getSection({ + lift: async () => Promise.resolve(0), + }); + + expect(await E(wallet).getBalance('alice')).toBe(42); + + sheaf.revokeAll(); + + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'Section revoked', + ); + }); + + it('getExported returns union of active section guards', () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + + // No sections granted yet + expect(sheaf.getExported()).toBeUndefined(); + + sheaf.getSection({ lift: async () => Promise.resolve(0) }); + + const exported = sheaf.getExported(); + expect(exported).toBeDefined(); + const { methodGuards } = getInterfaceGuardPayload(exported!); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('getExported excludes revoked sections', () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: { cost: 1 }, + }, + ]; + + const sheaf = sheafify({ name: 'Wallet', sections }); + sheaf.getSection({ lift: async () => Promise.resolve(0) }); + + expect(sheaf.getExported()).toBeDefined(); + + sheaf.revokeAll(); + expect(sheaf.getExported()).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts new file mode 100644 index 0000000000..ab55845ac4 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -0,0 +1,298 @@ +/** + * Sheafify a presheaf into an authority manager. + * + * `sheafify({ name, sections })` returns a `Sheaf` — an immutable object + * that tracks granted authority and produces revocable dispatch sections. + * + * Each dispatch through a granted section: + * 1. Computes the stalk (getStalk — presheaf sections matching the point) + * 2. Collapses equivalent germs (same metadata → one representative) + * 3. Decomposes metadata into constraints + options + * 4. Invokes the lift on the distinguished options + * 5. Dispatches to some element of the opted germ + */ + +import { makeExo } from '@endo/exo'; +import { + M, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import { stringify } from '../stringify.ts'; +import { collectSheafGuard } from './guard.ts'; +import type { MethodGuardPayload } from './guard.ts'; +import { getStalk, guardCoversPoint } from './stalk.ts'; +import type { Lift, PresheafSection, Section, Sheaf } from './types.ts'; + +/** + * Serialize metadata for equivalence-class keying (collapse step). + * + * @param metadata - The metadata value to serialize. + * @returns A string key for equivalence comparison. + */ +const metadataKey = (metadata: unknown): string => { + if (metadata === undefined || metadata === null) { + return 'null'; + } + if (typeof metadata !== 'object') { + return JSON.stringify(metadata); + } + const entries = Object.entries(metadata as Record).sort( + ([a], [b]) => a.localeCompare(b), + ); + return JSON.stringify(entries); +}; + +/** + * Collapse stalk entries into equivalence classes (germs) by metadata identity. + * Returns one representative per class; the choice within a class is arbitrary. + * + * @param stalk - The stalk entries to collapse. + * @returns One representative per equivalence class. + */ +const collapseEquivalent = ( + stalk: PresheafSection[], +): PresheafSection[] => { + const seen = new Set(); + const representatives: PresheafSection[] = []; + for (const entry of stalk) { + const key = metadataKey(entry.metadata); + if (!seen.has(key)) { + seen.add(key); + representatives.push(entry); + } + } + return representatives; +}; + +/** + * Decompose stalk metadata into constraints (shared by all germs) and + * stripped germs (carrying only distinguishing keys). + * + * @param stalk - The collapsed stalk entries. + * @returns Constraints and stripped germs. + */ +const decomposeMetadata = ( + stalk: PresheafSection[], +): { + constraints: Partial; + stripped: PresheafSection>[]; +} => { + const constraints: Record = {}; + + const first = stalk[0]?.metadata; + if (first !== undefined && first !== null && typeof first === 'object') { + for (const key of Object.keys(first as Record)) { + const val = (first as Record)[key]; + const shared = stalk.every((entry) => { + if ( + entry.metadata === undefined || + entry.metadata === null || + typeof entry.metadata !== 'object' + ) { + return false; + } + const meta = entry.metadata as Record; + return key in meta && meta[key] === val; + }); + if (shared) { + constraints[key] = val; + } + } + } + + const stripped = stalk.map((entry) => { + if ( + entry.metadata === undefined || + entry.metadata === null || + typeof entry.metadata !== 'object' + ) { + return { exo: entry.exo }; + } + const remaining: Record = {}; + for (const [key, val] of Object.entries( + entry.metadata as Record, + )) { + if (!(key in constraints)) { + remaining[key] = val; + } + } + return { exo: entry.exo, metadata: remaining as Partial }; + }); + + return { constraints: constraints as Partial, stripped }; +}; + +/** + * Upgrade all method guards to M.callWhen for async dispatch. + * + * @param resolvedGuard - The interface guard to upgrade. + * @returns A record of async method guards. + */ +const asyncifyMethodGuards = ( + resolvedGuard: InterfaceGuard, +): Record => { + const { methodGuards: resolvedMethodGuards } = getInterfaceGuardPayload( + resolvedGuard, + ) as unknown as { methodGuards: Record }; + + const asyncMethodGuards: Record = {}; + for (const [methodName, methodGuard] of Object.entries( + resolvedMethodGuards, + )) { + const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = + getMethodGuardPayload(methodGuard) as unknown as MethodGuardPayload; + const optionals = optionalArgGuards ?? []; + const base = M.callWhen(...argGuards); + if (optionals.length > 0 && restArgGuard !== undefined) { + asyncMethodGuards[methodName] = base + .optional(...optionals) + .rest(restArgGuard) + .returns(returnGuard); + } else if (optionals.length > 0) { + asyncMethodGuards[methodName] = base + .optional(...optionals) + .returns(returnGuard); + } else if (restArgGuard === undefined) { + asyncMethodGuards[methodName] = base.returns(returnGuard); + } else { + asyncMethodGuards[methodName] = base + .rest(restArgGuard) + .returns(returnGuard); + } + } + return asyncMethodGuards; +}; + +type Grant = { + exo: Section; + guard: InterfaceGuard; + revoke: () => void; + isRevoked: () => boolean; +}; + +export const sheafify = ({ + name, + sections, +}: { + name: string; + sections: PresheafSection[]; +}): Sheaf => { + const frozenSections = [...sections]; + const grants: Grant[] = []; + + const getSection = ({ + guard, + lift, + }: { + guard?: InterfaceGuard; + lift: Lift; + }): object => { + const resolvedGuard = + guard ?? + collectSheafGuard( + name, + frozenSections.map(({ exo }) => exo), + ); + + const asyncMethodGuards = asyncifyMethodGuards(resolvedGuard); + const asyncGuard = M.interface(`${name}:section`, asyncMethodGuards); + + let revoked = false; + + const dispatch = async ( + method: string, + args: unknown[], + ): Promise => { + if (revoked) { + throw new Error(`Section revoked: ${name}`); + } + + const stalk = getStalk(frozenSections, method, args); + let winner: PresheafSection; + switch (stalk.length) { + case 0: + throw new Error(`No section covers ${method}(${stringify(args, 0)})`); + case 1: + winner = stalk[0] as PresheafSection; + break; + default: { + const collapsed = collapseEquivalent(stalk); + if (collapsed.length === 1) { + winner = collapsed[0] as PresheafSection; + break; + } + const { constraints, stripped } = decomposeMetadata(collapsed); + const index = await lift(stripped, { method, args, constraints }); + winner = collapsed[index] as PresheafSection; + break; + } + } + + const obj = winner.exo as Record unknown>; + const fn = obj[method]; + if (fn === undefined) { + throw new Error(`Section has guard for '${method}' but no handler`); + } + return fn.call(obj, ...args); + }; + + const handlers: Record Promise> = + {}; + for (const method of Object.keys(asyncMethodGuards)) { + handlers[method] = async (...args: unknown[]) => dispatch(method, args); + } + + const exo = makeExo( + `${name}:section`, + asyncGuard, + handlers, + ) as unknown as Section; + + grants.push({ + exo, + guard: resolvedGuard, + revoke: () => { + revoked = true; + }, + isRevoked: () => revoked, + }); + + return exo; + }; + + const revokePoint = (method: string, ...args: unknown[]): void => { + for (const grant of grants) { + if (!grant.isRevoked() && guardCoversPoint(grant.guard, method, args)) { + grant.revoke(); + } + } + }; + + const getExported = (): InterfaceGuard | undefined => { + const activeExos = grants + .filter((grant) => !grant.isRevoked()) + .map((grant) => grant.exo); + if (activeExos.length === 0) { + return undefined; + } + return collectSheafGuard(`${name}:exported`, activeExos); + }; + + const revokeAll = (): void => { + for (const grant of grants) { + if (!grant.isRevoked()) { + grant.revoke(); + } + } + }; + + return { + getSection, + revokePoint, + getExported, + revokeAll, + }; +}; diff --git a/packages/kernel-utils/src/sheaf/stalk.test.ts b/packages/kernel-utils/src/sheaf/stalk.test.ts new file mode 100644 index 0000000000..c0e909adea --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.test.ts @@ -0,0 +1,168 @@ +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { MethodGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { getStalk } from './stalk.ts'; +import type { PresheafSection, Section } from './types.ts'; + +const makePresheafSection = ( + tag: string, + guards: Record, + methods: Record unknown>, + metadata: { cost: number }, +): PresheafSection<{ cost: number }> => { + const interfaceGuard = M.interface(tag, guards); + const exo = makeExo(tag, interfaceGuard, methods); + return { exo: exo as unknown as Section, metadata }; +}; + +describe('getStalk', () => { + it('returns matching sections for a method and args', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1, 2]); + expect(stalk).toHaveLength(2); + }); + + it('filters out sections without matching method', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { sub: M.call(M.number()).returns(M.number()) }, + { sub: (a: number) => -a }, + { cost: 2 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1]); + expect(stalk).toHaveLength(1); + expect(stalk[0]!.metadata?.cost).toBe(1); + }); + + it('filters out sections with arg count mismatch', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', [1]); + expect(stalk).toHaveLength(0); + }); + + it('filters out sections with arg type mismatch', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', ['not-a-number']); + expect(stalk).toHaveLength(0); + }); + + it('returns empty array when no sections match', () => { + const sections = [ + makePresheafSection( + 'A', + { add: M.call(M.eq('alice')).returns(M.number()) }, + { add: (_a: string) => 42 }, + { cost: 1 }, + ), + ]; + + const stalk = getStalk(sections, 'add', ['bob']); + expect(stalk).toHaveLength(0); + }); + + it('matches sections with optional args when optional arg is provided', () => { + const sections = [ + makePresheafSection( + 'A', + { + greet: M.callWhen(M.string()) + .optional(M.string()) + .returns(M.string()), + }, + { greet: (name: string, _greeting?: string) => `hello ${name}` }, + { cost: 1 }, + ), + ]; + + expect(getStalk(sections, 'greet', ['alice'])).toHaveLength(1); + expect(getStalk(sections, 'greet', ['alice', 'hi'])).toHaveLength(1); + expect(getStalk(sections, 'greet', [])).toHaveLength(0); + expect(getStalk(sections, 'greet', ['alice', 'hi', 'extra'])).toHaveLength( + 0, + ); + }); + + it('matches sections with rest args', () => { + const sections = [ + makePresheafSection( + 'A', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + { cost: 1 }, + ), + ]; + + expect(getStalk(sections, 'log', ['info'])).toHaveLength(1); + expect(getStalk(sections, 'log', ['info', 'msg'])).toHaveLength(1); + expect(getStalk(sections, 'log', ['info', 'msg', 'extra'])).toHaveLength(1); + expect(getStalk(sections, 'log', [])).toHaveLength(0); + expect(getStalk(sections, 'log', [42])).toHaveLength(0); + }); + + it('returns all sections when all match', () => { + const sections = [ + makePresheafSection( + 'A', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 1 }, + { cost: 1 }, + ), + makePresheafSection( + 'B', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 2 }, + { cost: 2 }, + ), + makePresheafSection( + 'C', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 3 }, + { cost: 3 }, + ), + ]; + + const stalk = getStalk(sections, 'f', ['hello']); + expect(stalk).toHaveLength(3); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/stalk.ts b/packages/kernel-utils/src/sheaf/stalk.ts new file mode 100644 index 0000000000..2c17a5ecce --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -0,0 +1,80 @@ +/** + * Stalk computation: filter presheaf sections by guard matching. + */ + +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import type { MethodGuardPayload } from './guard.ts'; +import type { PresheafSection } from './types.ts'; + +/** + * Check whether an interface guard covers the invocation point (method, args). + * + * @param guard - The interface guard to test. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns True if the guard accepts the invocation. + */ +export const guardCoversPoint = ( + guard: InterfaceGuard, + method: string, + args: unknown[], +): boolean => { + const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + }; + if (!(method in methodGuards)) { + return false; + } + const methodGuard = methodGuards[method]; + if (!methodGuard) { + return false; + } + const { argGuards, optionalArgGuards, restArgGuard } = getMethodGuardPayload( + methodGuard, + ) as unknown as MethodGuardPayload; + const optionals = optionalArgGuards ?? []; + const maxFixedArgs = argGuards.length + optionals.length; + return ( + args.length >= argGuards.length && + (restArgGuard !== undefined || args.length <= maxFixedArgs) && + args + .slice(0, argGuards.length) + .every((arg, i) => matches(arg, argGuards[i])) && + args + .slice(argGuards.length, maxFixedArgs) + .every((arg, i) => matches(arg, optionals[i])) && + (restArgGuard === undefined || + args.slice(maxFixedArgs).every((arg) => matches(arg, restArgGuard))) + ); +}; + +/** + * Get the stalk at an invocation point. + * + * Returns the presheaf sections whose guards accept the given method + args. + * + * @param sections - The presheaf sections to filter. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns The presheaf sections whose guards accept the invocation. + */ +export const getStalk = ( + sections: PresheafSection[], + method: string, + args: unknown[], +): PresheafSection[] => { + return sections.filter(({ exo }) => { + const interfaceGuard = exo[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + return false; + } + return guardCoversPoint(interfaceGuard, method, args); + }); +}; diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts new file mode 100644 index 0000000000..295155f7f5 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -0,0 +1,79 @@ +/** + * Sheaf types: the product decomposition F_sem x F_op. + * + * The section (guard + behavior) is the semantic component F_sem. + * The metadata is the operational component F_op. + * Effect-equivalence (the sheaf condition) is asserted by the interface: + * sections covering the same open set produce the same observable result. + */ + +import type { GET_INTERFACE_GUARD, Methods } from '@endo/exo'; +import type { InterfaceGuard } from '@endo/patterns'; + +/** A section: a capability covering a region of the interface topology. */ +export type Section = Partial & { + [K in typeof GET_INTERFACE_GUARD]?: (() => InterfaceGuard) | undefined; +}; + +/** + * A presheaf section: a section (F_sem) paired with optional metadata (F_op). + * + * This is the input data to sheafify — an (exo, metadata) pair assigned over + * the open set defined by the exo's guard. + */ +export type PresheafSection = { + exo: Section; + metadata?: MetaData; +}; + +/** + * Context passed to the lift alongside the stalk. + * + * `constraints` holds metadata keys whose values are identical across every + * germ in the stalk — these are topologically determined and not a choice. + * Typed as `Partial` because the actual partition is runtime-dependent. + */ +export type LiftContext = { + method: string; + args: unknown[]; + constraints: Partial; +}; + +/** + * Lift: selects one germ from the stalk when multiple germs remain after + * collapsing equivalent presheaf sections. + * + * Each germ carries only distinguishing metadata (options); shared metadata + * (constraints) is delivered separately in the context. + * + * Returns a Promise — the index into the germs array. + */ +export type Lift = ( + germs: PresheafSection>[], + context: LiftContext, +) => Promise; + +/** + * A presheaf: a plain array of presheaf sections. + */ +export type Presheaf = PresheafSection[]; + +/** + * A sheaf: an authority manager over a presheaf. + * + * Produces revocable dispatch sections via `getSection` and tracks all + * granted authority for auditing and revocation. + */ +export type Sheaf = { + /** Produce a revocable dispatch exo over the given guard (or the full union). */ + getSection: (opts: { + guard?: InterfaceGuard; + lift: Lift; + }) => object; + /** Revoke every granted section whose guard covers the point (method, ...args). */ + revokePoint: (method: string, ...args: unknown[]) => void; + /** Union guard of all active (non-revoked) granted sections, or undefined. */ + getExported: () => InterfaceGuard | undefined; + /** Revoke all granted sections. */ + revokeAll: () => void; +}; From 9b9c80917da78185d4bc65d7476e76afe93f0d83 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:09:39 -0400 Subject: [PATCH 02/51] test(kernel-utils): update index exports snapshot for sheaf and GET_DESCRIPTION Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index cc1985bc46..05ccbc4f3c 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -13,9 +13,12 @@ describe('index', () => { 'GET_DESCRIPTION', 'abortableDelay', 'calculateReconnectionBackoff', + 'collectSheafGuard', 'delay', 'fetchValidatedJson', 'fromHex', + 'getStalk', + 'guardCoversPoint', 'ifDefined', 'installWakeDetector', 'isCapData', @@ -35,6 +38,7 @@ describe('index', () => { 'prettifySmallcaps', 'retry', 'retryWithBackoff', + 'sheafify', 'stringify', 'toHex', 'waitUntilQuiescent', From cd388187adab184cc519dfaa7e612ddd1c22e742 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:22:33 -0400 Subject: [PATCH 03/51] refactor(kernel-utils): require guard in getSection, add getGlobalSection `getSection({ guard, lift })` now requires an explicit interface guard, mirroring how `makeExo` always requires one. `getGlobalSection({ lift })` is the new convenience wrapper that computes the full union guard from all presheaf sections, analogous to `makeDefaultExo`. Co-Authored-By: Claude Sonnet 4.6 --- .../src/sheaf/sheafify.e2e.test.ts | 18 +++++---- .../kernel-utils/src/sheaf/sheafify.test.ts | 38 ++++++++++--------- packages/kernel-utils/src/sheaf/sheafify.ts | 20 ++++++---- packages/kernel-utils/src/sheaf/types.ts | 9 ++--- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index afee771b41..b9717214be 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -54,7 +54,7 @@ describe('e2e: cost-optimal routing', () => { }, ]; - let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); @@ -75,7 +75,7 @@ describe('e2e: cost-optimal routing', () => { ) as unknown as Section, metadata: { cost: 2 }, }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); @@ -145,7 +145,7 @@ describe('e2e: multi-tier capability routing', () => { metadata: { latencyMs: 500, label: 'network' }, }); - let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, }); @@ -173,7 +173,7 @@ describe('e2e: multi-tier capability routing', () => { ) as unknown as Section, metadata: { latencyMs: 1, label: 'local' }, }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, }); @@ -202,7 +202,7 @@ describe('e2e: multi-tier capability routing', () => { ) as unknown as Section, metadata: { latencyMs: 0, label: 'cache' }, }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, }); @@ -246,7 +246,7 @@ describe('e2e: multi-tier capability routing', () => { ) as unknown as Section, metadata: { latencyMs: 200, label: 'write-backend' }, }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, }); @@ -308,7 +308,9 @@ describe('e2e: multi-tier capability routing', () => { ]; return { - wallet: sheafify({ name: 'Wallet', sections }).getSection({ lift }), + wallet: sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift, + }), log, }; }; @@ -373,7 +375,7 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { }, ]; - const wallet = sheafify({ name: 'PushPull', sections }).getSection({ + const wallet = sheafify({ name: 'PushPull', sections }).getGlobalSection({ lift: preferPush, }); diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 8a0d268e4a..d350dd7170 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -36,7 +36,9 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ lift }); + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift, + }); expect(await E(wallet).getBalance('alice')).toBe(42); expect(liftCalled).toBe(false); }); @@ -55,7 +57,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: async (_germs) => Promise.resolve(0), }); await expect(E(wallet).getBalance('bob')).rejects.toThrow( @@ -99,7 +101,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); // argmin picks cost=1 section which returns 42 @@ -131,7 +133,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: async (_germs) => Promise.resolve(0), }); const guard = wallet[GET_INTERFACE_GUARD](); @@ -167,7 +169,7 @@ describe('sheafify', () => { }, ]; - let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); expect(await E(wallet).getBalance('alice')).toBe(100); @@ -189,7 +191,7 @@ describe('sheafify', () => { ) as unknown as Section, metadata: { cost: 1 }, }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); @@ -215,7 +217,7 @@ describe('sheafify', () => { { exo: exo as unknown as Section, metadata: { cost: 1 } }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: async (_germs) => Promise.resolve(0), }); expect(await E(wallet).getBalance('alice')).toBe(42); @@ -247,7 +249,7 @@ describe('sheafify', () => { }, ]; - let wallet = sheafify({ name: 'Wallet', sections }).getSection({ + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); expect(await E(wallet).getBalance('alice')).toBe(100); @@ -267,7 +269,7 @@ describe('sheafify', () => { }, ); sections.push({ exo: exo as unknown as Section, metadata: { cost: 1 } }); - wallet = sheafify({ name: 'Wallet', sections }).getSection({ + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); @@ -293,7 +295,7 @@ describe('sheafify', () => { { exo: exo as unknown as Section, metadata: { cost: 1 } }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: async (_germs) => Promise.resolve(0), }); const guard = wallet[GET_INTERFACE_GUARD](); @@ -337,7 +339,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: spy, }); await E(wallet).getBalance('alice'); @@ -387,7 +389,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: spy, }); await E(wallet).getBalance('alice'); @@ -424,7 +426,7 @@ describe('sheafify', () => { }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: async (_germs) => { liftCalled = true; return Promise.resolve(0); @@ -470,7 +472,7 @@ describe('sheafify', () => { { exo: exo as unknown as Section, metadata: { cost: 1 } }, ]; - const wallet = sheafify({ name: 'Wallet', sections }).getSection({ + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); // argmin picks the exo section (cost=1) @@ -496,7 +498,7 @@ describe('sheafify', () => { ]; const sheaf = sheafify({ name: 'Wallet', sections }); - const wallet = sheaf.getSection({ + const wallet = sheaf.getGlobalSection({ lift: async () => Promise.resolve(0), }); @@ -528,7 +530,7 @@ describe('sheafify', () => { ]; const sheaf = sheafify({ name: 'Wallet', sections }); - const wallet = sheaf.getSection({ + const wallet = sheaf.getGlobalSection({ lift: async () => Promise.resolve(0), }); @@ -560,7 +562,7 @@ describe('sheafify', () => { // No sections granted yet expect(sheaf.getExported()).toBeUndefined(); - sheaf.getSection({ lift: async () => Promise.resolve(0) }); + sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); const exported = sheaf.getExported(); expect(exported).toBeDefined(); @@ -583,7 +585,7 @@ describe('sheafify', () => { ]; const sheaf = sheafify({ name: 'Wallet', sections }); - sheaf.getSection({ lift: async () => Promise.resolve(0) }); + sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); expect(sheaf.getExported()).toBeDefined(); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index ab55845ac4..faa9c5cfc9 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -187,15 +187,10 @@ export const sheafify = ({ guard, lift, }: { - guard?: InterfaceGuard; + guard: InterfaceGuard; lift: Lift; }): object => { - const resolvedGuard = - guard ?? - collectSheafGuard( - name, - frozenSections.map(({ exo }) => exo), - ); + const resolvedGuard = guard; const asyncMethodGuards = asyncifyMethodGuards(resolvedGuard); const asyncGuard = M.interface(`${name}:section`, asyncMethodGuards); @@ -263,6 +258,16 @@ export const sheafify = ({ return exo; }; + const getGlobalSection = ({ lift }: { lift: Lift }): object => { + return getSection({ + guard: collectSheafGuard( + name, + frozenSections.map(({ exo }) => exo), + ), + lift, + }); + }; + const revokePoint = (method: string, ...args: unknown[]): void => { for (const grant of grants) { if (!grant.isRevoked() && guardCoversPoint(grant.guard, method, args)) { @@ -291,6 +296,7 @@ export const sheafify = ({ return { getSection, + getGlobalSection, revokePoint, getExported, revokeAll, diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index 295155f7f5..abb78ab890 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -65,11 +65,10 @@ export type Presheaf = PresheafSection[]; * granted authority for auditing and revocation. */ export type Sheaf = { - /** Produce a revocable dispatch exo over the given guard (or the full union). */ - getSection: (opts: { - guard?: InterfaceGuard; - lift: Lift; - }) => object; + /** Produce a revocable dispatch exo over the given guard. */ + getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; + /** Produce a revocable dispatch exo over the full union guard of all presheaf sections. */ + getGlobalSection: (opts: { lift: Lift }) => object; /** Revoke every granted section whose guard covers the point (method, ...args). */ revokePoint: (method: string, ...args: unknown[]) => void; /** Union guard of all active (non-revoked) granted sections, or undefined. */ From 9e21a83a49917dffbc2104163dce99a57f7ef929 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:44:14 -0400 Subject: [PATCH 04/51] test(kernel-utils): use vi.fn() spies in sheafify e2e tests to verify dispatch Replace string-log side-channels and return-value inference with explicit vi.fn() spy assertions. Each section's handler is a named mock; tests call expect(spy).toHaveBeenCalledWith(...) and .not.toHaveBeenCalled() to verify routing directly rather than inferring it from coincident return values. Co-Authored-By: Claude Sonnet 4.6 --- .../src/sheaf/sheafify.e2e.test.ts | 263 ++++++++++-------- 1 file changed, 143 insertions(+), 120 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index b9717214be..e355d59c98 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -1,6 +1,6 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; -import { describe, it, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { sheafify } from './sheafify.ts'; import type { Lift, PresheafSection, Section } from './types.ts'; @@ -29,6 +29,9 @@ describe('e2e: cost-optimal routing', () => { ), ); + const remote0GetBalance = vi.fn((_acct: string): number => 0); + const local1GetBalance = vi.fn((_acct: string): number => 0); + const sections: PresheafSection<{ cost: number }>[] = [ { // Remote: covers all accounts, expensive @@ -37,7 +40,7 @@ describe('e2e: cost-optimal routing', () => { M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), - { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + { getBalance: remote0GetBalance }, ) as unknown as Section, metadata: { cost: 100 }, }, @@ -48,7 +51,7 @@ describe('e2e: cost-optimal routing', () => { M.interface('Wallet:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), - { getBalance: (_acct: string) => 1000 }, + { getBalance: local1GetBalance }, ) as unknown as Section, metadata: { cost: 1 }, }, @@ -59,19 +62,26 @@ describe('e2e: cost-optimal routing', () => { }); // alice: both sections match, argmin picks local (cost=1) - expect(await E(wallet).getBalance('alice')).toBe(1000); + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local1GetBalance.mockClear(); // bob: only remote matches (stalk=1, lift not invoked) - expect(await E(wallet).getBalance('bob')).toBe(500); + await E(wallet).getBalance('bob'); + expect(remote0GetBalance).toHaveBeenCalledWith('bob'); + expect(local1GetBalance).not.toHaveBeenCalled(); + remote0GetBalance.mockClear(); // Expand with a broader local cache (cost=2), re-sheafify. + const local2GetBalance = vi.fn((_acct: string): number => 0); sections.push({ exo: makeExo( 'Wallet:2', M.interface('Wallet:2', { getBalance: M.call(M.string()).returns(M.number()), }), - { getBalance: (acct: string) => (acct === 'alice' ? 1000 : 500) }, + { getBalance: local2GetBalance }, ) as unknown as Section, metadata: { cost: 2 }, }); @@ -80,10 +90,16 @@ describe('e2e: cost-optimal routing', () => { }); // bob: now remote (cost=100) and new local (cost=2) both match, argmin picks cost=2 - expect(await E(wallet).getBalance('bob')).toBe(500); + await E(wallet).getBalance('bob'); + expect(local2GetBalance).toHaveBeenCalledWith('bob'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local2GetBalance.mockClear(); // alice: three sections match, argmin still picks cost=1 - expect(await E(wallet).getBalance('alice')).toBe(1000); + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + expect(local2GetBalance).not.toHaveBeenCalled(); }); }); @@ -113,10 +129,6 @@ describe('e2e: multi-tier capability routing', () => { ); it('routes reads to the fastest matching tier and writes to the only capable section', async () => { - // Dispatch log — sections push their label on every call so we can - // observe which tier actually handled each request. - const log: string[] = []; - // Shared ledger — all sections read from this, so the sheaf condition // (effect-equivalence) holds by construction. const ledger: Record = { @@ -125,6 +137,26 @@ describe('e2e: multi-tier capability routing', () => { carol: 250, }; + const networkGetBalance = vi.fn( + (acct: string): number => ledger[acct] ?? 0, + ); + const localGetBalance = vi.fn((_acct: string): number => ledger.alice ?? 0); + const cacheGetBalance = vi.fn((acct: string): number => ledger[acct] ?? 0); + const writeBackendGetBalance = vi.fn( + (acct: string): number => ledger[acct] ?? 0, + ); + const writeBackendTransfer = vi.fn( + (from: string, to: string, amt: number): boolean => { + const fromBal = ledger[from] ?? 0; + if (fromBal < amt) { + return false; + } + ledger[from] = fromBal - amt; + ledger[to] = (ledger[to] ?? 0) + amt; + return true; + }, + ); + const sections: PresheafSection[] = []; // ── Tier 1: Network RPC ────────────────────────────────── @@ -135,12 +167,7 @@ describe('e2e: multi-tier capability routing', () => { M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), - { - getBalance: (acct: string) => { - log.push('network'); - return ledger[acct] ?? 0; - }, - }, + { getBalance: networkGetBalance }, ) as unknown as Section, metadata: { latencyMs: 500, label: 'network' }, }); @@ -150,11 +177,14 @@ describe('e2e: multi-tier capability routing', () => { }); // Phase 1 — single backend: stalk is always 1, lift never fires. - expect(await E(wallet).getBalance('alice')).toBe(1000); - expect(await E(wallet).getBalance('bob')).toBe(500); - expect(await E(wallet).getBalance('dave')).toBe(0); - expect(log).toStrictEqual(['network', 'network', 'network']); - log.length = 0; + await E(wallet).getBalance('alice'); + await E(wallet).getBalance('bob'); + await E(wallet).getBalance('dave'); + expect(networkGetBalance).toHaveBeenCalledTimes(3); + expect(networkGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('bob'); + expect(networkGetBalance).toHaveBeenCalledWith('dave'); + networkGetBalance.mockClear(); // ── Tier 2: Local state for owned account ──────────────── // Only covers 'alice' (M.eq), 1ms. @@ -164,12 +194,7 @@ describe('e2e: multi-tier capability routing', () => { M.interface('Wallet:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), - { - getBalance: (_acct: string) => { - log.push('local'); - return ledger.alice ?? 0; - }, - }, + { getBalance: localGetBalance }, ) as unknown as Section, metadata: { latencyMs: 1, label: 'local' }, }); @@ -178,10 +203,14 @@ describe('e2e: multi-tier capability routing', () => { }); // Phase 2 — alice routes to local (1ms < 500ms), bob still hits network. - expect(await E(wallet).getBalance('alice')).toBe(1000); - expect(await E(wallet).getBalance('bob')).toBe(500); - expect(log).toStrictEqual(['local', 'network']); - log.length = 0; + await E(wallet).getBalance('alice'); + await E(wallet).getBalance('bob'); + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('bob'); + expect(networkGetBalance).not.toHaveBeenCalledWith('alice'); + expect(localGetBalance).not.toHaveBeenCalledWith('bob'); + localGetBalance.mockClear(); + networkGetBalance.mockClear(); // ── Tier 3: In-memory cache for specific accounts ──────── // Covers bob and carol via M.or, instant (0ms). @@ -193,12 +222,7 @@ describe('e2e: multi-tier capability routing', () => { M.number(), ), }), - { - getBalance: (acct: string) => { - log.push('cache'); - return ledger[acct] ?? 0; - }, - }, + { getBalance: cacheGetBalance }, ) as unknown as Section, metadata: { latencyMs: 0, label: 'cache' }, }); @@ -207,12 +231,20 @@ describe('e2e: multi-tier capability routing', () => { }); // Phase 3 — every known account hits its optimal tier. - expect(await E(wallet).getBalance('alice')).toBe(1000); // local (1ms) - expect(await E(wallet).getBalance('bob')).toBe(500); // cache (0ms) - expect(await E(wallet).getBalance('carol')).toBe(250); // cache (0ms) - expect(await E(wallet).getBalance('dave')).toBe(0); // network (only match) - expect(log).toStrictEqual(['local', 'cache', 'cache', 'network']); - log.length = 0; + await E(wallet).getBalance('alice'); // local (1ms) + await E(wallet).getBalance('bob'); // cache (0ms) + await E(wallet).getBalance('carol'); // cache (0ms) + await E(wallet).getBalance('dave'); // network (only match) + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(cacheGetBalance).toHaveBeenCalledWith('bob'); + expect(cacheGetBalance).toHaveBeenCalledWith('carol'); + expect(networkGetBalance).toHaveBeenCalledWith('dave'); + expect(networkGetBalance).toHaveBeenCalledTimes(1); + expect(localGetBalance).toHaveBeenCalledTimes(1); + expect(cacheGetBalance).toHaveBeenCalledTimes(2); + localGetBalance.mockClear(); + cacheGetBalance.mockClear(); + networkGetBalance.mockClear(); // ── Tier 4: Heterogeneous methods ──────────────────────── // A write-capable section that declares `transfer`. None of the @@ -228,20 +260,8 @@ describe('e2e: multi-tier capability routing', () => { ), }), { - getBalance: (acct: string) => { - log.push('write-backend'); - return ledger[acct] ?? 0; - }, - transfer: (from: string, to: string, amt: number) => { - log.push('write-backend'); - const fromBal = ledger[from] ?? 0; - if (fromBal < amt) { - return false; - } - ledger[from] = fromBal - amt; - ledger[to] = (ledger[to] ?? 0) + amt; - return true; - }, + getBalance: writeBackendGetBalance, + transfer: writeBackendTransfer, }, ) as unknown as Section, metadata: { latencyMs: 200, label: 'write-backend' }, @@ -255,70 +275,61 @@ describe('e2e: multi-tier capability routing', () => { string, (...args: unknown[]) => unknown >; - expect(await E(facade).transfer('alice', 'dave', 100)).toBe(true); - expect(log).toStrictEqual(['write-backend']); - log.length = 0; + await E(facade).transfer('alice', 'dave', 100); + expect(writeBackendTransfer).toHaveBeenCalledWith('alice', 'dave', 100); + writeBackendTransfer.mockClear(); // The shared ledger is mutated. All tiers see the new state because // they all close over the same ledger (sheaf condition by construction). - expect(await E(wallet).getBalance('alice')).toBe(900); // local (1ms), was 1000 - expect(await E(wallet).getBalance('dave')).toBe(100); // write-backend (200ms < 500ms) - expect(await E(wallet).getBalance('bob')).toBe(500); // cache, unchanged - expect(log).toStrictEqual(['local', 'write-backend', 'cache']); + await E(wallet).getBalance('alice'); // local (1ms), was 1000 + await E(wallet).getBalance('dave'); // write-backend (200ms < 500ms for dave) + await E(wallet).getBalance('bob'); // cache, unchanged + expect(localGetBalance).toHaveBeenCalledWith('alice'); + expect(writeBackendGetBalance).toHaveBeenCalledWith('dave'); + expect(cacheGetBalance).toHaveBeenCalledWith('bob'); + expect(ledger.alice).toBe(900); + expect(ledger.dave).toBe(100); + expect(ledger.bob).toBe(500); }); it('same germ structure, different lifts, different routing', async () => { // The lift is the operational policy — swap it and the same // set of sections produces different routing behavior. - const ledger: Record = { alice: 1000, bob: 500 }; + const networkGetBalance = vi.fn((_acct: string): number => 0); + const mirrorGetBalance = vi.fn((_acct: string): number => 0); - const build = (lift: Lift) => { - const log: string[] = []; - const sections: PresheafSection[] = [ - { - exo: makeExo( - 'Wallet:0', - M.interface('Wallet:0', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { - getBalance: (acct: string) => { - log.push('network'); - return ledger[acct] ?? 0; - }, - }, - ) as unknown as Section, - metadata: { latencyMs: 500, label: 'network' }, - }, - { - exo: makeExo( - 'Wallet:1', - M.interface('Wallet:1', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { - getBalance: (acct: string) => { - log.push('mirror'); - return ledger[acct] ?? 0; - }, - }, - ) as unknown as Section, - metadata: { latencyMs: 50, label: 'mirror' }, - }, - ]; - - return { - wallet: sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift, - }), - log, - }; - }; + const makeSections = (): PresheafSection[] => [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ) as unknown as Section, + metadata: { latencyMs: 500, label: 'network' }, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: mirrorGetBalance }, + ) as unknown as Section, + metadata: { latencyMs: 50, label: 'mirror' }, + }, + ]; // Policy A: fastest wins (mirror at 50ms < network at 500ms). - const { wallet: walletA, log: logA } = build(fastest); - expect(await E(walletA).getBalance('alice')).toBe(1000); - expect(logA).toStrictEqual(['mirror']); + const walletA = sheafify({ + name: 'Wallet', + sections: makeSections(), + }).getGlobalSection({ lift: fastest }); + await E(walletA).getBalance('alice'); + expect(mirrorGetBalance).toHaveBeenCalledWith('alice'); + expect(networkGetBalance).not.toHaveBeenCalled(); + mirrorGetBalance.mockClear(); // Policy B: highest latency wins (simulate "prefer-canonical-source"). const slowest: Lift = async (germs) => @@ -332,9 +343,13 @@ describe('e2e: multi-tier capability routing', () => { 0, ), ); - const { wallet: walletB, log: logB } = build(slowest); - expect(await E(walletB).getBalance('alice')).toBe(1000); - expect(logB).toStrictEqual(['network']); + const walletB = sheafify({ + name: 'Wallet', + sections: makeSections(), + }).getGlobalSection({ lift: slowest }); + await E(walletB).getBalance('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('alice'); + expect(mirrorGetBalance).not.toHaveBeenCalled(); }); }); @@ -350,15 +365,18 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { return Promise.resolve(pushIdx >= 0 ? pushIdx : 0); }; + const pullGetBalance = vi.fn((_acct: string): number => 0); + const pushGetBalance = vi.fn((_acct: string): number => 0); + const sections: PresheafSection<{ push: boolean }>[] = [ { - // Pull section: M.any() guards, push=false + // Pull section: M.string() guards, push=false exo: makeExo( 'PushPull:0', M.interface('PushPull:0', { getBalance: M.call(M.string()).returns(M.number()), }), - { getBalance: (_acct: string) => 999 }, + { getBalance: pullGetBalance }, ) as unknown as Section, metadata: { push: false }, }, @@ -369,7 +387,7 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { M.interface('PushPull:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), - { getBalance: (_acct: string) => 42 }, + { getBalance: pushGetBalance }, ) as unknown as Section, metadata: { push: true }, }, @@ -380,9 +398,14 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { }); // alice: both match, preferPush picks push section - expect(await E(wallet).getBalance('alice')).toBe(42); + await E(wallet).getBalance('alice'); + expect(pushGetBalance).toHaveBeenCalledWith('alice'); + expect(pullGetBalance).not.toHaveBeenCalled(); + pushGetBalance.mockClear(); // bob: only pull matches (stalk=1, lift bypassed) - expect(await E(wallet).getBalance('bob')).toBe(999); + await E(wallet).getBalance('bob'); + expect(pullGetBalance).toHaveBeenCalledWith('bob'); + expect(pushGetBalance).not.toHaveBeenCalled(); }); }); From 03e3ac6d6f1ef864186961b4bf7ce7df66aff94a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:41:55 -0400 Subject: [PATCH 05/51] fix(kernel-utils): fix false negative in collectSheafGuard for rest-arg sections `getGuardAt` was returning `undefined` for positions beyond a section's fixed argument range, even when a `restArgGuard` was present. This caused rest-arg sections to be absent from optional-position unions, producing a false negative: e.g. `M.call().rest(M.string())` would not cover position 0 in the union, so a call `['hello']` would fail the collected guard even though the section accepts it. Fix: fall through to `payload.restArgGuard` after exhausting the optional array, so rest-arg sections contribute to every optional position in the union. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/guard.test.ts | 25 +++++++++++++++++++ packages/kernel-utils/src/sheaf/guard.ts | 9 ++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/sheaf/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts index 35e5b75dc4..ffac24fd86 100644 --- a/packages/kernel-utils/src/sheaf/guard.test.ts +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -9,6 +9,7 @@ import type { MethodGuard, Pattern } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; import { collectSheafGuard } from './guard.ts'; +import { guardCoversPoint } from './stalk.ts'; import type { Section } from './types.ts'; const makeSection = ( @@ -159,6 +160,30 @@ describe('collectSheafGuard', () => { expect(matches(42, restArgGuard)).toBe(true); }); + it('rest-arg section covers optional positions (no false negative)', () => { + // Section A requires 1 number; Section B requires 0 args but accepts any + // number of strings via rest. A call ['hello'] is covered by B — the + // collected guard must pass it too. + const sections = [ + makeSection( + 'AB:0', + { f: M.call(M.number()).returns(M.any()) }, + { f: (_: number) => undefined }, + ), + makeSection( + 'AB:1', + { f: M.call().rest(M.string()).returns(M.any()) }, + { f: (..._args: string[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('AB', sections); + + expect(guardCoversPoint(guard, 'f', ['hello'])).toBe(true); // covered by B + expect(guardCoversPoint(guard, 'f', [42])).toBe(true); // covered by A + expect(guardCoversPoint(guard, 'f', [])).toBe(true); // covered by B (0 required) + }); + it('multi-method guard collection', () => { const sections = [ makeSection( diff --git a/packages/kernel-utils/src/sheaf/guard.ts b/packages/kernel-utils/src/sheaf/guard.ts index 6666e9d52c..36b29df97a 100644 --- a/packages/kernel-utils/src/sheaf/guard.ts +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -74,7 +74,14 @@ export const collectSheafGuard = ( if (idx < payload.argGuards.length) { return payload.argGuards[idx]; } - return payload.optionalArgGuards?.[idx - payload.argGuards.length]; + const optIdx = idx - payload.argGuards.length; + if ( + payload.optionalArgGuards && + optIdx < payload.optionalArgGuards.length + ) { + return payload.optionalArgGuards[optIdx]; + } + return payload.restArgGuard; }; const unionMethodGuards: Record = {}; From 64fb99e1c4e424c041c59aec5f7ae8c88091eade Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:19:53 -0400 Subject: [PATCH 06/51] feat(kernel-utils): metadata as polynomials of invocation data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MetaDataSpec discriminated union (constant | source | callable) so that sheaf metadata can vary with call arguments rather than being static. - constant(v) — static value, evaluated once - source(s) — JS source string compiled via Compartment at sheafify construction time, called at dispatch time - callable(fn) — live function called at dispatch time PresheafSection.metadata changes from M to MetaDataSpec (breaking). A new EvaluatedSection type carries post-evaluation metadata and is what Lift receives as its germs array. EvaluatedSection is distinct from PresheafSection because the "germ" in the sheaf-theoretic sense only exists after quotienting by the metadata-equivalence relation (the collapseEquivalent step); EvaluatedSection describes the pre-collapse stage where the spec has been applied to the invocation args. getStalk is generalised to so it works over ResolvedSection (the internal post-resolution type) without a cast. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 3 + packages/kernel-utils/src/index.ts | 3 + .../kernel-utils/src/sheaf/metadata.test.ts | 80 +++++++++++++ packages/kernel-utils/src/sheaf/metadata.ts | 85 ++++++++++++++ .../src/sheaf/sheafify.e2e.test.ts | 106 +++++++++++++++-- .../sheaf/sheafify.string-metadata.test.ts | 110 ++++++++++++++++++ .../kernel-utils/src/sheaf/sheafify.test.ts | 64 +++++----- packages/kernel-utils/src/sheaf/sheafify.ts | 55 ++++++--- packages/kernel-utils/src/sheaf/stalk.test.ts | 5 +- packages/kernel-utils/src/sheaf/stalk.ts | 8 +- packages/kernel-utils/src/sheaf/types.ts | 24 +++- 11 files changed, 483 insertions(+), 60 deletions(-) create mode 100644 packages/kernel-utils/src/sheaf/metadata.test.ts create mode 100644 packages/kernel-utils/src/sheaf/metadata.ts create mode 100644 packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 05ccbc4f3c..e897f50762 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -13,7 +13,9 @@ describe('index', () => { 'GET_DESCRIPTION', 'abortableDelay', 'calculateReconnectionBackoff', + 'callable', 'collectSheafGuard', + 'constant', 'delay', 'fetchValidatedJson', 'fromHex', @@ -39,6 +41,7 @@ describe('index', () => { 'retry', 'retryWithBackoff', 'sheafify', + 'source', 'stringify', 'toHex', 'waitUntilQuiescent', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 94fa2d7a76..e40797c3f2 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -47,11 +47,14 @@ export type { RetryBackoffOptions, RetryOnRetryInfo } from './retry.ts'; export type { Section, PresheafSection, + EvaluatedSection, + MetaDataSpec, Lift, LiftContext, Presheaf, Sheaf, } from './sheaf/types.ts'; +export { constant, source, callable } from './sheaf/metadata.ts'; export { sheafify } from './sheaf/sheafify.ts'; export { collectSheafGuard } from './sheaf/guard.ts'; export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/metadata.test.ts b/packages/kernel-utils/src/sheaf/metadata.test.ts new file mode 100644 index 0000000000..5421094a18 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { + callable, + constant, + evaluateMetadata, + resolveMetaDataSpec, + source, +} from './metadata.ts'; + +describe('constant', () => { + it('returns a constant spec with the given value', () => { + expect(constant(42)).toStrictEqual({ kind: 'constant', value: 42 }); + }); + + it('evaluateMetadata returns the value regardless of args', () => { + const spec = resolveMetaDataSpec(constant({ cost: 7 })); + expect(evaluateMetadata(spec, [])).toStrictEqual({ cost: 7 }); + expect(evaluateMetadata(spec, [1, 2, 3])).toStrictEqual({ cost: 7 }); + }); +}); + +describe('callable', () => { + it('returns a callable spec wrapping the function', () => { + const fn = (args: unknown[]) => args[0] as number; + const spec = callable(fn); + expect(spec).toStrictEqual({ kind: 'callable', fn }); + }); + + it('evaluateMetadata calls fn with args', () => { + const fn = vi.fn((args: unknown[]) => (args[0] as number) * 2); + const spec = resolveMetaDataSpec(callable(fn)); + expect(evaluateMetadata(spec, [5])).toBe(10); + expect(fn).toHaveBeenCalledWith([5]); + }); +}); + +describe('source', () => { + it('returns a source spec with the src string', () => { + expect(source('(args) => args[0]')).toStrictEqual({ + kind: 'source', + src: '(args) => args[0]', + }); + }); + + it('resolveMetaDataSpec compiles source to callable via compartment', () => { + const mockFn = (args: unknown[]) => args[0] as number; + const compartment = { evaluate: vi.fn(() => mockFn) }; + const spec = resolveMetaDataSpec(source('(args) => args[0]'), compartment); + expect(spec.kind).toBe('callable'); + expect(compartment.evaluate).toHaveBeenCalledWith('(args) => args[0]'); + expect(evaluateMetadata(spec, [99])).toBe(99); + }); +}); + +describe('resolveMetaDataSpec', () => { + it('passes constant spec through unchanged', () => { + const spec = constant(42); + expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + }); + + it('passes callable spec through unchanged', () => { + const fn = (_args: unknown[]) => 0; + const spec = callable(fn); + expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + }); + + it("throws if kind is 'source' and no compartment supplied", () => { + expect(() => resolveMetaDataSpec(source('() => 0'))).toThrow( + "compartment required to evaluate 'source' metadata", + ); + }); +}); + +describe('evaluateMetadata', () => { + it('returns undefined when spec is undefined', () => { + expect(evaluateMetadata(undefined, [])).toBeUndefined(); + expect(evaluateMetadata(undefined, [1, 2])).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/metadata.ts b/packages/kernel-utils/src/sheaf/metadata.ts new file mode 100644 index 0000000000..33846c247a --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -0,0 +1,85 @@ +/** + * MetaDataSpec constructors and evaluation helpers. + */ + +import type { MetaDataSpec } from './types.ts'; + +/** Resolved spec: 'source' has been compiled away; only constant or callable remain. */ +export type ResolvedMetaDataSpec = + | { kind: 'constant'; value: M } + | { kind: 'callable'; fn: (args: unknown[]) => M }; + +/** + * Wrap a static value as a constant metadata spec. + * + * @param value - The static metadata value. + * @returns A constant MetaDataSpec wrapping the value. + */ +export const constant = (value: M): MetaDataSpec => + harden({ kind: 'constant', value }); + +/** + * Wrap JS function source. Evaluated in a Compartment at sheafify construction time. + * + * @param src - JS source string of the form `(args) => M`. + * @returns A source MetaDataSpec wrapping the source string. + */ +export const source = (src: string): MetaDataSpec => + harden({ kind: 'source', src }); + +/** + * Wrap a live function as a callable metadata spec. + * + * @param fn - Function from invocation args to metadata value. + * @returns A callable MetaDataSpec wrapping the function. + */ +export const callable = (fn: (args: unknown[]) => M): MetaDataSpec => + harden({ kind: 'callable', fn }); + +/** + * Compile a 'source' spec to 'callable' using the supplied compartment. + * 'constant' and 'callable' pass through unchanged. + * + * @param spec - The MetaDataSpec to resolve. + * @param compartment - Compartment used to evaluate 'source' specs. Required when spec is 'source'. + * @param compartment.evaluate - Evaluate a JS source string and return the result. + * @returns A ResolvedMetaDataSpec with no 'source' variant. + */ +export const resolveMetaDataSpec = ( + spec: MetaDataSpec, + compartment?: { evaluate: (src: string) => unknown }, +): ResolvedMetaDataSpec => { + if (spec.kind === 'source') { + if (!compartment) { + throw new Error( + `sheafify: compartment required to evaluate 'source' metadata`, + ); + } + return { + kind: 'callable', + fn: compartment.evaluate(spec.src) as (args: unknown[]) => M, + }; + } + return spec; +}; + +/** + * Evaluate a resolved metadata spec against the invocation args. + * Returns undefined if spec is undefined (no metadata on the section). + * + * @param spec - The resolved spec to evaluate, or undefined. + * @param args - The invocation arguments. + * @returns The evaluated metadata value, or undefined. + */ +export const evaluateMetadata = ( + spec: ResolvedMetaDataSpec | undefined, + args: unknown[], +): M | undefined => { + if (spec === undefined) { + return undefined; + } + if (spec.kind === 'constant') { + return spec.value; + } + return spec.fn(args); +}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index e355d59c98..976b819fb9 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -2,6 +2,7 @@ import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { describe, expect, it, vi } from 'vitest'; +import { callable, constant } from './metadata.ts'; import { sheafify } from './sheafify.ts'; import type { Lift, PresheafSection, Section } from './types.ts'; @@ -42,7 +43,7 @@ describe('e2e: cost-optimal routing', () => { }), { getBalance: remote0GetBalance }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, { // Local cache: covers only 'alice', cheap @@ -53,7 +54,7 @@ describe('e2e: cost-optimal routing', () => { }), { getBalance: local1GetBalance }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -83,7 +84,7 @@ describe('e2e: cost-optimal routing', () => { }), { getBalance: local2GetBalance }, ) as unknown as Section, - metadata: { cost: 2 }, + metadata: constant({ cost: 2 }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, @@ -169,7 +170,7 @@ describe('e2e: multi-tier capability routing', () => { }), { getBalance: networkGetBalance }, ) as unknown as Section, - metadata: { latencyMs: 500, label: 'network' }, + metadata: constant({ latencyMs: 500, label: 'network' }), }); let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -196,7 +197,7 @@ describe('e2e: multi-tier capability routing', () => { }), { getBalance: localGetBalance }, ) as unknown as Section, - metadata: { latencyMs: 1, label: 'local' }, + metadata: constant({ latencyMs: 1, label: 'local' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, @@ -224,7 +225,7 @@ describe('e2e: multi-tier capability routing', () => { }), { getBalance: cacheGetBalance }, ) as unknown as Section, - metadata: { latencyMs: 0, label: 'cache' }, + metadata: constant({ latencyMs: 0, label: 'cache' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, @@ -264,7 +265,7 @@ describe('e2e: multi-tier capability routing', () => { transfer: writeBackendTransfer, }, ) as unknown as Section, - metadata: { latencyMs: 200, label: 'write-backend' }, + metadata: constant({ latencyMs: 200, label: 'write-backend' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: fastest, @@ -307,7 +308,7 @@ describe('e2e: multi-tier capability routing', () => { }), { getBalance: networkGetBalance }, ) as unknown as Section, - metadata: { latencyMs: 500, label: 'network' }, + metadata: constant({ latencyMs: 500, label: 'network' }), }, { exo: makeExo( @@ -317,7 +318,7 @@ describe('e2e: multi-tier capability routing', () => { }), { getBalance: mirrorGetBalance }, ) as unknown as Section, - metadata: { latencyMs: 50, label: 'mirror' }, + metadata: constant({ latencyMs: 50, label: 'mirror' }), }, ]; @@ -359,7 +360,7 @@ describe('e2e: multi-tier capability routing', () => { describe('e2e: preferAutonomous recovered as degenerate case', () => { it('binary push metadata recovers push-pull lift rule', async () => { - // Binary metadata: { push: true } = push section, { push: false } = pull + // Binary metadata: constant({ push: true }) = push section, { push: false } = pull const preferPush: Lift<{ push: boolean }> = async (germs) => { const pushIdx = germs.findIndex((entry) => entry.metadata?.push); return Promise.resolve(pushIdx >= 0 ? pushIdx : 0); @@ -378,7 +379,7 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { }), { getBalance: pullGetBalance }, ) as unknown as Section, - metadata: { push: false }, + metadata: constant({ push: false }), }, { // Push section: narrow guard, push=true @@ -389,7 +390,7 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { }), { getBalance: pushGetBalance }, ) as unknown as Section, - metadata: { push: true }, + metadata: constant({ push: true }), }, ]; @@ -409,3 +410,84 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { expect(pushGetBalance).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// E2E: callable metadata — cost varies with invocation args +// --------------------------------------------------------------------------- + +describe('e2e: callable metadata — cost varies with invocation args', () => { + // Two swap sections whose cost is a function of the swap amount. + // Swap A is cheaper for small amounts; Swap B is cheaper for large amounts. + // Breakeven ≈ 90.9 (1 + 0.1x = 10 + 0.001x → 0.099x = 9 → x ≈ 90.9) + + type SwapCost = { cost: number }; + + const cheapest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes swap(50) to A and swap(100) to B based on callable cost metadata', async () => { + const swapAFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ) as unknown as Section, + // cost(amount) = 1 + 0.1 * amount + metadata: callable((args) => ({ + cost: 1 + 0.1 * (args[0] as number), + })), + }, + { + exo: makeExo( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ) as unknown as Section, + // cost(amount) = 10 + 0.001 * amount + metadata: callable((args) => ({ + cost: 10 + 0.001 * (args[0] as number), + })), + }, + ]; + + const facade = sheafify({ name: 'Swap', sections }).getGlobalSection({ + lift: cheapest, + }) as unknown as Record Promise>; + + // swap(50): A costs 6, B costs 10.05 → A wins + await facade.swap(50, 'FUZ', 'BIZ'); + expect(swapAFn).toHaveBeenCalledWith(50, 'FUZ', 'BIZ'); + expect(swapBFn).not.toHaveBeenCalled(); + swapAFn.mockClear(); + + // swap(100): A costs 11, B costs 10.1 → B wins + await facade.swap(100, 'FUZ', 'BIZ'); + expect(swapBFn).toHaveBeenCalledWith(100, 'FUZ', 'BIZ'); + expect(swapAFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts new file mode 100644 index 0000000000..302d70300f --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts @@ -0,0 +1,110 @@ +// This test verifies that source-kind metadata specs are compiled via a +// compartment at sheafify construction time and evaluated at dispatch time. +// +// We use a new Function()-based compartment rather than a real SES Compartment +// because importing 'ses' alongside '@endo/exo' triggers a module-evaluation +// ordering conflict in the test environment: @endo/patterns module initialization +// calls assertPattern() under SES lockdown before its internal objects are frozen. +// That conflict is an environment limitation, not a feature limitation. +// +// The functional properties under test are identical regardless of which +// Compartment implementation compiles the source string. + +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, it, expect, vi } from 'vitest'; + +import { source } from './metadata.ts'; +import { sheafify } from './sheafify.ts'; +import type { Lift, PresheafSection, Section } from './types.ts'; + +// Thin cast for calling exo methods directly in tests without going through +// HandledPromise (which is not available in the test environment). +// eslint-disable-next-line id-length +const E = (obj: unknown) => + obj as Record Promise>; + +// A Compartment-shaped object that actually evaluates JS source strings. +/* eslint-disable @typescript-eslint/no-implied-eval, no-new-func */ +const makeTestCompartment = () => ({ + evaluate: (src: string) => new Function(`return (${src})`)(), +}); +/* eslint-enable @typescript-eslint/no-implied-eval, no-new-func */ + +describe('e2e: source metadata — compartment evaluates cost function', () => { + // Same two-swap scenario as the callable e2e test, but cost functions are + // provided as JS source strings and compiled via the test compartment. + // Breakeven ≈ 90.9 (same arithmetic as callable variant). + + type SwapCost = { cost: number }; + + const cheapest: Lift = async (germs) => + Promise.resolve( + germs.reduce( + (bestIdx, entry, idx) => + (entry.metadata?.cost ?? Infinity) < + (germs[bestIdx]!.metadata?.cost ?? Infinity) + ? idx + : bestIdx, + 0, + ), + ); + + it('routes swap(50) to A and swap(100) to B using source-kind metadata', async () => { + const swapAFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ) as unknown as Section, + // cost(amount) = 1 + 0.1 * amount + metadata: source(`(args) => ({ cost: 1 + 0.1 * args[0] })`), + }, + { + exo: makeExo( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ) as unknown as Section, + // cost(amount) = 10 + 0.001 * amount + metadata: source(`(args) => ({ cost: 10 + 0.001 * args[0] })`), + }, + ]; + + const facade = sheafify({ + name: 'Swap', + sections, + compartment: makeTestCompartment(), + }).getGlobalSection({ lift: cheapest }) as unknown as Record< + string, + (...args: unknown[]) => Promise + >; + + // swap(50): A costs 6, B costs 10.05 → A wins + await E(facade).swap(50, 'FUZ', 'BIZ'); + expect(swapAFn).toHaveBeenCalledWith(50, 'FUZ', 'BIZ'); + expect(swapBFn).not.toHaveBeenCalled(); + swapAFn.mockClear(); + + // swap(100): A costs 11, B costs 10.1 → B wins + await E(facade).swap(100, 'FUZ', 'BIZ'); + expect(swapBFn).toHaveBeenCalledWith(100, 'FUZ', 'BIZ'); + expect(swapAFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index d350dd7170..82fb42444c 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -2,8 +2,15 @@ import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; import { M, getInterfaceGuardPayload } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; +import { constant } from './metadata.ts'; import { sheafify } from './sheafify.ts'; -import type { Lift, LiftContext, PresheafSection, Section } from './types.ts'; +import type { + EvaluatedSection, + Lift, + LiftContext, + PresheafSection, + Section, +} from './types.ts'; // Thin cast for calling exo methods directly in tests without going through // HandledPromise (which is not available in the test environment). @@ -32,7 +39,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -53,7 +60,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -87,7 +94,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, { exo: makeExo( @@ -97,7 +104,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -119,7 +126,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, { exo: makeExo( @@ -129,7 +136,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 50 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -165,7 +172,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, ]; @@ -189,7 +196,7 @@ describe('sheafify', () => { transfer: (_from: string, _to: string, _amt: number) => true, }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, @@ -214,7 +221,7 @@ describe('sheafify', () => { { getBalance: (_acct: string) => 42 }, ); const sections: PresheafSection<{ cost: number }>[] = [ - { exo: exo as unknown as Section, metadata: { cost: 1 } }, + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -245,7 +252,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, ]; @@ -268,7 +275,10 @@ describe('sheafify', () => { transfer: (_from: string, _to: string, _amt: number) => true, }, ); - sections.push({ exo: exo as unknown as Section, metadata: { cost: 1 } }); + sections.push({ + exo: exo as unknown as Section, + metadata: constant({ cost: 1 }), + }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ lift: argmin, }); @@ -292,7 +302,7 @@ describe('sheafify', () => { { getBalance: (_acct: string) => 42 }, ); const sections: PresheafSection<{ cost: number }>[] = [ - { exo: exo as unknown as Section, metadata: { cost: 1 } }, + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -307,7 +317,7 @@ describe('sheafify', () => { it('lift receives constraints in context and only distinguishing metadata', async () => { type Meta = { region: string; cost: number }; - let capturedGerms: PresheafSection>[] = []; + let capturedGerms: EvaluatedSection>[] = []; let capturedContext: LiftContext | undefined; const spy: Lift = async (germs, context) => { @@ -325,7 +335,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { region: 'us', cost: 100 }, + metadata: constant({ region: 'us', cost: 100 }), }, { exo: makeExo( @@ -335,7 +345,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { region: 'us', cost: 1 }, + metadata: constant({ region: 'us', cost: 1 }), }, ]; @@ -357,7 +367,7 @@ describe('sheafify', () => { it('all-shared metadata yields empty distinguishing metadata', async () => { type Meta = { region: string }; - let capturedGerms: PresheafSection>[] = []; + let capturedGerms: EvaluatedSection>[] = []; let capturedContext: LiftContext | undefined; const spy: Lift = async (germs, context) => { @@ -375,7 +385,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { region: 'us' }, + metadata: constant({ region: 'us' }), }, { exo: makeExo( @@ -385,7 +395,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { region: 'us' }, + metadata: constant({ region: 'us' }), }, ]; @@ -412,7 +422,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, { exo: makeExo( @@ -422,7 +432,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -467,9 +477,9 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 100 }, ) as unknown as Section, - metadata: { cost: 100 }, + metadata: constant({ cost: 100 }), }, - { exo: exo as unknown as Section, metadata: { cost: 1 } }, + { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -493,7 +503,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -525,7 +535,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -553,7 +563,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; @@ -580,7 +590,7 @@ describe('sheafify', () => { }), { getBalance: (_acct: string) => 42 }, ) as unknown as Section, - metadata: { cost: 1 }, + metadata: constant({ cost: 1 }), }, ]; diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index faa9c5cfc9..07ac36df74 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -23,8 +23,16 @@ import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; import { stringify } from '../stringify.ts'; import { collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; +import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; +import type { ResolvedMetaDataSpec } from './metadata.ts'; import { getStalk, guardCoversPoint } from './stalk.ts'; -import type { Lift, PresheafSection, Section, Sheaf } from './types.ts'; +import type { + EvaluatedSection, + Lift, + PresheafSection, + Section, + Sheaf, +} from './types.ts'; /** * Serialize metadata for equivalence-class keying (collapse step). @@ -53,10 +61,10 @@ const metadataKey = (metadata: unknown): string => { * @returns One representative per equivalence class. */ const collapseEquivalent = ( - stalk: PresheafSection[], -): PresheafSection[] => { + stalk: EvaluatedSection[], +): EvaluatedSection[] => { const seen = new Set(); - const representatives: PresheafSection[] = []; + const representatives: EvaluatedSection[] = []; for (const entry of stalk) { const key = metadataKey(entry.metadata); if (!seen.has(key)) { @@ -75,10 +83,10 @@ const collapseEquivalent = ( * @returns Constraints and stripped germs. */ const decomposeMetadata = ( - stalk: PresheafSection[], + stalk: EvaluatedSection[], ): { constraints: Partial; - stripped: PresheafSection>[]; + stripped: EvaluatedSection>[]; } => { const constraints: Record = {}; @@ -173,14 +181,29 @@ type Grant = { isRevoked: () => boolean; }; +type ResolvedSection = { + exo: Section; + spec: ResolvedMetaDataSpec | undefined; +}; + export const sheafify = ({ name, sections, + compartment, }: { name: string; sections: PresheafSection[]; + compartment?: { evaluate: (src: string) => unknown }; }): Sheaf => { - const frozenSections = [...sections]; + const frozenSections: readonly ResolvedSection[] = Object.freeze( + sections.map((section) => ({ + exo: section.exo, + spec: + section.metadata === undefined + ? undefined + : resolveMetaDataSpec(section.metadata, compartment), + })), + ); const grants: Grant[] = []; const getSection = ({ @@ -206,22 +229,28 @@ export const sheafify = ({ } const stalk = getStalk(frozenSections, method, args); - let winner: PresheafSection; - switch (stalk.length) { + const evaluatedStalk: EvaluatedSection[] = stalk.map( + (section) => ({ + exo: section.exo, + metadata: evaluateMetadata(section.spec, args), + }), + ); + let winner: EvaluatedSection; + switch (evaluatedStalk.length) { case 0: throw new Error(`No section covers ${method}(${stringify(args, 0)})`); case 1: - winner = stalk[0] as PresheafSection; + winner = evaluatedStalk[0] as EvaluatedSection; break; default: { - const collapsed = collapseEquivalent(stalk); + const collapsed = collapseEquivalent(evaluatedStalk); if (collapsed.length === 1) { - winner = collapsed[0] as PresheafSection; + winner = collapsed[0] as EvaluatedSection; break; } const { constraints, stripped } = decomposeMetadata(collapsed); const index = await lift(stripped, { method, args, constraints }); - winner = collapsed[index] as PresheafSection; + winner = collapsed[index] as EvaluatedSection; break; } } diff --git a/packages/kernel-utils/src/sheaf/stalk.test.ts b/packages/kernel-utils/src/sheaf/stalk.test.ts index c0e909adea..534f576b59 100644 --- a/packages/kernel-utils/src/sheaf/stalk.test.ts +++ b/packages/kernel-utils/src/sheaf/stalk.test.ts @@ -3,6 +3,7 @@ import { M } from '@endo/patterns'; import type { MethodGuard } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; +import { constant } from './metadata.ts'; import { getStalk } from './stalk.ts'; import type { PresheafSection, Section } from './types.ts'; @@ -14,7 +15,7 @@ const makePresheafSection = ( ): PresheafSection<{ cost: number }> => { const interfaceGuard = M.interface(tag, guards); const exo = makeExo(tag, interfaceGuard, methods); - return { exo: exo as unknown as Section, metadata }; + return { exo: exo as unknown as Section, metadata: constant(metadata) }; }; describe('getStalk', () => { @@ -56,7 +57,7 @@ describe('getStalk', () => { const stalk = getStalk(sections, 'add', [1]); expect(stalk).toHaveLength(1); - expect(stalk[0]!.metadata?.cost).toBe(1); + expect(stalk[0]!.metadata).toStrictEqual(constant({ cost: 1 })); }); it('filters out sections with arg count mismatch', () => { diff --git a/packages/kernel-utils/src/sheaf/stalk.ts b/packages/kernel-utils/src/sheaf/stalk.ts index 2c17a5ecce..ad06eee0af 100644 --- a/packages/kernel-utils/src/sheaf/stalk.ts +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -11,7 +11,7 @@ import { import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; import type { MethodGuardPayload } from './guard.ts'; -import type { PresheafSection } from './types.ts'; +import type { Section } from './types.ts'; /** * Check whether an interface guard covers the invocation point (method, args). @@ -65,11 +65,11 @@ export const guardCoversPoint = ( * @param args - The arguments to the method invocation. * @returns The presheaf sections whose guards accept the invocation. */ -export const getStalk = ( - sections: PresheafSection[], +export const getStalk = ( + sections: T[], method: string, args: unknown[], -): PresheafSection[] => { +): T[] => { return sections.filter(({ exo }) => { const interfaceGuard = exo[GET_INTERFACE_GUARD]?.(); if (!interfaceGuard) { diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index abb78ab890..ad31e5e1de 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -16,12 +16,32 @@ export type Section = Partial & { }; /** - * A presheaf section: a section (F_sem) paired with optional metadata (F_op). + * A metadata specification: either a static value, a JS source string, or a + * live function. Source strings are compiled once at sheafify construction time. + */ +export type MetaDataSpec = + | { kind: 'constant'; value: M } + | { kind: 'source'; src: string } + | { kind: 'callable'; fn: (args: unknown[]) => M }; + +/** + * A presheaf section: a section (F_sem) paired with an optional metadata spec (F_op). * * This is the input data to sheafify — an (exo, metadata) pair assigned over * the open set defined by the exo's guard. */ export type PresheafSection = { + exo: Section; + metadata?: MetaDataSpec; +}; + +/** + * A section with evaluated metadata: the metadata spec has been computed against + * the invocation args, yielding a concrete value. Used internally during dispatch + * and as the element type of the `germs` array received by Lift (where each entry + * is already a representative of an equivalence class after collapsing). + */ +export type EvaluatedSection = { exo: Section; metadata?: MetaData; }; @@ -49,7 +69,7 @@ export type LiftContext = { * Returns a Promise — the index into the germs array. */ export type Lift = ( - germs: PresheafSection>[], + germs: EvaluatedSection>[], context: LiftContext, ) => Promise; From 449819d8771b8a948f18f09b1590c511237303f3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:39:05 -0400 Subject: [PATCH 07/51] fix(kernel-utils): fix build errors in sheafify metadata eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getStalk: accept readonly T[] to allow frozen section arrays - evaluatedStalk map: omit metadata when undefined via ifDefined for exactOptionalPropertyTypes (metadata?: M ≠ metadata: M | undefined) Co-Authored-By: Claude Sonnet 4.6 Made-with: Cursor --- packages/kernel-utils/src/sheaf/sheafify.ts | 5 ++++- packages/kernel-utils/src/sheaf/stalk.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 07ac36df74..61cac8f095 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -20,6 +20,7 @@ import { } from '@endo/patterns'; import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; +import { ifDefined } from '../misc.ts'; import { stringify } from '../stringify.ts'; import { collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; @@ -232,7 +233,9 @@ export const sheafify = ({ const evaluatedStalk: EvaluatedSection[] = stalk.map( (section) => ({ exo: section.exo, - metadata: evaluateMetadata(section.spec, args), + ...ifDefined({ + metadata: evaluateMetadata(section.spec, args), + }), }), ); let winner: EvaluatedSection; diff --git a/packages/kernel-utils/src/sheaf/stalk.ts b/packages/kernel-utils/src/sheaf/stalk.ts index ad06eee0af..f7988ba15d 100644 --- a/packages/kernel-utils/src/sheaf/stalk.ts +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -66,7 +66,7 @@ export const guardCoversPoint = ( * @returns The presheaf sections whose guards accept the invocation. */ export const getStalk = ( - sections: T[], + sections: readonly T[], method: string, args: unknown[], ): T[] => { From fe344b1e4d6e053cfb316f4eaa16ccae72e20360 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:23:52 -0400 Subject: [PATCH 08/51] feat(kernel-utils): treat {} as empty sheaf metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evaluateMetadata returns a plain object; missing spec and nullish raw → {} - reject primitives, arrays, and non-plain objects; hint { value: myValue } - require EvaluatedSection.metadata; MetaData extends Record - simplify metadataKey and decomposeMetadata; drop ifDefined in dispatch Made-with: Cursor --- .../kernel-utils/src/sheaf/metadata.test.ts | 85 +++++++++++++++---- packages/kernel-utils/src/sheaf/metadata.ts | 80 ++++++++++++----- .../kernel-utils/src/sheaf/sheafify.test.ts | 37 ++++++++ packages/kernel-utils/src/sheaf/sheafify.ts | 69 ++++++--------- packages/kernel-utils/src/sheaf/types.ts | 22 +++-- 5 files changed, 206 insertions(+), 87 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/metadata.test.ts b/packages/kernel-utils/src/sheaf/metadata.test.ts index 5421094a18..8b77f80ff6 100644 --- a/packages/kernel-utils/src/sheaf/metadata.test.ts +++ b/packages/kernel-utils/src/sheaf/metadata.test.ts @@ -10,7 +10,10 @@ import { describe('constant', () => { it('returns a constant spec with the given value', () => { - expect(constant(42)).toStrictEqual({ kind: 'constant', value: 42 }); + expect(constant({ n: 42 })).toStrictEqual({ + kind: 'constant', + value: { n: 42 }, + }); }); it('evaluateMetadata returns the value regardless of args', () => { @@ -22,59 +25,109 @@ describe('constant', () => { describe('callable', () => { it('returns a callable spec wrapping the function', () => { - const fn = (args: unknown[]) => args[0] as number; + const fn = (args: unknown[]) => ({ out: args[0] as number }); const spec = callable(fn); expect(spec).toStrictEqual({ kind: 'callable', fn }); }); it('evaluateMetadata calls fn with args', () => { - const fn = vi.fn((args: unknown[]) => (args[0] as number) * 2); + const fn = vi.fn((args: unknown[]) => ({ + value: (args[0] as number) * 2, + })); const spec = resolveMetaDataSpec(callable(fn)); - expect(evaluateMetadata(spec, [5])).toBe(10); + expect(evaluateMetadata(spec, [5])).toStrictEqual({ value: 10 }); expect(fn).toHaveBeenCalledWith([5]); }); }); describe('source', () => { it('returns a source spec with the src string', () => { - expect(source('(args) => args[0]')).toStrictEqual({ + expect(source('(args) => ({ x: args[0] })')).toStrictEqual({ kind: 'source', - src: '(args) => args[0]', + src: '(args) => ({ x: args[0] })', }); }); it('resolveMetaDataSpec compiles source to callable via compartment', () => { - const mockFn = (args: unknown[]) => args[0] as number; + const mockFn = (args: unknown[]) => ({ value: args[0] as number }); const compartment = { evaluate: vi.fn(() => mockFn) }; - const spec = resolveMetaDataSpec(source('(args) => args[0]'), compartment); + const spec = resolveMetaDataSpec( + source<{ value: number }>('(args) => ({ value: args[0] })'), + compartment, + ); expect(spec.kind).toBe('callable'); - expect(compartment.evaluate).toHaveBeenCalledWith('(args) => args[0]'); - expect(evaluateMetadata(spec, [99])).toBe(99); + expect(compartment.evaluate).toHaveBeenCalledWith( + '(args) => ({ value: args[0] })', + ); + expect(evaluateMetadata(spec, [99])).toStrictEqual({ value: 99 }); }); }); describe('resolveMetaDataSpec', () => { it('passes constant spec through unchanged', () => { - const spec = constant(42); + const spec = constant({ answer: 42 }); expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); }); it('passes callable spec through unchanged', () => { - const fn = (_args: unknown[]) => 0; + const fn = (_args: unknown[]) => ({ count: 0 }); const spec = callable(fn); expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); }); it("throws if kind is 'source' and no compartment supplied", () => { - expect(() => resolveMetaDataSpec(source('() => 0'))).toThrow( + expect(() => resolveMetaDataSpec(source('() => ({})'))).toThrow( "compartment required to evaluate 'source' metadata", ); }); }); describe('evaluateMetadata', () => { - it('returns undefined when spec is undefined', () => { - expect(evaluateMetadata(undefined, [])).toBeUndefined(); - expect(evaluateMetadata(undefined, [1, 2])).toBeUndefined(); + it('returns empty object when spec is undefined', () => { + expect(evaluateMetadata(undefined, [])).toStrictEqual({}); + expect(evaluateMetadata(undefined, [1, 2])).toStrictEqual({}); + }); + + it('normalizes null from callable to empty object', () => { + const spec = resolveMetaDataSpec( + callable( + ((_args: unknown[]) => null) as unknown as ( + args: unknown[], + ) => Record, + ), + ); + expect(evaluateMetadata(spec, [])).toStrictEqual({}); + }); + + it('throws when callable returns a primitive', () => { + const spec = resolveMetaDataSpec( + callable( + ((_args: unknown[]) => 7) as unknown as ( + args: unknown[], + ) => Record, + ), + ); + expect(() => evaluateMetadata(spec, [])).toThrow(/cannot be a primitive/u); + expect(() => evaluateMetadata(spec, [])).toThrow(/value: myValue/u); + }); + + it('throws when callable returns an array', () => { + const spec = resolveMetaDataSpec( + callable(((_args: unknown[]) => [1, 2]) as unknown as ( + args: unknown[], + ) => Record), + ); + expect(() => evaluateMetadata(spec, [])).toThrow(/cannot be an array/u); + }); + + it('throws when callable returns a Date', () => { + const spec = resolveMetaDataSpec( + callable( + ((_args: unknown[]) => new Date()) as unknown as ( + args: unknown[], + ) => Record, + ), + ); + expect(() => evaluateMetadata(spec, [])).toThrow(/must be a plain object/u); }); }); diff --git a/packages/kernel-utils/src/sheaf/metadata.ts b/packages/kernel-utils/src/sheaf/metadata.ts index 33846c247a..6ee84ceee8 100644 --- a/packages/kernel-utils/src/sheaf/metadata.ts +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -5,18 +5,57 @@ import type { MetaDataSpec } from './types.ts'; /** Resolved spec: 'source' has been compiled away; only constant or callable remain. */ -export type ResolvedMetaDataSpec = +export type ResolvedMetaDataSpec> = | { kind: 'constant'; value: M } | { kind: 'callable'; fn: (args: unknown[]) => M }; +const metadataPlainObjectHint = + 'Sheaf metadata must be a plain object; use e.g. { value: myValue } if you need to attach a primitive.'; + +const isPlainObjectRecord = (value: object): boolean => { + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +}; + +/** + * Normalize evaluated metadata: empty sentinel is `{}`; invalid shapes throw. + * + * @param raw - Result from constant value or callable, before validation. + * @returns A plain object suitable for stalk metadata. + */ +const normalizeEvaluatedSheafMetadata = ( + raw: unknown, +): Record => { + if (raw === undefined || raw === null) { + return {}; + } + if (typeof raw !== 'object') { + throw new Error( + `sheafify: metadata cannot be a primitive (${typeof raw}). ${metadataPlainObjectHint}`, + ); + } + if (Array.isArray(raw)) { + throw new Error( + `sheafify: metadata cannot be an array. ${metadataPlainObjectHint}`, + ); + } + if (!isPlainObjectRecord(raw)) { + throw new Error( + `sheafify: metadata must be a plain object. ${metadataPlainObjectHint}`, + ); + } + return raw as Record; +}; + /** * Wrap a static value as a constant metadata spec. * * @param value - The static metadata value. * @returns A constant MetaDataSpec wrapping the value. */ -export const constant = (value: M): MetaDataSpec => - harden({ kind: 'constant', value }); +export const constant = >( + value: M, +): MetaDataSpec => harden({ kind: 'constant', value }); /** * Wrap JS function source. Evaluated in a Compartment at sheafify construction time. @@ -24,17 +63,19 @@ export const constant = (value: M): MetaDataSpec => * @param src - JS source string of the form `(args) => M`. * @returns A source MetaDataSpec wrapping the source string. */ -export const source = (src: string): MetaDataSpec => - harden({ kind: 'source', src }); +export const source = >( + src: string, +): MetaDataSpec => harden({ kind: 'source', src }); /** * Wrap a live function as a callable metadata spec. * * @param fn - Function from invocation args to metadata value. - * @returns A callable MetaDataSpec wrapping the function. + * @returns A callable metadata spec. */ -export const callable = (fn: (args: unknown[]) => M): MetaDataSpec => - harden({ kind: 'callable', fn }); +export const callable = >( + fn: (args: unknown[]) => M, +): MetaDataSpec => harden({ kind: 'callable', fn }); /** * Compile a 'source' spec to 'callable' using the supplied compartment. @@ -45,7 +86,7 @@ export const callable = (fn: (args: unknown[]) => M): MetaDataSpec => * @param compartment.evaluate - Evaluate a JS source string and return the result. * @returns A ResolvedMetaDataSpec with no 'source' variant. */ -export const resolveMetaDataSpec = ( +export const resolveMetaDataSpec = >( spec: MetaDataSpec, compartment?: { evaluate: (src: string) => unknown }, ): ResolvedMetaDataSpec => { @@ -65,21 +106,22 @@ export const resolveMetaDataSpec = ( /** * Evaluate a resolved metadata spec against the invocation args. - * Returns undefined if spec is undefined (no metadata on the section). + * + * Missing spec yields `{}` (no metadata). Callable/constant results must be plain objects; + * `undefined`/`null` from the producer normalize to `{}`. Primitives, arrays, and non-plain + * objects throw with guidance to use an explicit record such as `{ value: myValue }`. * * @param spec - The resolved spec to evaluate, or undefined. * @param args - The invocation arguments. - * @returns The evaluated metadata value, or undefined. + * @returns The evaluated metadata object (possibly empty). */ -export const evaluateMetadata = ( - spec: ResolvedMetaDataSpec | undefined, +export const evaluateMetadata = >( + spec: ResolvedMetaDataSpec | undefined, args: unknown[], -): M | undefined => { +): MetaData => { if (spec === undefined) { - return undefined; - } - if (spec.kind === 'constant') { - return spec.value; + return {} as MetaData; } - return spec.fn(args); + const raw = spec.kind === 'constant' ? spec.value : spec.fn(args); + return normalizeEvaluatedSheafMetadata(raw) as MetaData; }; diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 82fb42444c..868dd2b255 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -448,6 +448,43 @@ describe('sheafify', () => { expect(liftCalled).toBe(false); }); + it('collapses no-metadata and empty-object metadata as equivalent', async () => { + type Meta = Record; + let liftCalled = false; + + const sections: PresheafSection[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ) as unknown as Section, + }, + { + exo: makeExo( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + metadata: constant({}), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: async (_germs) => { + liftCalled = true; + return Promise.resolve(0); + }, + }); + await E(wallet).getBalance('alice'); + + expect(liftCalled).toBe(false); + }); + it('mixed sections participate in lift', async () => { const argmin: Lift<{ cost: number }> = async (germs) => Promise.resolve( diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 61cac8f095..33c0f09f36 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -20,7 +20,6 @@ import { } from '@endo/patterns'; import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; -import { ifDefined } from '../misc.ts'; import { stringify } from '../stringify.ts'; import { collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; @@ -41,15 +40,13 @@ import type { * @param metadata - The metadata value to serialize. * @returns A string key for equivalence comparison. */ -const metadataKey = (metadata: unknown): string => { - if (metadata === undefined || metadata === null) { +const metadataKey = (metadata: Record): string => { + const keys = Object.keys(metadata); + if (keys.length === 0) { return 'null'; } - if (typeof metadata !== 'object') { - return JSON.stringify(metadata); - } - const entries = Object.entries(metadata as Record).sort( - ([a], [b]) => a.localeCompare(b), + const entries = Object.entries(metadata).sort(([a], [b]) => + a.localeCompare(b), ); return JSON.stringify(entries); }; @@ -61,7 +58,7 @@ const metadataKey = (metadata: unknown): string => { * @param stalk - The stalk entries to collapse. * @returns One representative per equivalence class. */ -const collapseEquivalent = ( +const collapseEquivalent = >( stalk: EvaluatedSection[], ): EvaluatedSection[] => { const seen = new Set(); @@ -83,7 +80,7 @@ const collapseEquivalent = ( * @param stalk - The collapsed stalk entries. * @returns Constraints and stripped germs. */ -const decomposeMetadata = ( +const decomposeMetadata = >( stalk: EvaluatedSection[], ): { constraints: Partial; @@ -91,39 +88,25 @@ const decomposeMetadata = ( } => { const constraints: Record = {}; - const first = stalk[0]?.metadata; - if (first !== undefined && first !== null && typeof first === 'object') { - for (const key of Object.keys(first as Record)) { - const val = (first as Record)[key]; - const shared = stalk.every((entry) => { - if ( - entry.metadata === undefined || - entry.metadata === null || - typeof entry.metadata !== 'object' - ) { - return false; - } - const meta = entry.metadata as Record; - return key in meta && meta[key] === val; - }); - if (shared) { - constraints[key] = val; - } + const head = stalk[0]; + if (head === undefined) { + return { constraints: {} as Partial, stripped: [] }; + } + const first = head.metadata; + for (const key of Object.keys(first)) { + const val = first[key]; + const shared = stalk.every((entry) => { + const meta = entry.metadata; + return key in meta && meta[key] === val; + }); + if (shared) { + constraints[key] = val; } } const stripped = stalk.map((entry) => { - if ( - entry.metadata === undefined || - entry.metadata === null || - typeof entry.metadata !== 'object' - ) { - return { exo: entry.exo }; - } const remaining: Record = {}; - for (const [key, val] of Object.entries( - entry.metadata as Record, - )) { + for (const [key, val] of Object.entries(entry.metadata)) { if (!(key in constraints)) { remaining[key] = val; } @@ -182,12 +165,14 @@ type Grant = { isRevoked: () => boolean; }; -type ResolvedSection = { +type ResolvedSection> = { exo: Section; spec: ResolvedMetaDataSpec | undefined; }; -export const sheafify = ({ +export const sheafify = < + MetaData extends Record = Record, +>({ name, sections, compartment, @@ -233,9 +218,7 @@ export const sheafify = ({ const evaluatedStalk: EvaluatedSection[] = stalk.map( (section) => ({ exo: section.exo, - ...ifDefined({ - metadata: evaluateMetadata(section.spec, args), - }), + metadata: evaluateMetadata(section.spec, args), }), ); let winner: EvaluatedSection; diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index ad31e5e1de..b42f3e89ee 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -18,8 +18,10 @@ export type Section = Partial & { /** * A metadata specification: either a static value, a JS source string, or a * live function. Source strings are compiled once at sheafify construction time. + * Evaluated metadata must be a plain object (`{}` means no metadata; primitives + * must be wrapped, e.g. `{ value: n }`). */ -export type MetaDataSpec = +export type MetaDataSpec> = | { kind: 'constant'; value: M } | { kind: 'source'; src: string } | { kind: 'callable'; fn: (args: unknown[]) => M }; @@ -30,20 +32,21 @@ export type MetaDataSpec = * This is the input data to sheafify — an (exo, metadata) pair assigned over * the open set defined by the exo's guard. */ -export type PresheafSection = { +export type PresheafSection> = { exo: Section; metadata?: MetaDataSpec; }; /** * A section with evaluated metadata: the metadata spec has been computed against - * the invocation args, yielding a concrete value. Used internally during dispatch + * the invocation args, yielding a concrete plain object. Used internally during dispatch * and as the element type of the `germs` array received by Lift (where each entry * is already a representative of an equivalence class after collapsing). + * Empty `{}` means no metadata. */ -export type EvaluatedSection = { +export type EvaluatedSection> = { exo: Section; - metadata?: MetaData; + metadata: MetaData; }; /** @@ -53,7 +56,7 @@ export type EvaluatedSection = { * germ in the stalk — these are topologically determined and not a choice. * Typed as `Partial` because the actual partition is runtime-dependent. */ -export type LiftContext = { +export type LiftContext> = { method: string; args: unknown[]; constraints: Partial; @@ -68,7 +71,7 @@ export type LiftContext = { * * Returns a Promise — the index into the germs array. */ -export type Lift = ( +export type Lift> = ( germs: EvaluatedSection>[], context: LiftContext, ) => Promise; @@ -76,7 +79,8 @@ export type Lift = ( /** * A presheaf: a plain array of presheaf sections. */ -export type Presheaf = PresheafSection[]; +export type Presheaf> = + PresheafSection[]; /** * A sheaf: an authority manager over a presheaf. @@ -84,7 +88,7 @@ export type Presheaf = PresheafSection[]; * Produces revocable dispatch sections via `getSection` and tracks all * granted authority for auditing and revocation. */ -export type Sheaf = { +export type Sheaf> = { /** Produce a revocable dispatch exo over the given guard. */ getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; /** Produce a revocable dispatch exo over the full union guard of all presheaf sections. */ From e758e7661ff344eb28c7ca2d3c6aca51ac11bcc6 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:10:32 -0400 Subject: [PATCH 09/51] refactor(kernel-utils): redesign Lift as AsyncGenerator coroutine Replace the one-shot `Lift = (...) => Promise` with an AsyncGenerator coroutine protocol. The lift receives a snapshot of the accumulated error array on each `gen.next(errors)` call, yields candidates one at a time, and can stop early or fall through based on the error history. Add `drive.ts` with `driveLift` to encapsulate the retry loop used by `sheafify.ts`. Add `compose.ts` with `proxyLift`, `withFilter`, `withRanking`, and `fallthrough` as composition primitives. Export all four from `index.ts`. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 4 + packages/kernel-utils/src/index.ts | 6 + packages/kernel-utils/src/sheaf/compose.ts | 106 ++++++++++++++++ packages/kernel-utils/src/sheaf/drive.ts | 43 +++++++ .../src/sheaf/sheafify.e2e.test.ts | 66 ++++------ .../sheaf/sheafify.string-metadata.test.ts | 14 +-- .../kernel-utils/src/sheaf/sheafify.test.ts | 117 +++++++++--------- packages/kernel-utils/src/sheaf/sheafify.ts | 61 ++++++--- packages/kernel-utils/src/sheaf/types.ts | 15 ++- 9 files changed, 301 insertions(+), 131 deletions(-) create mode 100644 packages/kernel-utils/src/sheaf/compose.ts create mode 100644 packages/kernel-utils/src/sheaf/drive.ts diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index e897f50762..aa7694ed12 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -17,6 +17,7 @@ describe('index', () => { 'collectSheafGuard', 'constant', 'delay', + 'fallthrough', 'fetchValidatedJson', 'fromHex', 'getStalk', @@ -38,6 +39,7 @@ describe('index', () => { 'mergeDisjointRecords', 'methodArgsToStruct', 'prettifySmallcaps', + 'proxyLift', 'retry', 'retryWithBackoff', 'sheafify', @@ -45,6 +47,8 @@ describe('index', () => { 'stringify', 'toHex', 'waitUntilQuiescent', + 'withFilter', + 'withRanking', ]); }); }); diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index e40797c3f2..df7a6b05fa 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -56,5 +56,11 @@ export type { } from './sheaf/types.ts'; export { constant, source, callable } from './sheaf/metadata.ts'; export { sheafify } from './sheaf/sheafify.ts'; +export { + proxyLift, + withFilter, + withRanking, + fallthrough, +} from './sheaf/compose.ts'; export { collectSheafGuard } from './sheaf/guard.ts'; export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/compose.ts b/packages/kernel-utils/src/sheaf/compose.ts new file mode 100644 index 0000000000..66e323bdb6 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/compose.ts @@ -0,0 +1,106 @@ +import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; + +/** + * Proxy a lift coroutine, forwarding yielded candidates up and received + * error arrays down to the inner generator. + * + * Note: async generator `yield*` DOES forward `.next(value)` to the + * delegated async iterator, so for simple sequential composition (e.g. + * `fallthrough`) you can use `yield*` directly. `proxyLift` is the right + * primitive when you need to add logic between yields — for example, + * logging, counting attempts, or conditionally stopping early based on the + * error history. + * + * @param gen - The inner async generator to proxy. + * @yields Candidates from the inner generator. + * @returns void when the inner generator is exhausted. + * @example + * // Lift that logs each retry + * const withLogging = (inner: Lift): Lift => + * async function*(germs, context) { + * const gen = inner(germs, context); + * let next = await gen.next([]); + * while (!next.done) { + * const errors: unknown[] = yield next.value; + * if (errors.length > 0) console.log(`retry #${errors.length}`); + * next = await gen.next(errors); + * } + * }; + * // The above pattern is exactly proxyLift with a side-effect added. + */ +export async function* proxyLift>( + gen: AsyncGenerator>, void, unknown[]>, +): AsyncGenerator>, void, unknown[]> { + let next = await gen.next([]); + while (!next.done) { + const errors: unknown[] = yield next.value; + next = await gen.next(errors); + } +} + +/** + * Filter germs before passing to a lift. + * + * Returns the inner lift's generator directly — no proxying needed since + * this is a pure input transform that delegates entirely to the inner lift. + * + * @param predicate - Returns true for germs that should be passed to the inner lift. + * @returns A lift combinator that filters its germs before delegating. + */ +export const withFilter = + >( + predicate: ( + germ: EvaluatedSection>, + ctx: LiftContext, + ) => boolean, + ) => + (inner: Lift): Lift => + (germs, context) => + inner( + germs.filter((germ) => predicate(germ, context)), + context, + ); + +/** + * Sort germs by a comparator before passing to a lift. + * + * Returns the inner lift's generator directly — no proxying needed since + * this is a pure input transform that delegates entirely to the inner lift. + * The original germs array is not mutated. + * + * @param comparator - Comparator function for sorting (same signature as Array.sort). + * @returns A lift combinator that sorts its germs before delegating. + */ +export const withRanking = + >( + comparator: ( + a: EvaluatedSection>, + b: EvaluatedSection>, + ) => number, + ) => + (inner: Lift): Lift => + (germs, context) => + inner([...germs].sort(comparator), context); + +/** + * Try all candidates from liftA, then all candidates from liftB. + * + * Uses `yield*` directly since async generator delegation forwards + * `.next(value)` to the inner iterator, so error arrays are correctly + * threaded through each inner lift. + * + * liftB starts fresh and only sees errors from its own failed attempts, + * not from liftA's attempts. + * + * @param liftA - First lift; its candidates are tried before liftB's. + * @param liftB - Fallback lift; only invoked after liftA is exhausted. + * @returns A combined lift that sequences liftA then liftB. + */ +export const fallthrough = >( + liftA: Lift, + liftB: Lift, +): Lift => + async function* (germs, context) { + yield* liftA(germs, context); + yield* liftB(germs, context); + }; diff --git a/packages/kernel-utils/src/sheaf/drive.ts b/packages/kernel-utils/src/sheaf/drive.ts new file mode 100644 index 0000000000..5b4f199ebd --- /dev/null +++ b/packages/kernel-utils/src/sheaf/drive.ts @@ -0,0 +1,43 @@ +import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; + +/** + * Drive a lift coroutine, retrying on failure and accumulating errors. + * + * Primes the generator with gen.next([]), then calls gen.next(errors) after + * each failed attempt where errors is the full ordered history. Returns the + * first successful result, or rethrows the last error when exhausted. + * + * @param lift - The lift coroutine to drive. + * @param germs - The evaluated sections to pass to the lift. + * @param context - The dispatch context (method, args, constraints). + * @param invoke - Calls the section exo; throws on failure. + * @returns The result of the first successful invocation. + * @internal + */ +export const driveLift = async >( + lift: Lift, + germs: EvaluatedSection>[], + context: LiftContext, + invoke: (germ: EvaluatedSection>) => Promise, +): Promise => { + const errors: unknown[] = []; + const gen = lift(germs, context); + let next = await gen.next(errors); + while (!next.done) { + try { + const result = await invoke(next.value); + await gen.return(undefined); + return result; + } catch (error) { + errors.push(error); + next = await gen.next(errors); + } + } + const lastError = errors.at(-1); + if (lastError instanceof Error) { + throw lastError; + } + throw new Error(`No viable section for ${context.method}`, { + cause: lastError, + }); +}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index 976b819fb9..5e669acc19 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -18,17 +18,12 @@ const E = (obj: unknown) => describe('e2e: cost-optimal routing', () => { it('argmin picks cheapest section, re-sheafification expands landscape', async () => { - const argmin: Lift<{ cost: number }> = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; const remote0GetBalance = vi.fn((_acct: string): number => 0); const local1GetBalance = vi.fn((_acct: string): number => 0); @@ -117,17 +112,13 @@ describe('e2e: multi-tier capability routing', () => { type Tier = { latencyMs: number; label: string }; - const fastest: Lift = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.latencyMs ?? Infinity) < - (germs[bestIdx]!.metadata?.latencyMs ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const fastest: Lift = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.latencyMs ?? Infinity) - + (b.metadata?.latencyMs ?? Infinity), ); + }; it('routes reads to the fastest matching tier and writes to the only capable section', async () => { // Shared ledger — all sections read from this, so the sheaf condition @@ -333,17 +324,11 @@ describe('e2e: multi-tier capability routing', () => { mirrorGetBalance.mockClear(); // Policy B: highest latency wins (simulate "prefer-canonical-source"). - const slowest: Lift = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.latencyMs ?? 0) > - (germs[bestIdx]!.metadata?.latencyMs ?? 0) - ? idx - : bestIdx, - 0, - ), + const slowest: Lift = async function* (germs) { + yield* [...germs].sort( + (a, b) => (b.metadata?.latencyMs ?? 0) - (a.metadata?.latencyMs ?? 0), ); + }; const walletB = sheafify({ name: 'Wallet', sections: makeSections(), @@ -360,10 +345,9 @@ describe('e2e: multi-tier capability routing', () => { describe('e2e: preferAutonomous recovered as degenerate case', () => { it('binary push metadata recovers push-pull lift rule', async () => { - // Binary metadata: constant({ push: true }) = push section, { push: false } = pull - const preferPush: Lift<{ push: boolean }> = async (germs) => { - const pushIdx = germs.findIndex((entry) => entry.metadata?.push); - return Promise.resolve(pushIdx >= 0 ? pushIdx : 0); + const preferPush: Lift<{ push: boolean }> = async function* (germs) { + yield* germs.filter((germ) => germ.metadata?.push); + yield* germs.filter((germ) => !germ.metadata?.push); }; const pullGetBalance = vi.fn((_acct: string): number => 0); @@ -422,17 +406,11 @@ describe('e2e: callable metadata — cost varies with invocation args', () => { type SwapCost = { cost: number }; - const cheapest: Lift = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const cheapest: Lift = async function* (germs) { + yield* [...germs].sort( + (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; it('routes swap(50) to A and swap(100) to B based on callable cost metadata', async () => { const swapAFn = vi.fn( diff --git a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts index 302d70300f..e0037042f5 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts @@ -38,17 +38,11 @@ describe('e2e: source metadata — compartment evaluates cost function', () => { type SwapCost = { cost: number }; - const cheapest: Lift = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const cheapest: Lift = async function* (germs) { + yield* [...germs].sort( + (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; it('routes swap(50) to A and swap(100) to B using source-kind metadata', async () => { const swapAFn = vi.fn( diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 868dd2b255..34decee2a7 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -25,9 +25,10 @@ const E = (obj: unknown) => describe('sheafify', () => { it('single-section bypass: lift not invoked', async () => { let liftCalled = false; - const lift: Lift<{ cost: number }> = async (_germs) => { + // eslint-disable-next-line require-yield + const lift: Lift<{ cost: number }> = async function* (_germs) { liftCalled = true; - return Promise.resolve(0); + // unreachable — fast path bypasses lift for single section }; const sections: PresheafSection<{ cost: number }>[] = [ @@ -65,7 +66,9 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => Promise.resolve(0), + async *lift(_germs) { + // unreachable — zero-coverage path throws before reaching lift + }, }); await expect(E(wallet).getBalance('bob')).rejects.toThrow( 'No section covers', @@ -73,17 +76,12 @@ describe('sheafify', () => { }); it('lift receives metadata and picks winner', async () => { - const argmin: Lift<{ cost: number }> = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; const sections: PresheafSection<{ cost: number }>[] = [ { @@ -141,7 +139,9 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => Promise.resolve(0), + async *lift(germs) { + yield germs[0]!; + }, }); const guard = wallet[GET_INTERFACE_GUARD](); expect(guard).toBeDefined(); @@ -151,17 +151,12 @@ describe('sheafify', () => { }); it('re-sheafification picks up new sections and methods', async () => { - const argmin: Lift<{ cost: number }> = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; const sections: PresheafSection<{ cost: number }>[] = [ { @@ -225,23 +220,20 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => Promise.resolve(0), + async *lift(germs) { + yield germs[0]!; + }, }); expect(await E(wallet).getBalance('alice')).toBe(42); }); it('re-sheafification with pre-built exo picks up new methods', async () => { - const argmin: Lift<{ cost: number }> = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; const sections: PresheafSection<{ cost: number }>[] = [ { @@ -306,7 +298,9 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => Promise.resolve(0), + async *lift(germs) { + yield germs[0]!; + }, }); const guard = wallet[GET_INTERFACE_GUARD](); expect(guard).toBeDefined(); @@ -320,10 +314,10 @@ describe('sheafify', () => { let capturedGerms: EvaluatedSection>[] = []; let capturedContext: LiftContext | undefined; - const spy: Lift = async (germs, context) => { + const spy: Lift = async function* (germs, context) { capturedGerms = germs; capturedContext = context; - return Promise.resolve(0); + yield germs[0]!; }; const sections: PresheafSection[] = [ @@ -370,10 +364,10 @@ describe('sheafify', () => { let capturedGerms: EvaluatedSection>[] = []; let capturedContext: LiftContext | undefined; - const spy: Lift = async (germs, context) => { + const spy: Lift = async function* (germs, context) { capturedGerms = germs; capturedContext = context; - return Promise.resolve(0); + yield germs[0]!; }; const sections: PresheafSection[] = [ @@ -437,9 +431,9 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => { + // eslint-disable-next-line require-yield + async *lift(_germs) { liftCalled = true; - return Promise.resolve(0); }, }); await E(wallet).getBalance('alice'); @@ -475,9 +469,9 @@ describe('sheafify', () => { ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ - lift: async (_germs) => { + // eslint-disable-next-line require-yield + async *lift(_germs) { liftCalled = true; - return Promise.resolve(0); }, }); await E(wallet).getBalance('alice'); @@ -486,17 +480,12 @@ describe('sheafify', () => { }); it('mixed sections participate in lift', async () => { - const argmin: Lift<{ cost: number }> = async (germs) => - Promise.resolve( - germs.reduce( - (bestIdx, entry, idx) => - (entry.metadata?.cost ?? Infinity) < - (germs[bestIdx]!.metadata?.cost ?? Infinity) - ? idx - : bestIdx, - 0, - ), + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), ); + }; const exo = makeExo( 'cheap', @@ -546,7 +535,9 @@ describe('sheafify', () => { const sheaf = sheafify({ name: 'Wallet', sections }); const wallet = sheaf.getGlobalSection({ - lift: async () => Promise.resolve(0), + async *lift(germs) { + yield germs[0]!; + }, }); expect(await E(wallet).getBalance('alice')).toBe(42); @@ -578,7 +569,9 @@ describe('sheafify', () => { const sheaf = sheafify({ name: 'Wallet', sections }); const wallet = sheaf.getGlobalSection({ - lift: async () => Promise.resolve(0), + async *lift(germs) { + yield germs[0]!; + }, }); expect(await E(wallet).getBalance('alice')).toBe(42); @@ -609,7 +602,11 @@ describe('sheafify', () => { // No sections granted yet expect(sheaf.getExported()).toBeUndefined(); - sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); + sheaf.getGlobalSection({ + async *lift(germs) { + yield germs[0]!; + }, + }); const exported = sheaf.getExported(); expect(exported).toBeDefined(); @@ -632,7 +629,11 @@ describe('sheafify', () => { ]; const sheaf = sheafify({ name: 'Wallet', sections }); - sheaf.getGlobalSection({ lift: async () => Promise.resolve(0) }); + sheaf.getGlobalSection({ + async *lift(germs) { + yield germs[0]!; + }, + }); expect(sheaf.getExported()).toBeDefined(); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 33c0f09f36..be576a5147 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -21,6 +21,7 @@ import { import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; import { stringify } from '../stringify.ts'; +import { driveLift } from './drive.ts'; import { collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; @@ -158,6 +159,23 @@ const asyncifyMethodGuards = ( return asyncMethodGuards; }; +/** + * Invoke a method on a section exo, throwing if the handler is missing. + * + * @param exo - The section exo to invoke. + * @param method - The method name to call. + * @param args - The positional arguments. + * @returns The synchronous return value of the method (typically a Promise). + */ +const invokeExo = (exo: Section, method: string, args: unknown[]): unknown => { + const obj = exo as Record unknown>; + const fn = obj[method]; + if (fn === undefined) { + throw new Error(`Section has guard for '${method}' but no handler`); + } + return fn.call(obj, ...args); +}; + type Grant = { exo: Section; guard: InterfaceGuard; @@ -221,32 +239,45 @@ export const sheafify = < metadata: evaluateMetadata(section.spec, args), }), ); - let winner: EvaluatedSection; switch (evaluatedStalk.length) { case 0: throw new Error(`No section covers ${method}(${stringify(args, 0)})`); case 1: - winner = evaluatedStalk[0] as EvaluatedSection; - break; + return invokeExo( + (evaluatedStalk[0] as EvaluatedSection).exo, + method, + args, + ); default: { const collapsed = collapseEquivalent(evaluatedStalk); if (collapsed.length === 1) { - winner = collapsed[0] as EvaluatedSection; - break; + return invokeExo( + (collapsed[0] as EvaluatedSection).exo, + method, + args, + ); } const { constraints, stripped } = decomposeMetadata(collapsed); - const index = await lift(stripped, { method, args, constraints }); - winner = collapsed[index] as EvaluatedSection; - break; + const strippedToCollapsed = new Map( + stripped.map((strippedGerm, i) => [ + strippedGerm, + collapsed[i] as EvaluatedSection, + ]), + ); + return driveLift( + lift, + stripped, + { method, args, constraints }, + async (germ) => { + const section = strippedToCollapsed.get(germ); + if (section === undefined) { + throw new Error('lift yielded an unknown germ'); + } + return invokeExo(section.exo, method, args); + }, + ); } } - - const obj = winner.exo as Record unknown>; - const fn = obj[method]; - if (fn === undefined) { - throw new Error(`Section has guard for '${method}' but no handler`); - } - return fn.call(obj, ...args); }; const handlers: Record Promise> = diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index b42f3e89ee..613d484d91 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -63,18 +63,25 @@ export type LiftContext> = { }; /** - * Lift: selects one germ from the stalk when multiple germs remain after - * collapsing equivalent presheaf sections. + * Lift: a coroutine that yields candidates in preference order and receives + * the accumulated error list after each failed attempt. * * Each germ carries only distinguishing metadata (options); shared metadata * (constraints) is delivered separately in the context. * - * Returns a Promise — the index into the germs array. + * The sheaf calls gen.next([]) to prime the coroutine, then gen.next(errors) + * after each failure, where errors is the ordered list of every error + * encountered so far. The generator can inspect the history to decide whether + * to yield another candidate or return (signal exhaustion). The sheaf + * rethrows the last error when the generator is done. + * + * Simple lifts that do not need retry logic can ignore the error input: + * async function*(germs) { yield* [...germs].sort(comparator); } */ export type Lift> = ( germs: EvaluatedSection>[], context: LiftContext, -) => Promise; +) => AsyncGenerator>, void, unknown[]>; /** * A presheaf: a plain array of presheaf sections. From e2d2783dfae7d199862d4ef69cda9192cdc4834b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:12:35 -0400 Subject: [PATCH 10/51] test(kernel-utils): add unit tests for lift composition helpers Cover proxyLift, withFilter, withRanking, fallthrough, and composed combinations in compose.test.ts. Includes driveToExhaustion and driveWithSuccessOn test helpers that pass error snapshots (not mutable references) to gen.next, so inner generators can safely store the received arrays. Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/compose.test.ts | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 packages/kernel-utils/src/sheaf/compose.test.ts diff --git a/packages/kernel-utils/src/sheaf/compose.test.ts b/packages/kernel-utils/src/sheaf/compose.test.ts new file mode 100644 index 0000000000..446221c807 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/compose.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { fallthrough, proxyLift, withFilter, withRanking } from './compose.ts'; +import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Meta = { id: string; cost: number }; +type G = EvaluatedSection>; + +const makeGerm = (id: string, cost = 0): G => ({ + exo: {} as G['exo'], + metadata: { id, cost }, +}); + +const ctx: LiftContext = { + method: 'transfer', + args: ['alice', 100n], + constraints: {}, +}; + +/** + * Drive a lift to exhaustion, simulating a failure after each yielded + * candidate. Returns all yielded germs in order and the error arrays + * the generator received. + * + * @param lift - The lift to drive. + * @param germs - The germs to pass to the lift. + * @param context - The lift context. + * @returns Yielded germs and error snapshots received by the generator. + */ +const driveToExhaustion = async ( + lift: Lift, + germs: G[], + context: LiftContext = ctx, +): Promise<{ yielded: G[]; receivedErrors: unknown[][] }> => { + const yielded: G[] = []; + const receivedErrors: unknown[][] = []; + const errors: unknown[] = []; + const gen = lift(germs, context); + let next = await gen.next([...errors]); + while (!next.done) { + yielded.push(next.value); + errors.push(new Error(`attempt ${errors.length + 1} failed`)); + receivedErrors.push([...errors]); + next = await gen.next([...errors]); + } + return { yielded, receivedErrors }; +}; + +/** + * Drive a lift, succeeding on the nth candidate (1-based). + * Returns the winning germ. + * + * @param lift - The lift to drive. + * @param germs - The germs to pass to the lift. + * @param successOn - Which attempt number (1-based) should succeed. + * @param context - The lift context. + * @returns The germ that won on attempt `successOn`. + */ +const driveWithSuccessOn = async ( + lift: Lift, + germs: G[], + successOn: number, + context: LiftContext = ctx, +): Promise => { + const errors: unknown[] = []; + const gen = lift(germs, context); + let attempt = 0; + let next = await gen.next([...errors]); + while (!next.done) { + attempt += 1; + if (attempt === successOn) { + await gen.return(undefined); + return next.value; + } + errors.push(new Error(`attempt ${attempt} failed`)); + next = await gen.next([...errors]); + } + throw new Error('generator exhausted before success'); +}; + +// --------------------------------------------------------------------------- +// proxyLift +// --------------------------------------------------------------------------- + +describe('proxyLift', () => { + it('forwards all yielded values from inner generator', async () => { + const [germA, germB, germC] = [makeGerm('a'), makeGerm('b'), makeGerm('c')]; + const inner = async function* (): AsyncGenerator { + yield germA; + yield germB; + yield germC; + }; + + const { yielded } = await driveToExhaustion(() => proxyLift(inner()), []); + expect(yielded).toStrictEqual([germA, germB, germC]); + }); + + it('forwards error arrays down to inner generator', async () => { + const [germA, germB] = [makeGerm('a'), makeGerm('b')]; + const receivedByInner: unknown[][] = []; + + const inner = async function* (): AsyncGenerator { + const errors1: unknown[] = yield germA; + receivedByInner.push(errors1); + const errors2: unknown[] = yield germB; + receivedByInner.push(errors2); + }; + + await driveToExhaustion(() => proxyLift(inner()), []); + + expect(receivedByInner).toHaveLength(2); + expect(receivedByInner[0]).toHaveLength(1); // one error after first attempt + expect(receivedByInner[1]).toHaveLength(2); // two errors after second attempt + }); + + it('stops when inner generator is done', async () => { + const inner = async function* (): AsyncGenerator { + // immediately done + }; + + const { yielded } = await driveToExhaustion(() => proxyLift(inner()), []); + expect(yielded).toHaveLength(0); + }); + + it('allows inner generator to stop early based on errors', async () => { + const [germA, germB, germC] = [makeGerm('a'), makeGerm('b'), makeGerm('c')]; + + const inner = async function* (): AsyncGenerator { + let errors: unknown[] = yield germA; + // stop after first failure + if (errors.length > 0) { + return; + } + errors = yield germB; + if (errors.length > 0) { + return; + } + yield germC; + }; + + const { yielded } = await driveToExhaustion(() => proxyLift(inner()), []); + // Only 'a' yielded — inner stops after receiving the first error + expect(yielded).toStrictEqual([germA]); + }); +}); + +// --------------------------------------------------------------------------- +// withFilter +// --------------------------------------------------------------------------- + +describe('withFilter', () => { + it('passes only matching germs to the inner lift', async () => { + const germs = [makeGerm('a', 1), makeGerm('b', 2), makeGerm('c', 3)]; + const received = vi.fn(); + + const inner: Lift = async function* (allGerms) { + received(allGerms.map((item) => item.metadata.id)); + yield* allGerms; + }; + + const lift = withFilter((germ) => (germ.metadata.cost ?? 0) >= 2)( + inner, + ); + await driveToExhaustion(lift, germs); + + expect(received).toHaveBeenCalledWith(['b', 'c']); + }); + + it('passes context to the predicate', async () => { + const germs = [makeGerm('alice'), makeGerm('bob')]; + const contextUsed: LiftContext[] = []; + + const lift = withFilter((_germ, liftContext) => { + contextUsed.push(liftContext); + return true; + })(async function* (allGerms) { + yield* allGerms; + }); + + await driveToExhaustion(lift, germs); + + expect(contextUsed.length).toBeGreaterThan(0); + expect(contextUsed[0]).toStrictEqual(ctx); + }); + + it('yields nothing when no germs match', async () => { + const germs = [makeGerm('a', 1)]; + const lift = withFilter(() => false)(async function* (allGerms) { + yield* allGerms; + }); + + const { yielded } = await driveToExhaustion(lift, germs); + expect(yielded).toHaveLength(0); + }); + + it('returns the inner lift generator directly (no extra wrapping)', () => { + // withFilter is a pure input transform — it returns the inner lift's + // generator, not a new proxy generator. + const innerGen = {} as AsyncGenerator; + const inner: Lift = vi.fn(() => innerGen); + const lift = withFilter(() => true)(inner); + + const result = lift([], ctx); + expect(result).toBe(innerGen); + }); +}); + +// --------------------------------------------------------------------------- +// withRanking +// --------------------------------------------------------------------------- + +describe('withRanking', () => { + it('sorts germs before passing to inner lift', async () => { + const germs = [makeGerm('a', 3), makeGerm('b', 1), makeGerm('c', 2)]; + const received = vi.fn(); + + const inner: Lift = async function* (allGerms) { + received(allGerms.map((item) => item.metadata.id)); + yield* allGerms; + }; + + const lift = withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(inner); + await driveToExhaustion(lift, germs); + + expect(received).toHaveBeenCalledWith(['b', 'c', 'a']); + }); + + it('does not mutate the original germs array', async () => { + const germs = [makeGerm('a', 3), makeGerm('b', 1)]; + const original = [...germs]; + + const lift = withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(async function* (allGerms) { + yield* allGerms; + }); + + await driveToExhaustion(lift, germs); + expect(germs).toStrictEqual(original); + }); + + it('returns the inner lift generator directly (no extra wrapping)', () => { + const innerGen = {} as AsyncGenerator; + const inner: Lift = vi.fn(() => innerGen); + const lift = withRanking(() => 0)(inner); + + const result = lift([], ctx); + expect(result).toBe(innerGen); + }); +}); + +// --------------------------------------------------------------------------- +// fallthrough +// --------------------------------------------------------------------------- + +describe('fallthrough', () => { + it('yields all candidates from liftA then liftB', async () => { + const [a1, a2, b1, b2] = [ + makeGerm('a1'), + makeGerm('a2'), + makeGerm('b1'), + makeGerm('b2'), + ]; + + const liftA: Lift = async function* () { + yield a1; + yield a2; + }; + const liftB: Lift = async function* () { + yield b1; + yield b2; + }; + + const { yielded } = await driveToExhaustion(fallthrough(liftA, liftB), []); + expect(yielded).toStrictEqual([a1, a2, b1, b2]); + }); + + it('stops at liftA winner and does not invoke liftB', async () => { + const [a1, a2] = [makeGerm('a1'), makeGerm('a2')]; + const liftBInvoked = vi.fn(); + + const liftA: Lift = async function* () { + yield a1; + yield a2; + }; + const liftB: Lift = async function* () { + liftBInvoked(); + yield makeGerm('b1'); + }; + + // Succeed on first candidate + const winner = await driveWithSuccessOn(fallthrough(liftA, liftB), [], 1); + expect(winner).toBe(a1); + expect(liftBInvoked).not.toHaveBeenCalled(); + }); + + it('falls through to liftB when liftA is exhausted', async () => { + const [a1, b1] = [makeGerm('a1'), makeGerm('b1')]; + + const liftA: Lift = async function* () { + yield a1; + }; + const liftB: Lift = async function* () { + yield b1; + }; + + // liftA has one candidate (a1), fail it, then liftB kicks in + const winner = await driveWithSuccessOn(fallthrough(liftA, liftB), [], 2); + expect(winner).toBe(b1); + }); + + it('forwards error arrays through yield* to each inner lift', async () => { + const [a1, b1] = [makeGerm('a1'), makeGerm('b1')]; + const errorsReceivedByA: unknown[][] = []; + const errorsReceivedByB: unknown[][] = []; + + const liftA: Lift = async function* () { + const errors: unknown[] = yield a1; + errorsReceivedByA.push(errors); + }; + const liftB: Lift = async function* () { + const errors: unknown[] = yield b1; + errorsReceivedByB.push(errors); + }; + + await driveToExhaustion(fallthrough(liftA, liftB), []); + + // liftA's first yield received one error (a1 failed) + expect(errorsReceivedByA[0]).toHaveLength(1); + // liftB's first yield received two errors (a1 + b1 both failed) + expect(errorsReceivedByB[0]).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// Composition: withFilter + withRanking + fallthrough +// --------------------------------------------------------------------------- + +describe('composition', () => { + it('withFilter composed with withRanking applies both transforms', async () => { + const germs = [ + makeGerm('a', 3), + makeGerm('b', 1), + makeGerm('c', 2), + makeGerm('d', 4), // filtered out (cost > 3) + ]; + const received = vi.fn(); + + const base: Lift = async function* (allGerms) { + received(allGerms.map((item) => item.metadata.id)); + yield* allGerms; + }; + + const lift = withFilter((germ) => (germ.metadata.cost ?? 0) <= 3)( + withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(base), + ); + + await driveToExhaustion(lift, germs); + // filtered to a/b/c, sorted by cost ascending + expect(received).toHaveBeenCalledWith(['b', 'c', 'a']); + }); + + it('proxyLift wrapping fallthrough threads errors through both layers', async () => { + const [a1, b1] = [makeGerm('a1'), makeGerm('b1')]; + const inner: Lift = fallthrough( + async function* () { + yield a1; + }, + async function* () { + yield b1; + }, + ); + + // proxyLift wrapping the whole fallthrough + const lift: Lift = () => proxyLift(inner([], ctx)); + + const { yielded } = await driveToExhaustion(lift, []); + expect(yielded).toStrictEqual([a1, b1]); + }); +}); From 1f2963e3dcb2b01429a15562cf02b280de7727f1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:36:32 -0400 Subject: [PATCH 11/51] fix(kernel-utils): driveLift throws with accumulated errors as cause On exhaustion, throw a new Error with the full errors array as `cause` rather than re-throwing the last error. This preserves the complete failure history for diagnostics. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/drive.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/drive.ts b/packages/kernel-utils/src/sheaf/drive.ts index 5b4f199ebd..2bba341509 100644 --- a/packages/kernel-utils/src/sheaf/drive.ts +++ b/packages/kernel-utils/src/sheaf/drive.ts @@ -5,7 +5,8 @@ import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; * * Primes the generator with gen.next([]), then calls gen.next(errors) after * each failed attempt where errors is the full ordered history. Returns the - * first successful result, or rethrows the last error when exhausted. + * first successful result, or throws a new error with all accumulated errors + * as the cause when exhausted. * * @param lift - The lift coroutine to drive. * @param germs - The evaluated sections to pass to the lift. @@ -33,11 +34,7 @@ export const driveLift = async >( next = await gen.next(errors); } } - const lastError = errors.at(-1); - if (lastError instanceof Error) { - throw lastError; - } throw new Error(`No viable section for ${context.method}`, { - cause: lastError, + cause: errors, }); }; From 79299121506bfb37b356f1e98d591ec8ad465e1e Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:43:14 -0400 Subject: [PATCH 12/51] docs(kernel-utils): document sheaf module in README Add a Sheaf Module section covering sheafify, metadata kinds, lift authoring, composition helpers, and error handling on exhaustion. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/README.md | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/kernel-utils/README.md b/packages/kernel-utils/README.md index 7bc7e8cf29..53abcb1b0a 100644 --- a/packages/kernel-utils/README.md +++ b/packages/kernel-utils/README.md @@ -26,6 +26,110 @@ or npm install --save-dev patch-package ``` +## Sheaf Module + +The sheaf module provides a dispatch abstraction for routing method calls across multiple capability objects (_sections_) that each cover a region of a shared interface. + +### Overview + +``` +sheafify({ name, sections, compartment? }) → Sheaf +sheaf.getGlobalSection({ lift }) → section proxy +sheaf.getSection({ guard, lift }) → section proxy +``` + +Each call on the proxy is dispatched to whichever section covers that method. When multiple sections are eligible, a **lift** selects among them. A lift is an `AsyncGenerator` coroutine that yields candidates one at a time and receives the accumulated error history on each resume — enabling retry, fallback, and cost-aware routing without callers needing to know the selection strategy. + +### Defining sections + +```ts +import { sheafify, constant, callable } from '@metamask/kernel-utils'; + +const sheaf = sheafify({ + name: 'Wallet', + sections: [ + { + exo: walletA, + metadata: constant({ cost: 10, push: false }), + }, + { + exo: walletB, + // callable metadata is evaluated per-call with the actual arguments + metadata: callable((args) => ({ cost: 1 + 0.1 * (args[0] as number) })), + }, + { + exo: walletC, + // source metadata is compiled once at sheafify time via the compartment + metadata: source(`(args) => ({ cost: 5 + 0.01 * args[0] })`), + }, + ], + compartment, // required only when using source-kind metadata +}); +``` + +**Metadata kinds:** +| Kind | When evaluated | Use case | +|------|---------------|----------| +| `constant(v)` | Never (static) | Fixed priority or capability flags | +| `callable(fn)` | Each call | Arg-dependent cost, remaining spend | +| `source(str)` | Each call (compiled at construction) | Sandboxed cost functions | + +### Writing a lift + +A lift receives `EvaluatedSection>[]` (germs) and a context, and yields candidates in preference order. It receives a snapshot of all accumulated errors on each `gen.next(errors)` call. + +```ts +import type { Lift } from '@metamask/kernel-utils'; + +// Yield cheapest section first; fall back in cost order on failure +const cheapest: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); +}; + +const section = sheaf.getGlobalSection({ lift: cheapest }); +``` + +### Composing lifts + +```ts +import { + withFilter, + withRanking, + fallthrough, + proxyLift, +} from '@metamask/kernel-utils'; + +// Filter out sections with insufficient remaining spend +const spendable = withFilter( + (germ, { args }) => + (germ.metadata?.remainingSpend ?? Infinity) >= (args[0] as number), +); + +// Sort by cost before passing to the inner lift +const byCost = withRanking( + (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), +); + +// Try local sections first, fall through to remote on exhaustion +const withFallback = fallthrough(localLift, remoteLift); + +// Compose: filter → rank → select +const lift = spendable(byCost(cheapest)); +``` + +`withFilter` and `withRanking` are pure input transforms that return the inner lift's generator directly. `fallthrough` sequences two lifts via `yield*`, which forwards the error array to each inner lift. `proxyLift` is the primitive for adding logic (logging, circuit-breaking) between yields. + +### Error handling + +When all candidates are exhausted, `driveLift` throws: + +``` +Error: No viable section for + cause: [Error: ..., Error: ..., ...] // all accumulated attempt errors +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). From eccd3eb22752fce40ce60d95cea0f1b978f34a02 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:47:56 -0400 Subject: [PATCH 13/51] feat(kernel-utils): add makeRemoteSection for CapTP remote refs as sheaf sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `makeRemoteSection(name, remoteRef, metadata?)` which asynchronously fetches the interface guard from a CapTP remote ref via E()[GET_INTERFACE_GUARD]() and returns a PresheafSection with a local forwarding exo — eliminating the boilerplate of building per-method handlers by hand when wrapping remote caps. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/package.json | 1 + packages/kernel-utils/src/index.test.ts | 1 + packages/kernel-utils/src/index.ts | 1 + .../kernel-utils/src/sheaf/remote.test.ts | 111 ++++++++++++++++++ packages/kernel-utils/src/sheaf/remote.ts | 50 ++++++++ 5 files changed, 164 insertions(+) create mode 100644 packages/kernel-utils/src/sheaf/remote.test.ts create mode 100644 packages/kernel-utils/src/sheaf/remote.ts diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 9e3643ab73..f972620b0a 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -121,6 +121,7 @@ "@chainsafe/libp2p-yamux": "8.0.1", "@endo/captp": "^4.4.8", "@endo/errors": "^1.2.13", + "@endo/eventual-send": "^1.3.0", "@endo/exo": "^1.5.12", "@endo/patterns": "^1.7.0", "@endo/promise-kit": "^1.1.13", diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index aa7694ed12..5d7737a767 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -36,6 +36,7 @@ describe('index', () => { 'makeDefaultExo', 'makeDefaultInterface', 'makeDiscoverableExo', + 'makeRemoteSection', 'mergeDisjointRecords', 'methodArgsToStruct', 'prettifySmallcaps', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index df7a6b05fa..03ecc6a7c5 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -63,4 +63,5 @@ export { fallthrough, } from './sheaf/compose.ts'; export { collectSheafGuard } from './sheaf/guard.ts'; +export { makeRemoteSection } from './sheaf/remote.ts'; export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/remote.test.ts b/packages/kernel-utils/src/sheaf/remote.test.ts new file mode 100644 index 0000000000..55c731494d --- /dev/null +++ b/packages/kernel-utils/src/sheaf/remote.test.ts @@ -0,0 +1,111 @@ +import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import { describe, it, expect, vi } from 'vitest'; + +import { constant } from './metadata.ts'; +import { makeRemoteSection } from './remote.ts'; +import type { Section } from './types.ts'; + +// Mirrors the local-E pattern used throughout sheaf tests: the test +// environment has no HandledPromise, so we mock E as a transparent cast. +// With this mock, E(exo) === exo, so [GET_INTERFACE_GUARD] and method calls +// resolve locally against the exo — equivalent to a local CapTP loopback. +vi.mock('@endo/eventual-send', () => ({ + E: (ref: unknown) => ref, +})); + +const makeRemoteExo = (tag: string) => + makeExo( + tag, + M.interface( + tag, + { + greet: M.callWhen(M.string()).returns(M.string()), + add: M.callWhen(M.number(), M.number()).returns(M.number()), + }, + { defaultGuards: 'passable' }, + ), + { + greet: async (name: string) => `Hello, ${name}!`, + add: async (a: number, b: number) => a + b, + }, + ) as unknown as Section; + +describe('makeRemoteSection', () => { + it('fetches the interface guard from the remote ref', async () => { + const remoteExo = makeRemoteExo('Remote'); + const { exo } = await makeRemoteSection('Wrapper', remoteExo); + expect(exo[GET_INTERFACE_GUARD]?.()).toStrictEqual( + remoteExo[GET_INTERFACE_GUARD]?.(), + ); + }); + + it('forwards method calls to the remote ref', async () => { + const greet = vi.fn(async (name: string) => `Hello, ${name}!`); + const remoteExo = makeExo( + 'Remote', + M.interface( + 'Remote', + { greet: M.callWhen(M.string()).returns(M.string()) }, + { defaultGuards: 'passable' }, + ), + { greet }, + ) as unknown as Section; + + const { exo } = await makeRemoteSection('Wrapper', remoteExo); + const wrapper = exo as Record< + string, + (...a: unknown[]) => Promise + >; + const result = await wrapper.greet('Alice'); + + expect(greet).toHaveBeenCalledWith('Alice'); + expect(result).toBe('Hello, Alice!'); + }); + + it('forwards all methods declared in the guard', async () => { + const greet = vi.fn(async (_: string) => ''); + const add = vi.fn(async (a: number, b: number) => a + b); + const remoteExo = makeExo( + 'Remote', + M.interface( + 'Remote', + { + greet: M.callWhen(M.string()).returns(M.string()), + add: M.callWhen(M.number(), M.number()).returns(M.number()), + }, + { defaultGuards: 'passable' }, + ), + { greet, add }, + ) as unknown as Section; + + const { exo } = await makeRemoteSection('Wrapper', remoteExo); + const wrapper = exo as Record< + string, + (...a: unknown[]) => Promise + >; + await wrapper.greet('x'); + await wrapper.add(2, 3); + + expect(greet).toHaveBeenCalledTimes(1); + expect(add).toHaveBeenCalledWith(2, 3); + }); + + it('passes metadata through to the section', async () => { + const metadata = constant({ mode: 'remote' as const }); + const { metadata: actual } = await makeRemoteSection( + 'Wrapper', + makeRemoteExo('Remote'), + metadata, + ); + expect(actual).toBe(metadata); + }); + + it('metadata is undefined when not provided', async () => { + const { metadata } = await makeRemoteSection( + 'Wrapper', + makeRemoteExo('Remote'), + ); + expect(metadata).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/remote.ts b/packages/kernel-utils/src/sheaf/remote.ts new file mode 100644 index 0000000000..d905bd2996 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -0,0 +1,50 @@ +import { E } from '@endo/eventual-send'; +import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; +import { getInterfaceGuardPayload } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; + +import type { MetaDataSpec, PresheafSection } from './types.ts'; + +/** + * Wrap a remote (CapTP) reference as a PresheafSection. + * + * The sheaf requires synchronous [GET_INTERFACE_GUARD] access on every section, + * but remote references are opaque CapTP handles that cannot provide this + * synchronously. This function fetches the guard from the remote via E() once + * at construction time, then creates a local wrapper exo that carries it and + * forwards every method call back to the remote via E(). + * + * @param name - Name for the wrapper exo. + * @param remoteRef - The remote reference to forward calls to. + * @param metadata - Optional metadata spec for the presheaf section. + * @returns A PresheafSection whose exo forwards method calls to the remote. + */ +export const makeRemoteSection = async >( + name: string, + remoteRef: object, + metadata?: MetaDataSpec, +): Promise> => { + const eProxy = E(remoteRef); + + const interfaceGuard: InterfaceGuard = await ( + eProxy as unknown as { [GET_INTERFACE_GUARD](): Promise } + )[GET_INTERFACE_GUARD](); + + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { + methodGuards: Record; + }; + + const remote = eProxy as unknown as Record< + string, + (...args: unknown[]) => Promise + >; + const handlers: Record Promise> = {}; + for (const method of Object.keys(methodGuards)) { + handlers[method] = async (...args: unknown[]) => remote[method](...args); + } + + const exo = makeExo(name, interfaceGuard, handlers); + return { exo, metadata }; +}; From 2fcd8f434db5c32dcd39e97cd7d892c6180bad64 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:48:27 -0400 Subject: [PATCH 14/51] feat(kernel-utils): add getDiscoverableSection, deprecate global section methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds getDiscoverableSection and getDiscoverableGlobalSection to the Sheaf API so callers can attach a MethodSchema (for __getDescription__) to the caller-facing dispatch section rather than inside individual capability wrappers. Marks getGlobalSection and getDiscoverableGlobalSection as @deprecated — callers should supply an explicit InterfaceGuard via getSection/getDiscoverableSection instead of relying on the auto-computed union. Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/sheafify.test.ts | 58 +++++++++++++++++ packages/kernel-utils/src/sheaf/sheafify.ts | 64 ++++++++++++++----- packages/kernel-utils/src/sheaf/types.ts | 23 ++++++- 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 34decee2a7..2c98994a1e 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -2,6 +2,7 @@ import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; import { M, getInterfaceGuardPayload } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; +import { GET_DESCRIPTION } from '../discoverable.ts'; import { constant } from './metadata.ts'; import { sheafify } from './sheafify.ts'; import type { @@ -614,6 +615,63 @@ describe('sheafify', () => { expect(methodGuards).toHaveProperty('getBalance'); }); + it('getDiscoverableGlobalSection exposes __getDescription__', async () => { + const schema = { + getBalance: { + description: 'Get account balance.', + args: { acct: { type: 'string' as const, description: 'Account id.' } }, + returns: { type: 'number' as const, description: 'Balance.' }, + }, + }; + const sections: PresheafSection>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + }, + ]; + + const section = sheafify({ + name: 'Wallet', + sections, + }).getDiscoverableGlobalSection({ + async *lift(germs) { + yield germs[0]!; + }, + schema, + }); + + expect(E(section)[GET_DESCRIPTION]()).toStrictEqual(schema); + }); + + it('getSection does not expose __getDescription__', () => { + const sections: PresheafSection>[] = [ + { + exo: makeExo( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ) as unknown as Section, + }, + ]; + + const section = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + yield germs[0]!; + }, + }); + + expect( + (section as Record)[GET_DESCRIPTION], + ).toBeUndefined(); + }); + it('getExported excludes revoked sections', () => { const sections: PresheafSection<{ cost: number }>[] = [ { diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index be576a5147..ca74773081 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -20,6 +20,8 @@ import { } from '@endo/patterns'; import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; +import { makeDiscoverableExo } from '../discoverable.ts'; +import type { MethodSchema } from '../schema.ts'; import { stringify } from '../stringify.ts'; import { driveLift } from './drive.ts'; import { collectSheafGuard } from './guard.ts'; @@ -210,12 +212,14 @@ export const sheafify = < ); const grants: Grant[] = []; - const getSection = ({ + const buildSection = ({ guard, lift, + schema, }: { guard: InterfaceGuard; lift: Lift; + schema?: Record; }): object => { const resolvedGuard = guard; @@ -286,11 +290,14 @@ export const sheafify = < handlers[method] = async (...args: unknown[]) => dispatch(method, args); } - const exo = makeExo( - `${name}:section`, - asyncGuard, - handlers, - ) as unknown as Section; + const exo = (schema === undefined + ? makeExo(`${name}:section`, asyncGuard, handlers) + : makeDiscoverableExo( + `${name}:section`, + handlers, + schema, + asyncGuard, + )) as unknown as Section; grants.push({ exo, @@ -304,15 +311,40 @@ export const sheafify = < return exo; }; - const getGlobalSection = ({ lift }: { lift: Lift }): object => { - return getSection({ - guard: collectSheafGuard( - name, - frozenSections.map(({ exo }) => exo), - ), - lift, - }); - }; + const unionGuard = (): InterfaceGuard => + collectSheafGuard( + name, + frozenSections.map(({ exo }) => exo), + ); + + const getSection = ({ + guard, + lift, + }: { + guard: InterfaceGuard; + lift: Lift; + }): object => buildSection({ guard, lift }); + + const getDiscoverableSection = ({ + guard, + lift, + schema, + }: { + guard: InterfaceGuard; + lift: Lift; + schema: Record; + }): object => buildSection({ guard, lift, schema }); + + const getGlobalSection = ({ lift }: { lift: Lift }): object => + buildSection({ guard: unionGuard(), lift }); + + const getDiscoverableGlobalSection = ({ + lift, + schema, + }: { + lift: Lift; + schema: Record; + }): object => buildSection({ guard: unionGuard(), lift, schema }); const revokePoint = (method: string, ...args: unknown[]): void => { for (const grant of grants) { @@ -342,7 +374,9 @@ export const sheafify = < return { getSection, + getDiscoverableSection, getGlobalSection, + getDiscoverableGlobalSection, revokePoint, getExported, revokeAll, diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index 613d484d91..86e319f497 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -10,6 +10,8 @@ import type { GET_INTERFACE_GUARD, Methods } from '@endo/exo'; import type { InterfaceGuard } from '@endo/patterns'; +import type { MethodSchema } from '../schema.ts'; + /** A section: a capability covering a region of the interface topology. */ export type Section = Partial & { [K in typeof GET_INTERFACE_GUARD]?: (() => InterfaceGuard) | undefined; @@ -98,8 +100,27 @@ export type Presheaf> = export type Sheaf> = { /** Produce a revocable dispatch exo over the given guard. */ getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; - /** Produce a revocable dispatch exo over the full union guard of all presheaf sections. */ + /** Produce a revocable discoverable dispatch exo over the given guard. */ + getDiscoverableSection: (opts: { + guard: InterfaceGuard; + lift: Lift; + schema: Record; + }) => object; + /** + * Produce a revocable dispatch exo over the full union guard of all presheaf sections. + * + * @deprecated Provide an explicit guard via getSection instead. + */ getGlobalSection: (opts: { lift: Lift }) => object; + /** + * Produce a revocable discoverable dispatch exo over the full union guard of all presheaf sections. + * + * @deprecated Provide an explicit guard via getDiscoverableSection instead. + */ + getDiscoverableGlobalSection: (opts: { + lift: Lift; + schema: Record; + }) => object; /** Revoke every granted section whose guard covers the point (method, ...args). */ revokePoint: (method: string, ...args: unknown[]) => void; /** Union guard of all active (non-revoked) granted sections, or undefined. */ From 2c25f2fb5973cc310704c56b9e80e5fb8f2bc8c5 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:23:29 -0400 Subject: [PATCH 15/51] fix(kernel-utils): allow passable default guards for async section interfaces The async interface guard synthesized for a sheaf section must admit implicit exo methods like __getDescription__ that @endo/exo adds to every discoverable exo. Without passable default guards, those methods are rejected at dispatch time, preventing sheafs from being sent across a CapTP connection. Co-Authored-By: Claude Opus 4.7 --- packages/kernel-utils/src/sheaf/sheafify.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index ca74773081..29f924dc07 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -224,7 +224,12 @@ export const sheafify = < const resolvedGuard = guard; const asyncMethodGuards = asyncifyMethodGuards(resolvedGuard); - const asyncGuard = M.interface(`${name}:section`, asyncMethodGuards); + const asyncGuard = + schema === undefined + ? M.interface(`${name}:section`, asyncMethodGuards) + : M.interface(`${name}:section`, asyncMethodGuards, { + defaultGuards: 'passable', + }); let revoked = false; From 17ea8ec6bfdc924d679068d0203d2a91bd26024b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:23:58 -0400 Subject: [PATCH 16/51] fix(kernel-utils): bind method calls in makeRemoteSection via E proxy Detaching a method via destructuring or assignment strips the CapTP receiver binding and the remote rejects the call as an "Unexpected receiver". Invoke each method through a fresh E(remote)[method] access so the receiver is preserved on every dispatch. Co-Authored-By: Claude Opus 4.7 --- packages/kernel-utils/package.json | 2 +- packages/kernel-utils/src/sheaf/remote.ts | 20 ++++++++++++-------- yarn.lock | 1 + 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index f972620b0a..a7ea252378 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -121,7 +121,7 @@ "@chainsafe/libp2p-yamux": "8.0.1", "@endo/captp": "^4.4.8", "@endo/errors": "^1.2.13", - "@endo/eventual-send": "^1.3.0", + "@endo/eventual-send": "^1.3.4", "@endo/exo": "^1.5.12", "@endo/patterns": "^1.7.0", "@endo/promise-kit": "^1.1.13", diff --git a/packages/kernel-utils/src/sheaf/remote.ts b/packages/kernel-utils/src/sheaf/remote.ts index d905bd2996..e06592db2f 100644 --- a/packages/kernel-utils/src/sheaf/remote.ts +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -3,7 +3,8 @@ import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; import { getInterfaceGuardPayload } from '@endo/patterns'; import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; -import type { MetaDataSpec, PresheafSection } from './types.ts'; +import { ifDefined } from '../misc.ts'; +import type { MetaDataSpec, PresheafSection, Section } from './types.ts'; /** * Wrap a remote (CapTP) reference as a PresheafSection. @@ -24,10 +25,10 @@ export const makeRemoteSection = async >( remoteRef: object, metadata?: MetaDataSpec, ): Promise> => { - const eProxy = E(remoteRef); - const interfaceGuard: InterfaceGuard = await ( - eProxy as unknown as { [GET_INTERFACE_GUARD](): Promise } + E(remoteRef) as unknown as { + [GET_INTERFACE_GUARD](): Promise; + } )[GET_INTERFACE_GUARD](); const { methodGuards } = getInterfaceGuardPayload( @@ -36,15 +37,18 @@ export const makeRemoteSection = async >( methodGuards: Record; }; - const remote = eProxy as unknown as Record< + const remote = remoteRef as unknown as Record< string, (...args: unknown[]) => Promise >; const handlers: Record Promise> = {}; for (const method of Object.keys(methodGuards)) { - handlers[method] = async (...args: unknown[]) => remote[method](...args); + handlers[method] = async (...args: unknown[]) => + (E(remote) as Record Promise>)[ + method + ](...args); } - const exo = makeExo(name, interfaceGuard, handlers); - return { exo, metadata }; + const exo = makeExo(name, interfaceGuard, handlers) as unknown as Section; + return ifDefined({ exo, metadata }) as PresheafSection; }; diff --git a/yarn.lock b/yarn.lock index 6262616060..a39e6ecaaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2639,6 +2639,7 @@ __metadata: "@chainsafe/libp2p-yamux": "npm:8.0.1" "@endo/captp": "npm:^4.4.8" "@endo/errors": "npm:^1.2.13" + "@endo/eventual-send": "npm:^1.3.4" "@endo/exo": "npm:^1.5.12" "@endo/patterns": "npm:^1.7.0" "@endo/promise-kit": "npm:^1.1.13" From cadfa281b620ba5f514287d7919b332b0acf2464 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:31:11 -0400 Subject: [PATCH 17/51] fix(kernel-utils): suppress noUncheckedIndexedAccess false positive in makeRemoteSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E() proxy index lookup returns T|undefined under noUncheckedIndexedAccess, but method is always present — it comes from Object.keys(methodGuards). Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/remote.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/sheaf/remote.ts b/packages/kernel-utils/src/sheaf/remote.ts index e06592db2f..592dc70526 100644 --- a/packages/kernel-utils/src/sheaf/remote.ts +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -44,9 +44,11 @@ export const makeRemoteSection = async >( const handlers: Record Promise> = {}; for (const method of Object.keys(methodGuards)) { handlers[method] = async (...args: unknown[]) => + // method is always present: it comes from Object.keys(methodGuards) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion (E(remote) as Record Promise>)[ method - ](...args); + ]!(...args); } const exo = makeExo(name, interfaceGuard, handlers) as unknown as Section; From 03e8aceb1ed36b0b90d36084bb95c92803bc6984 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:51:29 -0400 Subject: [PATCH 18/51] docs: Update changelogs --- packages/kernel-utils/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 063ee0e4cb..2f8b9a252a 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add sheaf programming module ([#870](https://github.com/MetaMask/ocap-kernel/pull/870)) + - `sheafify()` for building a `Sheaf` capability authority from a collection of `PresheafSection`s, each an exo with optional invocation-dependent metadata + - `constant()`, `source()`, `callable()` for constructing metadata specs (static value, compartment-evaluated code string, and per-call function respectively) + - `proxyLift()`, `withFilter()`, `withRanking()`, `fallthrough()` for composing lifts to route and rank sections at dispatch time + - `collectSheafGuard()` for deriving a combined `InterfaceGuard` from all sections in a sheaf + - `getStalk()`, `guardCoversPoint()` for section lookup and guard checks + - `makeRemoteSection()` for wrapping a remote CapTP reference as a `PresheafSection`, fetching its interface guard once at construction and forwarding method calls via `E()` + - Types: `Sheaf`, `Section`, `PresheafSection`, `EvaluatedSection`, `MetaDataSpec`, `Lift`, `LiftContext`, `Presheaf` + ## [0.5.0] ### Added From 3f237865c39c632e6b505afefc2c6a2f275c7e85 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:50:34 -0400 Subject: [PATCH 19/51] docs(kernel-utils): Improve sheaf documentation --- packages/kernel-utils/src/sheaf/LIFT.md | 139 +++++++++++++++++ packages/kernel-utils/src/sheaf/README.md | 30 +++- packages/kernel-utils/src/sheaf/USAGE.md | 177 ++++++++++++++++++++++ 3 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 packages/kernel-utils/src/sheaf/LIFT.md create mode 100644 packages/kernel-utils/src/sheaf/USAGE.md diff --git a/packages/kernel-utils/src/sheaf/LIFT.md b/packages/kernel-utils/src/sheaf/LIFT.md new file mode 100644 index 0000000000..95445c69fe --- /dev/null +++ b/packages/kernel-utils/src/sheaf/LIFT.md @@ -0,0 +1,139 @@ +# Lift + +The lift is the caller-supplied selection policy in the sheaf dispatch +pipeline. It runs when the stalk at an invocation point contains more than one +germ and the sheaf has no data to resolve the ambiguity on its own. The caller +is responsible for writing a lift that is correct for the sections it will +receive. + +## Coroutine protocol + +The lift is an `async function*` generator, not a plain async function: + +```ts +type Lift = ( + germs: EvaluatedSection>[], + context: LiftContext, +) => AsyncGenerator>, void, unknown[]>; +``` + +The sheaf drives it with the following protocol: + +1. **Prime** — `gen.next([])` starts the coroutine. The empty array is + discarded; it exists only to satisfy the generator type. +2. **Yield** — the coroutine yields a candidate germ to try next. +3. **Attempt** — the sheaf calls the candidate's exo method. +4. **Success** — the result is returned; the generator is abandoned. +5. **Failure** — the sheaf calls `gen.next(errors)`, passing the ordered list + of every error thrown so far (cumulative, not just the last). The coroutine + receives this as the resolved value of its `yield` expression. +6. **Exhausted** — if the generator returns without yielding, the sheaf + rethrows the last error. + +Most lifts express a fixed priority order and can ignore the error input: + +```ts +const awayLift: Lift = async function* (germs) { + yield* germs.filter((g) => g.metadata?.mode === 'delegation'); + yield* germs.filter((g) => g.metadata?.mode === 'call-home'); +}; +``` + +A lift that inspects failure history can read the errors from yield: + +```ts +const cautious: Lift = async function* (germs) { + for (const germ of germs) { + const errors: unknown[] = yield germ; + // errors is the cumulative list of all failures so far, including the one + // just returned for this germ. Inspect to decide whether to continue. + if (errors.some(isUnrecoverable)) return; + } +}; +``` + +## LiftContext + +The second argument to the lift is a `LiftContext`: + +```ts +type LiftContext = { + method: string; // the method being dispatched + args: unknown[]; // the invocation arguments + constraints: Partial; // metadata keys identical across every germ +}; +``` + +**`constraints`** are metadata keys whose values are the same on every germ in +the stalk. Because all candidates agree on these keys, they carry no +information useful for choosing between them — the sheaf strips them from each +germ and delivers them separately. A lift that needs to know, say, the agreed +`protocol` version reads it from `context.constraints.protocol` rather than +from any individual germ. + +**`args`** is available for cases where the lift itself must inspect the call. +Most of the time, however, arg-dependent selection is better expressed as +`callable` metadata on the sections than as conditional logic in the lift. + +Consider a swap where each provider has a different cost curve over volume. +Encode each provider's cost as `callable` metadata evaluated at dispatch time: + +```ts +const sections: PresheafSection[] = [ + { + exo: providerAExo, + metadata: callable((args) => ({ cost: providerACost(Number(args[0])) })), + }, + { + exo: providerBExo, + metadata: callable((args) => ({ cost: providerBCost(Number(args[0])) })), + }, +]; +``` + +By the time the lift runs, `germ.metadata.cost` already holds the concrete +cost for this specific invocation — the swap amount has been applied. A lift +that sorts by cost needs no knowledge of `args` at all: + +```ts +const cheapestFirst: Lift = async function* (germs) { + yield* [...germs].sort( + (a, b) => (a.metadata?.cost ?? 0) - (b.metadata?.cost ?? 0), + ); +}; +``` + +This is why evaluable metadata exists: the arg-dependent logic lives with the +sections that own it, and the lift stays a pure selection policy. + +## Semantic equivalence assumption + +Two sections may differ in real ways — one might use TCP and the other UDP; one +might be a Rust implementation and the other JavaScript. The semantic +equivalence contract does not require that two sections be identical. It +requires only that **if two sections are indistinguishable by metadata, their +differences are immaterial to the authority invoker**. + +The sheaf relies on the following separation of responsibilities: + +- **Section constructors** are responsible for advertising every feature that + matters to callers. If transport protocol, latency tier, cost curve, or + freshness guarantee could affect the invoker's decision, it belongs in the + section's metadata. Omitting a distinguishing feature is a declaration that + callers need not care about it. + +- **Lift constructors** are responsible for selecting among the features that + section constructors have chosen to expose. The lift cannot see what was not + advertised. + +This is a semantic contract, not a runtime enforcement — the sheaf cannot +verify it. When a section constructor omits a feature from metadata, they are +asserting: for any authority invoker using this sheaf, that feature is +irrelevant. If the assertion is wrong, the collapse step may silently discard a +candidate that the lift would have ranked differently. + +> One `getBalance` provider uses a fully-synced node; another uses a lagging +> replica. If both are tagged `{ cost: 1 }` with no freshness field, the +> section constructors are asserting that freshness is immaterial to callers of +> this sheaf. If that is not true, `{ cost: 1, freshness: 'lagging' }` vs +> `{ cost: 1, freshness: 'live' }` would let the lift choose. diff --git a/packages/kernel-utils/src/sheaf/README.md b/packages/kernel-utils/src/sheaf/README.md index 19b5f7d2a0..c988136ac4 100644 --- a/packages/kernel-utils/src/sheaf/README.md +++ b/packages/kernel-utils/src/sheaf/README.md @@ -7,6 +7,9 @@ over a presheaf of capabilities. The sheaf grants revocable dispatch sections via `getSection`, tracks all delegated authority, and supports point-wise revocation. +See [USAGE.md](./USAGE.md) for annotated examples and [LIFT.md](./LIFT.md) for +the lift coroutine protocol and semantic equivalence assumption. + ## Concepts **Presheaf section** (`PresheafSection`) — The input data: a capability (exo) @@ -33,7 +36,11 @@ entries. > Stalk at `("getBalance", "alice")` might contain two germs (cost 1 vs 100); > stalk at `("transfer", ...)` might contain one. -**Lift** — An async function that selects one germ from a multi-germ stalk. +**Lift** — An `async function*` coroutine that yields candidates from a +multi-germ stalk in preference order. See [LIFT.md](./LIFT.md) for the +coroutine protocol, `LiftContext`, and the semantic equivalence assumption +required of all lifts. + At dispatch time, metadata is decomposed into **constraints** (keys with the same value across every germ — topologically determined, not a choice) and **options** (the remaining keys — the lift's actual decision space). The lift @@ -41,7 +48,9 @@ receives only options on each germ; constraints arrive separately in the context. > `argmin` by cost, `argmin` by latency, or any custom selection logic. The -> lift is never invoked for single-germ stalks. +> lift is never invoked when the stalk resolves to a single germ — either +> because only one section matched, or because all matching sections had +> identical metadata and collapsed to one representative. **Sheaf** — The authority manager returned by `sheafify`. Holds the presheaf 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. const sheaf = sheafify({ name: 'Wallet', sections }); ``` -- `sheaf.getSection({ guard?, lift })` — produce a revocable dispatch exo +- `sheaf.getSection({ guard, lift })` — produce a revocable dispatch exo +- `sheaf.getDiscoverableSection({ guard, lift, schema })` — same, but the exo exposes its guard - `sheaf.revokePoint(method, ...args)` — revoke every granted section whose guard covers the point - `sheaf.getExported()` — union guard of all active (non-revoked) sections @@ -62,13 +72,25 @@ At each invocation point `(method, args)` within a granted section: ``` getStalk(sections, method, args) presheaf → stalk (filter by guard) +evaluateMetadata(stalk, args) metadata specs → concrete values collapseEquivalent(stalk) locality condition (quotient by metadata) decomposeMetadata(collapsed) restriction map (constraints / options) lift(stripped, { method, args, operational selection (extra-theoretic) constraints }) -dispatch to collapsed[index].exo evaluation +dispatch to chosen.exo evaluation ``` +The pipeline short-circuits at two points: if only one section matches the +guard, it is invoked directly without evaluate/collapse/lift; if all matching +sections collapse to an identical germ, the single representative is invoked +without calling the lift. + +`callable` and `source` metadata specs make the stalk shape depend on the +invocation arguments. A `swap(amount)` section can produce `{ cost: 'low' }` +for small amounts and `{ cost: 'high' }` for large ones, yielding a different +set of germs — and potentially a different lift outcome — for the same method +called with different arguments. + ## Design choices **Germ identity is metadata identity.** The collapse step quotients by diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md new file mode 100644 index 0000000000..ac71282e6d --- /dev/null +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -0,0 +1,177 @@ +# Usage + +## Single provider + +When there is only one section per invocation point, no lift is needed — the +dispatch short-circuits before the lift is ever called. Provide a no-op lift +as a placeholder: + +```ts +import { M } from '@endo/patterns'; +import { makeDefaultExo } from ''; +import { sheafify } from '@metamask/kernel-utils'; + +const noop = async function* (germs) { + yield* germs; +}; + +const priceGuard = M.interface('PriceService', { + getPrice: M.callWhen(M.await(M.string())).returns(M.await(M.number())), +}); + +const priceExo = makeDefaultExo('PriceService', priceGuard, { + async getPrice(token) { + return fetchPrice(token); + }, +}); + +const sheaf = sheafify({ + name: 'PriceService', + sections: [{ exo: priceExo }], +}); + +const section = sheaf.getSection({ guard: priceGuard, lift: noop }); +// section is a revocable dispatch exo; call it like any capability +const price = await E(section).getPrice('ETH'); +``` + +## Multiple providers with a lift + +When the stalk at a given invocation point contains more than one germ, the +sheaf calls the lift to choose. The lift is an `async function*` coroutine that +yields candidates in preference order; it receives accumulated errors as the +argument to each subsequent `.next()` so it can adapt its ranking. + +The idiomatic pattern is a generator that `yield*`s candidates filtered by +metadata, expressing priority tiers in source order: + +```ts +import { sheafify, constant } from '@metamask/kernel-utils'; +import type { Lift } from '@metamask/kernel-utils'; + +type WalletMeta = { mode: 'fast' | 'reliable' }; + +const preferFast: Lift = async function* (germs) { + yield* germs.filter((g) => g.metadata?.mode === 'fast'); + yield* germs.filter((g) => g.metadata?.mode === 'reliable'); +}; + +const sheaf = sheafify({ + name: 'Wallet', + sections: [ + { exo: fastExo, metadata: constant({ mode: 'fast' }) }, + { exo: reliableExo, metadata: constant({ mode: 'reliable' }) }, + ], +}); + +// guard restricts which methods callers may invoke +const section = sheaf.getSection({ guard: clientGuard, lift: preferFast }); +``` + +The sheaf drives the generator: it primes it with `gen.next([])`, calls the +chosen candidate, then passes any thrown errors back as `gen.next(errors)` so +the lift can adapt before yielding the next candidate. + +Use the `constant`, `source`, or `callable` helpers to build metadata specs: + +```ts +import { constant, source, callable } from '@metamask/kernel-utils'; + +// static value known at construction time +constant({ mode: 'fast' }); + +// JS source string compiled once in the sheaf's compartment at construction time +source(`(args) => ({ cost: args[0] > 9000 ? 'high' : 'low' })`); + +// live function evaluated at each dispatch — useful when cost varies by argument, +// e.g. a swap whose metadata encodes volume-based cost tiers +callable((args) => ({ cost: Number(args[0]) > 9000 ? 'high' : 'low' })); +``` + +## Discoverable sections + +`getDiscoverableSection` works like `getSection` but the returned exo exposes +its guard — it can be introspected by the caller to discover what methods and +argument shapes it accepts. Use this when the recipient needs to advertise +capability to a third party. It requires a `schema` map describing each method: + +```ts +import type { MethodSchema } from '@metamask/kernel-utils'; + +const schema: Record = { + getPrice: { description: 'Get the current price of a token.' }, +}; + +const section = sheaf.getDiscoverableSection({ + guard: clientGuard, + lift, + schema, +}); +``` + +`getSection` is the non-discoverable variant (no `schema` required). + +`getGlobalSection` and `getDiscoverableGlobalSection` derive the guard +automatically from the union of all presheaf sections. They are `@deprecated` +as a nudge toward explicit guards once the caller knows the section set — +explicit guards make the capability's scope visible at the call site. When +sections are assembled dynamically (e.g., rebuilt at runtime from a set of +grants that changes) and the union guard isn't known until after `sheafify` +runs, the global variants are the right choice. + +## Revocation + +```ts +// revoke every granted section whose guard covers this invocation point +sheaf.revokePoint('getPrice', 'ETH'); + +// revoke all granted sections at once +sheaf.revokeAll(); + +// union guard of all currently active (non-revoked) sections +const exported = sheaf.getExported(); +``` + +## Remote sections + +`makeRemoteSection` wraps a CapTP remote reference as a `PresheafSection`, +fetching the remote's guard once at construction and forwarding all calls via +`E()`. This lets you mix local exos and remote capabilities in the same sheaf: + +```ts +import { makeRemoteSection, constant } from '@metamask/kernel-utils'; + +const remoteSection = await makeRemoteSection( + 'RemoteWallet', // name for the wrapper exo + remoteCapRef, // CapTP reference + constant({ mode: 'remote' }), // optional metadata +); + +const sheaf = sheafify({ + name: 'Mixed', + sections: [localSection, remoteSection], +}); +``` + +## Lift composition + +`@metamask/kernel-utils` exports helpers for building lifts from composable +parts, useful when lift logic would otherwise be duplicated across callers: + +```ts +import { + proxyLift, + withFilter, + withRanking, + fallthrough, +} from '@metamask/kernel-utils'; +``` + +- **`withRanking(comparator, inner)`** — sort germs by comparator before + passing to `inner` +- **`withFilter(predicate, inner)`** — remove germs that fail `predicate` + before passing to `inner` +- **`fallthrough(liftA, liftB)`** — try all candidates from `liftA` first; + if all fail, try `liftB` +- **`proxyLift(inner)`** — forward yielded candidates up and error arrays down; + useful when wrapping a lift in middleware From 996610ada077f62a256b12658473a9b0252b7aef Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:54:29 -0400 Subject: [PATCH 20/51] refactor(kernel-utils): remove unused sheaf revocation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revokePoint, revokeAll, and getExported are unused in application code — the evm-wallet prototype rebuilds the sheaf wholesale when the grant set changes rather than revoking individual sections. Remove the implementation, the Grant type, the revoked flag in buildSection, and all associated tests. Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/sheafify.test.ts | 126 ------------------ packages/kernel-utils/src/sheaf/sheafify.ts | 57 +------- packages/kernel-utils/src/sheaf/types.ts | 6 - 3 files changed, 2 insertions(+), 187 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 2c98994a1e..a44e5502c9 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -516,105 +516,6 @@ describe('sheafify', () => { expect(await E(wallet).getBalance('alice')).toBe(42); }); - // --------------------------------------------------------------------------- - // Revocation - // --------------------------------------------------------------------------- - - it('revokePoint revokes sections covering the point', async () => { - const sections: PresheafSection<{ cost: number }>[] = [ - { - exo: makeExo( - 'Wallet:0', - M.interface('Wallet:0', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, - metadata: constant({ cost: 1 }), - }, - ]; - - const sheaf = sheafify({ name: 'Wallet', sections }); - const wallet = sheaf.getGlobalSection({ - async *lift(germs) { - yield germs[0]!; - }, - }); - - expect(await E(wallet).getBalance('alice')).toBe(42); - - sheaf.revokePoint('getBalance', 'alice'); - - // Entire section is revoked, not just the specific point - await expect(E(wallet).getBalance('alice')).rejects.toThrow( - 'Section revoked', - ); - await expect(E(wallet).getBalance('bob')).rejects.toThrow( - 'Section revoked', - ); - }); - - it('revokeAll revokes all sections', async () => { - const sections: PresheafSection<{ cost: number }>[] = [ - { - exo: makeExo( - 'Wallet:0', - M.interface('Wallet:0', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, - metadata: constant({ cost: 1 }), - }, - ]; - - const sheaf = sheafify({ name: 'Wallet', sections }); - const wallet = sheaf.getGlobalSection({ - async *lift(germs) { - yield germs[0]!; - }, - }); - - expect(await E(wallet).getBalance('alice')).toBe(42); - - sheaf.revokeAll(); - - await expect(E(wallet).getBalance('alice')).rejects.toThrow( - 'Section revoked', - ); - }); - - it('getExported returns union of active section guards', () => { - const sections: PresheafSection<{ cost: number }>[] = [ - { - exo: makeExo( - 'Wallet:0', - M.interface('Wallet:0', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, - metadata: constant({ cost: 1 }), - }, - ]; - - const sheaf = sheafify({ name: 'Wallet', sections }); - - // No sections granted yet - expect(sheaf.getExported()).toBeUndefined(); - - sheaf.getGlobalSection({ - async *lift(germs) { - yield germs[0]!; - }, - }); - - const exported = sheaf.getExported(); - expect(exported).toBeDefined(); - const { methodGuards } = getInterfaceGuardPayload(exported!); - expect(methodGuards).toHaveProperty('getBalance'); - }); - it('getDiscoverableGlobalSection exposes __getDescription__', async () => { const schema = { getBalance: { @@ -671,31 +572,4 @@ describe('sheafify', () => { (section as Record)[GET_DESCRIPTION], ).toBeUndefined(); }); - - it('getExported excludes revoked sections', () => { - const sections: PresheafSection<{ cost: number }>[] = [ - { - exo: makeExo( - 'Wallet:0', - M.interface('Wallet:0', { - getBalance: M.call(M.string()).returns(M.number()), - }), - { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, - metadata: constant({ cost: 1 }), - }, - ]; - - const sheaf = sheafify({ name: 'Wallet', sections }); - sheaf.getGlobalSection({ - async *lift(germs) { - yield germs[0]!; - }, - }); - - expect(sheaf.getExported()).toBeDefined(); - - sheaf.revokeAll(); - expect(sheaf.getExported()).toBeUndefined(); - }); }); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 29f924dc07..905f826486 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -2,7 +2,7 @@ * Sheafify a presheaf into an authority manager. * * `sheafify({ name, sections })` returns a `Sheaf` — an immutable object - * that tracks granted authority and produces revocable dispatch sections. + * that produces dispatch sections over a fixed presheaf. * * Each dispatch through a granted section: * 1. Computes the stalk (getStalk — presheaf sections matching the point) @@ -28,7 +28,7 @@ import { collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; import type { ResolvedMetaDataSpec } from './metadata.ts'; -import { getStalk, guardCoversPoint } from './stalk.ts'; +import { getStalk } from './stalk.ts'; import type { EvaluatedSection, Lift, @@ -178,13 +178,6 @@ const invokeExo = (exo: Section, method: string, args: unknown[]): unknown => { return fn.call(obj, ...args); }; -type Grant = { - exo: Section; - guard: InterfaceGuard; - revoke: () => void; - isRevoked: () => boolean; -}; - type ResolvedSection> = { exo: Section; spec: ResolvedMetaDataSpec | undefined; @@ -210,8 +203,6 @@ export const sheafify = < : resolveMetaDataSpec(section.metadata, compartment), })), ); - const grants: Grant[] = []; - const buildSection = ({ guard, lift, @@ -231,16 +222,10 @@ export const sheafify = < defaultGuards: 'passable', }); - let revoked = false; - const dispatch = async ( method: string, args: unknown[], ): Promise => { - if (revoked) { - throw new Error(`Section revoked: ${name}`); - } - const stalk = getStalk(frozenSections, method, args); const evaluatedStalk: EvaluatedSection[] = stalk.map( (section) => ({ @@ -304,15 +289,6 @@ export const sheafify = < asyncGuard, )) as unknown as Section; - grants.push({ - exo, - guard: resolvedGuard, - revoke: () => { - revoked = true; - }, - isRevoked: () => revoked, - }); - return exo; }; @@ -351,39 +327,10 @@ export const sheafify = < schema: Record; }): object => buildSection({ guard: unionGuard(), lift, schema }); - const revokePoint = (method: string, ...args: unknown[]): void => { - for (const grant of grants) { - if (!grant.isRevoked() && guardCoversPoint(grant.guard, method, args)) { - grant.revoke(); - } - } - }; - - const getExported = (): InterfaceGuard | undefined => { - const activeExos = grants - .filter((grant) => !grant.isRevoked()) - .map((grant) => grant.exo); - if (activeExos.length === 0) { - return undefined; - } - return collectSheafGuard(`${name}:exported`, activeExos); - }; - - const revokeAll = (): void => { - for (const grant of grants) { - if (!grant.isRevoked()) { - grant.revoke(); - } - } - }; - return { getSection, getDiscoverableSection, getGlobalSection, getDiscoverableGlobalSection, - revokePoint, - getExported, - revokeAll, }; }; diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index 86e319f497..d63985fd25 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -121,10 +121,4 @@ export type Sheaf> = { lift: Lift; schema: Record; }) => object; - /** Revoke every granted section whose guard covers the point (method, ...args). */ - revokePoint: (method: string, ...args: unknown[]) => void; - /** Union guard of all active (non-revoked) granted sections, or undefined. */ - getExported: () => InterfaceGuard | undefined; - /** Revoke all granted sections. */ - revokeAll: () => void; }; From 3fa355f0be471ce7b3196221a75bfdf3166a28ce Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:19:15 -0400 Subject: [PATCH 21/51] docs(kernel-utils): fix sheaf doc errors from revocation removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale revokePoint/revokeAll/getExported references from README.md and USAGE.md (the revocation API was removed in the preceding commit but the docs were not updated) - Fix withRanking/withFilter signatures in USAGE.md: the combinators are curried — withRanking(comparator)(inner), not withRanking(comparator, inner) - Correct the fallthrough doc comment: liftB does receive accumulated errors from liftA's attempts via yield* after its own failures (the test at compose.test.ts:337 confirms this); liftB is only unaware of them at its prime call - Document the germ identity invariant in LIFT.md: the lift must yield elements from its input germs array, not reconstructed objects, because the sheaf resolves the dispatch target by object identity Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/LIFT.md | 5 ++++- packages/kernel-utils/src/sheaf/README.md | 11 +++-------- packages/kernel-utils/src/sheaf/USAGE.md | 19 +++---------------- packages/kernel-utils/src/sheaf/compose.ts | 5 +++-- 4 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/LIFT.md b/packages/kernel-utils/src/sheaf/LIFT.md index 95445c69fe..1c44ba14d0 100644 --- a/packages/kernel-utils/src/sheaf/LIFT.md +++ b/packages/kernel-utils/src/sheaf/LIFT.md @@ -21,7 +21,10 @@ The sheaf drives it with the following protocol: 1. **Prime** — `gen.next([])` starts the coroutine. The empty array is discarded; it exists only to satisfy the generator type. -2. **Yield** — the coroutine yields a candidate germ to try next. +2. **Yield** — the coroutine yields a candidate germ to try next. The yielded + value must be an element of the `germs` array received on entry — the sheaf + uses object identity to map it back to the original section, so constructing + a new object with the same shape will produce "lift yielded an unknown germ". 3. **Attempt** — the sheaf calls the candidate's exo method. 4. **Success** — the result is returned; the generator is abandoned. 5. **Failure** — the sheaf calls `gen.next(errors)`, passing the ordered list diff --git a/packages/kernel-utils/src/sheaf/README.md b/packages/kernel-utils/src/sheaf/README.md index c988136ac4..cbc51d3c76 100644 --- a/packages/kernel-utils/src/sheaf/README.md +++ b/packages/kernel-utils/src/sheaf/README.md @@ -3,9 +3,8 @@ Runtime capability routing adapted from sheaf theory in algebraic topology. `sheafify({ name, sections })` produces a **sheaf** — an authority manager -over a presheaf of capabilities. The sheaf grants revocable dispatch sections -via `getSection`, tracks all delegated authority, and supports point-wise -revocation. +over a presheaf of capabilities. The sheaf grants dispatch sections via +`getSection` and tracks all delegated authority. See [USAGE.md](./USAGE.md) for annotated examples and [LIFT.md](./LIFT.md) for the lift coroutine protocol and semantic equivalence assumption. @@ -59,12 +58,8 @@ data (captured at construction time) and a registry of all granted sections. const sheaf = sheafify({ name: 'Wallet', sections }); ``` -- `sheaf.getSection({ guard, lift })` — produce a revocable dispatch exo +- `sheaf.getSection({ guard, lift })` — produce a dispatch exo - `sheaf.getDiscoverableSection({ guard, lift, schema })` — same, but the exo exposes its guard -- `sheaf.revokePoint(method, ...args)` — revoke every granted section whose - guard covers the point -- `sheaf.getExported()` — union guard of all active (non-revoked) sections -- `sheaf.revokeAll()` — revoke every granted section ## Dispatch pipeline diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md index ac71282e6d..bf645db02d 100644 --- a/packages/kernel-utils/src/sheaf/USAGE.md +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -31,7 +31,7 @@ const sheaf = sheafify({ }); const section = sheaf.getSection({ guard: priceGuard, lift: noop }); -// section is a revocable dispatch exo; call it like any capability +// section is a dispatch exo; call it like any capability const price = await E(section).getPrice('ETH'); ``` @@ -119,19 +119,6 @@ sections are assembled dynamically (e.g., rebuilt at runtime from a set of grants that changes) and the union guard isn't known until after `sheafify` runs, the global variants are the right choice. -## Revocation - -```ts -// revoke every granted section whose guard covers this invocation point -sheaf.revokePoint('getPrice', 'ETH'); - -// revoke all granted sections at once -sheaf.revokeAll(); - -// union guard of all currently active (non-revoked) sections -const exported = sheaf.getExported(); -``` - ## Remote sections `makeRemoteSection` wraps a CapTP remote reference as a `PresheafSection`, @@ -167,9 +154,9 @@ import { } from '@metamask/kernel-utils'; ``` -- **`withRanking(comparator, inner)`** — sort germs by comparator before +- **`withRanking(comparator)(inner)`** — sort germs by comparator before passing to `inner` -- **`withFilter(predicate, inner)`** — remove germs that fail `predicate` +- **`withFilter(predicate)(inner)`** — remove germs that fail `predicate` before passing to `inner` - **`fallthrough(liftA, liftB)`** — try all candidates from `liftA` first; if all fail, try `liftB` diff --git a/packages/kernel-utils/src/sheaf/compose.ts b/packages/kernel-utils/src/sheaf/compose.ts index 66e323bdb6..26dc375d5f 100644 --- a/packages/kernel-utils/src/sheaf/compose.ts +++ b/packages/kernel-utils/src/sheaf/compose.ts @@ -89,8 +89,9 @@ export const withRanking = * `.next(value)` to the inner iterator, so error arrays are correctly * threaded through each inner lift. * - * liftB starts fresh and only sees errors from its own failed attempts, - * not from liftA's attempts. + * liftB is not informed of liftA's failures at its prime call, but via + * `yield*` it receives all accumulated errors (including liftA's) as the + * argument to each subsequent `next(errors)` after its own failed attempts. * * @param liftA - First lift; its candidates are tried before liftB's. * @param liftB - Fallback lift; only invoked after liftA is exhausted. From 73ae26636847dc7a47819363d1b48de55f48089c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:26:56 -0400 Subject: [PATCH 22/51] refactor(kernel-utils): extract buildMethodGuard to eliminate duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asyncifyMethodGuards (sheafify.ts) and collectSheafGuard (guard.ts) both contained an identical 4-way if/else for assembling a MethodGuard from its components. The chain order required by @endo/patterns (callWhen → optional → rest → returns) makes each branch non-obvious — a bad candidate for duplication. Extract buildMethodGuard into guard.ts and use it in both sites. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/guard.ts | 55 ++++++++++++++------- packages/kernel-utils/src/sheaf/sheafify.ts | 25 +++------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/guard.ts b/packages/kernel-utils/src/sheaf/guard.ts index 36b29df97a..e5347f509a 100644 --- a/packages/kernel-utils/src/sheaf/guard.ts +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -16,6 +16,38 @@ export type MethodGuardPayload = { returnGuard: Pattern; }; +/** + * Assemble a MethodGuard from its components. + * + * The @endo/patterns builder API requires a strict chain order: + * callWhen → optional → rest → returns. All four combinations of + * optional/rest presence are handled here so callers don't repeat this logic. + * + * @param base - Result of M.callWhen(...requiredArgs). + * @param optionals - Optional positional arg guards (may be empty). + * @param restGuard - Rest arg guard, or undefined if none. + * @param returnGuard - Return value guard. + * @returns The assembled MethodGuard. + */ +export const buildMethodGuard = ( + base: ReturnType, + optionals: Pattern[], + restGuard: Pattern | undefined, + returnGuard: Pattern, +): MethodGuard => { + if (optionals.length > 0 && restGuard !== undefined) { + return base + .optional(...optionals) + .rest(restGuard) + .returns(returnGuard); + } else if (optionals.length > 0) { + return base.optional(...optionals).returns(returnGuard); + } else if (restGuard === undefined) { + return base.returns(returnGuard); + } + return base.rest(restGuard).returns(returnGuard); +}; + /** * Naive union of guards via M.or — no pattern canonicalization. * @@ -121,23 +153,12 @@ export const collectSheafGuard = ( payloads.map((payload) => payload.returnGuard), ); - const base = M.callWhen(...requiredArgGuards); - if (optionalArgGuards.length > 0 && unionRestArgGuard !== undefined) { - unionMethodGuards[methodName] = base - .optional(...optionalArgGuards) - .rest(unionRestArgGuard) - .returns(returnGuard); - } else if (optionalArgGuards.length > 0) { - unionMethodGuards[methodName] = base - .optional(...optionalArgGuards) - .returns(returnGuard); - } else if (unionRestArgGuard === undefined) { - unionMethodGuards[methodName] = base.returns(returnGuard); - } else { - unionMethodGuards[methodName] = base - .rest(unionRestArgGuard) - .returns(returnGuard); - } + unionMethodGuards[methodName] = buildMethodGuard( + M.callWhen(...requiredArgGuards), + optionalArgGuards, + unionRestArgGuard, + returnGuard, + ); } return M.interface(name, unionMethodGuards); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 905f826486..ec5a7b5894 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -24,7 +24,7 @@ import { makeDiscoverableExo } from '../discoverable.ts'; import type { MethodSchema } from '../schema.ts'; import { stringify } from '../stringify.ts'; import { driveLift } from './drive.ts'; -import { collectSheafGuard } from './guard.ts'; +import { buildMethodGuard, collectSheafGuard } from './guard.ts'; import type { MethodGuardPayload } from './guard.ts'; import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; import type { ResolvedMetaDataSpec } from './metadata.ts'; @@ -140,23 +140,12 @@ const asyncifyMethodGuards = ( const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = getMethodGuardPayload(methodGuard) as unknown as MethodGuardPayload; const optionals = optionalArgGuards ?? []; - const base = M.callWhen(...argGuards); - if (optionals.length > 0 && restArgGuard !== undefined) { - asyncMethodGuards[methodName] = base - .optional(...optionals) - .rest(restArgGuard) - .returns(returnGuard); - } else if (optionals.length > 0) { - asyncMethodGuards[methodName] = base - .optional(...optionals) - .returns(returnGuard); - } else if (restArgGuard === undefined) { - asyncMethodGuards[methodName] = base.returns(returnGuard); - } else { - asyncMethodGuards[methodName] = base - .rest(restArgGuard) - .returns(returnGuard); - } + asyncMethodGuards[methodName] = buildMethodGuard( + M.callWhen(...argGuards), + optionals, + restArgGuard, + returnGuard, + ); } return asyncMethodGuards; }; From bb3618c63e93dc759d5392ac6f5c0587814f52fc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:27:24 -0400 Subject: [PATCH 23/51] fix(kernel-utils): harden sheafify return value and frozenSections sheafify returned a plain mutable object and used Object.freeze (shallow) for frozenSections. Replace both with harden() for deep transitive immutability under SES lockdown, consistent with the convention applied to constant/source/callable in metadata.ts. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index ec5a7b5894..7d195f4a6c 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -183,7 +183,7 @@ export const sheafify = < sections: PresheafSection[]; compartment?: { evaluate: (src: string) => unknown }; }): Sheaf => { - const frozenSections: readonly ResolvedSection[] = Object.freeze( + const frozenSections: readonly ResolvedSection[] = harden( sections.map((section) => ({ exo: section.exo, spec: @@ -316,10 +316,10 @@ export const sheafify = < schema: Record; }): object => buildSection({ guard: unionGuard(), lift, schema }); - return { + return harden({ getSection, getDiscoverableSection, getGlobalSection, getDiscoverableGlobalSection, - }; + }); }; From 5abc6e729861afc28de94afea52023a0b2b80157 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:21:00 -0400 Subject: [PATCH 24/51] feat(kernel-utils): export noopLift as canonical single-section placeholder lift Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 1 + packages/kernel-utils/src/index.ts | 1 + packages/kernel-utils/src/sheaf/USAGE.md | 8 ++------ packages/kernel-utils/src/sheaf/compose.ts | 16 ++++++++++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 5d7737a767..15430e43e8 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -39,6 +39,7 @@ describe('index', () => { 'makeRemoteSection', 'mergeDisjointRecords', 'methodArgsToStruct', + 'noopLift', 'prettifySmallcaps', 'proxyLift', 'retry', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 03ecc6a7c5..dee872dbb7 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -57,6 +57,7 @@ export type { export { constant, source, callable } from './sheaf/metadata.ts'; export { sheafify } from './sheaf/sheafify.ts'; export { + noopLift, proxyLift, withFilter, withRanking, diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md index bf645db02d..f81f1be63a 100644 --- a/packages/kernel-utils/src/sheaf/USAGE.md +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -9,11 +9,7 @@ as a placeholder: ```ts import { M } from '@endo/patterns'; import { makeDefaultExo } from ''; -import { sheafify } from '@metamask/kernel-utils'; - -const noop = async function* (germs) { - yield* germs; -}; +import { sheafify, noopLift } from '@metamask/kernel-utils'; const priceGuard = M.interface('PriceService', { getPrice: M.callWhen(M.await(M.string())).returns(M.await(M.number())), @@ -30,7 +26,7 @@ const sheaf = sheafify({ sections: [{ exo: priceExo }], }); -const section = sheaf.getSection({ guard: priceGuard, lift: noop }); +const section = sheaf.getSection({ guard: priceGuard, lift: noopLift }); // section is a dispatch exo; call it like any capability const price = await E(section).getPrice('ETH'); ``` diff --git a/packages/kernel-utils/src/sheaf/compose.ts b/packages/kernel-utils/src/sheaf/compose.ts index 26dc375d5f..754d8cbed6 100644 --- a/packages/kernel-utils/src/sheaf/compose.ts +++ b/packages/kernel-utils/src/sheaf/compose.ts @@ -1,5 +1,21 @@ import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; +/** + * A lift that yields all germs in their original order without filtering. + * + * Use as a placeholder when the sheaf always has a single-section stalk + * (the lift is never actually called) or to express "try everything in + * declaration order" as an explicit policy. + * + * @param germs - Evaluated sections to yield in order. + * @yields Each germ in the original array order. + */ +export async function* noopLift>( + germs: EvaluatedSection>[], +): AsyncGenerator>, void, unknown[]> { + yield* germs; +} + /** * Proxy a lift coroutine, forwarding yielded candidates up and received * error arrays down to the inner generator. From 62f697077928d66b247987609da9190d2c8f1149 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:31:28 -0400 Subject: [PATCH 25/51] feat(kernel-utils): add makeSection to eliminate as-unknown-as-Section cast Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 1 + packages/kernel-utils/src/index.ts | 1 + packages/kernel-utils/src/sheaf/USAGE.md | 6 +- packages/kernel-utils/src/sheaf/guard.test.ts | 54 +++++----- .../kernel-utils/src/sheaf/remote.test.ts | 16 +-- packages/kernel-utils/src/sheaf/remote.ts | 7 +- packages/kernel-utils/src/sheaf/section.ts | 22 ++++ .../src/sheaf/sheafify.e2e.test.ts | 56 +++++----- .../sheaf/sheafify.string-metadata.test.ts | 12 +-- .../kernel-utils/src/sheaf/sheafify.test.ts | 100 +++++++++--------- packages/kernel-utils/src/sheaf/stalk.test.ts | 13 ++- 11 files changed, 157 insertions(+), 131 deletions(-) create mode 100644 packages/kernel-utils/src/sheaf/section.ts diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 15430e43e8..a67abd3b64 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -37,6 +37,7 @@ describe('index', () => { 'makeDefaultInterface', 'makeDiscoverableExo', 'makeRemoteSection', + 'makeSection', 'mergeDisjointRecords', 'methodArgsToStruct', 'noopLift', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index dee872dbb7..d3d9bb0f8b 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -65,4 +65,5 @@ export { } from './sheaf/compose.ts'; export { collectSheafGuard } from './sheaf/guard.ts'; export { makeRemoteSection } from './sheaf/remote.ts'; +export { makeSection } from './sheaf/section.ts'; export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md index f81f1be63a..afdff6e416 100644 --- a/packages/kernel-utils/src/sheaf/USAGE.md +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -122,7 +122,11 @@ fetching the remote's guard once at construction and forwarding all calls via `E()`. This lets you mix local exos and remote capabilities in the same sheaf: ```ts -import { makeRemoteSection, constant } from '@metamask/kernel-utils'; +import { + makeSection, + makeRemoteSection, + constant, +} from '@metamask/kernel-utils'; const remoteSection = await makeRemoteSection( 'RemoteWallet', // name for the wrapper exo diff --git a/packages/kernel-utils/src/sheaf/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts index ffac24fd86..b01054ff47 100644 --- a/packages/kernel-utils/src/sheaf/guard.test.ts +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -1,4 +1,3 @@ -import { makeExo } from '@endo/exo'; import { M, matches, @@ -9,36 +8,29 @@ import type { MethodGuard, Pattern } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; import { collectSheafGuard } from './guard.ts'; +import { makeSection } from './section.ts'; import { guardCoversPoint } from './stalk.ts'; -import type { Section } from './types.ts'; - -const makeSection = ( - tag: string, - guards: Record, - methods: Record unknown>, -): Section => { - const interfaceGuard = M.interface(tag, guards); - return makeExo(tag, interfaceGuard, methods) as unknown as Section; -}; describe('collectSheafGuard', () => { it('variable arity: add with 1, 2, and 3 args', () => { const sections = [ makeSection( 'Calc:0', - { add: M.call(M.number()).returns(M.number()) }, + M.interface('Calc:0', { add: M.call(M.number()).returns(M.number()) }), { add: (a: number) => a }, ), makeSection( 'Calc:1', - { add: M.call(M.number(), M.number()).returns(M.number()) }, + M.interface('Calc:1', { + add: M.call(M.number(), M.number()).returns(M.number()), + }), { add: (a: number, b: number) => a + b }, ), makeSection( 'Calc:2', - { + M.interface('Calc:2', { add: M.call(M.number(), M.number(), M.number()).returns(M.number()), - }, + }), { add: (a: number, b: number, cc: number) => a + b + cc }, ), ]; @@ -61,12 +53,12 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'S:0', - { f: M.call(M.eq(0)).returns(M.eq(0)) }, + M.interface('S:0', { f: M.call(M.eq(0)).returns(M.eq(0)) }), { f: (_: number) => 0 }, ), makeSection( 'S:1', - { f: M.call(M.eq(1)).returns(M.eq(1)) }, + M.interface('S:1', { f: M.call(M.eq(1)).returns(M.eq(1)) }), { f: (_: number) => 1 }, ), ]; @@ -88,11 +80,11 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'Greeter', - { + M.interface('Greeter', { greet: M.callWhen(M.string()) .optional(M.string()) .returns(M.string()), - }, + }), { greet: (name: string, _greeting?: string) => `hello ${name}` }, ), ]; @@ -114,7 +106,9 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'Logger', - { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + M.interface('Logger', { + log: M.call(M.string()).rest(M.string()).returns(M.any()), + }), { log: (..._args: string[]) => undefined }, ), ]; @@ -138,12 +132,16 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'A', - { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + M.interface('A', { + log: M.call(M.string()).rest(M.string()).returns(M.any()), + }), { log: (..._args: string[]) => undefined }, ), makeSection( 'B', - { log: M.call(M.string()).rest(M.number()).returns(M.any()) }, + M.interface('B', { + log: M.call(M.string()).rest(M.number()).returns(M.any()), + }), { log: (..._args: unknown[]) => undefined }, ), ]; @@ -167,12 +165,12 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'AB:0', - { f: M.call(M.number()).returns(M.any()) }, + M.interface('AB:0', { f: M.call(M.number()).returns(M.any()) }), { f: (_: number) => undefined }, ), makeSection( 'AB:1', - { f: M.call().rest(M.string()).returns(M.any()) }, + M.interface('AB:1', { f: M.call().rest(M.string()).returns(M.any()) }), { f: (..._args: string[]) => undefined }, ), ]; @@ -188,19 +186,19 @@ describe('collectSheafGuard', () => { const sections = [ makeSection( 'Multi:0', - { + M.interface('Multi:0', { translate: M.call(M.string(), M.string()).returns(M.string()), - }, + }), { translate: (from: string, to: string) => `${from}->${to}`, }, ), makeSection( 'Multi:1', - { + M.interface('Multi:1', { translate: M.call(M.string(), M.string()).returns(M.string()), summarize: M.call(M.string()).returns(M.string()), - }, + }), { translate: (from: string, to: string) => `${from}->${to}`, summarize: (text: string) => `summary: ${text}`, diff --git a/packages/kernel-utils/src/sheaf/remote.test.ts b/packages/kernel-utils/src/sheaf/remote.test.ts index 55c731494d..e4439c8f15 100644 --- a/packages/kernel-utils/src/sheaf/remote.test.ts +++ b/packages/kernel-utils/src/sheaf/remote.test.ts @@ -1,10 +1,10 @@ -import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; +import { GET_INTERFACE_GUARD } from '@endo/exo'; import { M } from '@endo/patterns'; import { describe, it, expect, vi } from 'vitest'; import { constant } from './metadata.ts'; import { makeRemoteSection } from './remote.ts'; -import type { Section } from './types.ts'; +import { makeSection } from './section.ts'; // Mirrors the local-E pattern used throughout sheaf tests: the test // environment has no HandledPromise, so we mock E as a transparent cast. @@ -15,7 +15,7 @@ vi.mock('@endo/eventual-send', () => ({ })); const makeRemoteExo = (tag: string) => - makeExo( + makeSection( tag, M.interface( tag, @@ -29,7 +29,7 @@ const makeRemoteExo = (tag: string) => greet: async (name: string) => `Hello, ${name}!`, add: async (a: number, b: number) => a + b, }, - ) as unknown as Section; + ); describe('makeRemoteSection', () => { it('fetches the interface guard from the remote ref', async () => { @@ -42,7 +42,7 @@ describe('makeRemoteSection', () => { it('forwards method calls to the remote ref', async () => { const greet = vi.fn(async (name: string) => `Hello, ${name}!`); - const remoteExo = makeExo( + const remoteExo = makeSection( 'Remote', M.interface( 'Remote', @@ -50,7 +50,7 @@ describe('makeRemoteSection', () => { { defaultGuards: 'passable' }, ), { greet }, - ) as unknown as Section; + ); const { exo } = await makeRemoteSection('Wrapper', remoteExo); const wrapper = exo as Record< @@ -66,7 +66,7 @@ describe('makeRemoteSection', () => { it('forwards all methods declared in the guard', async () => { const greet = vi.fn(async (_: string) => ''); const add = vi.fn(async (a: number, b: number) => a + b); - const remoteExo = makeExo( + const remoteExo = makeSection( 'Remote', M.interface( 'Remote', @@ -77,7 +77,7 @@ describe('makeRemoteSection', () => { { defaultGuards: 'passable' }, ), { greet, add }, - ) as unknown as Section; + ); const { exo } = await makeRemoteSection('Wrapper', remoteExo); const wrapper = exo as Record< diff --git a/packages/kernel-utils/src/sheaf/remote.ts b/packages/kernel-utils/src/sheaf/remote.ts index 592dc70526..7689a79b4c 100644 --- a/packages/kernel-utils/src/sheaf/remote.ts +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -1,10 +1,11 @@ import { E } from '@endo/eventual-send'; -import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; +import { GET_INTERFACE_GUARD } from '@endo/exo'; import { getInterfaceGuardPayload } from '@endo/patterns'; import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; import { ifDefined } from '../misc.ts'; -import type { MetaDataSpec, PresheafSection, Section } from './types.ts'; +import { makeSection } from './section.ts'; +import type { MetaDataSpec, PresheafSection } from './types.ts'; /** * Wrap a remote (CapTP) reference as a PresheafSection. @@ -51,6 +52,6 @@ export const makeRemoteSection = async >( ]!(...args); } - const exo = makeExo(name, interfaceGuard, handlers) as unknown as Section; + const exo = makeSection(name, interfaceGuard, handlers); return ifDefined({ exo, metadata }) as PresheafSection; }; diff --git a/packages/kernel-utils/src/sheaf/section.ts b/packages/kernel-utils/src/sheaf/section.ts new file mode 100644 index 0000000000..9b9581b7ac --- /dev/null +++ b/packages/kernel-utils/src/sheaf/section.ts @@ -0,0 +1,22 @@ +import { makeExo } from '@endo/exo'; +import type { InterfaceGuard } from '@endo/patterns'; + +import type { Section } from './types.ts'; + +/** + * Create a local presheaf section from a name, guard, and handler map. + * + * Encapsulates the cast from makeExo's opaque return type to Section. + * Use this when constructing sections for a presheaf; do not use it for + * the dispatch exo produced by sheafify itself. + * + * @param name - Exo tag name. + * @param guard - Interface guard describing the section's methods. + * @param handlers - Method handler map. + * @returns A Section suitable for inclusion in a presheaf. + */ +export const makeSection = ( + name: string, + guard: InterfaceGuard, + handlers: Record unknown>, +): Section => makeExo(name, guard, handlers) as unknown as Section; diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index 5e669acc19..6e035cee8f 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -1,10 +1,10 @@ -import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { describe, expect, it, vi } from 'vitest'; import { callable, constant } from './metadata.ts'; +import { makeSection } from './section.ts'; import { sheafify } from './sheafify.ts'; -import type { Lift, PresheafSection, Section } from './types.ts'; +import type { Lift, PresheafSection } from './types.ts'; // Thin cast for calling exo methods directly in tests without going through // HandledPromise (which is not available in the test environment). @@ -31,24 +31,24 @@ describe('e2e: cost-optimal routing', () => { const sections: PresheafSection<{ cost: number }>[] = [ { // Remote: covers all accounts, expensive - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: remote0GetBalance }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, { // Local cache: covers only 'alice', cheap - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), { getBalance: local1GetBalance }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -72,13 +72,13 @@ describe('e2e: cost-optimal routing', () => { // Expand with a broader local cache (cost=2), re-sheafify. const local2GetBalance = vi.fn((_acct: string): number => 0); sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:2', M.interface('Wallet:2', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: local2GetBalance }, - ) as unknown as Section, + ), metadata: constant({ cost: 2 }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -154,13 +154,13 @@ describe('e2e: multi-tier capability routing', () => { // ── Tier 1: Network RPC ────────────────────────────────── // Covers ALL accounts (M.string()), but slow (500ms). sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: networkGetBalance }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 500, label: 'network' }), }); @@ -181,13 +181,13 @@ describe('e2e: multi-tier capability routing', () => { // ── Tier 2: Local state for owned account ──────────────── // Only covers 'alice' (M.eq), 1ms. sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), { getBalance: localGetBalance }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 1, label: 'local' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -207,7 +207,7 @@ describe('e2e: multi-tier capability routing', () => { // ── Tier 3: In-memory cache for specific accounts ──────── // Covers bob and carol via M.or, instant (0ms). sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:2', M.interface('Wallet:2', { getBalance: M.call(M.or(M.eq('bob'), M.eq('carol'))).returns( @@ -215,7 +215,7 @@ describe('e2e: multi-tier capability routing', () => { ), }), { getBalance: cacheGetBalance }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 0, label: 'cache' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -243,7 +243,7 @@ describe('e2e: multi-tier capability routing', () => { // read-only tiers above declared it, so writes route here // automatically — the guard algebra handles it, no config needed. sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:3', M.interface('Wallet:3', { getBalance: M.call(M.string()).returns(M.number()), @@ -255,7 +255,7 @@ describe('e2e: multi-tier capability routing', () => { getBalance: writeBackendGetBalance, transfer: writeBackendTransfer, }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 200, label: 'write-backend' }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -292,23 +292,23 @@ describe('e2e: multi-tier capability routing', () => { const makeSections = (): PresheafSection[] => [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: networkGetBalance }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 500, label: 'network' }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: mirrorGetBalance }, - ) as unknown as Section, + ), metadata: constant({ latencyMs: 50, label: 'mirror' }), }, ]; @@ -356,24 +356,24 @@ describe('e2e: preferAutonomous recovered as degenerate case', () => { const sections: PresheafSection<{ push: boolean }>[] = [ { // Pull section: M.string() guards, push=false - exo: makeExo( + exo: makeSection( 'PushPull:0', M.interface('PushPull:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: pullGetBalance }, - ) as unknown as Section, + ), metadata: constant({ push: false }), }, { // Push section: narrow guard, push=true - exo: makeExo( + exo: makeSection( 'PushPull:1', M.interface('PushPull:1', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), { getBalance: pushGetBalance }, - ) as unknown as Section, + ), metadata: constant({ push: true }), }, ]; @@ -422,7 +422,7 @@ describe('e2e: callable metadata — cost varies with invocation args', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'SwapA', M.interface('SwapA', { swap: M.call(M.number(), M.string(), M.string()).returns( @@ -430,14 +430,14 @@ describe('e2e: callable metadata — cost varies with invocation args', () => { ), }), { swap: swapAFn }, - ) as unknown as Section, + ), // cost(amount) = 1 + 0.1 * amount metadata: callable((args) => ({ cost: 1 + 0.1 * (args[0] as number), })), }, { - exo: makeExo( + exo: makeSection( 'SwapB', M.interface('SwapB', { swap: M.call(M.number(), M.string(), M.string()).returns( @@ -445,7 +445,7 @@ describe('e2e: callable metadata — cost varies with invocation args', () => { ), }), { swap: swapBFn }, - ) as unknown as Section, + ), // cost(amount) = 10 + 0.001 * amount metadata: callable((args) => ({ cost: 10 + 0.001 * (args[0] as number), diff --git a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts index e0037042f5..501adb3968 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts @@ -10,13 +10,13 @@ // The functional properties under test are identical regardless of which // Compartment implementation compiles the source string. -import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import { describe, it, expect, vi } from 'vitest'; import { source } from './metadata.ts'; +import { makeSection } from './section.ts'; import { sheafify } from './sheafify.ts'; -import type { Lift, PresheafSection, Section } from './types.ts'; +import type { Lift, PresheafSection } from './types.ts'; // Thin cast for calling exo methods directly in tests without going through // HandledPromise (which is not available in the test environment). @@ -54,7 +54,7 @@ describe('e2e: source metadata — compartment evaluates cost function', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'SwapA', M.interface('SwapA', { swap: M.call(M.number(), M.string(), M.string()).returns( @@ -62,12 +62,12 @@ describe('e2e: source metadata — compartment evaluates cost function', () => { ), }), { swap: swapAFn }, - ) as unknown as Section, + ), // cost(amount) = 1 + 0.1 * amount metadata: source(`(args) => ({ cost: 1 + 0.1 * args[0] })`), }, { - exo: makeExo( + exo: makeSection( 'SwapB', M.interface('SwapB', { swap: M.call(M.number(), M.string(), M.string()).returns( @@ -75,7 +75,7 @@ describe('e2e: source metadata — compartment evaluates cost function', () => { ), }), { swap: swapBFn }, - ) as unknown as Section, + ), // cost(amount) = 10 + 0.001 * amount metadata: source(`(args) => ({ cost: 10 + 0.001 * args[0] })`), }, diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index a44e5502c9..020b83fa4c 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -1,16 +1,16 @@ -import { makeExo, GET_INTERFACE_GUARD } from '@endo/exo'; +import { GET_INTERFACE_GUARD } from '@endo/exo'; import { M, getInterfaceGuardPayload } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; import { GET_DESCRIPTION } from '../discoverable.ts'; import { constant } from './metadata.ts'; +import { makeSection } from './section.ts'; import { sheafify } from './sheafify.ts'; import type { EvaluatedSection, Lift, LiftContext, PresheafSection, - Section, } from './types.ts'; // Thin cast for calling exo methods directly in tests without going through @@ -34,13 +34,13 @@ describe('sheafify', () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -55,13 +55,13 @@ describe('sheafify', () => { it('zero-coverage throws', async () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -86,23 +86,23 @@ describe('sheafify', () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -118,23 +118,23 @@ describe('sheafify', () => { it('GET_INTERFACE_GUARD returns collected guard', () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.eq('alice')).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.eq('bob')).returns(M.number()), }), { getBalance: (_acct: string) => 50 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -161,13 +161,13 @@ describe('sheafify', () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, ]; @@ -179,7 +179,7 @@ describe('sheafify', () => { // Add a cheaper section with a new method to the sections array, re-sheafify. sections.push({ - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), @@ -191,7 +191,7 @@ describe('sheafify', () => { getBalance: (_acct: string) => 42, transfer: (_from: string, _to: string, _amt: number) => true, }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -209,7 +209,7 @@ describe('sheafify', () => { }); it('pre-built exo dispatches correctly', async () => { - const exo = makeExo( + const exo = makeSection( 'bal', M.interface('bal', { getBalance: M.call(M.string()).returns(M.number()), @@ -217,7 +217,7 @@ describe('sheafify', () => { { getBalance: (_acct: string) => 42 }, ); const sections: PresheafSection<{ cost: number }>[] = [ - { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + { exo, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -238,13 +238,13 @@ describe('sheafify', () => { const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, ]; @@ -255,7 +255,7 @@ describe('sheafify', () => { expect(await E(wallet).getBalance('alice')).toBe(100); // Add a pre-built exo with a cheaper getBalance + new transfer method - const exo = makeExo( + const exo = makeSection( 'cheap', M.interface('cheap', { getBalance: M.call(M.string()).returns(M.number()), @@ -269,7 +269,7 @@ describe('sheafify', () => { }, ); sections.push({ - exo: exo as unknown as Section, + exo, metadata: constant({ cost: 1 }), }); wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -287,7 +287,7 @@ describe('sheafify', () => { }); it('guard reflected in GET_INTERFACE_GUARD for pre-built exo', () => { - const exo = makeExo( + const exo = makeSection( 'bal', M.interface('bal', { getBalance: M.call(M.string()).returns(M.number()), @@ -295,7 +295,7 @@ describe('sheafify', () => { { getBalance: (_acct: string) => 42 }, ); const sections: PresheafSection<{ cost: number }>[] = [ - { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + { exo, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -323,23 +323,23 @@ describe('sheafify', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ region: 'us', cost: 100 }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ region: 'us', cost: 1 }), }, ]; @@ -373,23 +373,23 @@ describe('sheafify', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ region: 'us' }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ region: 'us' }), }, ]; @@ -410,23 +410,23 @@ describe('sheafify', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({ cost: 1 }), }, ]; @@ -449,22 +449,22 @@ describe('sheafify', () => { const sections: PresheafSection[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), }, { - exo: makeExo( + exo: makeSection( 'Wallet:1', M.interface('Wallet:1', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), metadata: constant({}), }, ]; @@ -488,7 +488,7 @@ describe('sheafify', () => { ); }; - const exo = makeExo( + const exo = makeSection( 'cheap', M.interface('cheap', { getBalance: M.call(M.string()).returns(M.number()), @@ -497,16 +497,16 @@ describe('sheafify', () => { ); const sections: PresheafSection<{ cost: number }>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 100 }, - ) as unknown as Section, + ), metadata: constant({ cost: 100 }), }, - { exo: exo as unknown as Section, metadata: constant({ cost: 1 }) }, + { exo, metadata: constant({ cost: 1 }) }, ]; const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ @@ -526,13 +526,13 @@ describe('sheafify', () => { }; const sections: PresheafSection>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), }, ]; @@ -552,13 +552,13 @@ describe('sheafify', () => { it('getSection does not expose __getDescription__', () => { const sections: PresheafSection>[] = [ { - exo: makeExo( + exo: makeSection( 'Wallet:0', M.interface('Wallet:0', { getBalance: M.call(M.string()).returns(M.number()), }), { getBalance: (_acct: string) => 42 }, - ) as unknown as Section, + ), }, ]; diff --git a/packages/kernel-utils/src/sheaf/stalk.test.ts b/packages/kernel-utils/src/sheaf/stalk.test.ts index 534f576b59..3a7c9abb2f 100644 --- a/packages/kernel-utils/src/sheaf/stalk.test.ts +++ b/packages/kernel-utils/src/sheaf/stalk.test.ts @@ -1,22 +1,21 @@ -import { makeExo } from '@endo/exo'; import { M } from '@endo/patterns'; import type { MethodGuard } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; import { constant } from './metadata.ts'; +import { makeSection } from './section.ts'; import { getStalk } from './stalk.ts'; -import type { PresheafSection, Section } from './types.ts'; +import type { PresheafSection } from './types.ts'; const makePresheafSection = ( tag: string, guards: Record, methods: Record unknown>, metadata: { cost: number }, -): PresheafSection<{ cost: number }> => { - const interfaceGuard = M.interface(tag, guards); - const exo = makeExo(tag, interfaceGuard, methods); - return { exo: exo as unknown as Section, metadata: constant(metadata) }; -}; +): PresheafSection<{ cost: number }> => ({ + exo: makeSection(tag, M.interface(tag, guards), methods), + metadata: constant(metadata), +}); describe('getStalk', () => { it('returns matching sections for a method and args', () => { From fc2304c69626bb890a1d16e0a204e03686cc7358 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:35:35 -0400 Subject: [PATCH 26/51] refactor(kernel-utils): centralize guard payload helpers and move asyncifyMethodGuards to guard.ts Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/guard.test.ts | 62 +++++-------------- packages/kernel-utils/src/sheaf/guard.ts | 51 +++++++++++++++ packages/kernel-utils/src/sheaf/sheafify.ts | 41 +----------- packages/kernel-utils/src/sheaf/stalk.ts | 19 ++---- 4 files changed, 77 insertions(+), 96 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts index b01054ff47..b2cbbea38c 100644 --- a/packages/kernel-utils/src/sheaf/guard.test.ts +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -1,13 +1,11 @@ -import { - M, - matches, - getInterfaceGuardPayload, - getMethodGuardPayload, -} from '@endo/patterns'; -import type { MethodGuard, Pattern } from '@endo/patterns'; +import { M, matches } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; -import { collectSheafGuard } from './guard.ts'; +import { + collectSheafGuard, + getInterfaceMethodGuards, + getMethodPayload, +} from './guard.ts'; import { makeSection } from './section.ts'; import { guardCoversPoint } from './stalk.ts'; @@ -36,13 +34,8 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('Calc', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; - const payload = getMethodGuardPayload(methodGuards.add) as unknown as { - argGuards: Pattern[]; - optionalArgGuards?: Pattern[]; - }; + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.add!); // 1 required arg (present in all), 2 optional (variable arity) expect(payload.argGuards).toHaveLength(1); @@ -64,12 +57,8 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('S', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; - const { returnGuard } = getMethodGuardPayload( - methodGuards.f, - ) as unknown as { returnGuard: Pattern }; + const methodGuards = getInterfaceMethodGuards(guard); + const { returnGuard } = getMethodPayload(methodGuards.f!); // Return guard is union of eq(0) and eq(1) expect(matches(0, returnGuard)).toBe(true); @@ -90,13 +79,8 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('Greeter', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; - const payload = getMethodGuardPayload(methodGuards.greet) as unknown as { - argGuards: Pattern[]; - optionalArgGuards?: Pattern[]; - }; + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.greet!); expect(payload.argGuards).toHaveLength(1); expect(payload.optionalArgGuards).toHaveLength(1); @@ -114,14 +98,8 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('Logger', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; - const payload = getMethodGuardPayload(methodGuards.log) as unknown as { - argGuards: Pattern[]; - optionalArgGuards?: Pattern[]; - restArgGuard?: Pattern; - }; + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.log!); expect(payload.argGuards).toHaveLength(1); expect(payload.optionalArgGuards ?? []).toHaveLength(0); @@ -147,12 +125,8 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('AB', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; - const { restArgGuard } = getMethodGuardPayload( - methodGuards.log, - ) as unknown as { restArgGuard?: Pattern }; + const methodGuards = getInterfaceMethodGuards(guard); + const { restArgGuard } = getMethodPayload(methodGuards.log!); expect(matches('hello', restArgGuard)).toBe(true); expect(matches(42, restArgGuard)).toBe(true); @@ -207,9 +181,7 @@ describe('collectSheafGuard', () => { ]; const guard = collectSheafGuard('Multi', sections); - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; + const methodGuards = getInterfaceMethodGuards(guard); expect('translate' in methodGuards).toBe(true); expect('summarize' in methodGuards).toBe(true); }); diff --git a/packages/kernel-utils/src/sheaf/guard.ts b/packages/kernel-utils/src/sheaf/guard.ts index e5347f509a..cabe70dd94 100644 --- a/packages/kernel-utils/src/sheaf/guard.ts +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -16,6 +16,30 @@ export type MethodGuardPayload = { returnGuard: Pattern; }; +/** + * Extract the typed method guard map from an interface guard. + * + * @param guard - The interface guard to inspect. + * @returns A record mapping method names to their guards. + */ +export const getInterfaceMethodGuards = ( + guard: InterfaceGuard, +): Record => + ( + getInterfaceGuardPayload(guard) as unknown as { + methodGuards: Record; + } + ).methodGuards; + +/** + * Extract the typed payload from a method guard. + * + * @param guard - The method guard to inspect. + * @returns The guard's argument and return guard components. + */ +export const getMethodPayload = (guard: MethodGuard): MethodGuardPayload => + getMethodGuardPayload(guard) as unknown as MethodGuardPayload; + /** * Assemble a MethodGuard from its components. * @@ -163,3 +187,30 @@ export const collectSheafGuard = ( return M.interface(name, unionMethodGuards); }; + +/** + * Upgrade all method guards in an interface guard to M.callWhen for async dispatch. + * + * @param resolvedGuard - The interface guard whose methods should be upgraded. + * @returns A record of async method guards keyed by method name. + */ +export const asyncifyMethodGuards = ( + resolvedGuard: InterfaceGuard, +): Record => { + const resolvedMethodGuards = getInterfaceMethodGuards(resolvedGuard); + const asyncMethodGuards: Record = {}; + for (const [methodName, methodGuard] of Object.entries( + resolvedMethodGuards, + )) { + const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = + getMethodPayload(methodGuard); + const optionals = optionalArgGuards ?? []; + asyncMethodGuards[methodName] = buildMethodGuard( + M.callWhen(...argGuards), + optionals, + restArgGuard, + returnGuard, + ); + } + return asyncMethodGuards; +}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 7d195f4a6c..348386e72e 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -13,19 +13,14 @@ */ import { makeExo } from '@endo/exo'; -import { - M, - getInterfaceGuardPayload, - getMethodGuardPayload, -} from '@endo/patterns'; -import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; +import { M } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; import { makeDiscoverableExo } from '../discoverable.ts'; import type { MethodSchema } from '../schema.ts'; import { stringify } from '../stringify.ts'; import { driveLift } from './drive.ts'; -import { buildMethodGuard, collectSheafGuard } from './guard.ts'; -import type { MethodGuardPayload } from './guard.ts'; +import { asyncifyMethodGuards, collectSheafGuard } from './guard.ts'; import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; import type { ResolvedMetaDataSpec } from './metadata.ts'; import { getStalk } from './stalk.ts'; @@ -120,36 +115,6 @@ const decomposeMetadata = >( return { constraints: constraints as Partial, stripped }; }; -/** - * Upgrade all method guards to M.callWhen for async dispatch. - * - * @param resolvedGuard - The interface guard to upgrade. - * @returns A record of async method guards. - */ -const asyncifyMethodGuards = ( - resolvedGuard: InterfaceGuard, -): Record => { - const { methodGuards: resolvedMethodGuards } = getInterfaceGuardPayload( - resolvedGuard, - ) as unknown as { methodGuards: Record }; - - const asyncMethodGuards: Record = {}; - for (const [methodName, methodGuard] of Object.entries( - resolvedMethodGuards, - )) { - const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = - getMethodGuardPayload(methodGuard) as unknown as MethodGuardPayload; - const optionals = optionalArgGuards ?? []; - asyncMethodGuards[methodName] = buildMethodGuard( - M.callWhen(...argGuards), - optionals, - restArgGuard, - returnGuard, - ); - } - return asyncMethodGuards; -}; - /** * Invoke a method on a section exo, throwing if the handler is missing. * diff --git a/packages/kernel-utils/src/sheaf/stalk.ts b/packages/kernel-utils/src/sheaf/stalk.ts index f7988ba15d..01d50c2208 100644 --- a/packages/kernel-utils/src/sheaf/stalk.ts +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -3,14 +3,10 @@ */ import { GET_INTERFACE_GUARD } from '@endo/exo'; -import { - matches, - getInterfaceGuardPayload, - getMethodGuardPayload, -} from '@endo/patterns'; -import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; +import { matches } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; -import type { MethodGuardPayload } from './guard.ts'; +import { getInterfaceMethodGuards, getMethodPayload } from './guard.ts'; import type { Section } from './types.ts'; /** @@ -26,9 +22,7 @@ export const guardCoversPoint = ( method: string, args: unknown[], ): boolean => { - const { methodGuards } = getInterfaceGuardPayload(guard) as unknown as { - methodGuards: Record; - }; + const methodGuards = getInterfaceMethodGuards(guard); if (!(method in methodGuards)) { return false; } @@ -36,9 +30,8 @@ export const guardCoversPoint = ( if (!methodGuard) { return false; } - const { argGuards, optionalArgGuards, restArgGuard } = getMethodGuardPayload( - methodGuard, - ) as unknown as MethodGuardPayload; + const { argGuards, optionalArgGuards, restArgGuard } = + getMethodPayload(methodGuard); const optionals = optionalArgGuards ?? []; const maxFixedArgs = argGuards.length + optionals.length; return ( From 8a2c424f298b561a355c600b63a9595e025c63bf Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:36:00 -0400 Subject: [PATCH 27/51] fix(kernel-utils): improve error message for lift object-identity protocol violation Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/LIFT.md | 6 ++++-- packages/kernel-utils/src/sheaf/sheafify.ts | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/LIFT.md b/packages/kernel-utils/src/sheaf/LIFT.md index 1c44ba14d0..4359a58ab0 100644 --- a/packages/kernel-utils/src/sheaf/LIFT.md +++ b/packages/kernel-utils/src/sheaf/LIFT.md @@ -23,8 +23,10 @@ The sheaf drives it with the following protocol: discarded; it exists only to satisfy the generator type. 2. **Yield** — the coroutine yields a candidate germ to try next. The yielded value must be an element of the `germs` array received on entry — the sheaf - uses object identity to map it back to the original section, so constructing - a new object with the same shape will produce "lift yielded an unknown germ". + uses object identity to map it back to the original section. Constructing a + new object with the same shape will throw with a message like "Lift yielded + an unrecognized germ". Sorting with `[...germs].sort(...)` is safe because + sort preserves references; mapping to new objects is not. 3. **Attempt** — the sheaf calls the candidate's exo method. 4. **Success** — the result is returned; the generator is abandoned. 5. **Failure** — the sheaf calls `gen.next(errors)`, passing the ordered list diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 348386e72e..e49ace8fa2 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -219,7 +219,12 @@ export const sheafify = < async (germ) => { const section = strippedToCollapsed.get(germ); if (section === undefined) { - throw new Error('lift yielded an unknown germ'); + throw new Error( + `Lift yielded an unrecognized germ for '${method}'. ` + + `The yielded value must be one of the EvaluatedSection objects ` + + `passed into the lift (object identity, not structural equality). ` + + `Did the lift construct a new object instead of yielding from the germs array?`, + ); } return invokeExo(section.exo, method, args); }, From 881b5cc20ee91ea031f7fc647df63a1baac1b24f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:36:58 -0400 Subject: [PATCH 28/51] docs(kernel-utils): note that source metadata is for trust-boundary use only Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/USAGE.md | 3 ++- packages/kernel-utils/src/sheaf/metadata.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md index afdff6e416..41d19863d4 100644 --- a/packages/kernel-utils/src/sheaf/USAGE.md +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -76,7 +76,8 @@ import { constant, source, callable } from '@metamask/kernel-utils'; // static value known at construction time constant({ mode: 'fast' }); -// JS source string compiled once in the sheaf's compartment at construction time +// @experimental — prefer callable unless the function must cross a trust boundary +// or be serialized. Compiled once in the sheaf's compartment at construction time. source(`(args) => ({ cost: args[0] > 9000 ? 'high' : 'low' })`); // live function evaluated at each dispatch — useful when cost varies by argument, diff --git a/packages/kernel-utils/src/sheaf/metadata.ts b/packages/kernel-utils/src/sheaf/metadata.ts index 6ee84ceee8..e69e5c1052 100644 --- a/packages/kernel-utils/src/sheaf/metadata.ts +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -60,6 +60,10 @@ export const constant = >( /** * Wrap JS function source. Evaluated in a Compartment at sheafify construction time. * + * Prefer `callable` unless the metadata function must be supplied as a + * serializable source string — for example, when crossing a trust boundary or + * deserializing from storage. Requires a `compartment` passed to `sheafify`. + * * @param src - JS source string of the form `(args) => M`. * @returns A source MetaDataSpec wrapping the source string. */ From 2329cdfef011e73050013934eab4387956195c43 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:37:14 -0400 Subject: [PATCH 29/51] refactor(kernel-utils): remove getStalk and guardCoversPoint from public exports Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 2 -- packages/kernel-utils/src/index.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index a67abd3b64..891074cfff 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -20,8 +20,6 @@ describe('index', () => { 'fallthrough', 'fetchValidatedJson', 'fromHex', - 'getStalk', - 'guardCoversPoint', 'ifDefined', 'installWakeDetector', 'isCapData', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index d3d9bb0f8b..d2dd149183 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -66,4 +66,3 @@ export { export { collectSheafGuard } from './sheaf/guard.ts'; export { makeRemoteSection } from './sheaf/remote.ts'; export { makeSection } from './sheaf/section.ts'; -export { getStalk, guardCoversPoint } from './sheaf/stalk.ts'; From 0798843320f89903e2f98b62013b1c1381ff78b7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:37:57 -0400 Subject: [PATCH 30/51] refactor(kernel-utils): inline driveLift into sheafify and remove drive.ts Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/drive.ts | 40 --------------------- packages/kernel-utils/src/sheaf/sheafify.ts | 26 +++++++++++++- 2 files changed, 25 insertions(+), 41 deletions(-) delete mode 100644 packages/kernel-utils/src/sheaf/drive.ts diff --git a/packages/kernel-utils/src/sheaf/drive.ts b/packages/kernel-utils/src/sheaf/drive.ts deleted file mode 100644 index 2bba341509..0000000000 --- a/packages/kernel-utils/src/sheaf/drive.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { EvaluatedSection, Lift, LiftContext } from './types.ts'; - -/** - * Drive a lift coroutine, retrying on failure and accumulating errors. - * - * Primes the generator with gen.next([]), then calls gen.next(errors) after - * each failed attempt where errors is the full ordered history. Returns the - * first successful result, or throws a new error with all accumulated errors - * as the cause when exhausted. - * - * @param lift - The lift coroutine to drive. - * @param germs - The evaluated sections to pass to the lift. - * @param context - The dispatch context (method, args, constraints). - * @param invoke - Calls the section exo; throws on failure. - * @returns The result of the first successful invocation. - * @internal - */ -export const driveLift = async >( - lift: Lift, - germs: EvaluatedSection>[], - context: LiftContext, - invoke: (germ: EvaluatedSection>) => Promise, -): Promise => { - const errors: unknown[] = []; - const gen = lift(germs, context); - let next = await gen.next(errors); - while (!next.done) { - try { - const result = await invoke(next.value); - await gen.return(undefined); - return result; - } catch (error) { - errors.push(error); - next = await gen.next(errors); - } - } - throw new Error(`No viable section for ${context.method}`, { - cause: errors, - }); -}; diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index e49ace8fa2..d52733f286 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -19,7 +19,6 @@ import type { InterfaceGuard } from '@endo/patterns'; import { makeDiscoverableExo } from '../discoverable.ts'; import type { MethodSchema } from '../schema.ts'; import { stringify } from '../stringify.ts'; -import { driveLift } from './drive.ts'; import { asyncifyMethodGuards, collectSheafGuard } from './guard.ts'; import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; import type { ResolvedMetaDataSpec } from './metadata.ts'; @@ -27,6 +26,7 @@ import { getStalk } from './stalk.ts'; import type { EvaluatedSection, Lift, + LiftContext, PresheafSection, Section, Sheaf, @@ -137,6 +137,30 @@ type ResolvedSection> = { spec: ResolvedMetaDataSpec | undefined; }; +const driveLift = async >( + lift: Lift, + germs: EvaluatedSection>[], + context: LiftContext, + invoke: (germ: EvaluatedSection>) => Promise, +): Promise => { + const errors: unknown[] = []; + const gen = lift(germs, context); + let next = await gen.next(errors); + while (!next.done) { + try { + const result = await invoke(next.value); + await gen.return(undefined); + return result; + } catch (error) { + errors.push(error); + next = await gen.next(errors); + } + } + throw new Error(`No viable section for ${context.method}`, { + cause: errors, + }); +}; + export const sheafify = < MetaData extends Record = Record, >({ From 7a0a2f7cdc54f447b7c2387c67fcef23bf3597bf Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:48:20 -0400 Subject: [PATCH 31/51] =?UTF-8?q?refactor(kernel-utils):=20rename=20MetaDa?= =?UTF-8?q?taSpec=20=E2=86=92=20MetadataSpec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Metadata" is one compound word; the mid-word capital was inconsistent with the surrounding identifiers and prose docs. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.ts | 2 +- .../kernel-utils/src/sheaf/metadata.test.ts | 26 ++++++++--------- packages/kernel-utils/src/sheaf/metadata.ts | 28 +++++++++---------- packages/kernel-utils/src/sheaf/remote.ts | 4 +-- packages/kernel-utils/src/sheaf/sheafify.ts | 8 +++--- packages/kernel-utils/src/sheaf/types.ts | 4 +-- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index d2dd149183..040325ecc4 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -48,7 +48,7 @@ export type { Section, PresheafSection, EvaluatedSection, - MetaDataSpec, + MetadataSpec, Lift, LiftContext, Presheaf, diff --git a/packages/kernel-utils/src/sheaf/metadata.test.ts b/packages/kernel-utils/src/sheaf/metadata.test.ts index 8b77f80ff6..8257f03cad 100644 --- a/packages/kernel-utils/src/sheaf/metadata.test.ts +++ b/packages/kernel-utils/src/sheaf/metadata.test.ts @@ -4,7 +4,7 @@ import { callable, constant, evaluateMetadata, - resolveMetaDataSpec, + resolveMetadataSpec, source, } from './metadata.ts'; @@ -17,7 +17,7 @@ describe('constant', () => { }); it('evaluateMetadata returns the value regardless of args', () => { - const spec = resolveMetaDataSpec(constant({ cost: 7 })); + const spec = resolveMetadataSpec(constant({ cost: 7 })); expect(evaluateMetadata(spec, [])).toStrictEqual({ cost: 7 }); expect(evaluateMetadata(spec, [1, 2, 3])).toStrictEqual({ cost: 7 }); }); @@ -34,7 +34,7 @@ describe('callable', () => { const fn = vi.fn((args: unknown[]) => ({ value: (args[0] as number) * 2, })); - const spec = resolveMetaDataSpec(callable(fn)); + const spec = resolveMetadataSpec(callable(fn)); expect(evaluateMetadata(spec, [5])).toStrictEqual({ value: 10 }); expect(fn).toHaveBeenCalledWith([5]); }); @@ -48,10 +48,10 @@ describe('source', () => { }); }); - it('resolveMetaDataSpec compiles source to callable via compartment', () => { + it('resolveMetadataSpec compiles source to callable via compartment', () => { const mockFn = (args: unknown[]) => ({ value: args[0] as number }); const compartment = { evaluate: vi.fn(() => mockFn) }; - const spec = resolveMetaDataSpec( + const spec = resolveMetadataSpec( source<{ value: number }>('(args) => ({ value: args[0] })'), compartment, ); @@ -63,20 +63,20 @@ describe('source', () => { }); }); -describe('resolveMetaDataSpec', () => { +describe('resolveMetadataSpec', () => { it('passes constant spec through unchanged', () => { const spec = constant({ answer: 42 }); - expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + expect(resolveMetadataSpec(spec)).toStrictEqual(spec); }); it('passes callable spec through unchanged', () => { const fn = (_args: unknown[]) => ({ count: 0 }); const spec = callable(fn); - expect(resolveMetaDataSpec(spec)).toStrictEqual(spec); + expect(resolveMetadataSpec(spec)).toStrictEqual(spec); }); it("throws if kind is 'source' and no compartment supplied", () => { - expect(() => resolveMetaDataSpec(source('() => ({})'))).toThrow( + expect(() => resolveMetadataSpec(source('() => ({})'))).toThrow( "compartment required to evaluate 'source' metadata", ); }); @@ -89,7 +89,7 @@ describe('evaluateMetadata', () => { }); it('normalizes null from callable to empty object', () => { - const spec = resolveMetaDataSpec( + const spec = resolveMetadataSpec( callable( ((_args: unknown[]) => null) as unknown as ( args: unknown[], @@ -100,7 +100,7 @@ describe('evaluateMetadata', () => { }); it('throws when callable returns a primitive', () => { - const spec = resolveMetaDataSpec( + const spec = resolveMetadataSpec( callable( ((_args: unknown[]) => 7) as unknown as ( args: unknown[], @@ -112,7 +112,7 @@ describe('evaluateMetadata', () => { }); it('throws when callable returns an array', () => { - const spec = resolveMetaDataSpec( + const spec = resolveMetadataSpec( callable(((_args: unknown[]) => [1, 2]) as unknown as ( args: unknown[], ) => Record), @@ -121,7 +121,7 @@ describe('evaluateMetadata', () => { }); it('throws when callable returns a Date', () => { - const spec = resolveMetaDataSpec( + const spec = resolveMetadataSpec( callable( ((_args: unknown[]) => new Date()) as unknown as ( args: unknown[], diff --git a/packages/kernel-utils/src/sheaf/metadata.ts b/packages/kernel-utils/src/sheaf/metadata.ts index e69e5c1052..3a7aa03f79 100644 --- a/packages/kernel-utils/src/sheaf/metadata.ts +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -1,11 +1,11 @@ /** - * MetaDataSpec constructors and evaluation helpers. + * MetadataSpec constructors and evaluation helpers. */ -import type { MetaDataSpec } from './types.ts'; +import type { MetadataSpec } from './types.ts'; /** Resolved spec: 'source' has been compiled away; only constant or callable remain. */ -export type ResolvedMetaDataSpec> = +export type ResolvedMetadataSpec> = | { kind: 'constant'; value: M } | { kind: 'callable'; fn: (args: unknown[]) => M }; @@ -51,11 +51,11 @@ const normalizeEvaluatedSheafMetadata = ( * Wrap a static value as a constant metadata spec. * * @param value - The static metadata value. - * @returns A constant MetaDataSpec wrapping the value. + * @returns A constant MetadataSpec wrapping the value. */ export const constant = >( value: M, -): MetaDataSpec => harden({ kind: 'constant', value }); +): MetadataSpec => harden({ kind: 'constant', value }); /** * Wrap JS function source. Evaluated in a Compartment at sheafify construction time. @@ -65,11 +65,11 @@ export const constant = >( * deserializing from storage. Requires a `compartment` passed to `sheafify`. * * @param src - JS source string of the form `(args) => M`. - * @returns A source MetaDataSpec wrapping the source string. + * @returns A source MetadataSpec wrapping the source string. */ export const source = >( src: string, -): MetaDataSpec => harden({ kind: 'source', src }); +): MetadataSpec => harden({ kind: 'source', src }); /** * Wrap a live function as a callable metadata spec. @@ -79,21 +79,21 @@ export const source = >( */ export const callable = >( fn: (args: unknown[]) => M, -): MetaDataSpec => harden({ kind: 'callable', fn }); +): MetadataSpec => harden({ kind: 'callable', fn }); /** * Compile a 'source' spec to 'callable' using the supplied compartment. * 'constant' and 'callable' pass through unchanged. * - * @param spec - The MetaDataSpec to resolve. + * @param spec - The MetadataSpec to resolve. * @param compartment - Compartment used to evaluate 'source' specs. Required when spec is 'source'. * @param compartment.evaluate - Evaluate a JS source string and return the result. - * @returns A ResolvedMetaDataSpec with no 'source' variant. + * @returns A ResolvedMetadataSpec with no 'source' variant. */ -export const resolveMetaDataSpec = >( - spec: MetaDataSpec, +export const resolveMetadataSpec = >( + spec: MetadataSpec, compartment?: { evaluate: (src: string) => unknown }, -): ResolvedMetaDataSpec => { +): ResolvedMetadataSpec => { if (spec.kind === 'source') { if (!compartment) { throw new Error( @@ -120,7 +120,7 @@ export const resolveMetaDataSpec = >( * @returns The evaluated metadata object (possibly empty). */ export const evaluateMetadata = >( - spec: ResolvedMetaDataSpec | undefined, + spec: ResolvedMetadataSpec | undefined, args: unknown[], ): MetaData => { if (spec === undefined) { diff --git a/packages/kernel-utils/src/sheaf/remote.ts b/packages/kernel-utils/src/sheaf/remote.ts index 7689a79b4c..1a3f388f3b 100644 --- a/packages/kernel-utils/src/sheaf/remote.ts +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -5,7 +5,7 @@ import type { InterfaceGuard, MethodGuard } from '@endo/patterns'; import { ifDefined } from '../misc.ts'; import { makeSection } from './section.ts'; -import type { MetaDataSpec, PresheafSection } from './types.ts'; +import type { MetadataSpec, PresheafSection } from './types.ts'; /** * Wrap a remote (CapTP) reference as a PresheafSection. @@ -24,7 +24,7 @@ import type { MetaDataSpec, PresheafSection } from './types.ts'; export const makeRemoteSection = async >( name: string, remoteRef: object, - metadata?: MetaDataSpec, + metadata?: MetadataSpec, ): Promise> => { const interfaceGuard: InterfaceGuard = await ( E(remoteRef) as unknown as { diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index d52733f286..8da2cc4993 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -20,8 +20,8 @@ import { makeDiscoverableExo } from '../discoverable.ts'; import type { MethodSchema } from '../schema.ts'; import { stringify } from '../stringify.ts'; import { asyncifyMethodGuards, collectSheafGuard } from './guard.ts'; -import { evaluateMetadata, resolveMetaDataSpec } from './metadata.ts'; -import type { ResolvedMetaDataSpec } from './metadata.ts'; +import { evaluateMetadata, resolveMetadataSpec } from './metadata.ts'; +import type { ResolvedMetadataSpec } from './metadata.ts'; import { getStalk } from './stalk.ts'; import type { EvaluatedSection, @@ -134,7 +134,7 @@ const invokeExo = (exo: Section, method: string, args: unknown[]): unknown => { type ResolvedSection> = { exo: Section; - spec: ResolvedMetaDataSpec | undefined; + spec: ResolvedMetadataSpec | undefined; }; const driveLift = async >( @@ -178,7 +178,7 @@ export const sheafify = < spec: section.metadata === undefined ? undefined - : resolveMetaDataSpec(section.metadata, compartment), + : resolveMetadataSpec(section.metadata, compartment), })), ); const buildSection = ({ diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index d63985fd25..ff6da8c03c 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -23,7 +23,7 @@ export type Section = Partial & { * Evaluated metadata must be a plain object (`{}` means no metadata; primitives * must be wrapped, e.g. `{ value: n }`). */ -export type MetaDataSpec> = +export type MetadataSpec> = | { kind: 'constant'; value: M } | { kind: 'source'; src: string } | { kind: 'callable'; fn: (args: unknown[]) => M }; @@ -36,7 +36,7 @@ export type MetaDataSpec> = */ export type PresheafSection> = { exo: Section; - metadata?: MetaDataSpec; + metadata?: MetadataSpec; }; /** From af734892945177f3e752586365574957cf98dde2 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:48:37 -0400 Subject: [PATCH 32/51] refactor(kernel-utils): remove Presheaf thin alias The alias added a second public name for PresheafSection[] with no external consumers. Callers write the array type directly. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.ts | 1 - packages/kernel-utils/src/sheaf/types.ts | 6 ------ 2 files changed, 7 deletions(-) diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 040325ecc4..2ba078b777 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -51,7 +51,6 @@ export type { MetadataSpec, Lift, LiftContext, - Presheaf, Sheaf, } from './sheaf/types.ts'; export { constant, source, callable } from './sheaf/metadata.ts'; diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index ff6da8c03c..083c3391a5 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -85,12 +85,6 @@ export type Lift> = ( context: LiftContext, ) => AsyncGenerator>, void, unknown[]>; -/** - * A presheaf: a plain array of presheaf sections. - */ -export type Presheaf> = - PresheafSection[]; - /** * A sheaf: an authority manager over a presheaf. * From 573126b6b038d6f336ea6d648f00ef7bfb5519bc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:48:57 -0400 Subject: [PATCH 33/51] docs(kernel-utils): document why Sheaf.getSection returns object The guard is passed dynamically at call time so TypeScript cannot propagate the method signatures through Sheaf. The comment prevents future contributors from chasing a phantom improvement. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/types.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index 083c3391a5..87231b4fee 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -92,9 +92,20 @@ export type Lift> = ( * granted authority for auditing and revocation. */ export type Sheaf> = { - /** Produce a revocable dispatch exo over the given guard. */ + /** + * Produce a revocable dispatch exo over the given guard. + * + * Returns `object` rather than a typed exo because the guard is passed + * dynamically at call time — TypeScript cannot propagate the method + * signatures through `Sheaf` without knowing the specific guard. + * Cast to the interface type at the call site once you know the guard. + */ getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; - /** Produce a revocable discoverable dispatch exo over the given guard. */ + /** + * Produce a revocable discoverable dispatch exo over the given guard. + * + * Returns `object` for the same reason as `getSection`. + */ getDiscoverableSection: (opts: { guard: InterfaceGuard; lift: Lift; From 88d0e3157b88b6eb2aa8429d55ad11c497cffe4a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:16:52 -0400 Subject: [PATCH 34/51] refactor(kernel-utils): remove dead resolvedGuard alias in sheafify Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 8da2cc4993..2b8ec854d3 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -190,9 +190,7 @@ export const sheafify = < lift: Lift; schema?: Record; }): object => { - const resolvedGuard = guard; - - const asyncMethodGuards = asyncifyMethodGuards(resolvedGuard); + const asyncMethodGuards = asyncifyMethodGuards(guard); const asyncGuard = schema === undefined ? M.interface(`${name}:section`, asyncMethodGuards) From d4ab36e8230a4c21775b0986114c40a61991d0de Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:17:58 -0400 Subject: [PATCH 35/51] test(kernel-utils): add getSection explicit-guard test coverage Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/sheafify.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 020b83fa4c..7ece864a03 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -573,3 +573,110 @@ describe('sheafify', () => { ).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Unit: getSection with explicit guard +// --------------------------------------------------------------------------- + +describe('getSection with explicit guard', () => { + it('dispatches calls that fall within the explicit guard', async () => { + const sections: PresheafSection>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.number()).returns(M.boolean()), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_to: string, _amt: number) => true, + }, + ), + }, + ]; + + const readGuard = M.interface('ReadOnly', { + getBalance: M.call(M.string()).returns(M.number()), + }); + + const section = sheafify({ name: 'Wallet', sections }).getSection({ + guard: readGuard, + async *lift(germs) { + yield germs[0]!; + }, + }); + + expect(await E(section).getBalance('alice')).toBe(42); + }); + + it('rejects method calls outside the explicit guard', async () => { + const sections: PresheafSection>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.number()).returns(M.boolean()), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_to: string, _amt: number) => true, + }, + ), + }, + ]; + + const readGuard = M.interface('ReadOnly', { + getBalance: M.call(M.string()).returns(M.number()), + }); + + const section = sheafify({ name: 'Wallet', sections }).getSection({ + guard: readGuard, + async *lift(germs) { + yield germs[0]!; + }, + }); + + // makeExo only places methods from the guard on the object — transfer is absent + expect((section as Record).transfer).toBeUndefined(); + }); + + it('getDiscoverableSection exposes __getDescription__ and obeys explicit guard', async () => { + const sections: PresheafSection>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + transfer: M.call(M.string(), M.number()).returns(M.boolean()), + }), + { + getBalance: (_acct: string) => 42, + transfer: (_to: string, _amt: number) => true, + }, + ), + }, + ]; + + const readGuard = M.interface('ReadOnly', { + getBalance: M.call(M.string()).returns(M.number()), + }); + + const schema = { getBalance: { description: 'Get account balance.' } }; + + const section = sheafify({ + name: 'Wallet', + sections, + }).getDiscoverableSection({ + guard: readGuard, + async *lift(germs) { + yield germs[0]!; + }, + schema, + }); + + expect(E(section)[GET_DESCRIPTION]()).toStrictEqual(schema); + expect(await E(section).getBalance('alice')).toBe(42); + }); +}); From c6ed13e6aed37e3ecc192c4b455d754f8fe0fab9 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:22:25 -0400 Subject: [PATCH 36/51] test(kernel-utils): add e2e test for multi-candidate lift retry on handler failure Co-Authored-By: Claude Sonnet 4.6 --- .../src/sheaf/sheafify.e2e.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index 6e035cee8f..d22f6ab769 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -469,3 +469,115 @@ describe('e2e: callable metadata — cost varies with invocation args', () => { expect(swapAFn).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// E2E: lift retry — first candidate throws, sheaf recovers to fallback +// --------------------------------------------------------------------------- + +describe('e2e: lift retry on handler failure', () => { + it('recovers to next candidate when first throws, lift receives non-empty errors', async () => { + type RouteMeta = { priority: number }; + + const primaryFn = vi.fn((_acct: string): number => { + throw new Error('primary unavailable'); + }); + const fallbackFn = vi.fn((_acct: string): number => 99); + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Primary', + M.interface('Primary', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: primaryFn }, + ), + metadata: constant({ priority: 0 }), + }, + { + exo: makeSection( + 'Fallback', + M.interface('Fallback', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: fallbackFn }, + ), + metadata: constant({ priority: 1 }), + }, + ]; + + // Track the error-array length the lift receives after each failed attempt. + const errorCountsSeenByLift: number[] = []; + const priorityFirst: Lift = async function* (germs) { + const ordered = [...germs].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + for (const germ of ordered) { + const errors: unknown[] = yield germ; + errorCountsSeenByLift.push(errors.length); + } + }; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: priorityFirst, + }); + + const result = await E(wallet).getBalance('alice'); + + // fallback succeeded and both handlers were invoked + expect(result).toBe(99); + expect(primaryFn).toHaveBeenCalledWith('alice'); + expect(fallbackFn).toHaveBeenCalledWith('alice'); + + // after the primary failed the lift received an errors array with one entry + expect(errorCountsSeenByLift).toHaveLength(1); + expect(errorCountsSeenByLift[0]).toBe(1); + }); + + it('throws accumulated errors when all candidates fail', async () => { + type RouteMeta = { priority: number }; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'A', + M.interface('A', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { + getBalance: (_acct: string): number => { + throw new Error('A failed'); + }, + }, + ), + metadata: constant({ priority: 0 }), + }, + { + exo: makeSection( + 'B', + M.interface('B', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { + getBalance: (_acct: string): number => { + throw new Error('B failed'); + }, + }, + ), + metadata: constant({ priority: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + yield* [...germs].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + }, + }); + + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'No viable section', + ); + }); +}); From 04e180fa667793c36989ede84e4de6b58ed6ba82 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:22:53 -0400 Subject: [PATCH 37/51] refactor(kernel-utils): unexport collectSheafGuard from public index Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/index.test.ts | 1 - packages/kernel-utils/src/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 891074cfff..255fba8ed1 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -14,7 +14,6 @@ describe('index', () => { 'abortableDelay', 'calculateReconnectionBackoff', 'callable', - 'collectSheafGuard', 'constant', 'delay', 'fallthrough', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 2ba078b777..0382ef716e 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -62,6 +62,5 @@ export { withRanking, fallthrough, } from './sheaf/compose.ts'; -export { collectSheafGuard } from './sheaf/guard.ts'; export { makeRemoteSection } from './sheaf/remote.ts'; export { makeSection } from './sheaf/section.ts'; From 4f47c89c47e1e9683fcceec7a14eb0210fe2d0fb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:24:55 -0400 Subject: [PATCH 38/51] docs(kernel-utils): documentation pass for sheaf module - LIFT.md: fix exhaustion description to match actual error shape - README.md: remove stale "registry" and "tracks" claims post-revocation-removal - types.ts: remove "revocable" from Sheaf method docs; clarify when to use global section variants vs explicit-guard variants - USAGE.md: use makeSection (public API) in single-provider example; clarify proxyLift vs yield* for lift composition Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/LIFT.md | 3 ++- packages/kernel-utils/src/sheaf/README.md | 7 ++++--- packages/kernel-utils/src/sheaf/USAGE.md | 12 +++++++----- packages/kernel-utils/src/sheaf/types.ts | 21 +++++++++++++++------ 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/LIFT.md b/packages/kernel-utils/src/sheaf/LIFT.md index 4359a58ab0..8f0abd9526 100644 --- a/packages/kernel-utils/src/sheaf/LIFT.md +++ b/packages/kernel-utils/src/sheaf/LIFT.md @@ -33,7 +33,8 @@ The sheaf drives it with the following protocol: of every error thrown so far (cumulative, not just the last). The coroutine receives this as the resolved value of its `yield` expression. 6. **Exhausted** — if the generator returns without yielding, the sheaf - rethrows the last error. + throws `new Error('No viable section for ', { cause: errors })` + where `errors` is the full accumulated list of every failure so far. Most lifts express a fixed priority order and can ignore the error input: diff --git a/packages/kernel-utils/src/sheaf/README.md b/packages/kernel-utils/src/sheaf/README.md index cbc51d3c76..45e10449ce 100644 --- a/packages/kernel-utils/src/sheaf/README.md +++ b/packages/kernel-utils/src/sheaf/README.md @@ -3,8 +3,8 @@ Runtime capability routing adapted from sheaf theory in algebraic topology. `sheafify({ name, sections })` produces a **sheaf** — an authority manager -over a presheaf of capabilities. The sheaf grants dispatch sections via -`getSection` and tracks all delegated authority. +over a presheaf of capabilities. The sheaf produces dispatch sections via +`getSection`, each of which routes invocations through the presheaf. See [USAGE.md](./USAGE.md) for annotated examples and [LIFT.md](./LIFT.md) for the lift coroutine protocol and semantic equivalence assumption. @@ -52,7 +52,8 @@ context. > identical metadata and collapsed to one representative. **Sheaf** — The authority manager returned by `sheafify`. Holds the presheaf -data (captured at construction time) and a registry of all granted sections. +data (sections frozen at construction time) and exposes factory methods that +produce dispatch exos on demand. ``` const sheaf = sheafify({ name: 'Wallet', sections }); diff --git a/packages/kernel-utils/src/sheaf/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md index 41d19863d4..307972faee 100644 --- a/packages/kernel-utils/src/sheaf/USAGE.md +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -8,14 +8,13 @@ as a placeholder: ```ts import { M } from '@endo/patterns'; -import { makeDefaultExo } from ''; -import { sheafify, noopLift } from '@metamask/kernel-utils'; +import { sheafify, makeSection, noopLift } from '@metamask/kernel-utils'; const priceGuard = M.interface('PriceService', { getPrice: M.callWhen(M.await(M.string())).returns(M.await(M.number())), }); -const priceExo = makeDefaultExo('PriceService', priceGuard, { +const priceExo = makeSection('PriceService', priceGuard, { async getPrice(token) { return fetchPrice(token); }, @@ -161,5 +160,8 @@ import { before passing to `inner` - **`fallthrough(liftA, liftB)`** — try all candidates from `liftA` first; if all fail, try `liftB` -- **`proxyLift(inner)`** — forward yielded candidates up and error arrays down; - useful when wrapping a lift in middleware +- **`proxyLift(gen)`** — forward yielded candidates up and error arrays down + to an already-started generator; useful when you need to add logic between + yields (logging, counting, conditional abort). For simple sequential + composition (`fallthrough`, `withFilter`) you do not need `proxyLift` — + `yield*` forwards `.next(value)` to the delegated iterator automatically. diff --git a/packages/kernel-utils/src/sheaf/types.ts b/packages/kernel-utils/src/sheaf/types.ts index 87231b4fee..afc9d98746 100644 --- a/packages/kernel-utils/src/sheaf/types.ts +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -88,12 +88,12 @@ export type Lift> = ( /** * A sheaf: an authority manager over a presheaf. * - * Produces revocable dispatch sections via `getSection` and tracks all - * granted authority for auditing and revocation. + * Produces dispatch sections via `getSection`, each routing invocations + * through the presheaf sections supplied at construction time. */ export type Sheaf> = { /** - * Produce a revocable dispatch exo over the given guard. + * Produce a dispatch exo over the given guard. * * Returns `object` rather than a typed exo because the guard is passed * dynamically at call time — TypeScript cannot propagate the method @@ -102,7 +102,7 @@ export type Sheaf> = { */ getSection: (opts: { guard: InterfaceGuard; lift: Lift }) => object; /** - * Produce a revocable discoverable dispatch exo over the given guard. + * Produce a discoverable dispatch exo over the given guard. * * Returns `object` for the same reason as `getSection`. */ @@ -112,13 +112,22 @@ export type Sheaf> = { schema: Record; }) => object; /** - * Produce a revocable dispatch exo over the full union guard of all presheaf sections. + * Produce a dispatch exo over the full union guard of all presheaf sections. + * + * Prefer `getSection` with an explicit guard when the guard is statically + * known — it makes the capability's scope visible at the call site. Use the + * global variant when sections are assembled dynamically at runtime and the + * union guard is not known until after `sheafify` runs. * * @deprecated Provide an explicit guard via getSection instead. */ getGlobalSection: (opts: { lift: Lift }) => object; /** - * Produce a revocable discoverable dispatch exo over the full union guard of all presheaf sections. + * Produce a discoverable dispatch exo over the full union guard of all presheaf sections. + * + * Prefer `getDiscoverableSection` with an explicit guard when the guard is + * statically known. Use the global variant when sections are assembled + * dynamically and the union guard is not known until after `sheafify` runs. * * @deprecated Provide an explicit guard via getDiscoverableSection instead. */ From 7f0d41a96d3b03912da3647c6ef03cb0b0f0d8f7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:06:52 -0400 Subject: [PATCH 39/51] test(kernel-utils): add failing tests for driveLift snapshot and metadataKey conflation bugs Co-Authored-By: Claude Sonnet 4.6 --- .../src/sheaf/sheafify.e2e.test.ts | 47 +++++++++++++++++++ .../kernel-utils/src/sheaf/sheafify.test.ts | 38 +++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts index d22f6ab769..05115d40dc 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -534,6 +534,53 @@ describe('e2e: lift retry on handler failure', () => { expect(errorCountsSeenByLift[0]).toBe(1); }); + it('lift receives error snapshots not live references', async () => { + type RouteMeta = { priority: number }; + + const handlers = [ + vi.fn((_acct: string): number => { + throw new Error('handler 0 failed'); + }), + vi.fn((_acct: string): number => { + throw new Error('handler 1 failed'); + }), + vi.fn((_acct: string): number => 99), + ]; + + const sections: PresheafSection[] = handlers.map((fn, i) => ({ + exo: makeSection( + `Section:${i}`, + M.interface(`Section:${i}`, { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: fn }, + ), + metadata: constant({ priority: i }), + })); + + let errorsAfterFirst: unknown[] | undefined; + const priorityFirst: Lift = async function* (germs) { + const ordered = [...germs].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + for (const germ of ordered) { + const errors: unknown[] = yield germ; + errorsAfterFirst ??= errors; + } + }; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: priorityFirst, + }); + + await E(wallet).getBalance('alice'); + + // After the first handler fails and the second handler is attempted, + // the errors array grows. errorsAfterFirst should be a snapshot with + // exactly one entry — not a live reference that was later mutated to two. + expect(errorsAfterFirst).toHaveLength(1); + }); + it('throws accumulated errors when all candidates fail', async () => { type RouteMeta = { priority: number }; diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 7ece864a03..515caf9650 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -443,6 +443,44 @@ describe('sheafify', () => { expect(liftCalled).toBe(false); }); + it('does not collapse Infinity and null metadata as equivalent', async () => { + type Meta = { cost: number | null }; + let germCount = 0; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: Infinity }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: null }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + germCount = germs.length; + yield germs[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + expect(germCount).toBe(2); + }); + it('collapses no-metadata and empty-object metadata as equivalent', async () => { type Meta = Record; let liftCalled = false; From 88bc8a3029cf8fe0883d42e69e9a9e07ee1ba25c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:07:18 -0400 Subject: [PATCH 40/51] fix(kernel-utils): pass error snapshots to lift generator in driveLift gen.next(errors) was passing the same live mutable array reference on every resume. A lift that stores the received value from one yield and inspects it after a later yield would see mutations from subsequent failures. Pass [...errors] snapshots so each yield receives an independent copy. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 2b8ec854d3..35bdca929c 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -145,7 +145,7 @@ const driveLift = async >( ): Promise => { const errors: unknown[] = []; const gen = lift(germs, context); - let next = await gen.next(errors); + let next = await gen.next([...errors]); while (!next.done) { try { const result = await invoke(next.value); @@ -153,7 +153,7 @@ const driveLift = async >( return result; } catch (error) { errors.push(error); - next = await gen.next(errors); + next = await gen.next([...errors]); } } throw new Error(`No viable section for ${context.method}`, { From 8b0520f49ce0eeaf1f7d20cfac4f0c2702df504b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:07:52 -0400 Subject: [PATCH 41/51] fix(kernel-utils): use type-tagged encoding in metadataKey to prevent conflation JSON.stringify maps undefined, NaN, Infinity, and -Infinity all to null, so sections with e.g. { cost: Infinity } and { cost: null } produced identical keys and were incorrectly collapsed into one germ. Replace the plain JSON.stringify(entries) with encodeMetadataEntry, which includes a typeof tag in each tuple so all of these distinct values produce distinct keys. BigInt metadata values no longer throw at serialization time either. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 35bdca929c..21aa12bc7a 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -32,9 +32,35 @@ import type { Sheaf, } from './types.ts'; +type EncodedEntry = [key: string, type: string, value: unknown]; + +const encodeMetadataEntry = (key: string, value: unknown): EncodedEntry => { + if (value === undefined) { + return [key, 'undefined', null]; + } + if (typeof value === 'bigint') { + return [key, 'bigint', String(value)]; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) { + return [key, 'NaN', null]; + } + if (value === Infinity) { + return [key, '+Infinity', null]; + } + if (value === -Infinity) { + return [key, '-Infinity', null]; + } + } + return [key, typeof value, value]; +}; + /** * Serialize metadata for equivalence-class keying (collapse step). * + * Uses type-tagged encoding so that values JSON.stringify conflates + * (undefined, null, NaN, Infinity, -Infinity) produce distinct keys. + * * @param metadata - The metadata value to serialize. * @returns A string key for equivalence comparison. */ @@ -43,9 +69,9 @@ const metadataKey = (metadata: Record): string => { if (keys.length === 0) { return 'null'; } - const entries = Object.entries(metadata).sort(([a], [b]) => - a.localeCompare(b), - ); + const entries = Object.entries(metadata) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, val]) => encodeMetadataEntry(key, val)); return JSON.stringify(entries); }; From d3b397d4be4badead149a03ea5374c8267855130 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:21:10 -0400 Subject: [PATCH 42/51] refactor(kernel-utils): move sheaf exports to ./sheaf subpath Sheaf is a large, self-contained subsystem. Keeping it under its own subpath import reduces coupling on consumers who don't need it, and keeps the main index focused on general utilities. - Add @metamask/kernel-utils/sheaf entry point (src/sheaf/index.ts) - Remove sheaf re-exports from the main index - Add ./sheaf export to package.json alongside the other subpaths - Remove sheaf overview from README (belongs in sheaf/README.md) - Update CHANGELOG: use subpath import, drop internal exports (collectSheafGuard, getStalk, guardCoversPoint), add makeSection and noopLift, fix MetadataSpec capitalisation Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/CHANGELOG.md | 9 +- packages/kernel-utils/README.md | 104 ----------------------- packages/kernel-utils/package.json | 10 +++ packages/kernel-utils/src/index.test.ts | 11 --- packages/kernel-utils/src/index.ts | 20 ----- packages/kernel-utils/src/sheaf/index.ts | 20 +++++ 6 files changed, 34 insertions(+), 140 deletions(-) create mode 100644 packages/kernel-utils/src/sheaf/index.ts diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 2f8b9a252a..80591b058b 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -9,14 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add sheaf programming module ([#870](https://github.com/MetaMask/ocap-kernel/pull/870)) +- Add `@metamask/kernel-utils/sheaf` subpath export ([#870](https://github.com/MetaMask/ocap-kernel/pull/870)) - `sheafify()` for building a `Sheaf` capability authority from a collection of `PresheafSection`s, each an exo with optional invocation-dependent metadata - `constant()`, `source()`, `callable()` for constructing metadata specs (static value, compartment-evaluated code string, and per-call function respectively) - - `proxyLift()`, `withFilter()`, `withRanking()`, `fallthrough()` for composing lifts to route and rank sections at dispatch time - - `collectSheafGuard()` for deriving a combined `InterfaceGuard` from all sections in a sheaf - - `getStalk()`, `guardCoversPoint()` for section lookup and guard checks + - `noopLift()`, `proxyLift()`, `withFilter()`, `withRanking()`, `fallthrough()` for composing lifts to route and rank sections at dispatch time + - `makeSection()` for constructing a typed exo section from a guard and handler map - `makeRemoteSection()` for wrapping a remote CapTP reference as a `PresheafSection`, fetching its interface guard once at construction and forwarding method calls via `E()` - - Types: `Sheaf`, `Section`, `PresheafSection`, `EvaluatedSection`, `MetaDataSpec`, `Lift`, `LiftContext`, `Presheaf` + - Types: `Sheaf`, `Section`, `PresheafSection`, `EvaluatedSection`, `MetadataSpec`, `Lift`, `LiftContext` ## [0.5.0] diff --git a/packages/kernel-utils/README.md b/packages/kernel-utils/README.md index 53abcb1b0a..7bc7e8cf29 100644 --- a/packages/kernel-utils/README.md +++ b/packages/kernel-utils/README.md @@ -26,110 +26,6 @@ or npm install --save-dev patch-package ``` -## Sheaf Module - -The sheaf module provides a dispatch abstraction for routing method calls across multiple capability objects (_sections_) that each cover a region of a shared interface. - -### Overview - -``` -sheafify({ name, sections, compartment? }) → Sheaf -sheaf.getGlobalSection({ lift }) → section proxy -sheaf.getSection({ guard, lift }) → section proxy -``` - -Each call on the proxy is dispatched to whichever section covers that method. When multiple sections are eligible, a **lift** selects among them. A lift is an `AsyncGenerator` coroutine that yields candidates one at a time and receives the accumulated error history on each resume — enabling retry, fallback, and cost-aware routing without callers needing to know the selection strategy. - -### Defining sections - -```ts -import { sheafify, constant, callable } from '@metamask/kernel-utils'; - -const sheaf = sheafify({ - name: 'Wallet', - sections: [ - { - exo: walletA, - metadata: constant({ cost: 10, push: false }), - }, - { - exo: walletB, - // callable metadata is evaluated per-call with the actual arguments - metadata: callable((args) => ({ cost: 1 + 0.1 * (args[0] as number) })), - }, - { - exo: walletC, - // source metadata is compiled once at sheafify time via the compartment - metadata: source(`(args) => ({ cost: 5 + 0.01 * args[0] })`), - }, - ], - compartment, // required only when using source-kind metadata -}); -``` - -**Metadata kinds:** -| Kind | When evaluated | Use case | -|------|---------------|----------| -| `constant(v)` | Never (static) | Fixed priority or capability flags | -| `callable(fn)` | Each call | Arg-dependent cost, remaining spend | -| `source(str)` | Each call (compiled at construction) | Sandboxed cost functions | - -### Writing a lift - -A lift receives `EvaluatedSection>[]` (germs) and a context, and yields candidates in preference order. It receives a snapshot of all accumulated errors on each `gen.next(errors)` call. - -```ts -import type { Lift } from '@metamask/kernel-utils'; - -// Yield cheapest section first; fall back in cost order on failure -const cheapest: Lift<{ cost: number }> = async function* (germs) { - yield* [...germs].sort( - (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), - ); -}; - -const section = sheaf.getGlobalSection({ lift: cheapest }); -``` - -### Composing lifts - -```ts -import { - withFilter, - withRanking, - fallthrough, - proxyLift, -} from '@metamask/kernel-utils'; - -// Filter out sections with insufficient remaining spend -const spendable = withFilter( - (germ, { args }) => - (germ.metadata?.remainingSpend ?? Infinity) >= (args[0] as number), -); - -// Sort by cost before passing to the inner lift -const byCost = withRanking( - (a, b) => (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), -); - -// Try local sections first, fall through to remote on exhaustion -const withFallback = fallthrough(localLift, remoteLift); - -// Compose: filter → rank → select -const lift = spendable(byCost(cheapest)); -``` - -`withFilter` and `withRanking` are pure input transforms that return the inner lift's generator directly. `fallthrough` sequences two lifts via `yield*`, which forwards the error array to each inner lift. `proxyLift` is the primitive for adding logic (logging, circuit-breaking) between yields. - -### Error handling - -When all candidates are exhausted, `driveLift` throws: - -``` -Error: No viable section for - cause: [Error: ..., Error: ..., ...] // all accumulated attempt errors -``` - ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index a7ea252378..b11b3df789 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -79,6 +79,16 @@ "default": "./dist/nodejs/index.cjs" } }, + "./sheaf": { + "import": { + "types": "./dist/sheaf/index.d.mts", + "default": "./dist/sheaf/index.mjs" + }, + "require": { + "types": "./dist/sheaf/index.d.cts", + "default": "./dist/sheaf/index.cjs" + } + }, "./vite-plugins": { "import": { "types": "./dist/vite-plugins/index.d.mts", diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 255fba8ed1..cc1985bc46 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -13,10 +13,7 @@ describe('index', () => { 'GET_DESCRIPTION', 'abortableDelay', 'calculateReconnectionBackoff', - 'callable', - 'constant', 'delay', - 'fallthrough', 'fetchValidatedJson', 'fromHex', 'ifDefined', @@ -33,22 +30,14 @@ describe('index', () => { 'makeDefaultExo', 'makeDefaultInterface', 'makeDiscoverableExo', - 'makeRemoteSection', - 'makeSection', 'mergeDisjointRecords', 'methodArgsToStruct', - 'noopLift', 'prettifySmallcaps', - 'proxyLift', 'retry', 'retryWithBackoff', - 'sheafify', - 'source', 'stringify', 'toHex', 'waitUntilQuiescent', - 'withFilter', - 'withRanking', ]); }); }); diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 0382ef716e..bc895d4a1b 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -44,23 +44,3 @@ export { DEFAULT_MAX_DELAY_MS, } from './retry.ts'; export type { RetryBackoffOptions, RetryOnRetryInfo } from './retry.ts'; -export type { - Section, - PresheafSection, - EvaluatedSection, - MetadataSpec, - Lift, - LiftContext, - Sheaf, -} from './sheaf/types.ts'; -export { constant, source, callable } from './sheaf/metadata.ts'; -export { sheafify } from './sheaf/sheafify.ts'; -export { - noopLift, - proxyLift, - withFilter, - withRanking, - fallthrough, -} from './sheaf/compose.ts'; -export { makeRemoteSection } from './sheaf/remote.ts'; -export { makeSection } from './sheaf/section.ts'; diff --git a/packages/kernel-utils/src/sheaf/index.ts b/packages/kernel-utils/src/sheaf/index.ts new file mode 100644 index 0000000000..1f735d3083 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/index.ts @@ -0,0 +1,20 @@ +export type { + Section, + PresheafSection, + EvaluatedSection, + MetadataSpec, + Lift, + LiftContext, + Sheaf, +} from './types.ts'; +export { constant, source, callable } from './metadata.ts'; +export { sheafify } from './sheafify.ts'; +export { + noopLift, + proxyLift, + withFilter, + withRanking, + fallthrough, +} from './compose.ts'; +export { makeRemoteSection } from './remote.ts'; +export { makeSection } from './section.ts'; From cf3ff6d67e1ae9043189f1f439544c7b6958e204 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:24:11 -0400 Subject: [PATCH 43/51] test(kernel-utils): add failing test for NaN constraint detection in decomposeMetadata Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/sheafify.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 515caf9650..8fbaf72075 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -443,6 +443,53 @@ describe('sheafify', () => { expect(liftCalled).toBe(false); }); + it('extracts shared NaN metadata values into constraints', async () => { + type Meta = { cost: number; priority: number }; + let capturedGerms: EvaluatedSection>[] = []; + let capturedContext: LiftContext | undefined; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: NaN, priority: 0 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: NaN, priority: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs, context) { + capturedGerms = germs; + capturedContext = context; + yield germs[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + + // NaN is shared across all germs, so it should be extracted as a constraint + // — not left as distinguishing metadata in each germ's options. + expect(Number.isNaN(capturedContext?.constraints.cost)).toBe(true); + expect(capturedGerms.map((germ) => germ.metadata)).toStrictEqual([ + { priority: 0 }, + { priority: 1 }, + ]); + }); + it('does not collapse Infinity and null metadata as equivalent', async () => { type Meta = { cost: number | null }; let germCount = 0; From 2d84d4a5251456bffb8e3a376938938281984eb4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:24:31 -0400 Subject: [PATCH 44/51] fix(kernel-utils): use Object.is for value equality in decomposeMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === fails for NaN (NaN !== NaN), so a NaN value shared by all germs was never promoted to a constraint — it remained in each germ's distinguishing metadata instead. Object.is correctly treats NaN === NaN and is consistent with the type-tagged encoding already used in collapseEquivalent. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 21aa12bc7a..2d971506bd 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -121,7 +121,7 @@ const decomposeMetadata = >( const val = first[key]; const shared = stalk.every((entry) => { const meta = entry.metadata; - return key in meta && meta[key] === val; + return key in meta && Object.is(meta[key], val); }); if (shared) { constraints[key] = val; From b5efb50463a2bc3fb069b2cae8a61c454355620b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:40:42 -0400 Subject: [PATCH 45/51] test(kernel-utils): add failing test for -0 vs +0 collapse in metadataKey Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-utils/src/sheaf/sheafify.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.test.ts b/packages/kernel-utils/src/sheaf/sheafify.test.ts index 8fbaf72075..b66c71cccd 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.test.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -490,6 +490,44 @@ describe('sheafify', () => { ]); }); + it('does not collapse +0 and -0 metadata as equivalent', async () => { + type Meta = { cost: number }; + let germCount = 0; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: +0 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: -0 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + germCount = germs.length; + yield germs[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + expect(germCount).toBe(2); + }); + it('does not collapse Infinity and null metadata as equivalent', async () => { type Meta = { cost: number | null }; let germCount = 0; From c43872e83193f7e75bb827b789fad53ad251ae86 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:41:01 -0400 Subject: [PATCH 46/51] fix(kernel-utils): handle -0 in encodeMetadataEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON.stringify(-0) produces "0", so -0 and +0 were serialised to the same metadataKey and incorrectly collapsed into one germ by collapseEquivalent. Object.is(0, -0) is false, so decomposeMetadata already treated them as distinct — making the two functions inconsistent. Add -0 as an explicit special case alongside NaN, +Infinity, -Infinity. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/sheaf/sheafify.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts index 2d971506bd..1a3331b300 100644 --- a/packages/kernel-utils/src/sheaf/sheafify.ts +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -51,6 +51,9 @@ const encodeMetadataEntry = (key: string, value: unknown): EncodedEntry => { if (value === -Infinity) { return [key, '-Infinity', null]; } + if (Object.is(value, -0)) { + return [key, '-0', null]; + } } return [key, typeof value, value]; }; From c293202ba3f4bca40ec29d68a2d9a8c03c460dd1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:02:13 -0400 Subject: [PATCH 47/51] feat(evm-wallet): use sheaves for capability routing in away-coordinator --- .../src/lib/delegation-twin.ts | 22 +- .../src/vats/away-coordinator.ts | 202 +++++++++--------- 2 files changed, 112 insertions(+), 112 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 251d347e08..0fa6c95d8f 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,13 +1,13 @@ import { M } from '@endo/patterns'; +import { constant } from '@metamask/kernel-utils'; +import type { PresheafSection } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; import { encodeTransfer } from './erc20.ts'; import { METHOD_CATALOG } from './method-catalog.ts'; import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; -export type DelegationSection = - | { exo: object; method: 'transferNative' } - | { exo: object; method: 'transferFungible'; token: Address }; +type AwayMetadata = { mode: string; delegationId?: string }; type DelegationTwinOptions = { grant: DelegationGrant; @@ -15,18 +15,18 @@ type DelegationTwinOptions = { }; /** - * Build a DelegationSection for a delegation grant. + * Build a PresheafSection for a delegation grant. * The resulting exo exposes the method covered by the grant, enforcing * local guards and (for transferFungible) a local budget tracker. * * @param options - Twin construction options. * @param options.grant - The semantic delegation grant to wrap. * @param options.redeemFn - Submits an Execution to the delegation framework. - * @returns A DelegationSection wrapping the delegation exo. + * @returns A PresheafSection with constant delegation metadata. */ export function makeDelegationTwin( options: DelegationTwinOptions, -): DelegationSection { +): PresheafSection { const { grant, redeemFn } = options; const { delegation } = grant; const idPrefix = delegation.id.slice(0, 12); @@ -90,7 +90,10 @@ export function makeDelegationTwin( interfaceGuard, ); - return { exo, method: 'transferNative' }; + return { + exo, + metadata: constant({ mode: 'delegation', delegationId: delegation.id }), + }; } // transferFungible — normalize token address to lowercase for consistent matching. @@ -152,5 +155,8 @@ export function makeDelegationTwin( interfaceGuard, ); - return { exo, method: 'transferFungible', token }; + return { + exo, + metadata: constant({ mode: 'delegation', delegationId: delegation.id }), + }; } diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index c2301ec32c..9b76cb08a5 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,9 +1,10 @@ import { E } from '@endo/eventual-send'; +import { sheafify, constant, makeRemoteSection } from '@metamask/kernel-utils'; +import type { Lift, PresheafSection, Sheaf } from '@metamask/kernel-utils'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; -import type { DelegationSection } from '../lib/delegation-twin.ts'; import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { decodeBalanceOfResult, @@ -15,6 +16,7 @@ import { encodeName, encodeSymbol, } from '../lib/erc20.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; import { registerEnvironment, resolveEnvironment, @@ -46,6 +48,13 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); +// --------------------------------------------------------------------------- +// Local metadata type +// --------------------------------------------------------------------------- + +type AwayMetadata = { mode: string; delegationId?: string }; + + /** * Convert a wei amount in hex to a human-readable ETH string. * @@ -191,6 +200,22 @@ type OcapURLRedemptionFacet = { redeem: (url: string) => Promise; }; +// --------------------------------------------------------------------------- +// Module-level awayLift (preference: delegation > call-home) +// --------------------------------------------------------------------------- + +/** + * Lift coroutine for the away sheaf. + * Tries all matching delegation twins before falling back to phoning home. + * + * @param germs - Evaluated sections with partial metadata, one per matching presheaf section. + * @yields The next candidate section to attempt dispatch on. + */ +const awayLift: Lift = async function* (germs) { + yield* germs.filter((germ) => germ.metadata?.mode === 'delegation'); + yield* germs.filter((germ) => germ.metadata?.mode === 'call-home'); +}; + // --------------------------------------------------------------------------- // buildRootObject // --------------------------------------------------------------------------- @@ -198,11 +223,11 @@ type OcapURLRedemptionFacet = { /** * Build the root object for the away coordinator vat. * - * The away coordinator manages routing for the semantic wallet API on the away - * (agent) side. It keeps execution infrastructure (provider, bundler, smart - * account, tx submission, ERC-20 queries) and routes semantic calls - * (`transferNative`, `transferFungible`) through delegation twins first, then - * falls back to calling home. + * The away coordinator handles sheaf-based capability routing for the semantic + * wallet API on the away (agent) side. It keeps execution infrastructure + * (provider, bundler, smart account, tx submission, ERC-20 queries) and routes + * semantic calls (`transferNative`, `transferFungible`) via the away sheaf to + * either a delegation twin (autonomous) or the call-home section (ask home). * * @param vatPowers - Special powers granted to this vat. * @param _parameters - Initialization parameters (role: 'away'). @@ -266,12 +291,12 @@ export function buildRootObject( // OCAP URL redemption service (wired from services in bootstrap) let redemptionService: OcapURLRedemptionFacet | undefined; - // Routing state + // Sheaf state + let redeemerVatRef: RedeemerFacet | undefined; // alias kept for clarity in sheaf rebuild let homeSection: object | undefined; // remote ref to home's homeSection exo let homeCoordRef: object | undefined; // remote ref to home coordinator (for delegate registration) - let delegationSections: DelegationSection[] = []; - // Keyed by delegation.id so rebuildRouting preserves in-memory spend counters. - const delegationTwinMap = new Map(); + let awaySheaf: Sheaf | undefined; + let currentSection: object | undefined; // ------------------------------------------------------------------------- // Baggage helpers @@ -308,6 +333,7 @@ export function buildRootObject( keyringVat = restoreFromBaggage('keyringVat'); providerVat = restoreFromBaggage('providerVat'); redeemerVat = restoreFromBaggage('redeemerVat'); + redeemerVatRef = redeemerVat; externalSigner = restoreFromBaggage('externalSigner'); bundlerConfig = restoreFromBaggage('bundlerConfig'); if (bundlerConfig?.environment) { @@ -1022,7 +1048,7 @@ export function buildRootObject( } // ------------------------------------------------------------------------- - // Routing helpers + // Sheaf helpers // ------------------------------------------------------------------------- /** @@ -1049,32 +1075,44 @@ export function buildRootObject( } /** - * Rebuild the delegation sections from current redeemer grants. + * Rebuild the away sheaf from current redeemer grants and homeSection state. * Called after `receiveDelegation` or `connectToPeer`. */ - async function rebuildRouting(): Promise { - const grants = redeemerVat ? await E(redeemerVat).listGrants() : []; - const currentIds = new Set(grants.map((grant) => grant.delegation.id)); - for (const id of delegationTwinMap.keys()) { - if (!currentIds.has(id)) { - delegationTwinMap.delete(id); - } - } - for (const grant of grants) { - if (!delegationTwinMap.has(grant.delegation.id)) { - delegationTwinMap.set( - grant.delegation.id, - makeDelegationTwin({ - grant, - redeemFn: makeRedeemFn(grant.delegation), - }), - ); - } - } - delegationSections = grants.map( + async function rebuildAwaySheaf(): Promise { + const grants = redeemerVatRef ? await E(redeemerVatRef).listGrants() : []; + + const delegationSections: PresheafSection[] = grants.map( (grant) => - delegationTwinMap.get(grant.delegation.id) as DelegationSection, + makeDelegationTwin({ + grant, + redeemFn: makeRedeemFn(grant.delegation), + }), ); + + const sections: PresheafSection[] = [...delegationSections]; + + if (homeSection) { + sections.push( + await makeRemoteSection( + 'CallHome', + homeSection, + constant({ mode: 'call-home' }), + ), + ); + } + + if (sections.length === 0) { + // No sections yet — currentSection remains undefined + awaySheaf = undefined; + currentSection = undefined; + return; + } + + awaySheaf = sheafify({ name: 'AwayWallet', sections }); + currentSection = awaySheaf.getDiscoverableGlobalSection({ + lift: awayLift, + schema: METHOD_CATALOG, + }); } // ------------------------------------------------------------------------- @@ -1097,6 +1135,7 @@ export function buildRootObject( keyringVat = vats.keyring as KeyringFacet | undefined; providerVat = vats.provider as ProviderFacet | undefined; redeemerVat = vats.redeemer as RedeemerFacet | undefined; + redeemerVatRef = redeemerVat; redemptionService = services.ocapURLRedemptionService as | OcapURLRedemptionFacet | undefined; @@ -1117,10 +1156,10 @@ export function buildRootObject( hasRedeemer: Boolean(redeemerVat), }); - // Rebuild routing from persisted state (e.g. after kernel restart). + // Rebuild sheaf from persisted state (e.g. after kernel restart). // homeSection is already restored from baggage; grants come from redeemerVat. if (redeemerVat || homeSection) { - await rebuildRouting(); + await rebuildAwaySheaf(); } }, @@ -1484,7 +1523,7 @@ export function buildRootObject( /** * Send a transaction using the direct path only (no delegation matching). - * Away's delegation routing goes through transferNative/transferFungible, not sendTransaction. + * Away's delegation routing goes through the sheaf, not sendTransaction. * * @param tx - The transaction request. * @returns The transaction hash. @@ -1784,55 +1823,30 @@ export function buildRootObject( // ------------------------------------------------------------------ /** - * Transfer native ETH. - * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, throws with all collected errors - * as the cause array; does not fall through to the home section. + * Transfer native ETH via the away sheaf. + * Routes to the best matching delegation twin (autonomous) or the + * call-home section (ask home) based on the awayLift preference order. * * @param to - Recipient address. * @param amount - Amount in wei. * @returns The transaction hash. */ - async transferNative( - to: Address, - amount: string | number | bigint, - ): Promise { - // Coerce at the JSON boundary — CLI callers pass numeric strings. - const amt = BigInt(amount); - const matching = delegationSections.filter( - (sec) => sec.method === 'transferNative', - ); - if (matching.length > 0) { - const errors: unknown[] = []; - for (const section of matching) { - try { - return await E(section.exo).transferNative(to, amt); - } catch (error) { - errors.push(error); - } - } + async transferNative(to: Address, amount: bigint): Promise { + if (!currentSection) { throw new Error( - `All delegation twins failed: ${errors - .map((cause) => - cause instanceof Error ? cause.message : String(cause), - ) - .join('; ')}`, - { cause: errors }, + 'Away sheaf not ready — call connectToPeer first or receive a delegation', ); } - if (homeSection) { - return E(homeSection).transferNative(to, amt); - } - throw new Error( - 'No routing available — call connectToPeer first or receive a delegation', + return E(currentSection).transferNative( + to, + BigInt(amount as unknown as string | number | bigint), ); }, /** - * Transfer ERC-20 tokens. - * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, throws with all collected errors - * as the cause array; does not fall through to the home section. + * Transfer ERC-20 tokens via the away sheaf. + * Routes to the best matching delegation twin (autonomous) or the + * call-home section (ask home) based on the awayLift preference order. * * @param token - ERC-20 token contract address. * @param to - Recipient address. @@ -1844,41 +1858,21 @@ export function buildRootObject( to: Address, amount: string | number | bigint, ): Promise { - // Coerce at the JSON boundary — CLI callers pass numeric strings. - const amt = BigInt(amount); - const tokenLower = token.toLowerCase() as Address; - const matching = delegationSections.filter( - (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, - ); - if (matching.length > 0) { - const errors: unknown[] = []; - for (const section of matching) { - try { - return await E(section.exo).transferFungible(tokenLower, to, amt); - } catch (error) { - errors.push(error); - } - } + if (!currentSection) { throw new Error( - `All delegation twins failed: ${errors - .map((cause) => - cause instanceof Error ? cause.message : String(cause), - ) - .join('; ')}`, - { cause: errors }, + 'Away sheaf not ready — call connectToPeer first or receive a delegation', ); } - if (homeSection) { - return E(homeSection).transferFungible(token, to, amt); - } - throw new Error( - 'No routing available — call connectToPeer first or receive a delegation', + return E(currentSection).transferFungible( + token, + to, + BigInt(amount as unknown as string | number | bigint), ); }, /** * Receive a delegation grant from home and persist it to the redeemer vat. - * Rebuilds the delegation sections to incorporate the new grant. + * Rebuilds the away sheaf to incorporate the new grant. * * @param grant - The semantic delegation grant to store. */ @@ -1887,7 +1881,7 @@ export function buildRootObject( throw new Error('Redeemer vat not available'); } await E(redeemerVat).receiveGrant(grant); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** @@ -1905,8 +1899,8 @@ export function buildRootObject( /** * Connect to the home coordinator via an OCAP URL. * Redeems the URL to obtain a remote reference to the home coordinator, - * then fetches the home section exo for the call-home fallback path. - * Persists homeCoordRef and homeSection and rebuilds routing. + * then fetches the home section exo for the call-home sheaf path. + * Persists the homeSection reference and rebuilds the away sheaf. * * @param ocapUrl - The OCAP URL issued by the home coordinator. */ @@ -1918,7 +1912,7 @@ export function buildRootObject( homeSection = await E(homeCoordRef).getHomeSection(); persistBaggage('homeCoordRef', homeCoordRef); persistBaggage('homeSection', homeSection); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** From 9a16612bceac7d9e5940537d876ca52c2f8d2542 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:24:33 -0400 Subject: [PATCH 48/51] fix(evm-wallet): lowercase token address in transferFungible dispatch The delegation twin's interface guard uses M.eq(lowercaseToken) to match the token address, so dispatch through the sheaf must pass the token in canonical lowercase form. Callers that supply a mixed-case address were being silently filtered out of the twin stalk and falling through to the home section. Co-Authored-By: Claude Opus 4.7 --- packages/evm-wallet-experiment/src/vats/away-coordinator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 9b76cb08a5..2c6cfb675a 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -54,7 +54,6 @@ const harden = globalThis.harden ?? ((value: T): T => value); type AwayMetadata = { mode: string; delegationId?: string }; - /** * Convert a wei amount in hex to a human-readable ETH string. * @@ -1864,7 +1863,7 @@ export function buildRootObject( ); } return E(currentSection).transferFungible( - token, + token.toLowerCase() as Address, to, BigInt(amount as unknown as string | number | bigint), ); From 883ba7725d8b9cbafcce373c76b48b881b728239 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:24:45 -0400 Subject: [PATCH 49/51] test(evm-wallet): update delegation twin e2e for sheaf fall-through semantics Once a matched stalk section throws, the sheaf continues dispatch through the remaining sections. The e2e scenario now asserts that an over-budget spend falls through from the delegation twin to the call-home section and that transfer still succeeds via the home coordinator (using a direct EOA tx, so no UserOp polling). Also extend dockerExec with an optional timeoutMs so the two-spend scenario fits within a longer window on bundler-hybrid. Co-Authored-By: Claude Opus 4.7 --- .../test/e2e/docker/docker-e2e.test.ts | 3 +- .../test/e2e/docker/helpers/docker-exec.ts | 10 ++++-- .../e2e/docker/run-delegation-twin-e2e.mjs | 32 ++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts index 139a5bd35d..1b609fb3b4 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts @@ -334,7 +334,7 @@ describe('Docker E2E', () => { // ------------------------------------------------------------------------- describe('delegation twin', () => { - it('enforces cumulativeSpend locally; chain enforces expired timestamp', () => { + it('routes transfers through the delegation twin; falls back to home when twin rejects', () => { const delegate = resolveOnChainDelegateAddress({ delegationMode, home: homeResult, @@ -348,6 +348,7 @@ describe('Docker E2E', () => { output = dockerExec( kernelServices.away, `node --conditions development ${scriptPath} ${delegationMode} ${homeResult.kref} ${awayResult.kref} ${delegate}`, + { timeoutMs: 170_000 }, ); } catch (error) { throw new Error( diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts index e3f1a87234..87641722b7 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts @@ -133,12 +133,18 @@ function shellSingleQuote(value: string): string { * * @param service - The compose service name. * @param command - The command to run. + * @param options - Options bag. + * @param options.timeoutMs - Timeout in milliseconds (default 60_000). * @returns stdout as a string. */ -export function dockerExec(service: string, command: string): string { +export function dockerExec( + service: string, + command: string, + options: { timeoutMs?: number } = {}, +): string { return execSync(`docker ${composePrefix()} exec -T ${service} ${command}`, { encoding: 'utf-8', - timeout: 60_000, + timeout: options.timeoutMs ?? 60_000, }).trim(); } diff --git a/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs index ff1accda27..86ce924232 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs @@ -12,8 +12,9 @@ * 1. Home builds a transfer-fungible grant (max spend = 5 units, fake token). * 2. Away receives the grant and rebuilds the delegation routing. * 3. Away calls transferFungible(3) → twin succeeds (3 ≤ 5 remaining). - * 4. Away calls transferFungible(3) again → twin rejects LOCALLY - * ("Insufficient budget") before any network call is made. + * 4. Away calls transferFungible(3) again → twin rejects its budget, but + * the sheaf falls back to the call-home section and the transfer + * still succeeds via the home coordinator. * * ── Usage ───────────────────────────────────────────────────────────────── * @@ -147,19 +148,26 @@ assert( `first spend (3 units) → tx hash: ${String(txHash).slice(0, 20)}...`, ); -// Second spend: 3 + 3 = 6 > 5 → should be rejected LOCALLY by the -// delegation twin without making any network call. -console.log(' Calling transferFungible(3) again — should fail locally...'); -const secondError = await awayClient.callVatExpectError( - awayKref, - 'transferFungible', - [FAKE_TOKEN, BURN_ADDRESS, '3'], +// Second spend: 3 + 3 = 6 > 5 → twin rejects its budget, but the sheaf +// falls back to the call-home section and the transfer still succeeds +// via the home coordinator. +console.log( + ' Calling transferFungible(3) again — twin rejects, sheaf falls back to home...', ); +const fallbackTxHash = await awayClient.callVat(awayKref, 'transferFungible', [ + FAKE_TOKEN, + BURN_ADDRESS, + '3', +]); + +// Fallback goes through the home coordinator, which uses a direct EOA +// transaction (broadcastTransaction) rather than a UserOp — no receipt +// polling needed. assert( - typeof secondError === 'string' && - secondError.includes('Insufficient budget'), - `second spend (3 units) rejected locally: ${String(secondError).slice(0, 80)}`, + typeof fallbackTxHash === 'string' && + /^0x[\da-f]{64}$/iu.test(fallbackTxHash), + `second spend (3 units) fell back to home → tx hash: ${String(fallbackTxHash).slice(0, 20)}...`, ); // ── Results ──────────────────────────────────────────────────────────────── From 45cdd4dd8896db6e78e00ddb1e106dd5948c938d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:56:35 -0400 Subject: [PATCH 50/51] fix(evm-wallet): update sheaf imports to @metamask/kernel-utils/sheaf subpath The rebase onto grypez/bringing-in-the-sheaves introduced a semantic conflict: d3b397d moved sheaf exports from the main kernel-utils index to the ./sheaf subpath, but the evm-wallet commits still imported from the old path. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/lib/delegation-twin.ts | 4 ++-- .../src/vats/away-coordinator.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 0fa6c95d8f..65135d020f 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,7 +1,7 @@ import { M } from '@endo/patterns'; -import { constant } from '@metamask/kernel-utils'; -import type { PresheafSection } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; +import { constant } from '@metamask/kernel-utils/sheaf'; +import type { PresheafSection } from '@metamask/kernel-utils/sheaf'; import { encodeTransfer } from './erc20.ts'; import { METHOD_CATALOG } from './method-catalog.ts'; diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 2c6cfb675a..92202b28f4 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,7 +1,15 @@ import { E } from '@endo/eventual-send'; -import { sheafify, constant, makeRemoteSection } from '@metamask/kernel-utils'; -import type { Lift, PresheafSection, Sheaf } from '@metamask/kernel-utils'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { + sheafify, + constant, + makeRemoteSection, +} from '@metamask/kernel-utils/sheaf'; +import type { + Lift, + PresheafSection, + Sheaf, +} from '@metamask/kernel-utils/sheaf'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; From 637f95e141e9550daa66537dfc4dc688306a919f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:16:49 -0400 Subject: [PATCH 51/51] fix(evm-wallet): update token normalization test to inspect guard instead of section.token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old DelegationSection type exposed a .token property that reflected the normalized address. PresheafSection does not — the lowercased token is now embedded in the transferFungible method guard (M.eq(token)) rather than carried on the return value. Use getInterfaceGuardPayload/getMethodGuardPayload from @endo/patterns to extract the first arg guard and verify it accepts the lowercase form but rejects the original checksummed address. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.test.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts index 771864d616..313e19a207 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -1,3 +1,9 @@ +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; import { describe, expect, it, vi } from 'vitest'; import type { @@ -122,11 +128,11 @@ describe('makeDelegationTwin', () => { }); describe('transferFungible twin', () => { - it('normalizes checksummed token address to lowercase in section.token', () => { + it('normalizes checksummed token address to lowercase in transferFungible guard', () => { const CHECKSUMMED_TOKEN = '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' as Address; const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const section = makeDelegationTwin({ + makeDelegationTwin({ grant: { method: 'transferFungible', token: CHECKSUMMED_TOKEN, @@ -135,7 +141,18 @@ describe('makeDelegationTwin', () => { }, redeemFn, }); - expect(section.token).toBe(CHECKSUMMED_TOKEN.toLowerCase()); + // The guard's first arg for transferFungible is M.eq(token.toLowerCase()). + // If the token is not normalized here, incoming calls with the canonical + // lowercase address would be rejected by the guard at dispatch time. + const ifacePayload = getInterfaceGuardPayload( + lastInterfaceGuard as InterfaceGuard, + ) as unknown as { methodGuards: Record }; + const methodPayload = getMethodGuardPayload( + ifacePayload.methodGuards.transferFungible, + ) as unknown as { argGuards: Pattern[] }; + const tokenGuard = methodPayload.argGuards[0]; + expect(matches(CHECKSUMMED_TOKEN.toLowerCase(), tokenGuard)).toBe(true); + expect(matches(CHECKSUMMED_TOKEN, tokenGuard)).toBe(false); }); it('exposes transferFungible method', () => {