Skip to content

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

Open
grypez wants to merge 2 commits into
mainfrom
feat/described-exo-combinators
Open

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

Conversation

@grypez

@grypez grypez commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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.
const interfaceGuard = M.interface('Math', {
  add: M.callWhen(M.arrayOf(M.number())).returns(M.number()),
});
const schemas = {
  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 backward-compatible optional required on schemas; existing callers that omit required behave as before (all args required).

Overview
Introduces a ./described subpath (also S on the main package export) with combinators that build an @endo/patterns interface guard and a matching MethodSchema from one definition, for use with makeDiscoverableExo. Leaves (S.string, S.number, S.arrayOf, S.object, etc.), S.arg, S.method, and S.interface project both pattern and schema; method guards use M.callWhen with trailing optional args enforced at authoring time.

MethodSchema gains optional required (like object JSON Schema). methodArgsToStruct accepts { required } so args not listed are validated as optional. methodSchemaToMethodSpec in service-discovery-types marks those parameters as optional in MethodSpec.

Tests cover the new described module, optional-arg struct validation, and the converter behavior.

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

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.24%
⬆️ +0.14%
8830 / 12394
🔵 Statements 71.06%
⬆️ +0.15%
8978 / 12633
🔵 Functions 72.36%
⬆️ +0.17%
2126 / 2938
🔵 Branches 64.86%
⬆️ +0.18%
3569 / 5502
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/kernel-utils/src/described.ts 100% 100% 100% 100%
packages/kernel-utils/src/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/kernel-utils/src/json-schema-to-struct.ts 81.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.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/service-discovery-types/src/method-schema-convert.ts 94.73%
⬆️ +0.62%
92.3%
⬆️ +5.94%
100%
🟰 ±0%
94.73%
⬆️ +0.62%
52-53
Generated in workflow #4471 for commit 24644c5 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 grypez force-pushed the feat/described-exo-combinators branch from b8ac175 to 6bc1fee Compare June 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 owner June 18, 2026 16:07

@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 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 thread packages/kernel-utils/src/described.ts
Comment thread packages/kernel-utils/src/described.ts
…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>
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.

1 participant