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', () => { diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 251d347e08..65135d020f 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 { 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'; 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..92202b28f4 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,9 +1,18 @@ import { E } from '@endo/eventual-send'; 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'; -import type { DelegationSection } from '../lib/delegation-twin.ts'; import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { decodeBalanceOfResult, @@ -15,6 +24,7 @@ import { encodeName, encodeSymbol, } from '../lib/erc20.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; import { registerEnvironment, resolveEnvironment, @@ -46,6 +56,12 @@ 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 +207,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 +230,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 +298,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 +340,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 +1055,7 @@ export function buildRootObject( } // ------------------------------------------------------------------------- - // Routing helpers + // Sheaf helpers // ------------------------------------------------------------------------- /** @@ -1049,32 +1082,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 +1142,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 +1163,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 +1530,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 +1830,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 +1865,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.toLowerCase() as Address, + 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 +1888,7 @@ export function buildRootObject( throw new Error('Redeemer vat not available'); } await E(redeemerVat).receiveGrant(grant); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** @@ -1905,8 +1906,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 +1919,7 @@ export function buildRootObject( homeSection = await E(homeCoordRef).getHomeSection(); persistBaggage('homeCoordRef', homeCoordRef); persistBaggage('homeSection', homeSection); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** 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 ──────────────────────────────────────────────────────────────── diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 063ee0e4cb..80591b058b 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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) + - `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` + ## [0.5.0] ### Added diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 9e3643ab73..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", @@ -121,6 +131,7 @@ "@chainsafe/libp2p-yamux": "8.0.1", "@endo/captp": "^4.4.8", "@endo/errors": "^1.2.13", + "@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/LIFT.md b/packages/kernel-utils/src/sheaf/LIFT.md new file mode 100644 index 0000000000..8f0abd9526 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/LIFT.md @@ -0,0 +1,145 @@ +# 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. 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. 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 + 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 + 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: + +```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 new file mode 100644 index 0000000000..45e10449ce --- /dev/null +++ b/packages/kernel-utils/src/sheaf/README.md @@ -0,0 +1,121 @@ +# 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 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. + +## 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*` 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 +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 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 (sections frozen at construction time) and exposes factory methods that +produce dispatch exos on demand. + +``` +const sheaf = sheafify({ name: 'Wallet', sections }); +``` + +- `sheaf.getSection({ guard, lift })` — produce a dispatch exo +- `sheaf.getDiscoverableSection({ guard, lift, schema })` — same, but the exo exposes its guard + +## Dispatch pipeline + +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 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 +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/USAGE.md b/packages/kernel-utils/src/sheaf/USAGE.md new file mode 100644 index 0000000000..307972faee --- /dev/null +++ b/packages/kernel-utils/src/sheaf/USAGE.md @@ -0,0 +1,167 @@ +# 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 { 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 = makeSection('PriceService', priceGuard, { + async getPrice(token) { + return fetchPrice(token); + }, +}); + +const sheaf = sheafify({ + name: 'PriceService', + sections: [{ exo: priceExo }], +}); + +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'); +``` + +## 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' }); + +// @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, +// 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. + +## 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 { + makeSection, + 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(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/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]); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/compose.ts b/packages/kernel-utils/src/sheaf/compose.ts new file mode 100644 index 0000000000..754d8cbed6 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/compose.ts @@ -0,0 +1,123 @@ +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. + * + * 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 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. + * @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/guard.test.ts b/packages/kernel-utils/src/sheaf/guard.test.ts new file mode 100644 index 0000000000..b2cbbea38c --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.test.ts @@ -0,0 +1,188 @@ +import { M, matches } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { + collectSheafGuard, + getInterfaceMethodGuards, + getMethodPayload, +} from './guard.ts'; +import { makeSection } from './section.ts'; +import { guardCoversPoint } from './stalk.ts'; + +describe('collectSheafGuard', () => { + it('variable arity: add with 1, 2, and 3 args', () => { + const sections = [ + makeSection( + 'Calc:0', + M.interface('Calc:0', { add: M.call(M.number()).returns(M.number()) }), + { add: (a: number) => a }, + ), + makeSection( + 'Calc:1', + 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 }, + ), + ]; + + const guard = collectSheafGuard('Calc', sections); + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.add!); + + // 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', + M.interface('S:0', { f: M.call(M.eq(0)).returns(M.eq(0)) }), + { f: (_: number) => 0 }, + ), + makeSection( + 'S:1', + M.interface('S:1', { f: M.call(M.eq(1)).returns(M.eq(1)) }), + { f: (_: number) => 1 }, + ), + ]; + + const guard = collectSheafGuard('S', sections); + 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); + expect(matches(1, returnGuard)).toBe(true); + }); + + it('section with its own optional args: optional preserved in union', () => { + 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}` }, + ), + ]; + + const guard = collectSheafGuard('Greeter', sections); + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.greet!); + + expect(payload.argGuards).toHaveLength(1); + expect(payload.optionalArgGuards).toHaveLength(1); + }); + + it('rest arg guard preserved in collected union', () => { + const sections = [ + makeSection( + 'Logger', + M.interface('Logger', { + log: M.call(M.string()).rest(M.string()).returns(M.any()), + }), + { log: (..._args: string[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('Logger', sections); + const methodGuards = getInterfaceMethodGuards(guard); + const payload = getMethodPayload(methodGuards.log!); + + 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', + M.interface('A', { + log: M.call(M.string()).rest(M.string()).returns(M.any()), + }), + { log: (..._args: string[]) => undefined }, + ), + makeSection( + 'B', + M.interface('B', { + log: M.call(M.string()).rest(M.number()).returns(M.any()), + }), + { log: (..._args: unknown[]) => undefined }, + ), + ]; + + const guard = collectSheafGuard('AB', sections); + const methodGuards = getInterfaceMethodGuards(guard); + const { restArgGuard } = getMethodPayload(methodGuards.log!); + + expect(matches('hello', restArgGuard)).toBe(true); + 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', + M.interface('AB:0', { f: M.call(M.number()).returns(M.any()) }), + { f: (_: number) => undefined }, + ), + makeSection( + 'AB:1', + M.interface('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( + '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}`, + }, + ), + ]; + + const guard = collectSheafGuard('Multi', sections); + 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 new file mode 100644 index 0000000000..cabe70dd94 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/guard.ts @@ -0,0 +1,216 @@ +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; +}; + +/** + * 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. + * + * 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. + * + * @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]; + } + const optIdx = idx - payload.argGuards.length; + if ( + payload.optionalArgGuards && + optIdx < payload.optionalArgGuards.length + ) { + return payload.optionalArgGuards[optIdx]; + } + return payload.restArgGuard; + }; + + 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), + ); + + unionMethodGuards[methodName] = buildMethodGuard( + M.callWhen(...requiredArgGuards), + optionalArgGuards, + unionRestArgGuard, + returnGuard, + ); + } + + 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/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'; 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..8257f03cad --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.test.ts @@ -0,0 +1,133 @@ +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({ n: 42 })).toStrictEqual({ + kind: 'constant', + value: { n: 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[]) => ({ 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[]) => ({ + value: (args[0] as number) * 2, + })); + const spec = resolveMetadataSpec(callable(fn)); + 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) => ({ x: args[0] })')).toStrictEqual({ + kind: 'source', + src: '(args) => ({ x: args[0] })', + }); + }); + + 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( + source<{ value: number }>('(args) => ({ value: args[0] })'), + compartment, + ); + expect(spec.kind).toBe('callable'); + 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({ answer: 42 }); + 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); + }); + + it("throws if kind is 'source' and no compartment supplied", () => { + expect(() => resolveMetadataSpec(source('() => ({})'))).toThrow( + "compartment required to evaluate 'source' metadata", + ); + }); +}); + +describe('evaluateMetadata', () => { + 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 new file mode 100644 index 0000000000..3a7aa03f79 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/metadata.ts @@ -0,0 +1,131 @@ +/** + * 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 }; + +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 }); + +/** + * 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. + */ +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 metadata spec. + */ +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. + * + * 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 object (possibly empty). + */ +export const evaluateMetadata = >( + spec: ResolvedMetadataSpec | undefined, + args: unknown[], +): MetaData => { + if (spec === undefined) { + return {} as MetaData; + } + const raw = spec.kind === 'constant' ? spec.value : spec.fn(args); + return normalizeEvaluatedSheafMetadata(raw) as MetaData; +}; 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..e4439c8f15 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/remote.test.ts @@ -0,0 +1,111 @@ +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 { 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. +// 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) => + makeSection( + 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, + }, + ); + +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 = makeSection( + 'Remote', + M.interface( + 'Remote', + { greet: M.callWhen(M.string()).returns(M.string()) }, + { defaultGuards: 'passable' }, + ), + { greet }, + ); + + 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 = makeSection( + '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 }, + ); + + 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..1a3f388f3b --- /dev/null +++ b/packages/kernel-utils/src/sheaf/remote.ts @@ -0,0 +1,57 @@ +import { E } from '@endo/eventual-send'; +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 { makeSection } from './section.ts'; +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 interfaceGuard: InterfaceGuard = await ( + E(remoteRef) as unknown as { + [GET_INTERFACE_GUARD](): Promise; + } + )[GET_INTERFACE_GUARD](); + + const { methodGuards } = getInterfaceGuardPayload( + interfaceGuard, + ) as unknown as { + methodGuards: 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[]) => + // 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); + } + + 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 new file mode 100644 index 0000000000..05115d40dc --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.e2e.test.ts @@ -0,0 +1,630 @@ +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 } 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 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); + + const sections: PresheafSection<{ cost: number }>[] = [ + { + // Remote: covers all accounts, expensive + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: remote0GetBalance }, + ), + metadata: constant({ cost: 100 }), + }, + { + // Local cache: covers only 'alice', cheap + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: local1GetBalance }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + + // alice: both sections match, argmin picks local (cost=1) + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local1GetBalance.mockClear(); + + // bob: only remote matches (stalk=1, lift not invoked) + 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: makeSection( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: local2GetBalance }, + ), + metadata: constant({ cost: 2 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + + // bob: now remote (cost=100) and new local (cost=2) both match, argmin picks cost=2 + await E(wallet).getBalance('bob'); + expect(local2GetBalance).toHaveBeenCalledWith('bob'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + local2GetBalance.mockClear(); + + // alice: three sections match, argmin still picks cost=1 + await E(wallet).getBalance('alice'); + expect(local1GetBalance).toHaveBeenCalledWith('alice'); + expect(remote0GetBalance).not.toHaveBeenCalled(); + expect(local2GetBalance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// 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 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 + // (effect-equivalence) holds by construction. + const ledger: Record = { + alice: 1000, + bob: 500, + 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 ────────────────────────────────── + // Covers ALL accounts (M.string()), but slow (500ms). + sections.push({ + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ), + metadata: constant({ latencyMs: 500, label: 'network' }), + }); + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 1 — single backend: stalk is always 1, lift never fires. + 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. + sections.push({ + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: localGetBalance }, + ), + metadata: constant({ latencyMs: 1, label: 'local' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 2 — alice routes to local (1ms < 500ms), bob still hits network. + 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). + sections.push({ + exo: makeSection( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.or(M.eq('bob'), M.eq('carol'))).returns( + M.number(), + ), + }), + { getBalance: cacheGetBalance }, + ), + metadata: constant({ latencyMs: 0, label: 'cache' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // Phase 3 — every known account hits its optimal tier. + 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 + // read-only tiers above declared it, so writes route here + // automatically — the guard algebra handles it, no config needed. + sections.push({ + exo: makeSection( + '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: writeBackendGetBalance, + transfer: writeBackendTransfer, + }, + ), + metadata: constant({ latencyMs: 200, label: 'write-backend' }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: fastest, + }); + + // transfer: only write-backend declares it → stalk=1, lift bypassed. + const facade = wallet as unknown as Record< + string, + (...args: unknown[]) => unknown + >; + 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). + 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 networkGetBalance = vi.fn((_acct: string): number => 0); + const mirrorGetBalance = vi.fn((_acct: string): number => 0); + + const makeSections = (): PresheafSection[] => [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ), + metadata: constant({ latencyMs: 500, label: 'network' }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: mirrorGetBalance }, + ), + metadata: constant({ latencyMs: 50, label: 'mirror' }), + }, + ]; + + // Policy A: fastest wins (mirror at 50ms < network at 500ms). + 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 function* (germs) { + yield* [...germs].sort( + (a, b) => (b.metadata?.latencyMs ?? 0) - (a.metadata?.latencyMs ?? 0), + ); + }; + const walletB = sheafify({ + name: 'Wallet', + sections: makeSections(), + }).getGlobalSection({ lift: slowest }); + await E(walletB).getBalance('alice'); + expect(networkGetBalance).toHaveBeenCalledWith('alice'); + expect(mirrorGetBalance).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// E2E: preferAutonomous recovered as degenerate case +// --------------------------------------------------------------------------- + +describe('e2e: preferAutonomous recovered as degenerate case', () => { + it('binary push metadata recovers push-pull lift rule', async () => { + 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); + const pushGetBalance = vi.fn((_acct: string): number => 0); + + const sections: PresheafSection<{ push: boolean }>[] = [ + { + // Pull section: M.string() guards, push=false + exo: makeSection( + 'PushPull:0', + M.interface('PushPull:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: pullGetBalance }, + ), + metadata: constant({ push: false }), + }, + { + // Push section: narrow guard, push=true + exo: makeSection( + 'PushPull:1', + M.interface('PushPull:1', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: pushGetBalance }, + ), + metadata: constant({ push: true }), + }, + ]; + + const wallet = sheafify({ name: 'PushPull', sections }).getGlobalSection({ + lift: preferPush, + }); + + // alice: both match, preferPush picks push section + await E(wallet).getBalance('alice'); + expect(pushGetBalance).toHaveBeenCalledWith('alice'); + expect(pullGetBalance).not.toHaveBeenCalled(); + pushGetBalance.mockClear(); + + // bob: only pull matches (stalk=1, lift bypassed) + await E(wallet).getBalance('bob'); + expect(pullGetBalance).toHaveBeenCalledWith('bob'); + 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 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( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ), + // cost(amount) = 1 + 0.1 * amount + metadata: callable((args) => ({ + cost: 1 + 0.1 * (args[0] as number), + })), + }, + { + exo: makeSection( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ), + // 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(); + }); +}); + +// --------------------------------------------------------------------------- +// 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('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 }; + + 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', + ); + }); +}); 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..501adb3968 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.string-metadata.test.ts @@ -0,0 +1,104 @@ +// 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 { 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 } 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 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( + (_amount: number, _from: string, _to: string): boolean => true, + ); + const swapBFn = vi.fn( + (_amount: number, _from: string, _to: string): boolean => true, + ); + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'SwapA', + M.interface('SwapA', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapAFn }, + ), + // cost(amount) = 1 + 0.1 * amount + metadata: source(`(args) => ({ cost: 1 + 0.1 * args[0] })`), + }, + { + exo: makeSection( + 'SwapB', + M.interface('SwapB', { + swap: M.call(M.number(), M.string(), M.string()).returns( + M.boolean(), + ), + }), + { swap: swapBFn }, + ), + // 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 new file mode 100644 index 0000000000..b66c71cccd --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.test.ts @@ -0,0 +1,805 @@ +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, +} 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; + // eslint-disable-next-line require-yield + const lift: Lift<{ cost: number }> = async function* (_germs) { + liftCalled = true; + // unreachable — fast path bypasses lift for single section + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift, + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + expect(liftCalled).toBe(false); + }); + + it('zero-coverage throws', async () => { + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(_germs) { + // unreachable — zero-coverage path throws before reaching lift + }, + }); + 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 function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.eq('bob')).returns(M.number()), + }), + { getBalance: (_acct: string) => 50 }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + yield germs[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 function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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: makeSection( + '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, + }, + ), + metadata: constant({ cost: 1 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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 = makeSection( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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 function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + ]; + + let wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a pre-built exo with a cheaper getBalance + new transfer method + const exo = makeSection( + '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, + metadata: constant({ cost: 1 }), + }); + wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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 = makeSection( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { exo, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + yield germs[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: EvaluatedSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async function* (germs, context) { + capturedGerms = germs; + capturedContext = context; + yield germs[0]!; + }; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ region: 'us', cost: 100 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ region: 'us', cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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: EvaluatedSection>[] = []; + let capturedContext: LiftContext | undefined; + + const spy: Lift = async function* (germs, context) { + capturedGerms = germs; + capturedContext = context; + yield germs[0]!; + }; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ region: 'us' }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ region: 'us' }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + 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: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 1 }), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + // eslint-disable-next-line require-yield + async *lift(_germs) { + liftCalled = true; + }, + }); + await E(wallet).getBalance('alice'); + + // Both sections have identical metadata → collapsed to one germ → lift bypassed + 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 +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; + + 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; + + const sections: PresheafSection[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + }, + { + exo: makeSection( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({}), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + // eslint-disable-next-line require-yield + async *lift(_germs) { + liftCalled = true; + }, + }); + await E(wallet).getBalance('alice'); + + expect(liftCalled).toBe(false); + }); + + it('mixed sections participate in lift', async () => { + const argmin: Lift<{ cost: number }> = async function* (germs) { + yield* [...germs].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const exo = makeSection( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const sections: PresheafSection<{ cost: number }>[] = [ + { + exo: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { exo, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + lift: argmin, + }); + // argmin picks the exo section (cost=1) + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + 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: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + }, + ]; + + 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: makeSection( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + }, + ]; + + const section = sheafify({ name: 'Wallet', sections }).getGlobalSection({ + async *lift(germs) { + yield germs[0]!; + }, + }); + + expect( + (section as Record)[GET_DESCRIPTION], + ).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); + }); +}); diff --git a/packages/kernel-utils/src/sheaf/sheafify.ts b/packages/kernel-utils/src/sheaf/sheafify.ts new file mode 100644 index 0000000000..1a3331b300 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/sheafify.ts @@ -0,0 +1,346 @@ +/** + * Sheafify a presheaf into an authority manager. + * + * `sheafify({ name, sections })` returns a `Sheaf` — an immutable object + * that produces dispatch sections over a fixed presheaf. + * + * 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 } 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 { asyncifyMethodGuards, collectSheafGuard } from './guard.ts'; +import { evaluateMetadata, resolveMetadataSpec } from './metadata.ts'; +import type { ResolvedMetadataSpec } from './metadata.ts'; +import { getStalk } from './stalk.ts'; +import type { + EvaluatedSection, + Lift, + LiftContext, + PresheafSection, + Section, + 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]; + } + if (Object.is(value, -0)) { + return [key, '-0', 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. + */ +const metadataKey = (metadata: Record): string => { + const keys = Object.keys(metadata); + if (keys.length === 0) { + return 'null'; + } + const entries = Object.entries(metadata) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, val]) => encodeMetadataEntry(key, val)); + 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: EvaluatedSection[], +): EvaluatedSection[] => { + const seen = new Set(); + const representatives: EvaluatedSection[] = []; + 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: EvaluatedSection[], +): { + constraints: Partial; + stripped: EvaluatedSection>[]; +} => { + const constraints: Record = {}; + + 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 && Object.is(meta[key], val); + }); + if (shared) { + constraints[key] = val; + } + } + + const stripped = stalk.map((entry) => { + const remaining: Record = {}; + for (const [key, val] of Object.entries(entry.metadata)) { + if (!(key in constraints)) { + remaining[key] = val; + } + } + return { exo: entry.exo, metadata: remaining as Partial }; + }); + + return { constraints: constraints as Partial, stripped }; +}; + +/** + * 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 ResolvedSection> = { + exo: Section; + 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, +>({ + name, + sections, + compartment, +}: { + name: string; + sections: PresheafSection[]; + compartment?: { evaluate: (src: string) => unknown }; +}): Sheaf => { + const frozenSections: readonly ResolvedSection[] = harden( + sections.map((section) => ({ + exo: section.exo, + spec: + section.metadata === undefined + ? undefined + : resolveMetadataSpec(section.metadata, compartment), + })), + ); + const buildSection = ({ + guard, + lift, + schema, + }: { + guard: InterfaceGuard; + lift: Lift; + schema?: Record; + }): object => { + const asyncMethodGuards = asyncifyMethodGuards(guard); + const asyncGuard = + schema === undefined + ? M.interface(`${name}:section`, asyncMethodGuards) + : M.interface(`${name}:section`, asyncMethodGuards, { + defaultGuards: 'passable', + }); + + const dispatch = async ( + method: string, + args: unknown[], + ): Promise => { + const stalk = getStalk(frozenSections, method, args); + const evaluatedStalk: EvaluatedSection[] = stalk.map( + (section) => ({ + exo: section.exo, + metadata: evaluateMetadata(section.spec, args), + }), + ); + switch (evaluatedStalk.length) { + case 0: + throw new Error(`No section covers ${method}(${stringify(args, 0)})`); + case 1: + return invokeExo( + (evaluatedStalk[0] as EvaluatedSection).exo, + method, + args, + ); + default: { + const collapsed = collapseEquivalent(evaluatedStalk); + if (collapsed.length === 1) { + return invokeExo( + (collapsed[0] as EvaluatedSection).exo, + method, + args, + ); + } + const { constraints, stripped } = decomposeMetadata(collapsed); + 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 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); + }, + ); + } + } + }; + + const handlers: Record Promise> = + {}; + for (const method of Object.keys(asyncMethodGuards)) { + handlers[method] = async (...args: unknown[]) => dispatch(method, args); + } + + const exo = (schema === undefined + ? makeExo(`${name}:section`, asyncGuard, handlers) + : makeDiscoverableExo( + `${name}:section`, + handlers, + schema, + asyncGuard, + )) as unknown as Section; + + return exo; + }; + + 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 }); + + return harden({ + getSection, + getDiscoverableSection, + getGlobalSection, + getDiscoverableGlobalSection, + }); +}; 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..3a7c9abb2f --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.test.ts @@ -0,0 +1,168 @@ +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 } from './types.ts'; + +const makePresheafSection = ( + tag: string, + guards: Record, + methods: Record unknown>, + metadata: { cost: number }, +): 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', () => { + 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).toStrictEqual(constant({ cost: 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..01d50c2208 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/stalk.ts @@ -0,0 +1,73 @@ +/** + * Stalk computation: filter presheaf sections by guard matching. + */ + +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import { matches } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; + +import { getInterfaceMethodGuards, getMethodPayload } from './guard.ts'; +import type { Section } 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 = getInterfaceMethodGuards(guard); + if (!(method in methodGuards)) { + return false; + } + const methodGuard = methodGuards[method]; + if (!methodGuard) { + return false; + } + const { argGuards, optionalArgGuards, restArgGuard } = + getMethodPayload(methodGuard); + 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: readonly T[], + method: string, + args: unknown[], +): T[] => { + 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..afc9d98746 --- /dev/null +++ b/packages/kernel-utils/src/sheaf/types.ts @@ -0,0 +1,138 @@ +/** + * 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'; + +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; +}; + +/** + * 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> = + | { 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 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> = { + 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: 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. + * + * 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, +) => AsyncGenerator>, void, unknown[]>; + +/** + * A sheaf: an authority manager over a presheaf. + * + * Produces dispatch sections via `getSection`, each routing invocations + * through the presheaf sections supplied at construction time. + */ +export type Sheaf> = { + /** + * 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 + * 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 discoverable dispatch exo over the given guard. + * + * Returns `object` for the same reason as `getSection`. + */ + getDiscoverableSection: (opts: { + guard: InterfaceGuard; + lift: Lift; + schema: Record; + }) => object; + /** + * 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 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. + */ + getDiscoverableGlobalSection: (opts: { + lift: Lift; + schema: Record; + }) => object; +}; 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"