Skip to content

feat(kernel-utils): attenuation by narrowing - #1134

Merged
ci-belphegor merged 16 commits into
mainfrom
grypez/narrowing-lib
Oct 6, 2026
Merged

ci-belphegor merged 16 commits into
mainfrom
grypez/narrowing-lib

Conversation

@ci-belphegor

@ci-belphegor ci-belphegor commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

Adds attenuation by narrowing to @metamask/kernel-utils, as specified in docs/attenuation-by-narrowing.md:

  • narrow({ name, base, delta }) reads the base's interface guard over E(), conjoins the delta's per-position patterns onto it, and returns an exo under the derived guard that forwards to the base. Narrowing a narrowing flattens to one hop over the original base.
  • join({ name, refs }) returns a narrowing of the refs' common base admitting whatever any of them admits.
  • pathUnder(segments) is a pattern for segment arrays under a prefix, with .. excluded past it.
  • Bases built with makeDefaultExo (defaultGuards: 'passable', no method guards) can be narrowed: each named method gets a synthesized guard.

The commits are kept as review steps: API surface, the pure guard algebra (narrowInterfaceGuard, internal), narrow, join, default-guarded bases, then an end-to-end test in a real vat. The squashed result has no stubs and no it.fails cases.

Notes for reviewers

  • base is typed object. A real exo satisfies neither Methods nor Partial<Methods>, since Guarded<M> has no index signature. As a result, E() method access on a narrowing needs an explicit type parameter, e.g. narrow<{ read: (p: string[]) => Promise<string> }>(...).
  • Soundness comes from construction, not verification. narrow builds AND(guard(base), delta), so guard(narrowed) ≤ guard(base) holds without a pattern subtyping procedure. join relies on this: every operand must be minted by narrow from the same base (or be that base, which absorbs), so OR-ing the deltas stays within the base. A ref from anywhere else throws, as does a join whose refs are all unminted.
  • Narrowing defaults to removing authority. Methods the delta does not name are dropped. conjoinDeltas takes keys from the incoming delta only and throws on a key the existing narrowing lacks, so re-narrowing cannot restore a dropped method. A position landing in a rest guard constrains every trailing argument. An empty delta for a method is a no-op.
  • The forwarder must look up the method inside the closure. Hoisting E(target)[method] out of it fails with Unexpected receiver. The JSDoc records this.
  • Default-guarded bases trade an error for a rejection. Their guard names no methods, so a misspelled method in the delta cannot be refused while narrowing. Calling it rejects with target has no method. defaultGuards: 'raw' still throws, because raw arguments are not "any passable".
  • narrow has no unit tests in kernel-utils. E binds globalThis.HandledPromise at load, and mock-endoify sets that to plain Promise. The cases in @ocap/kernel-test cover it in a vat instead.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

🤖 Generated with Claude Code


Note

Medium Risk
New security-sensitive capability plumbing (guard derivation, join soundness, provenance); mistakes could admit calls operands would reject, though the design builds conjunctions rather than verifying subtyping.

Overview
Adds capability attenuation by narrowing to @metamask/kernel-utils: narrow, join, and pathUnder, plus NarrowingDelta / options types, exported from the main package entry.

narrow reads the base’s interface guard (via E()), conjoins per-method positional patterns, hardens the delta, and mints a forwarding exo with provenance so chains flatten to one hop over the original base. join unions authority from refs sharing that base by concatenating disjunctive rows per method (not per-position OR), rendering multi-row methods as rest-guard disjunctions so cross-argument combinations are never admitted. Guard construction lives in narrow-interface-guard.ts (including default-guarded makeDefaultExo bases and rest-position handling via M.splitArray).

Documentation in docs/attenuation-by-narrowing.md is updated to match (join DNF, flattening, rest args) and notes the library is still in progress. Coverage includes large unit tests for the guard algebra, direct narrow/join tests under lockdown, and kernel vat probes for end-to-end behavior.

Reviewed by Cursor Bugbot for commit 0e41efc. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 73.17%
⬆️ +0.02%
9999 / 13664
🔵 Statements 72.94%
🟰 ±0%
10135 / 13894
🔵 Functions 73.3%
⬇️ -0.25%
2348 / 3203
🔵 Branches 67.83%
⬆️ +0.31%
4140 / 6103
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/kernel-test/src/vats/narrowing-vat.ts 0% 100% 0% 0% 24-179
packages/kernel-utils/src/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/kernel-utils/src/narrow-interface-guard.ts 100% 100% 100% 100%
packages/kernel-utils/src/narrowing.ts 97.77% 93.75% 100% 97.67% 160-162
Generated in workflow #5150 for commit 0e41efc by the Vitest Coverage Report Action

