Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
62 changes: 47 additions & 15 deletions docs/attenuation-by-narrowing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ another — rather than by anticipation.

## The library

> **In progress:** the library described in this section has not fully landed.

Exported from `@metamask/kernel-utils`:

```ts
Expand Down Expand Up @@ -159,12 +157,12 @@ delta cannot promote an optional argument to required, and cannot change arity.
It is an error for a delta to name a method the base does not have, or a position
beyond the base's maximum arity when the base has no rest guard.

A position that lands in the rest guard conjoins onto it. Since a rest guard is
one pattern over all trailing arguments, the conjunction constrains every one of
them rather than only the position named, and several such positions conjoin onto
the same guard. `guard(B) ≤ guard(A)` still holds and the forward is still
unaltered, and the surprise runs in the safe direction: the author gets less
authority than intended, never more.
A position that lands in the rest guard still addresses its own argument. A rest
guard is one pattern over the array of trailing arguments, so the patterns at
rest positions are gathered into `M.splitArray([], [p, …])` over that array and
conjoined onto the base's rest guard. They sit in optional slots, so each
constrains its argument when present without making it required, and arguments
past the last pattern are left as the base has them.

### Default-guarded bases

Expand All @@ -185,16 +183,19 @@ This is sound on the same grounds as the general case: the synthesized guard
admits no more calls than the base's own, and the method still forwards
unaltered. Because the interface names no methods, a delta naming a method the
base does not implement is caught at call time rather than at narrowing time.
A method with several rows after a [join](#join) renders by the same rule, with
each row synthesized this way.

### Provenance and flattening

`narrow` records `{ base, delta, baseGuard }` in a `WeakMap` keyed by the exo it
returns. Caching `baseGuard` matters because narrowing a narrowing then needs no
returns, with `delta` lifted to one row per method (see [join](#join)). Caching `baseGuard` matters because narrowing a narrowing then needs no
guard fetch, so a chain of any depth costs one fetch in total.

Narrowing a narrowing **flattens**: `narrow(B, Y)` where `B` is
`{ base: A, delta: X }` records `{ base: A, delta: AND(X, Y) }`, and the returned
exo forwards directly to `A`.
exo forwards directly to `A`. When `X` has several rows for a method, as after a
[join](#join), `Y` is conjoined onto each.

```js
const b = await narrow({ name: 'B', base: a, delta: x });
Expand All @@ -221,21 +222,52 @@ admitting exactly what any of them admits.
// b: { readFile: [pathUnder(['srv', 'data'])] }
// b': { readFile: [pathUnder(['srv', 'logs'])], access: [pathUnder(['srv', 'logs'])] }
await join({ name: 'DataAndLogs', refs: [b, bPrime] });
// -> { readFile: [OR(data, logs)], access: [pathUnder(['srv', 'logs'])] }
// -> { readFile: [[data], [logs]], access: [[pathUnder(['srv', 'logs'])]] }
```

- Every ref must carry a provenance record naming the same base. A ref the
library did not mint throws.
- The unnarrowed base is a legal operand and absorbs, so the lattice has a
representable top and a fold over a list needs no special case.
- The result's method set is the union of the operands'.
- Where two operands name the same method, each argument position is disjoined.
- Where two operands name the same method, their rows are concatenated.

A recorded delta is in disjunctive normal form: each method maps to a non-empty
list of positional rows, each shaped like a `NarrowingDelta` entry, and the
method admits a call that any one row admits. The delta passed to `narrow` is the
one-row case. Disjoining position by position instead would be unsound with
respect to the operands. For

```js
const a = await narrow({ name: 'A', base: fs, delta: { copy: [data, data] } });
const b = await narrow({ name: 'B', base: fs, delta: { copy: [logs, logs] } });
```

it would give `copy: [OR(data, logs), OR(data, logs)]`, which admits
`copy(data/x, logs/y)` though neither operand does. Concatenating rows gives
`copy: [[data, data], [logs, logs]]`, which admits exactly the union.

A missing method and a hole behave differently, which is easier to read as one
rule than two: the join is a union of authority, a method absent from an operand
contributes the empty set, and a hole contributes everything. So a method only
one operand names appears at that operand's delta, while a hole on either side
leaves that position unconstrained in the result.
contributes no rows, and a hole contributes everything. So a method only one
operand names appears at that operand's rows, while a row of holes absorbs every
other row of its method. Beyond that absorption, rows are kept as they are, not
deduplicated or merged.

Narrowing a join distributes over its rows:
`(r1 ∪ … ∪ rn) ∧ x = (r1 ∧ x) ∪ … ∪ (rn ∧ x)`, so the incoming delta is conjoined
onto every row.

A method with one row renders as a positional guard, as `narrow` produces.
Positional guards have no disjunction across positions, so a method with several
rows renders as a guard with no fixed arguments whose rest guard is
`M.or(M.splitArray(required, optional, rest), …)`, one per row, each conjoined
with the base's guard at every position. A rest guard matches the trailing
arguments as one array, so with no fixed arguments it sees the whole argument
array and each row checks arity in full. A row whose base has no rest guard gets
`[]` as its rest, since `M.splitArray` would otherwise default it to `M.any()`
and admit trailing arguments. The rows survive only in the provenance record: a
receiver in another vat sees the rendered guard, not the positional form.

### Guard algebra

Expand Down
137 changes: 137 additions & 0 deletions packages/kernel-test/src/narrowing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
import { waitUntilQuiescent } from '@metamask/kernel-utils';
import { kunser } from '@metamask/ocap-kernel';
import type { Kernel, KRef, VatConfig } from '@metamask/ocap-kernel';
import { describe, expect, it } from 'vitest';

import { getBundleSpec, makeKernel, makeTestLogger } from './utils.ts';

const V1_ROOT: KRef = 'ko4';

/**
* Launch the narrowing vat.
*
* @returns The running kernel.
*/
const launchNarrowingVat = async (): Promise<Kernel> => {
const { logger } = makeTestLogger();
const database = await makeSQLKernelDatabase({});
const kernel = await makeKernel(database, true, logger);
const vat: VatConfig = {
bundleSpec: getBundleSpec('narrowing-vat'),
parameters: {},
};
await kernel.launchSubcluster({ bootstrap: 'main', vats: { main: vat } });
await waitUntilQuiescent();
return kernel;
};

/**
* Invoke one of the vat's probes.
*
* @param kernel - The kernel to send through.
* @param method - The probe to invoke.
* @param args - The probe's arguments.
* @returns `ok:<result>` or `rejected:<message>`, per the vat.
*/
const probe = async (
kernel: Kernel,
method: string,
args: unknown[],
): Promise<unknown> => kunser(await kernel.queueMessage(V1_ROOT, method, args));

describe('narrowing', () => {
it('narrows a vat-local exo', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['read', ['srv', 'data', 'x']]),
).toBe('ok:read:srv/data/x');
});

it('rejects a call outside the narrowing', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['read', ['etc', 'passwd']]),
).toMatch(/^rejected:.*\bread\b/u);
});

it('drops methods absent from the delta', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['stat', ['srv', 'data', 'x']]),
).toMatch(/^rejected:.*\bstat\b/u);
});

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',
);
});

it.each([
{ from: ['srv', 'data', 'x'], to: ['srv', 'data', 'y'] },
{ from: ['srv', 'logs', 'x'], to: ['srv', 'logs', 'y'] },
])(
'joins multi-argument narrowings: copy($from, $to)',
async ({ from, to }) => {
const kernel = await launchNarrowingVat();
expect(await probe(kernel, 'probeJoinedCopy', [from, to])).toBe(
`ok:copy:${from.join('/')}->${to.join('/')}`,
);
},
);

it('rejects a call combining the arguments of two joined narrowings', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeJoinedCopy', [
['srv', 'data', 'x'],
['srv', 'logs', 'y'],
]),
).toMatch(/^rejected:.*\bcopy\b/u);
});

it.each([
{
scenario: 'admits a call within one operand',
to: ['srv', 'logs', 'y'],
from: ['srv', 'logs', 'x'],
expected: /^ok:copy:srv\/logs\/x->srv\/logs\/y$/u,
},
{
scenario: 'rejects a call the narrowing excludes',
from: ['srv', 'logs', 'x'],
to: ['srv', 'logs', 'secret'],
expected: /^rejected:.*\bcopy\b/u,
},
{
scenario: 'rejects the cross-combination',
from: ['srv', 'data', 'x'],
to: ['srv', 'logs', 'y'],
expected: /^rejected:.*\bcopy\b/u,
},
])('narrows a join: $scenario', async ({ from, to, expected }) => {
const kernel = await launchNarrowingVat();
expect(await probe(kernel, 'probeNarrowedJoin', [from, to])).toMatch(
expected,
);
});

it('flattens a narrowed join onto the original base', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowedJoinWithBase', [
['srv', 'data', 'x'],
['srv', 'logs', 'secret'],
]),
).toBe('ok:copy:srv/data/x->srv/logs/secret');
});

it('narrows a default-guarded exo', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeDefaultGuarded', [['srv', 'data', 'x']]),
).toBe('ok:loose:srv/data/x');
});
});
Loading
Loading