Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kernel-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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 })` and `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **Both functions throw for now** — this release fixes their signatures so callers and deltas can be written against them, and the implementations follow ([#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))
Expand Down
1 change: 1 addition & 0 deletions packages/kernel-utils/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('index', () => {
'mergeDisjointRecords',
'methodArgsToStruct',
'narrow',
'narrowInterfaceGuard',
'pathUnder',
'prettifySmallcaps',
'resolveFetchInput',
Expand Down
8 changes: 3 additions & 5 deletions packages/kernel-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ export {
getMethodPayload,
} from './guard-algebra.ts';
export type { MethodGuardPayload } from './guard-algebra.ts';
export { narrowInterfaceGuard } from './narrow-interface-guard.ts';
export type { NarrowingDelta } from './narrow-interface-guard.ts';
export { join, narrow, pathUnder } from './narrowing.ts';
export type {
JoinOptions,
NarrowOptions,
NarrowingDelta,
} from './narrowing.ts';
export type { JoinOptions, NarrowOptions } from './narrowing.ts';
export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts';
export type { DiscoverableExo } from './discoverable.ts';
export { S } from './described.ts';
Expand Down
139 changes: 139 additions & 0 deletions packages/kernel-utils/src/narrow-interface-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { M, getMethodGuardPayload, matches } from '@endo/patterns';
import type { InterfaceGuard } from '@endo/patterns';
import { describe, it, expect } from 'vitest';

import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts';
import type { MethodGuardPayload } from './guard-algebra.ts';
import { narrowInterfaceGuard } from './narrow-interface-guard.ts';
import type { NarrowingDelta } from './narrow-interface-guard.ts';

// One fixture reaching all three positional categories.
const makeBaseGuard = (): InterfaceGuard =>
M.interface('Store', {
read: M.callWhen(M.string()).optional(M.number()).returns(M.any()),
write: M.callWhen(M.string()).rest(M.number()).returns(M.any()),
drop: M.callWhen(M.string()).returns(M.any()),
});

const payloadOf = (guard: InterfaceGuard, method: string): MethodGuardPayload =>
getMethodPayload(getInterfaceMethodGuards(guard)[method]!);

const narrowFrom = (
baseGuard: InterfaceGuard,
delta: NarrowingDelta,
): InterfaceGuard =>
narrowInterfaceGuard({ name: 'Narrowed', baseGuard, delta });

describe('narrowInterfaceGuard', () => {
it('drops methods the delta does not name', () => {
const result = narrowFrom(makeBaseGuard(), { read: [] });

expect(Object.keys(getInterfaceMethodGuards(result))).toStrictEqual([
'read',
]);
});

it('drops every method given an empty delta', () => {
const result = narrowFrom(makeBaseGuard(), {});

expect(getInterfaceMethodGuards(result)).toStrictEqual({});
});

it('leaves a method as the base has it when its delta is empty', () => {
const baseGuard = makeBaseGuard();

const result = narrowFrom(baseGuard, { read: [] });

expect(payloadOf(result, 'read')).toStrictEqual(
payloadOf(baseGuard, 'read'),
);
});

it('conjoins a pattern onto a required argument', () => {
const result = narrowFrom(makeBaseGuard(), { read: [M.eq('a')] });
const [argGuard] = payloadOf(result, 'read').argGuards;

expect(matches('a', argGuard)).toBe(true);
expect(matches('b', argGuard)).toBe(false);
expect(matches(1, argGuard)).toBe(false);
});

it('leaves positions the delta does not reach as the base has them', () => {
const baseGuard = makeBaseGuard();

const result = narrowFrom(baseGuard, { read: [M.eq('a')] });

expect(payloadOf(result, 'read').optionalArgGuards).toStrictEqual(
payloadOf(baseGuard, 'read').optionalArgGuards,
);
});

it('conjoins onto an optional argument without making it required', () => {
const baseGuard = makeBaseGuard();

const result = narrowFrom(baseGuard, { read: [undefined, M.lte(10)] });
const { argGuards, optionalArgGuards } = payloadOf(result, 'read');

expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'read').argGuards);
expect(matches(5, optionalArgGuards![0])).toBe(true);
expect(matches(20, optionalArgGuards![0])).toBe(false);
});

it('conjoins onto the rest guard past the fixed arity', () => {
const baseGuard = makeBaseGuard();

const result = narrowFrom(baseGuard, { write: [undefined, M.lte(10)] });
const { argGuards, restArgGuard } = payloadOf(result, 'write');

expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'write').argGuards);
expect(matches(5, restArgGuard)).toBe(true);
expect(matches(20, restArgGuard)).toBe(false);
});

it('inherits the return guard verbatim', () => {
const baseGuard = makeBaseGuard();

const result = narrowFrom(baseGuard, { read: [M.eq('a')] });

expect(payloadOf(result, 'read').returnGuard).toBe(
payloadOf(baseGuard, 'read').returnGuard,
);
});

it('asyncifies a synchronous method guard', () => {
const baseGuard = M.interface('Sync', {
read: M.call(M.string()).returns(M.any()),
});

const result = narrowFrom(baseGuard, { read: [] });
const { callKind } = getMethodGuardPayload(
getInterfaceMethodGuards(result).read!,
);

expect(callKind).toBe('async');
});

it.each([
{
scenario: 'names a method the base does not have',
makeGuard: makeBaseGuard,
delta: { missing: [] },
message: 'the base has no such method',
},
{
scenario:
'addresses a position past the arity of a base without a rest guard',
makeGuard: makeBaseGuard,
delta: { drop: [undefined, M.eq('x')] },
message: 'the base has arity 1 and no rest guard',
},
{
scenario: 'narrows a method the base guards by default',
makeGuard: () => M.interface('Any', {}, { defaultGuards: 'passable' }),
delta: { read: [M.eq('a')] },
message: 'there is no guard to conjoin onto',
},
])('rejects a delta that $scenario', ({ makeGuard, delta, message }) => {
expect(() => narrowFrom(makeGuard(), delta)).toThrow(message);
});
});
130 changes: 130 additions & 0 deletions packages/kernel-utils/src/narrow-interface-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { M, getInterfaceGuardPayload } from '@endo/patterns';
import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns';

import {
buildMethodGuard,
getInterfaceMethodGuards,
getMethodPayload,
} from './guard-algebra.ts';

/**
* Patterns to conjoin onto a base capability's method guards, addressed by
* argument position.
*
* A method the delta does not name is dropped, so forgetting a method removes
* authority rather than granting it. Within a method, a hole leaves that
* position as the base has it, and an empty array leaves every position as the
* base has it.
*/
export type NarrowingDelta = Record<string, (Pattern | undefined)[]>;

/**
* Conjoin a delta pattern onto a base guard.
*
* @param base - The base guard.
* @param pattern - The pattern to conjoin, or undefined at a hole.
* @returns The conjunction, or the base guard unchanged at a hole.
*/
const conjoin = (base: Pattern, pattern: Pattern | undefined): Pattern =>
pattern === undefined ? base : M.and(base, pattern);

/**
* Narrow one method guard by conjoining the delta's patterns onto the
* positions they address.
*
* Positions are walked as required arguments, then optionals, then the rest
* guard, and each stays in the category it lands in. Every position past the
* fixed arity conjoins onto the one rest guard, which is the only thing a rest
* position can express.
*
* @param methodName - The method being narrowed, for error messages.
* @param baseMethodGuard - The guard to narrow.
* @param patterns - The delta's patterns for this method.
* @returns The narrowed guard, asyncified for forwarding.
*/
const narrowMethodGuard = (
methodName: string,
baseMethodGuard: MethodGuard,
patterns: (Pattern | undefined)[],
): MethodGuard => {
const { argGuards, optionalArgGuards, restArgGuard, returnGuard } =
getMethodPayload(baseMethodGuard);
const optionals = optionalArgGuards ?? [];
const maxArity = argGuards.length + optionals.length;

const beyondArity = patterns.findIndex(
(pattern, index) => index >= maxArity && pattern !== undefined,
);
if (beyondArity !== -1 && restArgGuard === undefined) {
throw new Error(
`Cannot narrow argument ${beyondArity} of method "${methodName}": the base has arity ${maxArity} and no rest guard.`,
);
}

return buildMethodGuard(
M.callWhen(
...argGuards.map((guard, index) => conjoin(guard, patterns[index])),
),
optionals.map((guard, index) =>
conjoin(guard, patterns[argGuards.length + index]),
),
restArgGuard === undefined
? undefined
: patterns.slice(maxArity).reduce(conjoin, restArgGuard),
returnGuard,
);
};

/**
* Derive the interface guard of a narrowing of a base capability.
*
* Each delta pattern is conjoined onto the base's guard at the argument
* position it addresses. Arity, the required/optional/rest split, and return
* guards are inherited verbatim — a narrowed return guard could fail where the
* base succeeds, which would not be an unaltered forward. Methods the delta
* does not name are dropped.
*
* The result is constructed as a conjunction with the base's guard rather than
* checked against it, so it admits no call the base does not — the
* precondition that lets `join` disjoin deltas without deciding pattern
* subtyping.
*
* @param options - Options bag.
* @param options.name - The name for the derived interface guard.
* @param options.baseGuard - The interface guard being narrowed.
* @param options.delta - The patterns to conjoin, by method and position.
* @returns The derived interface guard.
*/
export const narrowInterfaceGuard = ({
name,
baseGuard,
delta,
}: {
name: string;
baseGuard: InterfaceGuard;
delta: NarrowingDelta;
}): InterfaceGuard => {
const baseMethodGuards = getInterfaceMethodGuards(baseGuard);
const { defaultGuards } = getInterfaceGuardPayload(baseGuard) as unknown as {
defaultGuards?: 'passable' | 'raw';
};

const narrowedMethodGuards: Record<string, MethodGuard> = {};
for (const [methodName, patterns] of Object.entries(delta)) {
const baseMethodGuard = baseMethodGuards[methodName];
if (baseMethodGuard === undefined) {
throw new Error(
defaultGuards === undefined
? `Cannot narrow method "${methodName}": the base has no such method.`
: `Cannot narrow method "${methodName}": the base guards it by default, so there is no guard to conjoin onto.`,
);
}
narrowedMethodGuards[methodName] = narrowMethodGuard(
methodName,
baseMethodGuard,
patterns,
);
}

return M.interface(name, narrowedMethodGuards);
};
8 changes: 1 addition & 7 deletions packages/kernel-utils/src/narrowing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import type { Guarded, Methods } from '@endo/exo';
import { M } from '@endo/patterns';
import type { Pattern } from '@endo/patterns';

/**
* A narrowing, addressed by method name and then by argument position.
*
* A method absent from the delta is dropped from the narrowing. An `undefined`
* slot leaves that argument position as the base has it.
*/
export type NarrowingDelta = Record<string, (Pattern | undefined)[]>;
import type { NarrowingDelta } from './narrow-interface-guard.ts';

/**
* `base` is `object` rather than `Methods` because an `@endo/exo` carries no
Expand Down
Loading