@ci-belphegor
ci-belphegor marked this pull request as ready for review October 2, 2026 16:27
@ci-belphegor
ci-belphegor requested a review from a team as a code owner October 2, 2026 16:27

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/kernel-utils/src/narrow-interface-guard.ts
Comment thread packages/kernel-utils/src/narrowing.ts Outdated
Comment thread packages/kernel-utils/src/narrowing.ts
ci-belphegor and others added 8 commits October 2, 2026 13:46
Fix the signatures of narrow and join and export them alongside a fully
implemented pathUnder, so that callers and deltas can be written against
the API before the algebra behind it exists. Both narrow and join throw;
the changelog says so.

pathUnder([]) matches every ..-free segment array rather than throwing.
It is the top of the prefix lattice and a well-defined element of the
vocabulary; a capability for which unbounded authority is a mistake
rejects an empty prefix in its own config validation, where throwing here
would buy nothing anyway since the caller could write the pattern by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Derive the interface guard of a narrowing: conjoin each delta pattern onto
the base's guard at the argument position it addresses, inherit arity, the
required/optional/rest split, and return guards verbatim, drop methods the
delta does not name, and asyncify every method guard for forwarding.

The guard is constructed as a conjunction with the base's own rather than
checked against it, so it admits no call the base does not. That is the
precondition join needs to disjoin two deltas without a pattern subtyping
decision procedure.

A delta naming a method the base guards by default still throws; PR-8
relaxes that arm into synthesizing a guard from the delta.

No exos, promises, or provenance — narrow wraps this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrap the derived interface guard in an exo whose methods forward to the
base through E(). The base's guard is read over E() too, so one code path
serves a local base and a base that later becomes a cross-vat presence.
Enforcement is the returned exo's own guard checking; there is no separate
check step.

A module-level WeakMap records what each narrowing was minted from. It
caches the base's guard, so narrowing a narrowing needs no read, and it
flattens: the result forwards straight to the original base under both
deltas conjoined, keeping a chain of any depth one hop deep. conjoinDeltas
takes its keys from the incoming delta alone and refuses one the existing
delta dropped, so flattening cannot reinstate authority an intermediate
narrowing gave up.

A forward for a method the base lacks rejects rather than yielding
undefined, so a caller can tell absent authority from a call that returned
nothing.

narrow has no unit tests in this package: E reads globalThis.HandledPromise
when it loads, and these tests run under mock-endoify, which sets that to
plain Promise. The three narrowing cases in @ocap/kernel-test cover it end
to end in a real vat instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A base built with makeDefaultExo carries defaultGuards: 'passable' and an
empty methodGuards map, so there is no per-method guard to conjoin onto.
Since 'passable' admits any passable arguments, the delta's patterns can be
the whole guard: the result admits no more calls than the base did, and a
delta of length 0 synthesizes M.callWhen().rest(M.any()).returns(M.any()),
which admits exactly what the base admits. That makes narrow usable against
the exos this repo actually builds.

The base names no methods, so a delta naming one it does not implement is
indistinguishable from one it does and can no longer be refused while
narrowing. Calling it rejects with 'target has no method', which is what a
caller holding no such authority must see. The error for a base that names
its methods is unchanged, as is the one for raw defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cover the narrowing library end to end in a real vat, where E and the exo
guards behave as in production rather than under mock-endoify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A synthesized guard made every delta position required, holes included.
A join pads the shorter operand's delta with holes, so it demanded
arguments that operand never required and refused calls it admitted.
Trailing holes are now dropped.

A base that guards methods by default contributed an empty delta as a
join operand, so it did not absorb, and the join kept the other
operands' constraints. Its methods cannot be enumerated, so no delta
represents it, and join now throws for such a base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sirtimid
sirtimid self-requested a review October 6, 2026 11:39
ci-belphegor and others added 4 commits October 6, 2026 08:19
join disjoined deltas position by position, so the result admitted the
product of the per-position unions rather than the union of the
operands. Joining narrowings to copy(data, data) and copy(logs, logs)
admitted copy(data/x, logs/y), which neither operand admits.

A narrowing now records its delta in disjunctive normal form: each
method maps to a non-empty list of positional rows, and admits a call
that any row admits. narrow's input keeps its single-row shape and is
lifted to one row. join concatenates the operands' rows per method,
with a row of holes absorbing the method's other rows, and narrowing a
join conjoins the incoming row onto every row.

A method with one row renders the same positional guard as before.
One with several renders as M.callWhen().rest(M.or(...)) with one
M.splitArray per row, each conjoined with the base's guard at every
position. A method guard's rest guard matches the trailing arguments
as one array, so with no fixed arguments it sees the whole argument
array. M.splitArray defaults an omitted rest to M.any(), so a row whose
base has no rest guard gets [] as its rest to refuse extra arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a vat

