Skip to content

feat(kernel-utils): add described*() combinators for guard+schema authoring - #958

Merged
grypez merged 4 commits into
mainfrom
feat/described-exo-combinators
Jun 23, 2026
Merged

feat(kernel-utils): add described*() combinators for guard+schema authoring#958
grypez merged 4 commits into
mainfrom
feat/described-exo-combinators

Conversation

@grypez

@grypezgrypez commented Jun 17, 2026

Copy link
Copy Markdown
Member

Explanation

Adds a ./described export whose combinators author an @endo/patterns
interface guard and a matching MethodSchema from a single source, so a
discoverable exo's enforced shape and its __getDescription__ hint cannot
drift.

The combinator namespace is exported as S (mirroring @endo/patterns's M
and @endo/eventual-send's E). D was avoided because it reads as liveslots'
D() device operator to readers familiar with that codebase. S is the single
authoring surface — there are no bare combinator function exports:

  • Leaves: S.string/S.number/S.boolean/S.arrayOf/S.record/S.object/S.nothing each yield a { pattern, schema } pair.
  • S.arg names a positional parameter.
  • S.method and S.interface assemble them into { guard, schema } / { interfaceGuard, schemas }, ready to splat into makeDiscoverableExo.

Because the enforced pattern and the descriptive schema are projected from the
same authored leaves, their conformance is a construction invariant. Method
guards use M.callWhen(...).returns(...) (exo methods are invoked across an
eventual-send boundary) and the interface guard sets defaultGuards: 'passable'
so the __getDescription__ method injected by makeDiscoverableExo is allowed.
Optional arguments must be trailing (enforced by S.method).

Before this PR

// guard and schema written by hand, independently — nothing keeps them in sync.constinterfaceGuard=M.interface('Math',{add: M.callWhen(M.arrayOf(M.number())).returns(M.number()),});constschemas={add: {description: 'Add a list of numbers.',args: {summands: {type: 'array',items: {type: 'number'}}},returns: {type: 'number'},},};// Relax the guard to accept M.string() and the schema still advertises number[]:// the membrane and the prompt now describe different methods, and nothing catches it.

After this PR

// one source — the guard and the MethodSchema are projected from the// same authored leaves, so they cannot drift.const{ interfaceGuard, schemas }=S.interface('Math',{add: S.method('Add a list of numbers.',[S.arg('summands',S.arrayOf(S.number()))],S.number('The sum of the numbers.'),),});

Test plan

  • yarn workspace @metamask/kernel-utils test:dev:quiet (304 pass, incl. 13 new described tests)
  • yarn workspace @metamask/kernel-utils build
  • yarn workspace @metamask/kernel-utils lint

Note

Low Risk
Additive API and schema metadata; behavior change is limited to optional-arg validation/conversion where required is set.

Overview
Adds @metamask/kernel-utils./described (and root S) so discoverable exos can author @endo/patterns guards and MethodSchema from one definition, avoiding drift between membrane enforcement and __getDescription__.

S exposes leaves (string, number, boolean, arrayOf, record, object, nothing), S.arg, S.method (async M.callWhen, trailing optional args), and S.interface ( defaultGuards: 'passable' for injected description). MethodSchema gains optional required; methodArgsToStruct honors it for Superstruct validation. service-discovery-typesmethodSchemaToMethodSpec now marks parameters not in required as optional.

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

@github-actions

github-actionsBot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

StatusCategoryPercentageCovered / Total
🔵Lines71.24%
⬆️ +0.14%
8830 / 12394
🔵Statements71.06%
⬆️ +0.15%
8978 / 12633
🔵Functions72.36%
⬆️ +0.17%
2126 / 2938
🔵Branches64.86%
⬆️ +0.18%
3569 / 5502
File Coverage
FileStmtsBranchesFunctionsLinesUncovered Lines
Changed Files
packages/kernel-utils/src/described.ts100%100%100%100%
packages/kernel-utils/src/index.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/kernel-utils/src/json-schema-to-struct.ts81.81%
⬆️ +0.86%
81.08%
⬆️ +2.96%
100%
🟰 ±0%
81.81%
⬆️ +0.86%
30, 40, 45-48, 91-92, 117
packages/kernel-utils/src/schema.ts100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/service-discovery-types/src/method-schema-convert.ts94.73%
⬆️ +0.62%
92.3%
⬆️ +5.94%
100%
🟰 ±0%
94.73%
⬆️ +0.62%
52-53
Generated in workflow #4473 for commit af20bba by the Vitest Coverage Report Action

…horing
Add a `./described` export whose combinators author an `@endo/patterns`
interface guard and a matching `MethodSchema` from a single source. The
combinator namespace is exported as `S` (mirroring `@endo/patterns`'s `M`):
each leaf (`S.string`/`number`/`boolean`/`arrayOf`/`record`/`object`/`nothing`)
yields a `{ pattern, schema }` pair; `S.arg` names a positional parameter; and
`S.method` / `S.interface` assemble them into a `{ guard, schema }` / `{
interfaceGuard, schemas }` ready to splat into `makeDiscoverableExo`.
Because the enforced pattern and the descriptive schema are projected from the
same authored leaves, their conformance is a construction invariant rather than
an after-the-fact check. Method guards use `M.callWhen(...).returns(...)` (exo
methods are invoked across an eventual-send boundary) and the interface guard
sets `defaultGuards: 'passable'` so the injected `__getDescription__` is allowed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@grypez
grypezforce-pushed the feat/described-exo-combinators branch from b8ac175 to 6bc1feeCompareJune 18, 2026 15:50
@grypez
grypez marked this pull request as ready for review June 18, 2026 16:07
@grypez
grypez requested a review from a team as a code ownerJune 18, 2026 16:07

@cursorcursorBot 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 and found 2 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 6bc1fee. Configure here.

Comment threadpackages/kernel-utils/src/described.ts
Comment threadpackages/kernel-utils/src/described.ts
grypezand others added 3 commits June 18, 2026 13:28
…ir guards
Resolve two review findings on the described() combinators:
- S.object is documented as a closed/shaped object but M.splitRecord defaults
its rest pattern to M.any(), so neither the pattern nor the schema rejected
extra keys. Close both: pass an empty-record rest pattern and emit
additionalProperties: false (which routes jsonSchemaToStruct through its
strict branch).
- S.method emits optional trailing args via M.callWhen(...).optional(...), but
MethodSchema could not express optionality, so methodArgsToStruct and
method-schema-convert treated every arg as required. Add an optional
`required` field to MethodSchema (mirroring object JsonSchema's `required`),
populate it from S.method, honor it in methodArgsToStruct via a `{ required }`
option, and map it to ValueSpec.optional in method-schema-convert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The converter now emits optional parameters from `MethodSchema.required`;
record that consumer-observable change under the unreleased section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@sirtimidsirtimid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@grypez
grypez added this pull request to the merge queueJun 23, 2026
Merged via the queue into main with commit 8989ceeJun 23, 2026
33 checks passed
@grypez
grypez deleted the feat/described-exo-combinators branch June 23, 2026 12:11
SherfeyInv pushed a commit to SherfeyInv/ocap-kernel that referenced this pull request Jul 29, 2026
…exos (MetaMask#959)
## Explanation
Rewrites the `math`, `end`, and `examples` capabilities as discoverable
exos
built with the `described*()` combinators (added in MetaMask#958, now on
`main`), so
each capability's argument shape is enforced by the exo's interface
guard at
invocation rather than only advertised in the prompt. A mistyped
argument now
fails with a guard rejection at the membrane instead of surfacing deep
inside
the capability.
Each module derives its `{ func, schema }` capability specs via a new
synchronous `makeInternalCapabilities` constructor, which builds the
pattern-guarded exo (kept private as the in-realm enforcement membrane)
and
projects a capability record from the just-authored schemas — without
round-tripping through `GET_DESCRIPTION`. All existing consumers
(example
transcripts, the REPL evaluator, `prepare-attempt`) keep the same spec
shape and
`makeEnd` stays synchronous. `end`'s closed-over result object is
intentionally
left un-hardened so the exo method can mutate it.
`makeInternalCapabilities` asserts at construction that the
implementation and
schema method sets match exactly. A missing implementation already
throws inside
`makeDiscoverableExo`, but an extra implementation absent from the
schema would
otherwise be silently accepted by the guard's `defaultGuards:
'passable'` and
never be reachable as a capability — so an authoring typo (e.g. `serch`
vs
`search`) now fails loudly at construction instead of surfacing as a
capability
that resolves to `undefined`. A colocated `discover.test.ts` covers the
positional-arg mapping, guard rejection at the membrane, and this
construction
check.
Installs the endoify mock as a package-wide vitest setup, since
capability
modules now build exos at import and need a `harden` global before they
load.
### Notable behavior changes
- `getMoonPhase` loses its (already unsupported, `@ts-expect-error`'d)
`enum` return hint.
- `end`'s off-spec per-argument `required` flags are gone; `final` is
required and `attachments` optional, expressed by the guard.
## Test plan
- [x] `yarn workspace @ocap/kernel-agents test` (58 pass) and
`test:dev:quiet`
- [x] `yarn workspace @ocap/kernel-agents-repl test` (178 pass)
- [x] `build` + `lint` for both packages; changelog validates
- [ ] Local-only `kernel-test-local` agent e2e: see MetaMask#961 (out of scope
here)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Touches core agent capability invocation and changes runtime
validation behavior (guard rejections vs deep errors), though external
capability shapes and `makeEnd` sync API stay the same.
> > **Overview**
> Built-in **`math`**, **`end`**, and **`examples`** capabilities are no
longer hand-authored with `capability()`; they are built via new
**`makeInternalCapabilities`**, which wraps implementations in a private
pattern-guarded discoverable exo and projects the same `{ func, schema
}` shape agents already use. Invalid or missing arguments are rejected
at the exo interface guard before implementation code runs.
> > **`discover`** is refactored to share **`capabilitiesFrom`** with the
local path so remote and in-realm invocation both map named-arg objects
to positional exo calls the same way. **`makeInternalCapabilities`**
also fails at construction if schema and implementation method names do
not match exactly.
> > Tests cover mapping, membrane rejection, and the construction check;
package **vitest** loads the endoify mock globally because capability
modules build exos at import. **`getMoonPhase`** no longer advertises an
unsupported `enum` return hint; **`end`** optional/required args are
expressed only via the guard schema.
> > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
06d6853. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
SherfeyInv pushed a commit to SherfeyInv/ocap-kernel that referenced this pull request Jul 29, 2026
…g enforcer (MetaMask#960)
## Explanation
The base PRs ([MetaMask#958](MetaMask#958),
[MetaMask#959](MetaMask#959)) made every
built-in capability a pattern-guarded discoverable exo. Now that the
exo's interface guard already enforces each capability's argument shape,
this PR retires the parallel membraneless authoring and validation paths
so the guard is the single argument enforcer:
- Removes the `capability()` authoring helper and the internal
`validateCapabilityArgs` validator (and its now-dead module). The chat
strategy no longer re-validates arguments before invoking — it relies on
the guard rejection it catches and reports as an `Error calling …` tool
message. That catch is hardened to handle a non-`Error` rejection, so an
invalid-argument tool call surfaces as a tool error instead of crashing
the task (covered by a new regression test).
- Collapses the redundant `CapabilitySchema` type into kernel-utils'
`MethodSchema` (a capability's `schema` is exactly the `MethodSchema`
its exo describes), removing the parallel type and its
`ExtractRecordKeys` helper.
- Adds a `test/make-method-capability.ts` helper that builds a guarded,
discovered single-method capability from an `S.method`, and migrates the
chat and JSON evaluator tests (and the capability test, repurposed to
cover the surviving `extract*` helpers) onto it.
- Drops the now-unused `@metamask/superstruct` dependency.
### Breaking changes
- The `capability()` authoring helper is no longer exported from
`@ocap/kernel-agents/capabilities/capability`. Author capabilities as
pattern-guarded discoverable exos (via the `described*()` combinators in
`@metamask/kernel-utils`) and convert them with `discover`.
(`validateCapabilityArgs` was internal and never exported.)
## Test plan
- [x] \`yarn workspace @ocap/kernel-agents test:dev:quiet\` (56 pass),
incl. a chat-strategy regression test that an invalid-argument tool call
comes back as an \`Error calling …\` tool message instead of crashing
the task
- [x] \`yarn workspace @ocap/kernel-agents-repl test:dev:quiet\` (178
pass)
- [x] \`build\` + \`lint\` for both packages; changelog validates
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Breaking public API (`capability()` removal) affects downstream
authors, but runtime behavior stays aligned with prior exo-backed
builtins; main risk is consumers still using the old helper or assuming
pre-invoke Superstruct errors.
> > **Overview**
> **Breaking:** Removes the exported `capability()` helper and the
internal Superstruct-based `validateCapabilityArgs` path. Capabilities
are expected to be authored as pattern-guarded discoverable exos
(`described*()` + `discover` / `makeInternalCapabilities`);
`CapabilitySpec.schema` is now kernel-utils `MethodSchema` instead of a
parallel `CapabilitySchema` type.
> > Invocation errors from the exo interface guard are normalized in
`capabilitiesFrom` to `Error calling <name>(<params>): …` so chat and
other callers can surface actionable tool messages without a second
validation layer. The chat agent parses tool JSON locally when needed,
invokes capabilities directly, and pushes guard/implementation failures
as tool errors (including a regression test for bad args) instead of
crashing the loop.
> > Tests migrate to `test/make-method-capability.ts`;
`@metamask/superstruct` is dropped from dependencies.
> > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6dc44a8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
SherfeyInv pushed a commit to SherfeyInv/ocap-kernel that referenced this pull request Aug 11, 2026
…un-queue cache fix (MetaMask#1007)
Extracts the kernel-side work developed on `chip/orchestration-demo`
into `main`. No demo code is included — every change here is
general-purpose kernel machinery. Each commit is independently
meaningful and reviewable in order.
## Why
A vat needed to serve a line-delimited JSON-RPC socket to more than one
local client at a time. It couldn't: `IOChannel` models exactly one
bidirectional stream, so the socket server destroyed every connection
after the first. Chasing that surfaced two further bugs, one of them a
latent kernel defect with a genuinely nasty failure mode.
## Commits
**`fix(ocap-kernel): honor the run-queue length cache's invalid
sentinel`**
`runQueueLengthCache` uses a negative value to mean "unknown, re-read
from the database", but `enqueueRun`/`dequeueRun` adjusted it
arithmetically without materializing it first. An enqueue while the
cache held its startup value of `-1` produced `0` for a queue that
actually held an item — and because `0` isn't negative, it was never
re-read. The run loop then saw an empty queue, went to sleep, and
stranded everything queued behind it. **No error, no log, no crash: the
kernel just silently stops delivering.** The run loop is also now woken
by any non-empty queue rather than only the empty→1 transition, so a
drifted count can't lose the wakeup either.
Latent for a long time — reachable only when something enqueues before
the run loop's first length read. Worth reviewing on its own merits
regardless of the rest.
**`feat(ocap-kernel): anonymous kernel-hosted objects`**
`registerAnonymousKernelObject()` / `releaseAnonymousKernelObject()`:
allocate a kref and enter the object in the by-kref routing table, but
deliberately *not* in the service-name index. The object therefore has
no name in the global service namespace and cannot be requested via a
cluster config's `services` list — authority comes from holding the
reference.
Needed by `accept()`, and the naming half is the part we specifically
didn't want: a per-session connection should be reachable by reference
only.
**`feat(ocap-kernel): IOListener with accept(), replacing single-client
channels`**
The BSD listen/accept split. A cluster config's `io` entry now creates a
listener; `accept()` yields one `IOChannel` per peer, each wrapped in
its own exo and hosted as an anonymous kernel object, so the vat
receives a Presence per connection.
Isolation is structural rather than by discipline: sessions are separate
objects, so holding one connection conveys no way to reach another. That
matters because the names a vat hands across a non-ocap boundary are
plain forgeable strings; scoping them per connection is what stops one
client naming another's references. `direction` moves to the connection,
where the data actually flows. `accept()` resolves `null` once the
listener closes, so an accept loop terminates instead of hanging.
**`feat(kernel-node-runtime): socket listener with per-connection
channels`**
`makeSocketIOChannel` → `makeSocketIOListener`. Each connection's
buffer, decoder, line queue, and reader queue are local to it, which is
precisely why many peers can now be served at once. Connections arriving
before `accept()` are queued rather than dropped.
Deleted with the single-client design: `currentSocket`,
`pendingSessionEnd`, the merged line queue, and the `socket.destroy()`
that rejected second connections. The session-boundary latch didn't need
replacing — one channel serves one peer, so the end of the socket simply
*is* the end of the channel.
**`test(kernel-test): io-vat accepts connections; cover two concurrent
peers`**
The integration test drops its hand-rolled duplicate channel in favour
of the real `makeIOListenerFactory`, and adds a case driving two
concurrent peers end to end through a real kernel, asserting neither
reads the other's data nor receives the other's writes. That case was
unrepresentable before — the second connection was destroyed on arrival.
**`feat(kernel-utils,service-discovery-types): interface variant for
JsonSchema`**
`{ type: 'interface', description?, methods }` describes an object whose
methods can be invoked, so a method returning an object reference can
declare that object's API inline instead of forcing a second round-trip.
`methods` is recursive. The variant describes an *interface*; whether
the reference is unforgeable is a property of the reference plumbing,
not the description, so one schema serves both cases.
`service-discovery-types` converts it to a `RemotableSpec`, which means
`remotable` is no longer among the kinds `JsonSchema` can't express.
## Renamed API surface
Nothing in this repository is left broken — every in-tree consumer is
updated in this PR, and the full suite passes. These renames are flagged
`**BREAKING:**` in the changelogs because the packages are published and
the exported surface changed, so release tooling and any external
consumer need the signal:
| Was | Now |
|---|---|
| `Kernel.make({ ioChannelFactory })` | `Kernel.make({ ioListenerFactory
})` |
| `IOChannelFactory` | `IOListener` / `IOListenerFactory` |
| `makeIOChannelFactory()` | `makeIOListenerFactory()` |
| `makeSocketIOChannel()` | `makeSocketIOListener()` |
The behavioural change behind the renames: a vat that previously read
and wrote an `io` endowment directly now calls `accept()` to obtain a
connection first. `IOChannel` itself is unchanged and still represents
exactly one connection.
## Validation
Full monorepo on this exact tree: **30/30 builds, 52/52 test tasks, lint
clean.** Files that `main` also changed since the branch point were
three-way merged and individually diffed against `main` to confirm
nothing of `main`'s was reverted — in particular `main`'s MetaMask#958
optional-parameter handling in `methodSchemaToMethodSpec` is preserved.
The whole stack has also been exercised live: two independent clients
holding concurrent connections to one vat, each with its own isolated
name table, driving a multi-service workflow end to end.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **High Risk**
> Touches core kernel IO, service routing, and run-queue delivery with
breaking public API renames; incorrect behavior could strand the run
loop or mishandle concurrent RPC sessions.
> > **Overview**
> Replaces single-client Unix-socket IO with a **listen/accept** model
so vats can serve many concurrent line-delimited peers. Cluster `io`
entries now create **`IOListener`** services; vats call **`accept()`**
to get a per-peer **`IOChannel`** (Presence), with **`direction`**
enforced on each connection.
> > **Breaking renames:** `ioChannelFactory` → `ioListenerFactory`,
`makeIOChannelFactory` / `makeSocketIOChannel` → `makeIOListenerFactory`
/ `makeSocketIOListener`. Node runtime gives each connection its own
buffer/decoder/queues; early connects are queued instead of dropped.
> > Kernel adds **anonymous kernel objects**
(`registerAnonymousKernelObject` / release + init sweep) so accepted
connections are routable by kref but not by global service name.
**`invokeKernelService`** rejects missing services with
**`ENDPOINT_UNREACHABLE`** instead of throwing (avoids killing the run
loop on stale IO refs after restart).
> > Fixes a **run-queue length cache** bug: enqueue/dequeue now
materialize the `-1` sentinel before arithmetic, and the run loop wakes
on any non-empty queue. **`JsonSchema`** gains an **`interface`**
variant (recursive methods) for inline return-type APIs;
service-discovery converts it to **`RemotableSpec`**.
> > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f7570df. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for freeto 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

@grypez@sirtimid