From 16c884fb0b39b1e2c759405f6f25fb42ab955d5f Mon Sep 17 00:00:00 2001 From: ci-belphegor <324126930+ci-belphegor@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:33:09 -0400 Subject: [PATCH] feat(kernel-utils): join Return a narrowing of the refs' common base admitting whatever any of them admits: the method set is the union of the operands', and where two name the same method each argument position is disjoined. disjoinDeltas sits beside conjoinDeltas as its dual, and the asymmetry is the whole missing-method rule. A join is a union of authority, so its keys are the union of both sides: a method absent from an operand contributes the empty set and survives at the other operand's delta, while a hole contributes everything and leaves that position unconstrained. Narrowing is the opposite, which is why conjoinDeltas takes its keys from one side. The base is a legal operand and absorbs, so the lattice has a representable top and a fold needs no special case. It is recognizable only by identity against a minted ref's record, so a call whose refs are all unminted throws rather than trusting one of them. The result records the same base and the disjoined delta, so a join can be narrowed or joined again. narrow and join now share the minting step, since join needs the same derive-forward-record sequence against a guard it already holds. Co-Authored-By: Claude Opus 5 --- packages/kernel-test/src/narrowing.test.ts | 3 +- packages/kernel-utils/CHANGELOG.md | 3 +- .../src/narrow-interface-guard.test.ts | 45 ++++++ .../src/narrow-interface-guard.ts | 40 ++++++ packages/kernel-utils/src/narrowing.test.ts | 19 ++- packages/kernel-utils/src/narrowing.ts | 135 ++++++++++++++---- 6 files changed, 205 insertions(+), 40 deletions(-) diff --git a/packages/kernel-test/src/narrowing.test.ts b/packages/kernel-test/src/narrowing.test.ts index 2002e01e4..1fedadbee 100644 --- a/packages/kernel-test/src/narrowing.test.ts +++ b/packages/kernel-test/src/narrowing.test.ts @@ -120,8 +120,7 @@ describe('narrowing', () => { ).toMatch(/^rejected:.*\bstat\b/u); }); - // Unmarks at PR-7. - it.fails('joins two narrowings of a common base', async () => { + it('joins two narrowings of a common base', async () => { const kernel = await launchNarrowingVat(); expect(await probe(kernel, 'probeJoined', [['srv', 'logs', 'y']])).toBe( 'ok:read:srv/logs/y', diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 926b76926..7576e7dff 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `narrowInterfaceGuard` and the `NarrowingDelta` type, which derive the interface guard of a narrowing: each delta pattern is conjoined onto the base's guard at the argument position it addresses, arity and return guards are inherited verbatim, methods the delta does not name are dropped, and every method guard is asyncified for forwarding ([#1051](https://github.com/MetaMask/ocap-kernel/pull/1051)) - Add `pathUnder(segments)`, which builds an `@endo/patterns` pattern matching segment arrays under a prefix, with `..` excluded past the prefix so that traversal out of it is unrepresentable. Empty `segments` matches every `..`-free segment array ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) - Add `narrow({ name, base, delta })`, which reads the base's interface guard over `E()` and returns an exo under the derived guard whose methods forward to the base. Narrowing a narrowing flattens: the result forwards straight to the original base under both deltas conjoined, so a chain of any depth stays one hop deep and costs one guard read. Every method of the result returns a promise ([#1052](https://github.com/MetaMask/ocap-kernel/pull/1052)) -- Add `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **`join` throws for now** — this release fixes its signature so callers and deltas can be written against it, and the implementation follows ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) +- Add `join({ name, refs })`, which returns a narrowing of its refs' common base admitting whatever any of them admits: the result's methods are the union of the operands', and where two operands name the same method each argument position is disjoined. Every ref must be one `narrow` minted from that base, or the base itself, which admits everything and so absorbs. A ref minted from some other base, or by nothing at all, throws ([#1053](https://github.com/MetaMask/ocap-kernel/pull/1053)) +- Add the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) - Add `getInterfaceMethodGuards`, `getMethodPayload`, `getGuardAt`, `buildMethodGuard`, and `asyncifyMethodGuards`, plus the `MethodGuardPayload` type, for reading an `@endo/patterns` interface guard by argument position — required arguments, then optionals, then the rest guard — and reassembling it ([#1048](https://github.com/MetaMask/ocap-kernel/pull/1048)) - Add `makeGuardedFetch` and the `FetchGuard` type, which wrap a `fetch` so that a guard runs before every request it makes, redirect hops included ([#1026](https://github.com/MetaMask/ocap-kernel/pull/1026)) - `redirect: 'follow'`, in the caller's `init` or on a `Request`, is overridden so that each hop can be checked; `manual` and `error` are honoured. `baseFetch` is therefore always called with `redirect: 'manual'` and must honour it diff --git a/packages/kernel-utils/src/narrow-interface-guard.test.ts b/packages/kernel-utils/src/narrow-interface-guard.test.ts index 0ac20bc5b..df526ab7d 100644 --- a/packages/kernel-utils/src/narrow-interface-guard.test.ts +++ b/packages/kernel-utils/src/narrow-interface-guard.test.ts @@ -6,6 +6,7 @@ import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts'; import type { MethodGuardPayload } from './guard-algebra.ts'; import { conjoinDeltas, + disjoinDeltas, narrowInterfaceGuard, } from './narrow-interface-guard.ts'; import type { NarrowingDelta } from './narrow-interface-guard.ts'; @@ -183,3 +184,47 @@ describe('conjoinDeltas', () => { expect(combined.read![0]).toBeUndefined(); }); }); + +describe('disjoinDeltas', () => { + it('takes the union of the methods the two deltas name', () => { + const disjoined = disjoinDeltas( + { read: [M.eq('a')] }, + { read: [M.eq('b')], stat: [M.eq('b')] }, + ); + + expect(Object.keys(disjoined).sort()).toStrictEqual(['read', 'stat']); + }); + + it('admits either pattern where both deltas name a method', () => { + const disjoined = disjoinDeltas( + { read: [M.eq('a')] }, + { read: [M.eq('b')] }, + ); + const [pattern] = disjoined.read!; + + expect(matches('a', pattern)).toBe(true); + expect(matches('b', pattern)).toBe(true); + expect(matches('c', pattern)).toBe(false); + }); + + it('carries a method only one delta names at that delta', () => { + const disjoined = disjoinDeltas({ read: [] }, { stat: [M.eq('b')] }); + const [pattern] = disjoined.stat!; + + expect(matches('b', pattern)).toBe(true); + expect(matches('a', pattern)).toBe(false); + }); + + it.each([ + { side: 'the left', left: [undefined], right: [M.eq('b')] }, + { side: 'the right', left: [M.eq('a')], right: [undefined] }, + { side: 'the shorter', left: [M.eq('a')], right: [M.eq('a'), M.eq('x')] }, + ])( + 'leaves a position unconstrained where $side delta holes it', + ({ left, right }) => { + const disjoined = disjoinDeltas({ read: left }, { read: right }); + + expect(disjoined.read!.at(-1)).toBeUndefined(); + }, + ); +}); diff --git a/packages/kernel-utils/src/narrow-interface-guard.ts b/packages/kernel-utils/src/narrow-interface-guard.ts index a3dac3484..5ea17f413 100644 --- a/packages/kernel-utils/src/narrow-interface-guard.ts +++ b/packages/kernel-utils/src/narrow-interface-guard.ts @@ -66,6 +66,46 @@ export const conjoinDeltas = ( return combined; }; +/** + * Disjoin two deltas, so that a join admits whatever either operand admits. + * + * A join is a union of authority, which settles the whole key-and-hole rule at + * once: a method absent from an operand contributes the empty set, so it + * survives at the other operand's delta, and a hole contributes everything, so + * a hole on either side leaves that position unconstrained. A position past the + * end of a delta is a hole. + * + * Hence the keys are the union of both sides — the opposite of + * `conjoinDeltas`, which takes its keys from one side because narrowing must + * not restore authority an intermediate narrowing dropped. + * + * @param left - One operand's delta. + * @param right - The other operand's delta. + * @returns The disjoined delta. + */ +export const disjoinDeltas = ( + left: NarrowingDelta, + right: NarrowingDelta, +): NarrowingDelta => { + const disjoined: NarrowingDelta = { ...left, ...right }; + for (const [methodName, leftPatterns] of Object.entries(left)) { + const rightPatterns = right[methodName]; + if (rightPatterns === undefined) { + continue; + } + const length = Math.max(leftPatterns.length, rightPatterns.length); + disjoined[methodName] = Array.from({ length }, (_, index) => { + const leftPattern = leftPatterns[index]; + const rightPattern = rightPatterns[index]; + if (leftPattern === undefined || rightPattern === undefined) { + return undefined; + } + return M.or(leftPattern, rightPattern); + }); + } + return disjoined; +}; + /** * Narrow one method guard by conjoining the delta's patterns onto the * positions they address. diff --git a/packages/kernel-utils/src/narrowing.test.ts b/packages/kernel-utils/src/narrowing.test.ts index 0cea21f52..7b83d429c 100644 --- a/packages/kernel-utils/src/narrowing.test.ts +++ b/packages/kernel-utils/src/narrowing.test.ts @@ -5,14 +5,19 @@ import { join, pathUnder } from './narrowing.ts'; const makeBase = (): object => ({ readFile: () => 'contents' }); -// `narrow` is exercised end to end in `@ocap/kernel-test`. It cannot be -// exercised here: `E` reads `globalThis.HandledPromise` when it loads, and this -// package's tests run under `mock-endoify`, which sets that to plain `Promise`. +// `narrow`, and every path of `join` that reaches a minted ref, are exercised +// end to end in `@ocap/kernel-test`. They cannot be exercised here: `E` reads +// `globalThis.HandledPromise` when it loads, and this package's tests run under +// `mock-endoify`, which sets that to plain `Promise`. What is reachable here is +// the refusal `join` raises before it mints anything. describe('join', () => { - it('is not implemented', async () => { - await expect( - join({ name: 'Joined', refs: [makeBase(), makeBase()] }), - ).rejects.toThrow('join is not implemented'); + it.each([ + { scenario: 'no ref was minted by narrowing', refs: [makeBase()] }, + { scenario: 'no refs were given at all', refs: [] }, + ])('refuses a join where $scenario', async ({ refs }) => { + await expect(join({ name: 'Joined', refs })).rejects.toThrow( + 'Cannot join "Joined": no ref was minted by narrowing.', + ); }); }); diff --git a/packages/kernel-utils/src/narrowing.ts b/packages/kernel-utils/src/narrowing.ts index c5d853776..e4ed3c542 100644 --- a/packages/kernel-utils/src/narrowing.ts +++ b/packages/kernel-utils/src/narrowing.ts @@ -9,6 +9,7 @@ import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; import { getInterfaceMethodGuards } from './guard-algebra.ts'; import { conjoinDeltas, + disjoinDeltas, narrowInterfaceGuard, } from './narrow-interface-guard.ts'; import type { NarrowingDelta } from './narrow-interface-guard.ts'; @@ -67,6 +68,47 @@ const makeForwarder = return E(target as Forwardable)[methodName]!(...args); }; +/** + * Mint an exo under the guard `delta` derives from `baseGuard`, forwarding to + * `base`, and record what it was minted from. + * + * @param options - The narrowing to mint. + * @param options.name - The name for the derived exo and its interface guard. + * @param options.base - The capability to forward to. + * @param options.baseGuard - That capability's interface guard. + * @param options.delta - The patterns to conjoin, by method and position. + * @returns The minted narrowing. + */ +const mint = ({ + name, + base, + baseGuard, + delta, +}: { + name: string; + base: object; + baseGuard: InterfaceGuard; + delta: NarrowingDelta; +}): Guarded => { + const derivedGuard = narrowInterfaceGuard({ name, baseGuard, delta }); + const methods = Object.fromEntries( + Object.keys(getInterfaceMethodGuards(derivedGuard)).map((methodName) => [ + methodName, + makeForwarder(base, methodName), + ]), + ) as unknown as Minted; + + // The derived guard's method set is computed at runtime, so nothing ties it + // to `Minted` at the type level. + const minted = makeExo( + name, + derivedGuard as InterfaceGuard<{ [Method in keyof Minted]: MethodGuard }>, + methods, + ); + provenance.set(minted, { base, delta, baseGuard }); + return minted; +}; + /** * Conjoin each pattern of `delta` onto the corresponding argument position of * `base`'s interface guard, and return an exo under that derived guard whose @@ -110,43 +152,76 @@ export const narrow = async ({ ); } - const derivedGuard = narrowInterfaceGuard({ - name, - baseGuard, - delta: combined, - }); - const methods = Object.fromEntries( - Object.keys(getInterfaceMethodGuards(derivedGuard)).map((methodName) => [ - methodName, - makeForwarder(target, methodName), - ]), - ) as unknown as Narrowed; - - // The derived guard's method set is computed at runtime, so nothing ties it - // to `Narrowed` at the type level. - const narrowed = makeExo( - name, - derivedGuard as InterfaceGuard<{ [Method in keyof Narrowed]: MethodGuard }>, - methods, - ); - provenance.set(narrowed, { base: target, delta: combined, baseGuard }); - return narrowed; + return mint({ name, base: target, baseGuard, delta: combined }); }; /** - * Not yet implemented; throws. - * * Return a narrowing of `refs`' common base admitting exactly what any of them - * admits. Every ref must be one this module minted from that base, or the base - * itself. + * admits, with the operands' deltas disjoined method by method and position by + * position. + * + * Every ref must be one this module minted from that base, or the base itself. + * The base admits everything and so absorbs, which gives the lattice a + * representable top and lets a fold over a list need no special case. * - * @param _options - The join to mint. + * At least one ref must be a minted narrowing, so the base absorbs only in + * company: the common base is discovered from a minted ref's record, and an + * unminted ref is recognizable as that base only by identity against it. A call + * whose refs are all unminted therefore throws, `refs: []` included, rather + * than trusting an unminted ref to be the base it cannot confirm. The + * degenerate `refs: [base]` — a request to copy the base — throws for the same + * reason, and is not worth a guard fetch to support. + * + * The result carries a record of its own, naming the same base and the + * disjoined delta, so a join can be narrowed or joined again. + * + * @param options - The join to mint. + * @param options.name - The name for the derived exo and its interface guard. + * @param options.refs - The narrowings to join, and optionally their base. * @returns The join of the refs. */ -export const join = async ( - _options: JoinOptions, -): Promise> => { - throw new Error('join is not implemented'); +export const join = async ({ + name, + refs, +}: JoinOptions): Promise> => { + const records = refs.map((ref) => provenance.get(ref)); + const minted = records.filter( + (record): record is Provenance => record !== undefined, + ); + const [first] = minted; + if (first === undefined) { + throw new Error(`Cannot join "${name}": no ref was minted by narrowing.`); + } + const { base, baseGuard } = first; + if (minted.some((record) => record.base !== base)) { + throw new Error(`Cannot join "${name}": the refs do not share a base.`); + } + + const unconstrained = Object.fromEntries( + Object.keys(getInterfaceMethodGuards(baseGuard)).map((methodName) => [ + methodName, + [], + ]), + ); + const deltas = refs.map((ref, index) => { + const record = records[index]; + if (record !== undefined) { + return record.delta; + } + if (ref !== base) { + throw new Error( + `Cannot join "${name}": ref ${index} was not minted by narrowing.`, + ); + } + return unconstrained; + }); + + return mint({ + name, + base, + baseGuard, + delta: deltas.reduce(disjoinDeltas), + }); }; /**