Cover a join of two copy narrowings rejecting the cross-combination of
their arguments, a narrowed join admitting and rejecting per row, and a
narrowed join recording the original base, shown by joining it with
that base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
join now admits exactly the union of what its operands admit. Describe
the row representation, how narrowing distributes over rows, and the
rendered guard for a method with several rows, which a receiver in
another vat sees in place of the positional form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A method guard's rest guard matches the trailing arguments as one
array, but narrowing conjoined each pattern past the fixed arity onto
it as though it matched each argument. A pattern such as M.lte(10) then
never matched the array, so the narrowed method refused every call,
and several rest positions collapsed onto one constraint.

The patterns at rest positions are now gathered into
M.splitArray([], [p, ...]) over the trailing array and conjoined onto
the base's rest guard. Each sits in an optional slot, so it constrains
its own argument when present without changing arity, and arguments
past the last pattern are left as the base has them. A row whose
patterns stop before the rest guard keeps it unchanged.

Each position is now an independent constraint on one argument, so a
row is a conjunction of per-argument constraints, and a join, which
only disjoins whole rows, still admits exactly what its operands admit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@sirtimid sirtimid left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there. Also the HandledPromise problem is real, but it doesn't mean narrow can only be tested inside a vat. @ocap/kernel-test already runs under real lockdown (endoify-node), so a plain test file there can import narrow and join and call them directly, with no kernel or vat. That's also a cheap place to test cases that should be refused, which the current vat tests mostly skip.

Cases with no test at the moment:

  • Narrowing a narrowing: re-adding a dropped method throws, and the first narrowing's patterns still apply.
  • join with refs from different bases, or with an object that is not a narrowing: both should throw.
  • A joined ref refuses calls that none of its operands allows. Right now only an allowed call is checked.
  • join with the base as one of the refs allows everything the base allows.
  • Changing a delta after narrow has no effect.

As it stands, deleting the ref !== base check in join, or passing delta instead of combined to mint in narrow, would not fail any test.

Comment thread packages/kernel-utils/src/narrowing.ts
Comment thread packages/kernel-utils/src/narrow-interface-guard.ts Outdated
Comment thread packages/kernel-utils/src/narrow-interface-guard.ts Outdated
Comment thread packages/kernel-utils/src/narrow-interface-guard.ts Outdated
Comment thread packages/kernel-utils/src/narrow-interface-guard.ts Outdated
A narrowing's record held the caller's row arrays, so changing a delta
after narrow changed the record, and a later join or narrow rendered
its guard from the changed delta. A delta narrowed to srv/data and then
overwritten with M.any() let a join read etc/passwd. Rows are now copied
when lifted into the record, and the record is hardened when minted.

Method lookups read inherited properties, so a method named after an
Object.prototype member, such as toString, crashed conjoinDeltas and
disjoinDeltas, and was taken for a guarded method of a default-guarded
base rather than synthesized. Lookups now use Object.hasOwn.

Filling holes with M.any() used map, which skips the holes of a sparse
array such as [, M.lte(10)], leaving holes in a guard's patterns. Holes
are now filled with Array.from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/kernel-utils/src/narrow-interface-guard.ts
…ance

narrow and join looked up a narrowing's record by the value they were
given, so a promise for a narrowing found no record. Narrowing it read
the narrowing's guard as though it were an original base, so the result
did not flatten, and joining it with its siblings threw because the
refs did not share a base. Both now await what they are given first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 939aa7f. Configure here.

Comment thread packages/kernel-utils/src/narrowing.ts
ci-belphegor and others added 2 commits October 6, 2026 12:38
Since narrow began awaiting its base, it copied the delta only after
that await, so a caller changing the delta in the same turn, or reusing
it for a second narrow, had the change recorded and a later join minted
from it. narrow now hardens the delta on entry, before any await, which
also freezes any mutable patterns it holds until the guard is built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover narrow and join without a kernel or vat, including the calls and
joins they must refuse: both deltas of a chain applying, restoring a
dropped method, the recorded base, a promise for a narrowing, a delta
changed before or after narrow settles, a join refusing the
cross-combination of its operands' arguments, the base absorbing, and
refs from different bases, refs that are neither a narrowing nor the
base, and a default-guarded base.

The vat test for a delta changed after narrowing moves here, since
narrow now hardens the delta and changing it throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ci-belphegor

Copy link
Copy Markdown
Contributor Author

@sirtimid, re your review: 0e41efc adds narrowing-library.test.ts, which calls narrow and join directly under lockdown and covers each case you listed. Deleting the ref !== base check, or passing delta instead of combined to mint, now fails tests. For "changing a delta after narrow has no effect", see also 35dc4bb.

@sirtimid sirtimid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@ci-belphegor
ci-belphegor added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 9b31ba3 Oct 6, 2026
49 of 52 checks passed
@ci-belphegor
ci-belphegor deleted the grypez/narrowing-lib branch October 6, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants