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
3 changes: 1 addition & 2 deletions packages/kernel-test/src/narrowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/kernel-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions packages/kernel-utils/src/narrow-interface-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
},
);
});
40 changes: 40 additions & 0 deletions packages/kernel-utils/src/narrow-interface-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 12 additions & 7 deletions packages/kernel-utils/src/narrowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
});
});

Expand Down
135 changes: 105 additions & 30 deletions packages/kernel-utils/src/narrowing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <Minted extends Methods>({
name,
base,
baseGuard,
delta,
}: {
name: string;
base: object;
baseGuard: InterfaceGuard;
delta: NarrowingDelta;
}): Guarded<Minted> => {
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
Expand Down Expand Up @@ -110,43 +152,76 @@ export const narrow = async <Narrowed extends Methods = Methods>({
);
}

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<Narrowed>({ 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 <Joined extends Methods = Methods>(
_options: JoinOptions,
): Promise<Guarded<Joined>> => {
throw new Error('join is not implemented');
export const join = async <Joined extends Methods = Methods>({
name,
refs,
}: JoinOptions): Promise<Guarded<Joined>> => {
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<Joined>({
name,
base,
baseGuard,
delta: deltas.reduce(disjoinDeltas),
});
};

/**
Expand Down
Loading