13  panproto-wasm: The WASM Boundary

The panproto-wasm crate is the glue layer between the Rust core and the TypeScript SDK. It exposes #[wasm_bindgen] entry points for schema construction, migration, analysis, lens operations, GAT engine access, and version control. Every structured value crossing the boundary is serialized as MessagePack bytes, never as a JS object graph.

13.1 Design rationale

Two design decisions shape everything:

  1. Handle-based API. Complex Rust objects (protocols, schemas, compiled migrations) never cross the boundary. Instead, they live in a Rust-side slab allocator, and JS holds opaque u32 handles. This avoids the overhead and fragility of serde-wasm-bindgen, which must recursively convert Rust structs into JS objects on every call.

  2. MessagePack over serde-wasm-bindgen. Structured arguments (builder operations, migration mappings, records) are encoded as MessagePack byte slices with rmp_serde. MessagePack is faster than JSON for encode/decode, produces smaller payloads, and (critically) is deterministic: the same Rust struct always encodes to the same bytes, which simplifies testing and caching.

Tip

The handle + MessagePack design means the WASM boundary has zero JS-heap allocations for data that stays on the Rust side. The only JS objects created are the Uint8Array wrappers around byte slices.

13.2 The resource enum and slab allocator

All WASM-side resources are stored in a thread-local Vec<Option<Resource>>. Each resource is one of ten variants:

/// A resource stored in the slab.
///
/// Schemas are stored behind `Arc` so that `MigrationWithSchemas`
/// can share ownership without deep-cloning on every `lift_record`
/// or `get_record` call.
pub enum Resource {
    /// A protocol specification.
    Protocol(Protocol),
    /// A built schema.
    Schema(Arc<Schema>),
    /// A compiled migration ready for per-record application.
    Migration(CompiledMigration),
    /// A compiled migration bundled with its source and target schemas,
    /// needed for lens put operations and accurate schema reconstruction.
    MigrationWithSchemas {
        /// The compiled migration.
        compiled: CompiledMigration,
        /// The source schema (pre-migration).
        src_schema: Arc<Schema>,
        /// The target schema (post-migration).
        tgt_schema: Arc<Schema>,
    },
    /// An I/O protocol registry with all 77 protocol codecs.
    IoRegistry(Box<ProtocolRegistry>),
    /// A GAT theory.
    Theory(Box<Theory>),
    /// A VCS in-memory repository.
    VcsRepo(Box<MemStore>),
    /// A protolens chain (reusable, schema-independent).
    ProtolensChain(Box<ProtolensChain>),
    /// A symmetric lens.
    SymmetricLensHandle(Box<SymmetricLens>),
    /// A data set (instances bound to a schema).
    DataSet(Box<DataSetObject>),
}

thread_local! {
    static SLAB: RefCell<Vec<Option<Resource>>> = const { RefCell::new(Vec::new()) };
}

The Resource enum captures core resource types (protocol, schema, compiled migration, I/O registry, theory, VCS repository, protolens chain, symmetric lens, data set) plus MigrationWithSchemas, which bundles a compiled migration with its source and target schemas (needed for lens get/put operations).

The alloc function scans for a freed slot before appending, so handle values are reused. Handles are u32 indices, supporting up to 4 billion simultaneous resources.

13.3 The with_resource pattern

Accessing a resource requires borrowing the thread-local slab. The with_resource function encapsulates this: it borrows the slab, looks up the handle, and passes a reference to a callback. The borrow is released when the callback returns, preventing references from escaping:

/// Access a resource by handle, returning an error if the handle is
/// invalid or the slot is empty.
///
/// The callback `f` receives a reference to the resource. The borrow
/// is released when the callback returns, so the reference must not
/// escape.
pub fn with_resource<T>(
    handle: u32,
    f: impl FnOnce(&Resource) -> Result<T, WasmError>,
) -> Result<T, JsError> {
    SLAB.with_borrow(|slab| {
        let idx = handle as usize;
        let resource = slab
            .get(idx)
            .and_then(Option::as_ref)
            .ok_or(WasmError::InvalidHandle { handle })?;
        f(resource).map_err(Into::into)
    })
}

This prevents a common bug: holding a reference to a slab entry while mutating the slab (which would panic due to RefCell rules). The callback-based API makes this structurally impossible.

Note

A with_two_resources variant exists for operations that need two handles simultaneously (e.g., compile_migration needs both source and target schemas). It takes the borrow once and looks up both indices.

TipWhy Not Return References?

Returning a &Resource would require the caller to hold the RefCell borrow for the reference’s lifetime. Any nested call that also borrows the slab would panic. The callback pattern scopes each borrow precisely.

13.4 The buildop tagged Enum

Schema construction operations are sent from JS as a MessagePack-encoded Vec<BuildOp>. The BuildOp enum uses serde’s internally-tagged representation, so each operation is a flat object with an op discriminant:

/// A serializable builder operation for constructing schemas.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op")]
enum BuildOp {
    /// Add a vertex.
    #[serde(rename = "vertex")]
    Vertex {
        /// Vertex identifier.
        id: String,
        /// Vertex kind.
        kind: String,
        /// Optional NSID.
        nsid: Option<String>,
    },
    /// Add a binary edge.
    #[serde(rename = "edge")]
    Edge {
        /// Source vertex ID.
        src: String,
        /// Target vertex ID.
        tgt: String,
        /// Edge kind.
        kind: String,
        /// Optional edge label.
        name: Option<String>,
    },
    /// Add a constraint.
    #[serde(rename = "constraint")]
    Constraint {
        /// Vertex ID.
        vertex: String,
        /// Constraint sort.
        sort: String,
        /// Constraint value.
        value: String,
    },
    /// Add a hyper-edge connecting multiple vertices via labeled positions.
    #[serde(rename = "hyper_edge")]
    HyperEdge {
        /// Hyper-edge identifier.
        id: String,
        /// Hyper-edge kind.
        kind: String,
        /// Maps label names to vertex IDs.
        signature: HashMap<String, String>,
        /// The label that identifies the parent vertex.
        parent: String,
    },
    /// Declare required edges for a vertex.
    #[serde(rename = "required")]
    Required {
        /// The vertex that owns the requirement.
        vertex: String,
        /// The edges that are required.
        edges: Vec<panproto_core::schema::Edge>,
    },
}

The five variants (Vertex, Edge, Constraint, HyperEdge, Required) mirror SchemaBuilder methods. The TypeScript SDK constructs these as plain objects and encodes them with @msgpack/msgpack.

13.5 Entry points

13.5.1 define_protocol

Registers a protocol specification and returns a handle:

/// Register a protocol specification and return a handle.
///
/// The `spec` bytes are MessagePack-encoded `Protocol` data.
///
/// # Errors
///
/// Returns `JsError` if deserialization fails.
#[wasm_bindgen]
pub fn define_protocol(spec: &[u8]) -> Result<u32, JsError> {
    let protocol: panproto_core::schema::Protocol =
        rmp_serde::from_slice(spec).map_err(|e| WasmError::DeserializationFailed {
            reason: e.to_string(),
        })?;
    Ok(slab::alloc(Resource::Protocol(protocol)))
}

The pattern is representative: deserialize MessagePack input with rmp_serde::from_slice, perform the operation, allocate the result in the slab, and return the handle.

13.5.2 build_schema

The most complex entry point. It takes a protocol handle and a Vec<BuildOp>, replays the operations through SchemaBuilder, and returns a schema handle:

/// Build a schema from a protocol handle and `MessagePack`-encoded
/// builder operations.
///
/// The `ops` bytes are a `MessagePack`-encoded `Vec<BuildOp>`.
///
/// # Errors
///
/// Returns `JsError` if the protocol handle is invalid, ops cannot
/// be deserialized, or schema building fails.
#[wasm_bindgen]
pub fn build_schema(proto: u32, ops: &[u8]) -> Result<u32, JsError> {
    let protocol = slab::with_resource(proto, |r| Ok(slab::as_protocol(r)?.clone()))?;

    let operations: Vec<BuildOp> =
        rmp_serde::from_slice(ops).map_err(|e| WasmError::DeserializationFailed {
            reason: e.to_string(),
        })?;

    let mut builder = SchemaBuilder::new(&protocol);

    for op in operations {
        match op {
            BuildOp::Vertex { id, kind, nsid } => {
                builder = builder.vertex(&id, &kind, nsid.as_deref()).map_err(|e| {
                    WasmError::SchemaBuildFailed {
                        reason: e.to_string(),
                    }
                })?;
            }
            BuildOp::Edge {
                src,
                tgt,
                kind,
                name,
            } => {
                builder = builder
                    .edge(&src, &tgt, &kind, name.as_deref())
                    .map_err(|e| WasmError::SchemaBuildFailed {
                        reason: e.to_string(),
                    })?;
            }
            BuildOp::Constraint {
                vertex,
                sort,
                value,
            } => {
                builder = builder.constraint(&vertex, &sort, &value);
            }
            BuildOp::HyperEdge {
                id,
                kind,
                signature,
                parent,
            } => {
                builder = builder
                    .hyper_edge(&id, &kind, signature, &parent)
                    .map_err(|e| WasmError::SchemaBuildFailed {
                        reason: e.to_string(),
                    })?;
            }
            BuildOp::Required { vertex, edges } => {
                builder = builder.required(&vertex, edges);
            }
        }
    }

    let schema = builder.build().map_err(|e| WasmError::SchemaBuildFailed {
        reason: e.to_string(),
    })?;

    Ok(slab::alloc(Resource::Schema(std::sync::Arc::new(schema))))
}

