Skip to content

Latest commit

 

History

History
146 lines (91 loc) · 11.9 KB

File metadata and controls

146 lines (91 loc) · 11.9 KB

Typed-CALL — engine-known dispatch surface

Status: Phase 3 — engine-side dispatch surface, napi/DSL bindings, and reserved-namespace registration reject all LIVE at phase-3-close.

This document is the canonical engineer-facing reference for typed-CALL, the engine's registry of named operations dispatched through the existing CALL primitive.

Typed-CALL is NOT a new primitive. The 12-primitive commitment holds. Typed-CALL is a seam on top of the CALL primitive: when a CALL operation node's target (handler id) starts with the reserved TYPED_CALL_PREFIX of engine:typed:, the eval-side dispatch fork routes the call through the typed-CALL registry instead of the user-handler registry.


Why typed-CALL exists

Phase 3 ships an Atrium / UCAN / DID / VC story; handlers need crypto operations (Ed25519 sign / verify, BLAKE3 hash, multibase, DID resolve, UCAN chain validation, VC verify). The SANDBOX host-fn surface stays intentionally narrow (time / log / kv:read / random only) — see HOST-FUNCTIONS.md; storage-mutating or domain-specific host-fns are explicitly NOT engine concerns. Crypto ops fit the CALL surface because they're input → typed result with no side effects on engine state. Adding them as typed-CALL ops gives handlers a low-friction call shape (an existing CALL Node) without widening the SANDBOX host surface or inventing a 13th primitive.

Decision tree for "where does this compute belong?":

Compute shape Belongs in
Composes from existing engine primitives (READ/WRITE/TRANSFORM/BRANCH/ITERATE/CALL/RESPOND/EMIT/WAIT/SUBSCRIBE/STREAM) A user-handler subgraph; dispatch via engine.call("<handler_id>", "<action>", input).
Engine-known op with a fixed input → typed-result shape, no side effects, fits in benten-id / benten-core / benten-graph already Typed-CALL. Add a TypedCallOp variant + per-op validate_input + per-op dispatch_typed_call arm. Closed registry (no user-registered typed-CALL ops).
Heavy compute that wasm runtime is needed for (ML inference, custom transformers, image processing) SANDBOX. Module manifest declares time / log / kv:read / random cap subset.
Storage mutation, capability gating, event emission NOT host-fns. The other 11 primitives already cover these; SANDBOX modules return values that the engine's WRITE primitive persists, emit events that EMIT broadcasts, signal completion via RESPOND.

Reserved namespace

The handler-id prefix engine:typed: is reserved by the engine for typed-CALL dispatch. The eval-side dispatch fork at the CALL primitive recognises this prefix and routes through benten_eval::TypedCallOp::parse. The trailing segment after the prefix is the op name; an unrecognised op name surfaces E_TYPED_CALL_UNKNOWN_OP (NOT E_NOT_FOUND — the latter is the user-handler-registry miss code).

User handler registration into the engine:typed: namespace is rejected at registration time with E_RESERVED_HANDLER_NAMESPACE. The eval-side dispatch fork additionally pre-empts user-handler routing for the prefix, so even if a registration somehow slipped through it would be dead code (pinned by crates/benten-engine/tests/typed_call_engine_dispatch.rs::typed_call_namespace_pre_empts_user_handler_registry_for_unknown_op).


The 10 ops

