diff --git a/packages/sheaves/CHANGELOG.md b/packages/sheaves/CHANGELOG.md new file mode 100644 index 000000000..030edf689 --- /dev/null +++ b/packages/sheaves/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] + +### Added + +- Initial release, extracted from `@metamask/kernel-utils`. +- `sheafify({ name, providers })` — constructs a sheaf authority manager over a + set of capability providers. +- `Provider` type — an input to `sheafify`: a `{ handler, metadata? }` pair + where `handler` is an exo and `metadata` is an optional `MetadataSpec`. +- `Candidate` type — a post-evaluation entry in the stalk: `{ handler, +metadata }` with metadata already resolved from its spec. +- `Handler` type — an exo capability covering a region of the interface + topology. +- `Policy` type — an `async function*` coroutine that receives candidates + and yields them in preference order; drives the sheaf dispatch loop. +- `PolicyContext` type — context passed to the policy: `{ method, args, +constraints }`. +- `MetadataSpec` discriminated union with three variants: `constant`, + `source`, and `callable`. +- `constant(value)` — static metadata spec; value is fixed at construction. +- `source(src)` — source-string metadata spec; compiled via the optional + compartment at `sheafify` construction time. +- `callable(fn)` — callable metadata spec; evaluated per-dispatch with the + invocation arguments. +- `makeHandler(name, guard, methods)` — creates a named, guarded exo handler. +- `makeRemoteSection(tag, remoteRef, metadata?)` — builds a provider that + wraps a remote capability, fetching its interface guard via `E`. +- `noopPolicy` — a policy that yields candidates in the order received. +- `proxyPolicy(gen)` — wraps an existing generator to satisfy the `Policy` + call signature. +- `withFilter(predicate)` — higher-order policy combinator that pre-filters + the candidate list before passing it to the inner policy. +- `withRanking(comparator)` — higher-order policy combinator that pre-sorts + the candidate list before passing it to the inner policy. +- `fallthrough(policyA, policyB)` — composes two policies so that `policyB` + is tried only after `policyA` is exhausted. +- `Sheaf` type — the authority manager returned by `sheafify`; exposes + `getSection`, `getDiscoverableSection`, `getGlobalSection`, and + `getDiscoverableGlobalSection`. +- `documents/POLICY.md` — documents the policy coroutine protocol, + `PolicyContext`, and the semantic equivalence assumption. +- `documents/USAGE.md` — annotated usage examples. + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/compare/@metamask/sheaves@0.1.0...HEAD +[0.1.0]: https://github.com/MetaMask/ocap-kernel/releases/tag/@metamask/sheaves@0.1.0 diff --git a/packages/sheaves/LICENSE.APACHE2 b/packages/sheaves/LICENSE.APACHE2 new file mode 100644 index 000000000..8194a06ae --- /dev/null +++ b/packages/sheaves/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Consensys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sheaves/LICENSE.MIT b/packages/sheaves/LICENSE.MIT new file mode 100644 index 000000000..658c855eb --- /dev/null +++ b/packages/sheaves/LICENSE.MIT @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 Consensys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/sheaves/README.md b/packages/sheaves/README.md new file mode 100644 index 000000000..edbe01811 --- /dev/null +++ b/packages/sheaves/README.md @@ -0,0 +1,131 @@ +# Sheaf + +Runtime capability routing adapted from sheaf theory in algebraic topology. + +`sheafify({ name, providers })` produces a **sheaf** — an authority manager +over a collection of capability providers. The sheaf produces dispatch handlers via +`getSection`, each of which routes invocations through the provider set. + +See [USAGE.md](./USAGE.md) for annotated examples and [POLICY.md](./POLICY.md) for +the policy coroutine protocol and semantic equivalence assumption. + +## Install + +```sh +yarn add @metamask/sheaves +``` + +```sh +npm install @metamask/sheaves +``` + +## Concepts + +**Provider** (`Provider`) — The input data: a capability handler paired with +operational metadata, assigned over the open set defined by the handler's guard. +This is an element of the presheaf F = F_sem x F_op. + +> A `getBalance(string)` provider with `{ cost: 100 }` is one provider. A +> `getBalance("alice")` provider with `{ cost: 1 }` is another, covering a +> narrower open set. + +**Candidate** — An equivalence class of providers at an invocation point, +identified by metadata. At dispatch time, providers in the stalk with identical +metadata are collapsed into a single candidate; 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 candidate. The policy never sees both — it receives one representative. + +**Stalk** — The set of candidates 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 candidates (cost 1 vs 100); +> stalk at `("transfer", ...)` might contain one. + +**Policy** — An `async function*` coroutine that yields candidates from a +multi-candidate stalk in preference order. See [LIFT.md](./LIFT.md) for the +coroutine protocol, `PolicyContext`, and the semantic equivalence assumption +required of all policies. + +At dispatch time, metadata is decomposed into **constraints** (keys with the +same value across every candidate — topologically determined, not a choice) and +**options** (the remaining keys — the policy's actual decision space). The policy +receives only options on each candidate; constraints arrive separately in the +context. + +> `argmin` by cost, `argmin` by latency, or any custom selection logic. The +> policy is never invoked when the stalk resolves to a single candidate — either +> because only one provider matched, or because all matching providers had +> identical metadata and collapsed to one representative. + +**Sheaf** — The authority manager returned by `sheafify`. Holds the provider +data (frozen at construction time) and exposes factory methods that +produce dispatch handlers on demand. + +``` +const sheaf = sheafify({ name: 'Wallet', providers }); +``` + +- `sheaf.getSection({ guard, lift })` — produce a dispatch handler +- `sheaf.getDiscoverableSection({ guard, lift, schema })` — same, but the handler exposes its guard + +## Dispatch pipeline + +At each invocation point `(method, args)` within a granted section: + +``` +getStalk(providers, 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) +policy(candidates, { method, args, operational selection (extra-theoretic) + constraints }) +dispatch to chosen.handler evaluation +``` + +The pipeline short-circuits at two points: if only one provider matches the +guard, it is invoked directly without evaluate/collapse/policy; if all matching +providers collapse to an identical candidate, the single representative is invoked +without calling the policy. + +`callable` and `source` metadata specs make the stalk shape depend on the +invocation arguments. A `swap(amount)` provider can produce `{ cost: 'low' }` +for small amounts and `{ cost: 'high' }` for large ones, yielding a different +set of candidates — and potentially a different policy outcome — for the same +method called with different arguments. + +## Design choices + +**Candidate identity is metadata identity.** The collapse step quotients by +metadata: if two providers should be distinguishable, the caller must give them +distinguishable metadata. Providers 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, select via policy. The trade-off is that global coherence +(a policy 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 providers (`getStalk`), then `decomposeMetadata` strips the metadata +to distinguishing keys — the restricted metadata over that point. The join +works dually: the union of two providers has the join of their metadata, and +restriction at any point recovers the local distinguishing keys in O(n). +Gluing follows: compatible providers (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 candidate, any representative +will do — authority-equivalence is asserted by constructor contract, not +verified at runtime. Between candidates, metadata distinguishes them and the +policy resolves the choice. diff --git a/packages/sheaves/documents/POLICY.md b/packages/sheaves/documents/POLICY.md new file mode 100644 index 000000000..d6da6fc7a --- /dev/null +++ b/packages/sheaves/documents/POLICY.md @@ -0,0 +1,145 @@ +# Policy + +The policy is the caller-supplied selection coroutine in the sheaf dispatch +pipeline. It runs when more than one candidate matches an invocation and the sheaf +has no data to resolve the ambiguity on its own. The +caller is responsible for writing a policy that is correct for the providers it +will receive. + +## Coroutine protocol + +The policy is an `async function*` generator, not a plain async function: + +```ts +type Policy = ( + candidates: Candidate>[], + context: PolicyContext, +) => 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 to try next. The yielded value + must be an element of the `candidates` array received on entry — the sheaf + uses object identity to map it back to the original provider. Constructing a + new object with the same shape will throw with a message like "Policy yielded + an unrecognized candidate". Sorting with `[...candidates].sort(...)` is safe + because sort preserves references; mapping to new objects is not. +3. **Attempt** — the sheaf calls the candidate's handler 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 handler for ', { cause: errors })` where + `errors` is the full accumulated list of every failure so far. + +Most policies express a fixed priority order and can ignore the error input: + +```ts +const awayPolicy: Policy = async function* (candidates) { + yield* candidates.filter((c) => c.metadata?.mode === 'delegation'); + yield* candidates.filter((c) => c.metadata?.mode === 'call-home'); +}; +``` + +A policy that inspects failure history can read the errors from yield: + +```ts +const cautious: Policy = async function* (candidates) { + for (const candidate of candidates) { + const errors: unknown[] = yield candidate; + // errors is the cumulative list of all failures so far, including the one + // just returned for this candidate. Inspect to decide whether to continue. + if (errors.some(isUnrecoverable)) return; + } +}; +``` + +## PolicyContext + +The second argument to the policy is a `PolicyContext`: + +```ts +type PolicyContext = { + method: string; // the method being dispatched + args: unknown[]; // the invocation arguments + constraints: Partial; // metadata keys identical across every candidate +}; +``` + +**`constraints`** are metadata keys whose values are the same across every +candidate. Because all candidates agree on these keys, they carry no +information useful for choosing between them — the sheaf strips them from +each candidate and delivers them separately. A policy that needs to know, say, +the agreed `protocol` version reads it from `context.constraints.protocol` +rather than from any individual candidate. + +**`args`** is available for cases where the policy itself must inspect the +call. Most of the time, however, arg-dependent selection is better expressed as +`callable` metadata on the providers than as conditional logic in the policy. + +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 providers: Provider[] = [ + { + handler: providerAHandler, + metadata: callable((args) => ({ cost: providerACost(Number(args[0])) })), + }, + { + handler: providerBHandler, + metadata: callable((args) => ({ cost: providerBCost(Number(args[0])) })), + }, +]; +``` + +By the time the policy runs, `candidate.metadata.cost` already holds the +concrete cost for this specific invocation — the swap amount has been applied. +A policy that sorts by cost needs no knowledge of `args` at all: + +```ts +const cheapestFirst: Policy = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => (a.metadata?.cost ?? 0) - (b.metadata?.cost ?? 0), + ); +}; +``` + +This is why evaluable metadata exists: the arg-dependent logic lives with the +providers that own it, and the policy stays a pure selection coroutine. + +## Semantic equivalence assumption + +Two providers 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 providers be identical. It +requires only that **if two providers are indistinguishable by metadata, their +differences are immaterial to the authority invoker**. + +The sheaf relies on the following separation of responsibilities: + +- **Provider 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 + provider's metadata. Omitting a distinguishing feature is a declaration that + callers need not care about it. + +- **Policy constructors** are responsible for selecting among the features that + provider constructors have chosen to expose. The policy cannot see what was + not advertised. + +This is a semantic contract, not a runtime enforcement — the sheaf cannot +verify it. When a provider 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 policy 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 +> provider 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 policy choose. diff --git a/packages/sheaves/documents/USAGE.md b/packages/sheaves/documents/USAGE.md new file mode 100644 index 000000000..847ac11b7 --- /dev/null +++ b/packages/sheaves/documents/USAGE.md @@ -0,0 +1,164 @@ +# Usage + +## Single provider + +When there is only one provider per invocation point, no policy is needed — +the dispatch short-circuits before the policy is ever called. Provide a no-op +policy as a placeholder: + +```ts +import { M } from '@endo/patterns'; +import { sheafify, makeHandler, noopPolicy } from '@metamask/sheaves'; + +const priceGuard = M.interface('PriceService', { + getPrice: M.callWhen(M.await(M.string())).returns(M.await(M.number())), +}); + +const priceHandler = makeHandler('PriceService', priceGuard, { + async getPrice(token) { + return fetchPrice(token); + }, +}); + +const sheaf = sheafify({ + name: 'PriceService', + providers: [{ handler: priceHandler }], +}); + +const section = sheaf.getSection({ guard: priceGuard, lift: noopPolicy }); +// section is a dispatch handler; call it like any capability +const price = await E(section).getPrice('ETH'); +``` + +## Multiple providers with a policy + +When more than one candidate matches an invocation, the sheaf calls the policy +to choose. The policy 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/sheaves'; +import type { Policy } from '@metamask/sheaves'; + +type WalletMeta = { mode: 'fast' | 'reliable' }; + +const preferFast: Policy = async function* (candidates) { + yield* candidates.filter((c) => c.metadata?.mode === 'fast'); + yield* candidates.filter((c) => c.metadata?.mode === 'reliable'); +}; + +const sheaf = sheafify({ + name: 'Wallet', + providers: [ + { handler: fastHandler, metadata: constant({ mode: 'fast' }) }, + { handler: reliableHandler, 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 policy 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/sheaves'; + +// 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 handler +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 providers. They are `@deprecated` as a +nudge toward explicit guards once the caller knows the provider set — explicit +guards make the capability's scope visible at the call site. When providers 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 providers + +`makeRemoteSection` wraps a CapTP remote reference as a `Provider`, fetching +the remote's guard once at construction and forwarding all calls via `E()`. +This lets you mix local handlers and remote capabilities in the same sheaf: + +```ts +import { makeHandler, makeRemoteSection, constant } from '@metamask/sheaves'; + +const remoteProvider = await makeRemoteSection( + 'RemoteWallet', // name for the wrapper handler + remoteCapRef, // CapTP reference + constant({ mode: 'remote' }), // optional metadata +); + +const sheaf = sheafify({ + name: 'Mixed', + providers: [localProvider, remoteProvider], +}); +``` + +## Policy composition + +`@metamask/sheaves` exports helpers for building policies from composable +parts, useful when policy logic would otherwise be duplicated across callers: + +```ts +import { + proxyPolicy, + withFilter, + withRanking, + fallthrough, +} from '@metamask/sheaves'; +``` + +- **`withRanking(comparator)(inner)`** — sort candidates by comparator before + passing to `inner` +- **`withFilter(predicate)(inner)`** — remove candidates that fail `predicate` + before passing to `inner` +- **`fallthrough(policyA, policyB)`** — try all candidates from `policyA` + first; if all fail, try `policyB` +- **`proxyPolicy(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 `proxyPolicy` — + `yield*` forwards `.next(value)` to the delegated iterator automatically. diff --git a/packages/sheaves/package.json b/packages/sheaves/package.json new file mode 100644 index 000000000..26ce3c810 --- /dev/null +++ b/packages/sheaves/package.json @@ -0,0 +1,105 @@ +{ + "name": "@metamask/sheaves", + "version": "0.1.0", + "description": "Capability routing via sheaf theory", + "keywords": [ + "MetaMask", + "object capabilities", + "ocap" + ], + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/sheaves#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "license": "(MIT OR Apache-2.0)", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/sheaves", + "changelog:update": "../../scripts/update-changelog.sh @metamask/sheaves", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + }, + "dependencies": { + "@endo/eventual-send": "^1.3.4", + "@endo/exo": "^1.5.12", + "@endo/patterns": "^1.7.0", + "@metamask/kernel-utils": "workspace:^" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.3.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@metamask/kernel-shims": "workspace:^", + "@ocap/repo-tools": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.14", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "ses": "^1.14.0", + "turbo": "^2.9.1", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^8.0.6", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/sheaves/src/compose.test.ts b/packages/sheaves/src/compose.test.ts new file mode 100644 index 000000000..87642475d --- /dev/null +++ b/packages/sheaves/src/compose.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { + fallthrough, + proxyPolicy, + withFilter, + withRanking, +} from './compose.ts'; +import type { Candidate, Policy, PolicyContext } from './types.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Meta = { id: string; cost: number }; +type C = Candidate>; + +const makeCandidate = (id: string, cost = 0): C => ({ + handler: {} as C['handler'], + metadata: { id, cost }, +}); + +const ctx: PolicyContext = { + method: 'transfer', + args: ['alice', 100n], + constraints: {}, +}; + +/** + * Drive a policy to exhaustion, simulating a failure after each yielded + * candidate. Returns all yielded candidates in order and the error arrays + * the generator received. + * + * @param policy - The policy to drive. + * @param candidates - The candidates to pass to the policy. + * @param context - The policy context. + * @returns Yielded candidates and error snapshots received by the generator. + */ +const driveToExhaustion = async ( + policy: Policy, + candidates: C[], + context: PolicyContext = ctx, +): Promise<{ yielded: C[]; receivedErrors: unknown[][] }> => { + const yielded: C[] = []; + const receivedErrors: unknown[][] = []; + const errors: unknown[] = []; + const gen = policy(candidates, 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 policy, succeeding on the nth candidate (1-based). + * Returns the winning candidate. + * + * @param policy - The policy to drive. + * @param candidates - The candidates to pass to the policy. + * @param successOn - Which attempt number (1-based) should succeed. + * @param context - The policy context. + * @returns The candidate that won on attempt `successOn`. + */ +const driveWithSuccessOn = async ( + policy: Policy, + candidates: C[], + successOn: number, + context: PolicyContext = ctx, +): Promise => { + const errors: unknown[] = []; + const gen = policy(candidates, 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'); +}; + +// --------------------------------------------------------------------------- +// proxyPolicy +// --------------------------------------------------------------------------- + +describe('proxyPolicy', () => { + it('forwards all yielded values from inner generator', async () => { + const [candidateA, candidateB, candidateC] = [ + makeCandidate('a'), + makeCandidate('b'), + makeCandidate('c'), + ]; + const inner = async function* (): AsyncGenerator { + yield candidateA; + yield candidateB; + yield candidateC; + }; + + const { yielded } = await driveToExhaustion(() => proxyPolicy(inner()), []); + expect(yielded).toStrictEqual([candidateA, candidateB, candidateC]); + }); + + it('forwards error arrays down to inner generator', async () => { + const [candidateA, candidateB] = [makeCandidate('a'), makeCandidate('b')]; + const receivedByInner: unknown[][] = []; + + const inner = async function* (): AsyncGenerator { + const errors1: unknown[] = yield candidateA; + receivedByInner.push(errors1); + const errors2: unknown[] = yield candidateB; + receivedByInner.push(errors2); + }; + + await driveToExhaustion(() => proxyPolicy(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(() => proxyPolicy(inner()), []); + expect(yielded).toHaveLength(0); + }); + + it('allows inner generator to stop early based on errors', async () => { + const [candidateA, candidateB, candidateC] = [ + makeCandidate('a'), + makeCandidate('b'), + makeCandidate('c'), + ]; + + const inner = async function* (): AsyncGenerator { + let errors: unknown[] = yield candidateA; + // stop after first failure + if (errors.length > 0) { + return; + } + errors = yield candidateB; + if (errors.length > 0) { + return; + } + yield candidateC; + }; + + const { yielded } = await driveToExhaustion(() => proxyPolicy(inner()), []); + // Only 'a' yielded — inner stops after receiving the first error + expect(yielded).toStrictEqual([candidateA]); + }); +}); + +// --------------------------------------------------------------------------- +// withFilter +// --------------------------------------------------------------------------- + +describe('withFilter', () => { + it('passes only matching candidates to the inner policy', async () => { + const candidates = [ + makeCandidate('a', 1), + makeCandidate('b', 2), + makeCandidate('c', 3), + ]; + const received = vi.fn(); + + const inner: Policy = async function* (allCandidates) { + received(allCandidates.map((item) => item.metadata.id)); + yield* allCandidates; + }; + + const policy = withFilter( + (candidate) => (candidate.metadata.cost ?? 0) >= 2, + )(inner); + await driveToExhaustion(policy, candidates); + + expect(received).toHaveBeenCalledWith(['b', 'c']); + }); + + it('passes context to the predicate', async () => { + const candidates = [makeCandidate('alice'), makeCandidate('bob')]; + const contextUsed: PolicyContext[] = []; + + const policy = withFilter((_candidate, policyContext) => { + contextUsed.push(policyContext); + return true; + })(async function* (allCandidates) { + yield* allCandidates; + }); + + await driveToExhaustion(policy, candidates); + + expect(contextUsed.length).toBeGreaterThan(0); + expect(contextUsed[0]).toStrictEqual(ctx); + }); + + it('yields nothing when no candidates match', async () => { + const candidates = [makeCandidate('a', 1)]; + const policy = withFilter(() => false)( + async function* (allCandidates) { + yield* allCandidates; + }, + ); + + const { yielded } = await driveToExhaustion(policy, candidates); + expect(yielded).toHaveLength(0); + }); + + it('returns the inner policy generator directly (no extra wrapping)', () => { + // withFilter is a pure input transform — it returns the inner policy's + // generator, not a new proxy generator. + const innerGen = {} as AsyncGenerator; + const inner: Policy = vi.fn(() => innerGen); + const policy = withFilter(() => true)(inner); + + const result = policy([], ctx); + expect(result).toBe(innerGen); + }); +}); + +// --------------------------------------------------------------------------- +// withRanking +// --------------------------------------------------------------------------- + +describe('withRanking', () => { + it('sorts candidates before passing to inner policy', async () => { + const candidates = [ + makeCandidate('a', 3), + makeCandidate('b', 1), + makeCandidate('c', 2), + ]; + const received = vi.fn(); + + const inner: Policy = async function* (allCandidates) { + received(allCandidates.map((item) => item.metadata.id)); + yield* allCandidates; + }; + + const policy = withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(inner); + await driveToExhaustion(policy, candidates); + + expect(received).toHaveBeenCalledWith(['b', 'c', 'a']); + }); + + it('does not mutate the original candidates array', async () => { + const candidates = [makeCandidate('a', 3), makeCandidate('b', 1)]; + const original = [...candidates]; + + const policy = withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(async function* (allCandidates) { + yield* allCandidates; + }); + + await driveToExhaustion(policy, candidates); + expect(candidates).toStrictEqual(original); + }); + + it('returns the inner policy generator directly (no extra wrapping)', () => { + const innerGen = {} as AsyncGenerator; + const inner: Policy = vi.fn(() => innerGen); + const policy = withRanking(() => 0)(inner); + + const result = policy([], ctx); + expect(result).toBe(innerGen); + }); +}); + +// --------------------------------------------------------------------------- +// fallthrough +// --------------------------------------------------------------------------- + +describe('fallthrough', () => { + it('yields all candidates from policyA then policyB', async () => { + const [a1, a2, b1, b2] = [ + makeCandidate('a1'), + makeCandidate('a2'), + makeCandidate('b1'), + makeCandidate('b2'), + ]; + + const policyA: Policy = async function* () { + yield a1; + yield a2; + }; + const policyB: Policy = async function* () { + yield b1; + yield b2; + }; + + const { yielded } = await driveToExhaustion( + fallthrough(policyA, policyB), + [], + ); + expect(yielded).toStrictEqual([a1, a2, b1, b2]); + }); + + it('stops at policyA winner and does not invoke policyB', async () => { + const [a1, a2] = [makeCandidate('a1'), makeCandidate('a2')]; + const policyBInvoked = vi.fn(); + + const policyA: Policy = async function* () { + yield a1; + yield a2; + }; + const policyB: Policy = async function* () { + policyBInvoked(); + yield makeCandidate('b1'); + }; + + // Succeed on first candidate + const winner = await driveWithSuccessOn( + fallthrough(policyA, policyB), + [], + 1, + ); + expect(winner).toBe(a1); + expect(policyBInvoked).not.toHaveBeenCalled(); + }); + + it('falls through to policyB when policyA is exhausted', async () => { + const [a1, b1] = [makeCandidate('a1'), makeCandidate('b1')]; + + const policyA: Policy = async function* () { + yield a1; + }; + const policyB: Policy = async function* () { + yield b1; + }; + + // policyA has one candidate (a1), fail it, then policyB kicks in + const winner = await driveWithSuccessOn( + fallthrough(policyA, policyB), + [], + 2, + ); + expect(winner).toBe(b1); + }); + + it('forwards error arrays through yield* to each inner policy', async () => { + const [a1, b1] = [makeCandidate('a1'), makeCandidate('b1')]; + const errorsReceivedByA: unknown[][] = []; + const errorsReceivedByB: unknown[][] = []; + + const policyA: Policy = async function* () { + const errors: unknown[] = yield a1; + errorsReceivedByA.push(errors); + }; + const policyB: Policy = async function* () { + const errors: unknown[] = yield b1; + errorsReceivedByB.push(errors); + }; + + await driveToExhaustion(fallthrough(policyA, policyB), []); + + // policyA's first yield received one error (a1 failed) + expect(errorsReceivedByA[0]).toHaveLength(1); + // policyB'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 candidates = [ + makeCandidate('a', 3), + makeCandidate('b', 1), + makeCandidate('c', 2), + makeCandidate('d', 4), // filtered out (cost > 3) + ]; + const received = vi.fn(); + + const base: Policy = async function* (allCandidates) { + received(allCandidates.map((item) => item.metadata.id)); + yield* allCandidates; + }; + + const policy = withFilter( + (candidate) => (candidate.metadata.cost ?? 0) <= 3, + )( + withRanking( + (a, b) => (a.metadata.cost ?? 0) - (b.metadata.cost ?? 0), + )(base), + ); + + await driveToExhaustion(policy, candidates); + // filtered to a/b/c, sorted by cost ascending + expect(received).toHaveBeenCalledWith(['b', 'c', 'a']); + }); + + it('proxyPolicy wrapping fallthrough threads errors through both layers', async () => { + const [a1, b1] = [makeCandidate('a1'), makeCandidate('b1')]; + const inner: Policy = fallthrough( + async function* () { + yield a1; + }, + async function* () { + yield b1; + }, + ); + + // proxyPolicy wrapping the whole fallthrough + const policy: Policy = () => proxyPolicy(inner([], ctx)); + + const { yielded } = await driveToExhaustion(policy, []); + expect(yielded).toStrictEqual([a1, b1]); + }); +}); diff --git a/packages/sheaves/src/compose.ts b/packages/sheaves/src/compose.ts new file mode 100644 index 000000000..ef1d7fc5c --- /dev/null +++ b/packages/sheaves/src/compose.ts @@ -0,0 +1,120 @@ +import type { Candidate, Policy, PolicyContext } from './types.ts'; + +/** + * A policy that yields all candidates in their original order without filtering. + * + * Use as a placeholder when the sheaf always has a single-candidate stalk + * (the policy is never actually called) or to express "try everything in + * declaration order" as an explicit policy. + * + * @param candidates - Candidates to yield in order. + * @yields Each candidate in the original array order. + */ +export async function* noopPolicy>( + candidates: Candidate>[], +): AsyncGenerator>, void, unknown[]> { + yield* candidates; +} + +/** + * Proxy a policy 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. `proxyPolicy` 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 + * // Policy that logs each retry + * const withLogging = (inner: Policy): Policy => + * async function*(candidates, context) { + * const gen = inner(candidates, 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 proxyPolicy with a side-effect added. + */ +export async function* proxyPolicy>( + 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 candidates before passing to a policy. + * + * Returns the inner policy's generator directly — no proxying needed since + * this is a pure input transform that delegates entirely to the inner policy. + * + * @param predicate - Returns true for candidates that should be passed to the inner policy. + * @returns A policy combinator that filters its candidates before delegating. + */ +export const withFilter = + >( + predicate: ( + candidate: Candidate>, + ctx: PolicyContext, + ) => boolean, + ) => + (inner: Policy): Policy => + (candidates, context) => + inner( + candidates.filter((candidate) => predicate(candidate, context)), + context, + ); + +/** + * Sort candidates by a comparator before passing to a policy. + * + * Returns the inner policy's generator directly — no proxying needed since + * this is a pure input transform that delegates entirely to the inner policy. + * The original candidates array is not mutated. + * + * @param comparator - Comparator function for sorting (same signature as Array.sort). + * @returns A policy combinator that sorts its candidates before delegating. + */ +export const withRanking = + >( + comparator: (a: Candidate>, b: Candidate>) => number, + ) => + (inner: Policy): Policy => + (candidates, context) => + inner([...candidates].sort(comparator), context); + +/** + * Try all candidates from policyA, then all candidates from policyB. + * + * Uses `yield*` directly since async generator delegation forwards + * `.next(value)` to the inner iterator, so error arrays are correctly + * threaded through each inner policy. + * + * policyB is not informed of policyA's failures at its prime call, but via + * `yield*` it receives all accumulated errors (including policyA's) as the + * argument to each subsequent `next(errors)` after its own failed attempts. + * + * @param policyA - First policy; its candidates are tried before policyB's. + * @param policyB - Fallback policy; only invoked after policyA is exhausted. + * @returns A combined policy that sequences policyA then policyB. + */ +export const fallthrough = >( + policyA: Policy, + policyB: Policy, +): Policy => + async function* (candidates, context) { + yield* policyA(candidates, context); + yield* policyB(candidates, context); + }; diff --git a/packages/sheaves/src/guard.test.ts b/packages/sheaves/src/guard.test.ts new file mode 100644 index 000000000..e98b3edcf --- /dev/null +++ b/packages/sheaves/src/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 { makeHandler } from './section.ts'; +import { guardCoversPoint } from './stalk.ts'; + +describe('collectSheafGuard', () => { + it('variable arity: add with 1, 2, and 3 args', () => { + const sections = [ + makeHandler( + 'Calc:0', + M.interface('Calc:0', { add: M.call(M.number()).returns(M.number()) }), + { add: (a: number) => a }, + ), + makeHandler( + 'Calc:1', + M.interface('Calc:1', { + add: M.call(M.number(), M.number()).returns(M.number()), + }), + { add: (a: number, b: number) => a + b }, + ), + makeHandler( + '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 = [ + makeHandler( + 'S:0', + M.interface('S:0', { f: M.call(M.eq(0)).returns(M.eq(0)) }), + { f: (_: number) => 0 }, + ), + makeHandler( + '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 = [ + makeHandler( + '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 = [ + makeHandler( + '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 = [ + makeHandler( + 'A', + M.interface('A', { + log: M.call(M.string()).rest(M.string()).returns(M.any()), + }), + { log: (..._args: string[]) => undefined }, + ), + makeHandler( + '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 = [ + makeHandler( + 'AB:0', + M.interface('AB:0', { f: M.call(M.number()).returns(M.any()) }), + { f: (_: number) => undefined }, + ), + makeHandler( + '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 = [ + makeHandler( + 'Multi:0', + M.interface('Multi:0', { + translate: M.call(M.string(), M.string()).returns(M.string()), + }), + { + translate: (from: string, to: string) => `${from}->${to}`, + }, + ), + makeHandler( + '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/sheaves/src/guard.ts b/packages/sheaves/src/guard.ts new file mode 100644 index 000000000..bb99a2756 --- /dev/null +++ b/packages/sheaves/src/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 { Handler } 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 handler guards — the open set covered by the sheafified facade. + * + * For each method name across all handlers, collects the arg guards at each + * position and produces a union via M.or. Handlers with fewer args than + * the maximum contribute to required args; the remainder become optional. + * + * @param name - The name for the collected interface guard. + * @param handlers - The handlers whose guards are collected. + * @returns An interface guard covering all handlers. + */ +export const collectSheafGuard = ( + name: string, + handlers: Handler[], +): InterfaceGuard => { + const payloadsByMethod = new Map(); + + for (const section of handlers) { + 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/sheaves/src/index.ts b/packages/sheaves/src/index.ts new file mode 100644 index 000000000..23d3f981d --- /dev/null +++ b/packages/sheaves/src/index.ts @@ -0,0 +1,20 @@ +export type { + Handler, + Provider, + Candidate, + MetadataSpec, + Policy, + PolicyContext, + Sheaf, +} from './types.ts'; +export { constant, source, callable } from './metadata.ts'; +export { sheafify } from './sheafify.ts'; +export { + noopPolicy, + proxyPolicy, + withFilter, + withRanking, + fallthrough, +} from './compose.ts'; +export { makeRemoteSection } from './remote.ts'; +export { makeHandler } from './section.ts'; diff --git a/packages/sheaves/src/metadata.test.ts b/packages/sheaves/src/metadata.test.ts new file mode 100644 index 000000000..8257f03ca --- /dev/null +++ b/packages/sheaves/src/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/sheaves/src/metadata.ts b/packages/sheaves/src/metadata.ts new file mode 100644 index 000000000..3a7aa03f7 --- /dev/null +++ b/packages/sheaves/src/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/sheaves/src/remote.test.ts b/packages/sheaves/src/remote.test.ts new file mode 100644 index 000000000..8039e7da0 --- /dev/null +++ b/packages/sheaves/src/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 { makeHandler } 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 handler — equivalent to a local CapTP loopback. +vi.mock('@endo/eventual-send', () => ({ + E: (ref: unknown) => ref, +})); + +const makeRemoteHandler = (tag: string) => + makeHandler( + 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 remoteHandler = makeRemoteHandler('Remote'); + const { handler } = await makeRemoteSection('Wrapper', remoteHandler); + expect(handler[GET_INTERFACE_GUARD]?.()).toStrictEqual( + remoteHandler[GET_INTERFACE_GUARD]?.(), + ); + }); + + it('forwards method calls to the remote ref', async () => { + const greet = vi.fn(async (name: string) => `Hello, ${name}!`); + const remoteHandler = makeHandler( + 'Remote', + M.interface( + 'Remote', + { greet: M.callWhen(M.string()).returns(M.string()) }, + { defaultGuards: 'passable' }, + ), + { greet }, + ); + + const { handler } = await makeRemoteSection('Wrapper', remoteHandler); + const wrapper = handler 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 remoteHandler = makeHandler( + '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 { handler } = await makeRemoteSection('Wrapper', remoteHandler); + const wrapper = handler 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 provider', async () => { + const metadata = constant({ mode: 'remote' as const }); + const { metadata: actual } = await makeRemoteSection( + 'Wrapper', + makeRemoteHandler('Remote'), + metadata, + ); + expect(actual).toBe(metadata); + }); + + it('metadata is undefined when not provided', async () => { + const { metadata } = await makeRemoteSection( + 'Wrapper', + makeRemoteHandler('Remote'), + ); + expect(metadata).toBeUndefined(); + }); +}); diff --git a/packages/sheaves/src/remote.ts b/packages/sheaves/src/remote.ts new file mode 100644 index 000000000..a6ae1c29e --- /dev/null +++ b/packages/sheaves/src/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 '@metamask/kernel-utils'; + +import { makeHandler } from './section.ts'; +import type { MetadataSpec, Provider } from './types.ts'; + +/** + * Wrap a remote (CapTP) reference as a Provider. + * + * The sheaf requires synchronous [GET_INTERFACE_GUARD] access on every handler, + * 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 provider. + * @returns A Provider whose handler 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 handler = makeHandler(name, interfaceGuard, handlers); + return ifDefined({ handler, metadata }) as Provider; +}; diff --git a/packages/sheaves/src/section.ts b/packages/sheaves/src/section.ts new file mode 100644 index 000000000..ff7699fca --- /dev/null +++ b/packages/sheaves/src/section.ts @@ -0,0 +1,22 @@ +import { makeExo } from '@endo/exo'; +import type { InterfaceGuard } from '@endo/patterns'; + +import type { Handler } from './types.ts'; + +/** + * Create a local handler from a name, guard, and method map. + * + * Encapsulates the cast from makeExo's opaque return type to Handler. + * Use this when constructing handlers for a sheaf; do not use it for + * the dispatch exo produced by sheafify itself. + * + * @param name - Exo tag name. + * @param guard - Interface guard describing the handler's methods. + * @param handlers - Method handler map. + * @returns A Handler suitable for inclusion in a sheaf. + */ +export const makeHandler = ( + name: string, + guard: InterfaceGuard, + handlers: Record unknown>, +): Handler => makeExo(name, guard, handlers) as unknown as Handler; diff --git a/packages/sheaves/src/sheafify.e2e.test.ts b/packages/sheaves/src/sheafify.e2e.test.ts new file mode 100644 index 000000000..80b307513 --- /dev/null +++ b/packages/sheaves/src/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 { makeHandler } from './section.ts'; +import { sheafify } from './sheafify.ts'; +import type { Policy, Provider } 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: Policy<{ cost: number }> = async function* (candidates) { + yield* [...candidates].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 providers: Provider<{ cost: number }>[] = [ + { + // Remote: covers all accounts, expensive + handler: makeHandler( + '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 + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: argmin, + }); + + // alice: both handlers 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); + providers.push({ + handler: makeHandler( + 'Wallet:2', + M.interface('Wallet:2', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: local2GetBalance }, + ), + metadata: constant({ cost: 2 }), + }); + wallet = sheafify({ name: 'Wallet', providers }).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 handlers 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: Policy = async function* (candidates) { + yield* [...candidates].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 handlers 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 providers: Provider[] = []; + + // ── Tier 1: Network RPC ────────────────────────────────── + // Covers ALL accounts (M.string()), but slow (500ms). + providers.push({ + handler: makeHandler( + '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', providers }).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. + providers.push({ + handler: makeHandler( + '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', providers }).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). + providers.push({ + handler: makeHandler( + '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', providers }).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. + providers.push({ + handler: makeHandler( + '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', providers }).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 candidate structure, different policies, different routing', async () => { + // The lift is the operational policy — swap it and the same + // set of providers produces different routing behavior. + const networkGetBalance = vi.fn((_acct: string): number => 0); + const mirrorGetBalance = vi.fn((_acct: string): number => 0); + + const makeProviders = (): Provider[] => [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: networkGetBalance }, + ), + metadata: constant({ latencyMs: 500, label: 'network' }), + }, + { + handler: makeHandler( + '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', + providers: makeProviders(), + }).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: Policy = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => (b.metadata?.latencyMs ?? 0) - (a.metadata?.latencyMs ?? 0), + ); + }; + const walletB = sheafify({ + name: 'Wallet', + providers: makeProviders(), + }).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: Policy<{ push: boolean }> = async function* (candidates) { + yield* candidates.filter((candidate) => candidate.metadata?.push); + yield* candidates.filter((candidate) => !candidate.metadata?.push); + }; + + const pullGetBalance = vi.fn((_acct: string): number => 0); + const pushGetBalance = vi.fn((_acct: string): number => 0); + + const providers: Provider<{ push: boolean }>[] = [ + { + // Pull section: M.string() guards, push=false + handler: makeHandler( + '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 + handler: makeHandler( + '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', providers }).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 handlers 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: Policy = async function* (candidates) { + yield* [...candidates].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 providers: Provider[] = [ + { + handler: makeHandler( + '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), + })), + }, + { + handler: makeHandler( + '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', providers }).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 providers: Provider[] = [ + { + handler: makeHandler( + 'Primary', + M.interface('Primary', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: primaryFn }, + ), + metadata: constant({ priority: 0 }), + }, + { + handler: makeHandler( + '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: Policy = async function* (candidates) { + const ordered = [...candidates].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + for (const candidate of ordered) { + const errors: unknown[] = yield candidate; + errorCountsSeenByLift.push(errors.length); + } + }; + + const wallet = sheafify({ name: 'Wallet', providers }).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 providers: Provider[] = handlers.map((fn, i) => ({ + handler: makeHandler( + `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: Policy = async function* (candidates) { + const ordered = [...candidates].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + for (const candidate of ordered) { + const errors: unknown[] = yield candidate; + errorsAfterFirst ??= errors; + } + }; + + const wallet = sheafify({ name: 'Wallet', providers }).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 providers: Provider[] = [ + { + handler: makeHandler( + '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 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(candidates) { + yield* [...candidates].sort( + (a, b) => (a.metadata?.priority ?? 0) - (b.metadata?.priority ?? 0), + ); + }, + }); + + await expect(E(wallet).getBalance('alice')).rejects.toThrow( + 'No viable handler', + ); + }); +}); diff --git a/packages/sheaves/src/sheafify.string-metadata.test.ts b/packages/sheaves/src/sheafify.string-metadata.test.ts new file mode 100644 index 000000000..78697bc35 --- /dev/null +++ b/packages/sheaves/src/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 { makeHandler } from './section.ts'; +import { sheafify } from './sheafify.ts'; +import type { Policy, Provider } 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: Policy = async function* (candidates) { + yield* [...candidates].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 providers: Provider[] = [ + { + handler: makeHandler( + '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] })`), + }, + { + handler: makeHandler( + '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', + providers, + 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/sheaves/src/sheafify.test.ts b/packages/sheaves/src/sheafify.test.ts new file mode 100644 index 000000000..c92e3a306 --- /dev/null +++ b/packages/sheaves/src/sheafify.test.ts @@ -0,0 +1,898 @@ +import { GET_INTERFACE_GUARD } from '@endo/exo'; +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import { GET_DESCRIPTION } from '@metamask/kernel-utils'; +import { describe, it, expect } from 'vitest'; + +import { constant } from './metadata.ts'; +import { makeHandler } from './section.ts'; +import { sheafify } from './sheafify.ts'; +import type { Candidate, Policy, PolicyContext, Provider } 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: Policy<{ cost: number }> = async function* (_candidates) { + liftCalled = true; + // unreachable — fast path bypasses lift for single section + }; + + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift, + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + expect(liftCalled).toBe(false); + }); + + it('zero-coverage throws', async () => { + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(_candidates) { + // unreachable — zero-coverage path throws before reaching lift + }, + }); + await expect(E(wallet).getBalance('bob')).rejects.toThrow( + 'No handler covers', + ); + }); + + it('lift receives metadata and picks winner', async () => { + const argmin: Policy<{ cost: number }> = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { + handler: makeHandler( + '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', providers }).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 providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.eq('alice')).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(candidates) { + yield candidates[0]!; + }, + }); + const guard = wallet[GET_INTERFACE_GUARD](); + expect(guard).toBeDefined(); + + const { methodGuards } = getInterfaceGuardPayload(guard); + expect(methodGuards).toHaveProperty('getBalance'); + }); + + it('re-sheafification picks up new providers and methods', async () => { + const argmin: Policy<{ cost: number }> = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a cheaper provider with a new method to the providers array, re-sheafify. + providers.push({ + handler: makeHandler( + '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', providers }).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 handler = makeHandler( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const providers: Provider<{ cost: number }>[] = [ + { handler, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', providers }).getGlobalSection({ + async *lift(candidates) { + yield candidates[0]!; + }, + }); + expect(await E(wallet).getBalance('alice')).toBe(42); + }); + + it('re-sheafification with pre-built exo picks up new methods', async () => { + const argmin: Policy<{ cost: number }> = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: argmin, + }); + expect(await E(wallet).getBalance('alice')).toBe(100); + + // Add a pre-built exo with a cheaper getBalance + new transfer method + const handler = makeHandler( + '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, + }, + ); + providers.push({ + handler, + metadata: constant({ cost: 1 }), + }); + wallet = sheafify({ name: 'Wallet', providers }).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 handler = makeHandler( + 'bal', + M.interface('bal', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const providers: Provider<{ cost: number }>[] = [ + { handler, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', providers }).getGlobalSection({ + async *lift(candidates) { + yield candidates[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 capturedCandidates: Candidate>[] = []; + let capturedContext: PolicyContext | undefined; + + const spy: Policy = async function* (candidates, context) { + capturedCandidates = candidates; + capturedContext = context; + yield candidates[0]!; + }; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ region: 'us', cost: 100 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + expect(capturedContext).toStrictEqual({ + method: 'getBalance', + args: ['alice'], + constraints: { region: 'us' }, + }); + expect( + capturedCandidates.map((candidate) => candidate.metadata), + ).toStrictEqual([{ cost: 100 }, { cost: 1 }]); + }); + + it('all-shared metadata yields empty distinguishing metadata', async () => { + type Meta = { region: string }; + let capturedCandidates: Candidate>[] = []; + let capturedContext: PolicyContext | undefined; + + const spy: Policy = async function* (candidates, context) { + capturedCandidates = candidates; + capturedContext = context; + yield candidates[0]!; + }; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ region: 'us' }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + // Both providers collapsed to one candidate → policy not invoked + expect(capturedContext).toBeUndefined(); + expect(capturedCandidates).toHaveLength(0); + }); + + it('collapses equivalent providers by metadata', async () => { + type Meta = { cost: number }; + let liftCalled = false; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 1 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + // eslint-disable-next-line require-yield + async *lift(_candidates) { + liftCalled = true; + }, + }); + await E(wallet).getBalance('alice'); + + // Both providers have identical metadata → collapsed to one candidate → policy bypassed + expect(liftCalled).toBe(false); + }); + + it('extracts shared NaN metadata values into constraints', async () => { + type Meta = { cost: number; priority: number }; + let capturedCandidates: Candidate>[] = []; + let capturedContext: PolicyContext | undefined; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: NaN, priority: 0 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(candidates, context) { + capturedCandidates = candidates; + capturedContext = context; + yield candidates[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + + // NaN is shared across all candidates, so it should be extracted as a constraint + // — not left as distinguishing metadata in each candidate's options. + expect(Number.isNaN(capturedContext?.constraints.cost)).toBe(true); + expect( + capturedCandidates.map((candidate) => candidate.metadata), + ).toStrictEqual([{ priority: 0 }, { priority: 1 }]); + }); + + it('does not collapse +0 and -0 metadata as equivalent', async () => { + type Meta = { cost: number }; + let candidateCount = 0; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: +0 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(candidates) { + candidateCount = candidates.length; + yield candidates[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + expect(candidateCount).toBe(2); + }); + + it('does not collapse Infinity and null metadata as equivalent', async () => { + type Meta = { cost: number | null }; + let candidateCount = 0; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 0 }, + ), + metadata: constant({ cost: Infinity }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + async *lift(candidates) { + candidateCount = candidates.length; + yield candidates[0]!; + }, + }); + + await E(wallet).getBalance('alice'); + expect(candidateCount).toBe(2); + }); + + it('collapses no-metadata and empty-object metadata as equivalent', async () => { + type Meta = Record; + let liftCalled = false; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + // eslint-disable-next-line require-yield + async *lift(_candidates) { + liftCalled = true; + }, + }); + await E(wallet).getBalance('alice'); + + expect(liftCalled).toBe(false); + }); + + it('mixed providers participate in policy', async () => { + const argmin: Policy<{ cost: number }> = async function* (candidates) { + yield* [...candidates].sort( + (a, b) => + (a.metadata?.cost ?? Infinity) - (b.metadata?.cost ?? Infinity), + ); + }; + + const handler = makeHandler( + 'cheap', + M.interface('cheap', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ); + const providers: Provider<{ cost: number }>[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ cost: 100 }), + }, + { handler, metadata: constant({ cost: 1 }) }, + ]; + + const wallet = sheafify({ name: 'Wallet', providers }).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 providers: Provider>[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + }, + ]; + + const section = sheafify({ + name: 'Wallet', + providers, + }).getDiscoverableGlobalSection({ + async *lift(candidates) { + yield candidates[0]!; + }, + schema, + }); + + expect(E(section)[GET_DESCRIPTION]()).toStrictEqual(schema); + }); + + it('getSection does not expose __getDescription__', () => { + const providers: Provider>[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + }, + ]; + + const section = sheafify({ name: 'Wallet', providers }).getGlobalSection({ + async *lift(candidates) { + yield candidates[0]!; + }, + }); + + expect( + (section as Record)[GET_DESCRIPTION], + ).toBeUndefined(); + }); + + it('does not drop prototype-named distinguishing metadata keys from stripped candidates', async () => { + // 'constructor' matches Object.prototype.constructor. Naive `key in constraints` + // returns true on an empty {} because of the prototype chain, causing the key to be + // silently dropped from every stripped candidate even though it was never a constraint. + type Meta = Record; + let capturedCandidates: Candidate>[] = []; + let capturedContext: PolicyContext | undefined; + + const spy: Policy = async function* (candidates, context) { + capturedCandidates = candidates; + capturedContext = context; + yield candidates[0]!; + }; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ constructor: 'typeA', cost: 100 }), + }, + { + handler: makeHandler( + 'Wallet:1', + M.interface('Wallet:1', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 42 }, + ), + metadata: constant({ constructor: 'typeB', cost: 1 }), + }, + ]; + + const wallet = sheafify({ name: 'Wallet', providers }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + expect(capturedContext).toStrictEqual({ + method: 'getBalance', + args: ['alice'], + constraints: {}, + }); + expect( + capturedCandidates.map((candidate) => candidate.metadata), + ).toStrictEqual([ + { constructor: 'typeA', cost: 100 }, + { constructor: 'typeB', cost: 1 }, + ]); + }); + + it('does not treat prototype-inherited value as shared when key is absent from some candidates', async () => { + // Provider A has { constructor: Object, cost: 100 }. Provider B has { cost: 1 }. + // Naive `key in meta` finds 'constructor' in B via Object.prototype, and + // Object.is(meta_B['constructor'], Object) is true ({}.constructor === Object), + // so the key is wrongly counted as shared and moved into constraints. + type Meta = Record; + let capturedContext: PolicyContext | undefined; + + const spy: Policy = async function* (candidates, context) { + capturedContext = context; + yield candidates[0]!; + }; + + const providers: Provider[] = [ + { + handler: makeHandler( + 'Wallet:0', + M.interface('Wallet:0', { + getBalance: M.call(M.string()).returns(M.number()), + }), + { getBalance: (_acct: string) => 100 }, + ), + metadata: constant({ constructor: Object, cost: 100 }), + }, + { + handler: makeHandler( + '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', providers }).getGlobalSection({ + lift: spy, + }); + await E(wallet).getBalance('alice'); + + // 'constructor' is only owned by provider A — must not appear in constraints + expect(capturedContext?.constraints).not.toHaveProperty('constructor'); + }); +}); + +// --------------------------------------------------------------------------- +// Unit: getSection with explicit guard +// --------------------------------------------------------------------------- + +describe('getSection with explicit guard', () => { + it('dispatches calls that fall within the explicit guard', async () => { + const providers: Provider>[] = [ + { + handler: makeHandler( + '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', providers }).getSection({ + guard: readGuard, + async *lift(candidates) { + yield candidates[0]!; + }, + }); + + expect(await E(section).getBalance('alice')).toBe(42); + }); + + it('rejects method calls outside the explicit guard', async () => { + const providers: Provider>[] = [ + { + handler: makeHandler( + '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', providers }).getSection({ + guard: readGuard, + async *lift(candidates) { + yield candidates[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 providers: Provider>[] = [ + { + handler: makeHandler( + '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', + providers, + }).getDiscoverableSection({ + guard: readGuard, + async *lift(candidates) { + yield candidates[0]!; + }, + schema, + }); + + expect(E(section)[GET_DESCRIPTION]()).toStrictEqual(schema); + expect(await E(section).getBalance('alice')).toBe(42); + }); +}); diff --git a/packages/sheaves/src/sheafify.ts b/packages/sheaves/src/sheafify.ts new file mode 100644 index 000000000..f16557932 --- /dev/null +++ b/packages/sheaves/src/sheafify.ts @@ -0,0 +1,350 @@ +/** + * Sheafify a set of providers into an authority manager. + * + * `sheafify({ name, providers })` returns a `Sheaf` — an immutable object + * that produces dispatch sections over a fixed set of providers. + * + * Each dispatch through a granted section: + * 1. Computes the matching providers (getStalk — providers whose guard covers the point) + * 2. Collapses equivalent candidates (same metadata → one representative) + * 3. Decomposes metadata into constraints + options + * 4. Invokes the policy on the distinguished options + * 5. Dispatches to some element of the chosen candidate + */ + +import { makeExo } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; +import type { MethodSchema } from '@metamask/kernel-utils'; +import { makeDiscoverableExo } from '@metamask/kernel-utils'; +import { stringify } from '@metamask/kernel-utils'; + +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 { + Candidate, + Handler, + Policy, + PolicyContext, + Provider, + 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 candidates into equivalence classes by metadata identity. + * Returns one representative per class; the choice within a class is arbitrary. + * + * @param candidates - The candidates to collapse. + * @returns One representative per equivalence class. + */ +const collapseEquivalent = >( + candidates: Candidate[], +): Candidate[] => { + const seen = new Set(); + const representatives: Candidate[] = []; + for (const entry of candidates) { + const key = metadataKey(entry.metadata); + if (!seen.has(key)) { + seen.add(key); + representatives.push(entry); + } + } + return representatives; +}; + +/** + * Decompose candidate metadata into constraints (shared by all) and + * stripped candidates (carrying only distinguishing keys). + * + * @param candidates - The collapsed candidates. + * @returns Constraints and stripped candidates. + */ +const decomposeMetadata = >( + candidates: Candidate[], +): { + constraints: Partial; + stripped: Candidate>[]; +} => { + const constraints: Record = {}; + + const head = candidates[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 = candidates.every((entry) => { + const meta = entry.metadata; + return Object.hasOwn(meta, key) && Object.is(meta[key], val); + }); + if (shared) { + constraints[key] = val; + } + } + + const stripped = candidates.map((entry) => { + const remaining: Record = {}; + for (const [key, val] of Object.entries(entry.metadata)) { + if (!Object.hasOwn(constraints, key)) { + remaining[key] = val; + } + } + return { handler: entry.handler, metadata: remaining as Partial }; + }); + + return { constraints: constraints as Partial, stripped }; +}; + +/** + * Invoke a method on a handler, throwing if the method is missing. + * + * @param handler - The handler 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 invokeHandler = ( + handler: Handler, + method: string, + args: unknown[], +): unknown => { + const obj = handler as Record unknown>; + const fn = obj[method]; + if (fn === undefined) { + throw new Error(`Handler has guard for '${method}' but no method`); + } + return fn.call(obj, ...args); +}; + +type ResolvedProvider> = { + handler: Handler; + spec: ResolvedMetadataSpec | undefined; +}; + +const drivePolicy = async >( + policy: Policy, + candidates: Candidate>[], + context: PolicyContext, + invoke: (candidate: Candidate>) => Promise, +): Promise => { + const errors: unknown[] = []; + const gen = policy(candidates, 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 handler for ${context.method}`, { + cause: errors, + }); +}; + +export const sheafify = < + MetaData extends Record = Record, +>({ + name, + providers, + compartment, +}: { + name: string; + providers: Provider[]; + compartment?: { evaluate: (src: string) => unknown }; +}): Sheaf => { + const frozenProviders: readonly ResolvedProvider[] = harden( + providers.map((provider) => ({ + handler: provider.handler, + spec: + provider.metadata === undefined + ? undefined + : resolveMetadataSpec(provider.metadata, compartment), + })), + ); + const buildSection = ({ + guard, + lift, + schema, + }: { + guard: InterfaceGuard; + lift: Policy; + 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 candidates = getStalk(frozenProviders, method, args); + const evaluatedCandidates: Candidate[] = candidates.map( + (provider) => ({ + handler: provider.handler, + metadata: evaluateMetadata(provider.spec, args), + }), + ); + switch (evaluatedCandidates.length) { + case 0: + throw new Error(`No handler covers ${method}(${stringify(args, 0)})`); + case 1: + return invokeHandler( + (evaluatedCandidates[0] as Candidate).handler, + method, + args, + ); + default: { + const collapsed = collapseEquivalent(evaluatedCandidates); + if (collapsed.length === 1) { + return invokeHandler( + (collapsed[0] as Candidate).handler, + method, + args, + ); + } + const { constraints, stripped } = decomposeMetadata(collapsed); + const strippedToCollapsed = new Map( + stripped.map((strippedCandidate, i) => [ + strippedCandidate, + collapsed[i] as Candidate, + ]), + ); + return drivePolicy( + lift, + stripped, + { method, args, constraints }, + async (candidate) => { + const resolved = strippedToCollapsed.get(candidate); + if (resolved === undefined) { + throw new Error( + `Policy yielded an unrecognized candidate for '${method}'. ` + + `The yielded value must be one of the Candidate objects ` + + `passed into the policy (object identity, not structural equality). ` + + `Did the policy construct a new object instead of yielding from the candidates array?`, + ); + } + return invokeHandler(resolved.handler, 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 Handler; + + return exo; + }; + + const unionGuard = (): InterfaceGuard => + collectSheafGuard( + name, + frozenProviders.map(({ handler }) => handler), + ); + + const getSection = ({ + guard, + lift, + }: { + guard: InterfaceGuard; + lift: Policy; + }): object => buildSection({ guard, lift }); + + const getDiscoverableSection = ({ + guard, + lift, + schema, + }: { + guard: InterfaceGuard; + lift: Policy; + schema: Record; + }): object => buildSection({ guard, lift, schema }); + + const getGlobalSection = ({ lift }: { lift: Policy }): object => + buildSection({ guard: unionGuard(), lift }); + + const getDiscoverableGlobalSection = ({ + lift, + schema, + }: { + lift: Policy; + schema: Record; + }): object => buildSection({ guard: unionGuard(), lift, schema }); + + return harden({ + getSection, + getDiscoverableSection, + getGlobalSection, + getDiscoverableGlobalSection, + }); +}; diff --git a/packages/sheaves/src/stalk.test.ts b/packages/sheaves/src/stalk.test.ts new file mode 100644 index 000000000..89266b24c --- /dev/null +++ b/packages/sheaves/src/stalk.test.ts @@ -0,0 +1,170 @@ +import { M } from '@endo/patterns'; +import type { MethodGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { constant } from './metadata.ts'; +import { makeHandler } from './section.ts'; +import { getStalk } from './stalk.ts'; +import type { Provider } from './types.ts'; + +const makeProvider = ( + tag: string, + guards: Record, + methods: Record unknown>, + metadata: { cost: number }, +): Provider<{ cost: number }> => ({ + handler: makeHandler(tag, M.interface(tag, guards), methods), + metadata: constant(metadata), +}); + +describe('getStalk', () => { + it('returns matching providers for a method and args', () => { + const providers = [ + makeProvider( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + makeProvider( + 'B', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 2 }, + ), + ]; + + const candidates = getStalk(providers, 'add', [1, 2]); + expect(candidates).toHaveLength(2); + }); + + it('filters out providers without matching method', () => { + const providers = [ + makeProvider( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + makeProvider( + 'B', + { sub: M.call(M.number()).returns(M.number()) }, + { sub: (a: number) => -a }, + { cost: 2 }, + ), + ]; + + const candidates = getStalk(providers, 'add', [1]); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.metadata).toStrictEqual(constant({ cost: 1 })); + }); + + it('filters out providers with arg count mismatch', () => { + const providers = [ + makeProvider( + 'A', + { add: M.call(M.number(), M.number()).returns(M.number()) }, + { add: (a: number, b: number) => a + b }, + { cost: 1 }, + ), + ]; + + const candidates = getStalk(providers, 'add', [1]); + expect(candidates).toHaveLength(0); + }); + + it('filters out providers with arg type mismatch', () => { + const providers = [ + makeProvider( + 'A', + { add: M.call(M.number()).returns(M.number()) }, + { add: (a: number) => a }, + { cost: 1 }, + ), + ]; + + const candidates = getStalk(providers, 'add', ['not-a-number']); + expect(candidates).toHaveLength(0); + }); + + it('returns empty array when no providers match', () => { + const providers = [ + makeProvider( + 'A', + { add: M.call(M.eq('alice')).returns(M.number()) }, + { add: (_a: string) => 42 }, + { cost: 1 }, + ), + ]; + + const candidates = getStalk(providers, 'add', ['bob']); + expect(candidates).toHaveLength(0); + }); + + it('matches providers with optional args when optional arg is provided', () => { + const providers = [ + makeProvider( + 'A', + { + greet: M.callWhen(M.string()) + .optional(M.string()) + .returns(M.string()), + }, + { greet: (name: string, _greeting?: string) => `hello ${name}` }, + { cost: 1 }, + ), + ]; + + expect(getStalk(providers, 'greet', ['alice'])).toHaveLength(1); + expect(getStalk(providers, 'greet', ['alice', 'hi'])).toHaveLength(1); + expect(getStalk(providers, 'greet', [])).toHaveLength(0); + expect(getStalk(providers, 'greet', ['alice', 'hi', 'extra'])).toHaveLength( + 0, + ); + }); + + it('matches providers with rest args', () => { + const providers = [ + makeProvider( + 'A', + { log: M.call(M.string()).rest(M.string()).returns(M.any()) }, + { log: (..._args: string[]) => undefined }, + { cost: 1 }, + ), + ]; + + expect(getStalk(providers, 'log', ['info'])).toHaveLength(1); + expect(getStalk(providers, 'log', ['info', 'msg'])).toHaveLength(1); + expect(getStalk(providers, 'log', ['info', 'msg', 'extra'])).toHaveLength( + 1, + ); + expect(getStalk(providers, 'log', [])).toHaveLength(0); + expect(getStalk(providers, 'log', [42])).toHaveLength(0); + }); + + it('returns all providers when all match', () => { + const providers = [ + makeProvider( + 'A', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 1 }, + { cost: 1 }, + ), + makeProvider( + 'B', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 2 }, + { cost: 2 }, + ), + makeProvider( + 'C', + { f: M.call(M.string()).returns(M.number()) }, + { f: () => 3 }, + { cost: 3 }, + ), + ]; + + const candidates = getStalk(providers, 'f', ['hello']); + expect(candidates).toHaveLength(3); + }); +}); diff --git a/packages/sheaves/src/stalk.ts b/packages/sheaves/src/stalk.ts new file mode 100644 index 000000000..37f0c4f0e --- /dev/null +++ b/packages/sheaves/src/stalk.ts @@ -0,0 +1,73 @@ +/** + * Stalk computation: filter providers 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 { Handler } 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 matching providers at an invocation point. + * + * Returns the providers whose guards accept the given method + args. + * + * @param providers - The providers to filter. + * @param method - The method name being invoked. + * @param args - The arguments to the method invocation. + * @returns The providers whose guards accept the invocation. + */ +export const getStalk = ( + providers: readonly T[], + method: string, + args: unknown[], +): T[] => { + return providers.filter(({ handler }) => { + const interfaceGuard = handler[GET_INTERFACE_GUARD]?.(); + if (!interfaceGuard) { + return false; + } + return guardCoversPoint(interfaceGuard, method, args); + }); +}; diff --git a/packages/sheaves/src/types.ts b/packages/sheaves/src/types.ts new file mode 100644 index 000000000..936d4cd56 --- /dev/null +++ b/packages/sheaves/src/types.ts @@ -0,0 +1,140 @@ +/** + * Sheaf types: the product decomposition F_sem x F_op. + * + * The handler (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: + * handlers 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 '@metamask/kernel-utils'; + +/** A handler: a capability covering a region of the interface topology. */ +export type Handler = 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 provider: a handler (F_sem) paired with an optional metadata spec (F_op). + * + * This is the input data to sheafify — a (handler, metadata) pair assigned over + * the open set defined by the handler's guard. + */ +export type Provider> = { + handler: Handler; + metadata?: MetadataSpec; +}; + +/** + * A candidate: a provider 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 array received by + * Policy (where each entry is already a representative of an equivalence class + * after collapsing). Empty `{}` means no metadata. + */ +export type Candidate> = { + handler: Handler; + metadata: MetaData; +}; + +/** + * Context passed to the policy alongside the candidates. + * + * `constraints` holds metadata keys whose values are identical across every + * candidate — these are topologically determined and not a choice. + * Typed as `Partial` because the actual partition is runtime-dependent. + */ +export type PolicyContext> = { + method: string; + args: unknown[]; + constraints: Partial; +}; + +/** + * Policy: a coroutine that yields candidates in preference order and receives + * the accumulated error list after each failed attempt. + * + * Each candidate 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 policies that do not need retry logic can ignore the error input: + * async function*(candidates) { yield* [...candidates].sort(comparator); } + */ +export type Policy> = ( + candidates: Candidate>[], + context: PolicyContext, +) => AsyncGenerator>, void, unknown[]>; + +/** + * A sheaf: an authority manager over a set of providers. + * + * Produces dispatch sections via `getSection`, each routing invocations + * through the providers 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: Policy; + }) => object; + /** + * Produce a discoverable dispatch exo over the given guard. + * + * Returns `object` for the same reason as `getSection`. + */ + getDiscoverableSection: (opts: { + guard: InterfaceGuard; + lift: Policy; + schema: Record; + }) => object; + /** + * Produce a dispatch exo over the full union guard of all providers. + * + * 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 providers 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: Policy }) => object; + /** + * Produce a discoverable dispatch exo over the full union guard of all providers. + * + * Prefer `getDiscoverableSection` with an explicit guard when the guard is + * statically known. Use the global variant when providers 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: Policy; + schema: Record; + }) => object; +}; diff --git a/packages/sheaves/tsconfig.build.json b/packages/sheaves/tsconfig.build.json new file mode 100644 index 000000000..85fa65ecb --- /dev/null +++ b/packages/sheaves/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": ["ses"] + }, + "references": [{ "path": "../kernel-utils/tsconfig.build.json" }], + "files": [], + "include": ["./src"] +} diff --git a/packages/sheaves/tsconfig.json b/packages/sheaves/tsconfig.json new file mode 100644 index 000000000..41d855675 --- /dev/null +++ b/packages/sheaves/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["ses", "vitest"] + }, + "references": [ + { "path": "../repo-tools" }, + { "path": "../kernel-utils" }, + { "path": "../kernel-shims" } + ], + "include": ["../../vitest.config.ts", "./src", "./vitest.config.ts"] +} diff --git a/packages/sheaves/typedoc.json b/packages/sheaves/typedoc.json new file mode 100644 index 000000000..f8eb78ae1 --- /dev/null +++ b/packages/sheaves/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/sheaves/vitest.config.ts b/packages/sheaves/vitest.config.ts new file mode 100644 index 000000000..8714c6afe --- /dev/null +++ b/packages/sheaves/vitest.config.ts @@ -0,0 +1,22 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'sheaves', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }), + ); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 936fb956d..62c8d97c5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./packages/ocap-kernel/tsconfig.build.json" }, { "path": "./packages/omnium-gatherum/tsconfig.build.json" }, { "path": "./packages/remote-iterables/tsconfig.build.json" }, + { "path": "./packages/sheaves/tsconfig.build.json" }, { "path": "./packages/streams/tsconfig.build.json" }, { "path": "./packages/template-package/tsconfig.build.json" }, { "path": "./packages/evm-wallet-experiment/tsconfig.build.json" } diff --git a/tsconfig.json b/tsconfig.json index 6116d9887..5f51f4fbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ { "path": "./packages/omnium-gatherum" }, { "path": "./packages/remote-iterables" }, { "path": "./packages/repo-tools" }, + { "path": "./packages/sheaves" }, { "path": "./packages/streams" }, { "path": "./packages/template-package" } ] diff --git a/yarn.lock b/yarn.lock index 626261606..89dd227ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3039,6 +3039,48 @@ __metadata: languageName: node linkType: hard +"@metamask/sheaves@workspace:packages/sheaves": + version: 0.0.0-use.local + resolution: "@metamask/sheaves@workspace:packages/sheaves" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" + "@endo/exo": "npm:^1.5.12" + "@endo/patterns": "npm:^1.7.0" + "@metamask/auto-changelog": "npm:^5.3.0" + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-shims": "workspace:^" + "@metamask/kernel-utils": "workspace:^" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.14" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + ses: "npm:^1.14.0" + turbo: "npm:^2.9.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^8.0.6" + vitest: "npm:^4.1.3" + languageName: unknown + linkType: soft + "@metamask/slip44@npm:^4.4.0": version: 4.4.0 resolution: "@metamask/slip44@npm:4.4.0"