13.5.3 Complete entry point catalog

The WASM boundary exposes far more than the original ten entry points. The full set is organized into six categories.

13.5.3.1 Core Schema and Migration

Entry Point Signature Returns
define_protocol (spec: &[u8]) -> Result<u32, JsError> Protocol handle
build_schema (proto: u32, ops: &[u8]) -> Result<u32, JsError> Schema handle
check_existence (proto: u32, src: u32, tgt: u32, mapping: &[u8]) -> Vec<u8> MessagePack report
compile_migration (src: u32, tgt: u32, mapping: &[u8]) -> Result<u32, JsError> Migration handle
lift_record (migration: u32, record: &[u8]) -> Result<Vec<u8>, JsError> MessagePack record
get_record (migration: u32, record: &[u8]) -> Result<Vec<u8>, JsError> MessagePack view+complement
put_record (migration: u32, view: &[u8], complement: &[u8]) -> Result<Vec<u8>, JsError> MessagePack record
compose_migrations (m1: u32, m2: u32) -> Result<u32, JsError> Migration handle
free_handle (handle: u32) (void)

13.5.3.2 Schema Analysis

Entry Point Signature Returns
diff_schemas (s1: u32, s2: u32) -> Vec<u8> MessagePack diff
diff_schemas_full (s1: u32, s2: u32) -> Vec<u8> Full MessagePack diff
classify_diff (proto: u32, diff_bytes: &[u8]) -> Vec<u8> MessagePack compat report
report_text (report_bytes: &[u8]) -> Result<String, JsError> Human-readable text
report_json (report_bytes: &[u8]) -> Result<String, JsError> JSON string
normalize_schema (schema_handle: u32) -> Result<u32, JsError> Normalized schema handle
validate_schema (schema_handle: u32, proto: u32) -> Result<Vec<u8>, JsError> MessagePack validation report

13.5.3.3 JSON and Instance Operations

Entry Point Signature Returns
lift_json (migration: u32, json_bytes: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> MessagePack lifted JSON
get_json (migration: u32, json_bytes: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> MessagePack view+complement
put_json (migration: u32, view: &[u8], complement: &[u8], root_vertex: &str) -> Result<Vec<u8>, JsError> MessagePack restored JSON
validate_instance (schema: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> MessagePack validation report
instance_to_json (schema: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> MessagePack JSON bytes
json_to_instance (schema: u32, json: &[u8]) -> Result<Vec<u8>, JsError> MessagePack instance bytes
json_to_instance_with_root (schema: u32, json: &[u8], root: &str) -> Result<Vec<u8>, JsError> MessagePack instance bytes
instance_element_count (instance: &[u8]) -> Result<u32, JsError> Node count
parse_instance (registry: u32, format: &str, data: &[u8], ...) -> Result<Vec<u8>, JsError> MessagePack instance bytes
emit_instance (registry: u32, format: &str, instance: &[u8], ...) -> Result<Vec<u8>, JsError> Serialized output bytes

13.5.3.4 Protolens and Lens Operations

Entry Point Signature Returns
auto_generate_protolens (schema1: u32, schema2: u32) -> Result<u32, JsError> ProtolensChain handle
check_lens_laws (migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> MessagePack law check
check_get_put (migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> MessagePack law check
check_put_get (migration: u32, instance: &[u8]) -> Result<Vec<u8>, JsError> MessagePack law check
invert_migration (mapping: &[u8], src: u32, tgt: u32) -> Result<Vec<u8>, JsError> MessagePack inverse
compose_lenses (l1: u32, l2: u32) -> Result<u32, JsError> Migration handle
instantiate_protolens (chain: u32, schema: u32) -> Result<u32, JsError> Migration handle
protolens_complement_spec (chain: u32, schema: u32) -> Result<Vec<u8>, JsError> MessagePack spec
protolens_from_diff (diff: &[u8], s1: u32, s2: u32) -> Result<u32, JsError> ProtolensChain handle
protolens_compose (c1: u32, c2: u32) -> Result<u32, JsError> ProtolensChain handle
protolens_chain_to_json (chain: u32) -> Result<Vec<u8>, JsError> JSON bytes
protolens_from_json (json: &[u8]) -> Result<u32, JsError> ProtolensChain handle
protolens_fuse (chain: u32) -> Result<u32, JsError> ProtolensChain handle
protolens_lift (chain: u32, morphism: &[u8]) -> Result<u32, JsError> ProtolensChain handle
protolens_check_applicability (chain: u32, schema: u32) -> Result<Vec<u8>, JsError> MessagePack report
protolens_fleet (chain: u32, schemas: &[u32]) -> Result<Vec<u8>, JsError> MessagePack fleet result
apply_protolens_step (protolens: &[u8], schema: u32) -> Result<u32, JsError> Schema handle
factorize_morphism (morphism: &[u8], domain: u32, codomain: u32) -> Result<u32, JsError> ProtolensChain handle
symmetric_lens_from_schemas (s1: u32, s2: u32) -> Result<u32, JsError> SymmetricLens handle
symmetric_lens_sync (lens: u32, dir: &str, data: &[u8], state: &[u8]) -> Result<Vec<u8>, JsError> MessagePack sync result

13.5.3.5 Expression & Query

Entry Point Signature Returns
parse_expr (source: &str) -> Vec<u8> MsgPack Expr
eval_func_expr (expr: &[u8], env: &[u8]) -> Vec<u8> MsgPack Literal
execute_query (query: &[u8], instance: &[u8]) -> Vec<u8> MsgPack results

parse_expr tokenizes and parses Haskell-style source text into an AST. eval_func_expr evaluates an expression with environment bindings. execute_query runs a declarative query against an instance.

13.5.3.6 GAT Engine

Entry Point Signature Returns
create_theory (spec: &[u8]) -> Result<u32, JsError> Theory handle
colimit_theories (t1: u32, t2: u32, shared: u32) -> Result<u32, JsError> Theory handle
check_morphism (morphism: &[u8], domain: u32, codomain: u32) -> Result<Vec<u8>, JsError> MessagePack result
migrate_model (model: &[u8], morphism: &[u8]) -> Result<Vec<u8>, JsError> MessagePack model
list_builtin_protocols () -> Vec<u8> MessagePack protocol list
get_builtin_protocol (name: &[u8]) -> Result<Vec<u8>, JsError> MessagePack protocol
register_io_protocols () -> u32 Registry handle
list_io_protocols (registry: u32) -> Result<Vec<u8>, JsError> MessagePack list

13.5.3.7 VCS and Data Versioning

Entry Point Signature Returns
vcs_init (protocol_name: &[u8]) -> u32 Repo handle
vcs_add (repo: u32, schema: u32) -> Result<Vec<u8>, JsError> MessagePack add result
vcs_commit (repo: u32, message: &[u8], author: &[u8]) -> Result<Vec<u8>, JsError> MessagePack commit hash
vcs_log (repo: u32, count: u32) -> Result<Vec<u8>, JsError> MessagePack log entries
vcs_status (repo: u32) -> Result<Vec<u8>, JsError> MessagePack status
vcs_diff (repo: u32) -> Result<Vec<u8>, JsError> MessagePack diff
vcs_branch (repo: u32, name: &[u8]) -> Result<Vec<u8>, JsError> MessagePack result
vcs_checkout (repo: u32, target: &[u8]) -> Result<Vec<u8>, JsError> MessagePack result
vcs_merge (repo: u32, branch: &[u8]) -> Result<Vec<u8>, JsError> MessagePack result
vcs_stash (repo: u32) -> Result<Vec<u8>, JsError> MessagePack result
vcs_stash_pop (repo: u32) -> Result<Vec<u8>, JsError> MessagePack result
vcs_blame (repo: u32, vertex: &[u8]) -> Result<Vec<u8>, JsError> MessagePack blame info
store_dataset (schema: u32, data: &[u8]) -> Result<u32, JsError> Dataset handle
get_dataset (dataset: u32) -> Result<Vec<u8>, JsError> MessagePack dataset
migrate_dataset_forward (dataset: u32, migration: u32, ...) -> Result<u32, JsError> Dataset handle
migrate_dataset_backward (dataset: u32, migration: u32, ...) -> Result<u32, JsError> Dataset handle
check_dataset_staleness (dataset: u32, schema: u32) -> Result<Vec<u8>, JsError> MessagePack staleness
store_protocol_definition (protocol: &[u8]) -> Result<u32, JsError> Handle
get_protocol_definition (handle: u32) -> Result<Vec<u8>, JsError> MessagePack protocol
get_migration_complement (complement: &[u8]) -> Result<Vec<u8>, JsError> MessagePack complement
Important

check_existence, diff_schemas, and diff_schemas_full never return errors at the WASM boundary. Instead, they encode error information inside the returned MessagePack report. This simplifies JS-side error handling for operations where “failure” is an expected, structured result.

13.6 Data flow: JS to Rust and back

The complete lifecycle of a call across the WASM boundary:

sequenceDiagram
    participant JS as TypeScript SDK
    participant Pack as packToWasm (msgpack)
    participant WB as wasm_bindgen
    participant Rust as panproto-wasm
    participant Unpack as unpackFromWasm

    JS->>Pack: structured JS value
    Pack->>WB: Uint8Array
    WB->>Rust: &[u8] (zero-copy view)
    Rust->>Rust: rmp_serde::from_slice → typed Rust struct
    Rust->>Rust: perform operation
    Rust->>Rust: rmp_serde::to_vec → Vec<u8>
    Rust->>WB: Vec<u8>
    WB->>JS: Uint8Array
    JS->>Unpack: Uint8Array
    Unpack->>JS: structured JS value

The key property: wasm_bindgen passes byte slices as zero-copy views into WASM linear memory. Copies happen only during MessagePack encode (JS side) and decode (Rust side), plus the reverse on the way back.

13.7 Slab lifecycle

Handles follow a simple lifecycle. Freed slots are reused on subsequent allocations:

stateDiagram-v2
    [*] --> Allocated: alloc(resource)
    Allocated --> InUse: with_resource(handle, f)
    InUse --> Allocated: callback returns
    Allocated --> Freed: free(handle)
    Freed --> Allocated: alloc reuses slot
    Freed --> [*]

On the TypeScript side, WasmHandle wraps each raw u32 handle and calls free_handle in its Symbol.dispose implementation. A FinalizationRegistry acts as a safety net: if a WasmHandle is garbage-collected without being disposed, the registry calls free_handle to prevent leaks.

13.8 Error handling

Errors are represented as WasmError variants (an internal thiserror enum) and converted to JsError at the boundary via Into. The JS side receives them as standard Error objects. The five error variants are:

  • DeserializationFailed: MessagePack input could not be decoded
  • InvalidHandle: the u32 index does not point to a live resource
  • TypeMismatch: the resource at a handle is the wrong variant (e.g., expected Protocol, got Schema)
  • SchemaBuildFailed: a SchemaBuilder operation failed validation
  • MigrationFailed / LiftFailed / PutFailed: runtime operation errors
NoteWhat Happens If JS Passes a Freed Handle?

The slab slot contains None, so with_resource returns Err(WasmError::InvalidHandle). The JS side receives a standard Error with message “invalid handle: 42”. No undefined behavior or silent corruption.

13.9 Adding a new entry point

To add a new WASM entry point:

  1. Add a #[wasm_bindgen] pub fn to crates/panproto-wasm/src/api.rs
  2. If the function needs a new resource type, add a variant to Resource in slab.rs and a corresponding as_* extractor
  3. Add the TypeScript signature to WasmExports in sdk/typescript/src/types.ts
  4. Wrap the call in a typed method on the appropriate SDK class
  5. Add an integration test in tests/integration/tests/wasm_boundary.rs
Tip

The check_existence entry point demonstrates the pattern for functions that should never throw: wrap the inner logic in a fallible helper, and convert errors into a structured report in the outer function.