The closed registry ships exactly 10 ops in Phase 3. Extension is a Rust-only engine concern (a new variant on the #[non_exhaustive] TypedCallOp enum + its per-op validate_input / required_cap / is_deterministic arms + a corresponding dispatch_typed_call arm in benten-engine). The registry is closed deliberately: typed-CALL is for engine-shipped ops, not user-extensible computation.

Op name Variant Required cap Deterministic Input shape Output shape
ed25519_sign Ed25519Sign cap:typed:crypto-sign yes { private_key: Bytes(32), message: Bytes } { signature: Bytes(64) }
ed25519_verify Ed25519Verify cap:typed:crypto-verify yes { public_key: Bytes(32), message: Bytes, signature: Bytes(64) } { valid: Bool }
keypair_generate KeypairGenerate cap:typed:crypto-keygen no (OS CSPRNG) {} (or { seed: Null }) { private_key: Bytes(32), public_key: Bytes(32) }
keypair_from_seed KeypairFromSeed cap:typed:crypto-keygen yes { seed: Bytes(32) } { private_key: Bytes(32), public_key: Bytes(32) }
blake3_hash Blake3Hash cap:typed:hash yes { data: Bytes } { hash: Bytes(32) }
multibase_encode MultibaseEncode cap:typed:codec yes { data: Bytes, base: Text } (b or z) { encoded: Text }
multibase_decode MultibaseDecode cap:typed:codec yes { encoded: Text } { data: Bytes, base: Text }
did_resolve DidResolve cap:typed:did-resolve no (conservative; see §did_resolve method validation) { did: Text } { method: Text, public_key: Bytes(32) }
ucan_validate_chain UcanValidateChain cap:typed:ucan-validate yes { tokens: List<Bytes>, audience: Text, capability: Text, now: Int } { valid: Bool, reason: Text }
vc_verify VcVerify cap:typed:vc-verify yes { credential: Bytes, expected_issuer_did: Text, now: Int } { valid: Bool, issuer: Text, subject: Text }

Authoritative per-op rustdoc lives at benten_eval::TypedCallOp; the dispatch implementations live at benten_engine::typed_call_dispatch::dispatch.


Determinism (Inv-9)

Per Inv-9 (engine-known determinism declarations) the bare CALL primitive classifies is_deterministic = true. Typed-CALL ops have per-op classifications layered on top: when a CALL Node's target starts with engine:typed:, the Inv-9 registration-time walker consults TypedCallOp::is_deterministic on top of the primitive's own classification.

Two ops classify non-deterministic:

  • keypair_generate — OS CSPRNG; the returned keypair varies per invocation.
  • did_resolve — conservative classification. did:key: resolution is provably pure (the public key is in the DID body), but the op's input space includes future methods (did:web: / did:plc: / etc.) that hit the network; conservative answer keeps Inv-9 honest under method extension.

All other typed-CALL ops are pure functions of their inputs (no clock, no RNG, no network).


Capability model

Each typed-CALL op declares a per-op required capability of shape cap:typed:<group>. The host's PrimitiveHost::check_capability hook gates the op BEFORE the underlying benten-id / benten-core op is invoked — a denied call has zero observable side effect.

Backends:

  • NoAuthBackend (default) — all cap:typed:* caps are permitted. Suitable for development + single-process deployments where capability checking is off.
  • UCANBackend (Phase 3) — gates per UCAN-claim. The Phase-3 close ships without a policy-mapping table from UCAN claim strings to cap:typed:* caps (see docs/future/phase-3-backlog.md §2.5(c) for the consumer-side mapping follow-up); under UCAN the cap-deny-by-default behavior surfaces because no UCAN claim grants a cap:typed:* capability yet.

A denied dispatch routes to ON_DENIED per the same precedent as E_CAP_DENIED / E_SANDBOX_HOST_FN_DENIED. The error code is E_TYPED_CALL_CAP_DENIED (catalog row in docs/ERROR-CATALOG.md).


Dispatch shapes

Direct (engine-side)

use benten_engine::Engine;

// A CALL operation node whose `target` property is "engine:typed:blake3_hash"
// + an `input` map property with shape `{ data: Bytes }`.
let outcome = engine.call("engine:typed:blake3_hash", "default", input_value)?;
// outcome.value: { hash: Bytes(32) }

Direct (napi-engine-bound)

The napi binding routes engine.typedCall(...) through Engine::dispatch_typed_call_public at crates/benten-engine/src/engine.rs. That entry mirrors the eval-side execute_typed_call cap-check arm (per-op cap:typed:* grant check) before invoking the underlying PrimitiveHost::dispatch_typed_call — so the napi path can't bypass the capability gate that the in-graph CALL primitive enforces.

TypeScript DSL

The napi/DSL surface exposes a typed call:

const { hash } = await engine.typedCall("blake3_hash", { data: bytes });

The bare engine.call("engine:typed:<op>", "default", input) API is equivalent.


did_resolve DID-method validation (§2.5(b))

The did_resolve op's current implementation hardcodes method: "key" in the output map. The input DID string is NOT parsed for its method prefix; a did:web:example.com input would surface a wrong method: "key" field rather than a typed error.

Phase-3 target: parse the method dynamically from the DID prefix (the segment between did: and the next :); if did:key:, route through the current benten_id::did::Did::resolve resolver; non-did:key: methods either route through future resolvers (did:web: / did:plc: etc. when added) OR reject with a typed E_TYPED_CALL_DISPATCH_ERROR carrying a "unsupported DID method" reason. Tracked at docs/future/phase-3-backlog.md §2.5(b) — "did_resolve op DID-method validation, sec-minor-3".

The conservative is_deterministic = false classification (see §Determinism) is set in anticipation of did:web: etc. landing later; current behavior with only did:key: would technically be pure, but the op signature must remain stable across method extension.


Error codes

Four engine-layer error codes surface from typed-CALL dispatch. Authoritative entries live in docs/ERROR-CATALOG.md.

Code Routes via Triggered by
E_TYPED_CALL_UNKNOWN_OP ON_ERROR engine:typed: prefix recognised but the trailing op name is not in the registry.
E_TYPED_CALL_INVALID_INPUT ON_ERROR Per-op input shape rejection (missing field, wrong CBOR type, fixed-width byte-length mismatch).
E_TYPED_CALL_CAP_DENIED ON_DENIED Per-op cap:typed:* cap not held by the dispatching grant.
E_TYPED_CALL_DISPATCH_ERROR ON_ERROR Underlying benten-id / benten-core op returned a typed error (malformed key, malformed UCAN/VC, unsupported DID method post-§2.5(b)).

A clean negative (Ed25519 verify returns false, UCAN chain expired) is NOT an error — those return a structured { valid: false, ... } Map with the op-internal failure reason. E_TYPED_CALL_DISPATCH_ERROR fires only when the underlying API call cannot produce a well-formed result.


Cross-references


Authored in Phase 3 to consolidate the typed-CALL engineer-facing reference. Updates accompany each typed-CALL extension.