See proposal.md, Why. The Rust extension is RustExtension in crates/morphir-rust-binding. It
+has the frontend and backend roles, and export_extension!(RustExtension, frontend, backend) makes it
+an Extism WASM guest (extism-kernel-v1). morphir-extension-sdk 0.4.0, in this workspace,
+already does the MEP 0.2.0-draft.1 work for a guest that opts in:
+
+
A guest opts in with ExtensionCapabilities::model_transport and implements
+Frontend::compile_model and Backend::generate_model. The trait defaults refuse.
+
The guest protocol session adds 0.2.0-draft.1 to the describe claims when the support is valid,
+selects it at initialize only when the host sends a model transport offer, intersects the complete
+tuples in the host's order and takes the smaller limits.
+
Before dispatch, it refuses a rich request whose tuple was not agreed, whose envelope does not match the
+tuple or whose payload fails its length or SHA-256 check. After the call, it checks a successful compile
+result's model.
+
The Ion reader is native only. On wasm32, a guest that advertises an Ion tuple is invalid.
+
+
The reference is finos/morphir-gleam rust/crates/morphir-gleam-binding/src/model_transport.rs
+(release extension/gleam/v0.4.0). The MoonBit CLI host offers morphir-model /
+ion-binary, then morphir-ir / morphir-json, both at IR 4.0.0, and
+uses the first tuple that the extension agrees on.
+
+
Goals and non-goals
+
Goals: the requirements in
+specs/rust-extension-model-operations/spec.md, through the SDK's existing opt-in, with no SDK
+change; MEP 0.1 behaviour unchanged; a released bundle that the MoonBit worker host runs on both
+lines.
+
Non-goals: Ion transport; IR v3 model tuples; multi-document compilation or compile
+dependencies (the frontend still compiles one document and refuses dependencies); release attestations,
+which this repository's extension release does not make today; the finos/morphir bundle pins.
+
+
Decisions
+
+
MEP-1. Use the SDK opt-in, not a second protocol layer
+
RustExtension::capabilities() sets model_transport, and the Frontend and
+Backend impls forward compile_model and generate_model to a new private
+module model_transport. The SDK session then gives negotiation, the 0.1 fallback and the
+admission checks to both the native NativeExtension dispatch and the WASM guest. The alternative, a
+guest-specific protocol layer like the Gleam core-Wasm guest, would duplicate the session for no gain: the
+Extism route already reaches the MoonBit worker host.
and the same tuple for morphir.backend.generateModel. No Ion tuple: the release artifact is a
+WASM guest, and the SDK refuses Ion support on wasm32 rather than transcode
+(morphir-opua.1.3.1.3 tracks a WASM Ion reader). A native-only Ion tuple would make the native
+and WASM claims differ, and the packager reads the claims from the WASM. No IR 3 tuple: the hosts offer only
+IR 4.0.0, and IR 3 stays available on the 0.1 line.
+
+
MEP-3. The payload is the legacy IR v4 document
+
compileModel runs the existing compile with irVersion4 and encodes
+the returned IR v4 JSON with ModelPayload::from_bytes. modelId is the package name and
+revision is sha256: and the payload digest, as in the Gleam extension.
+generateModel decodes the payload through the SDK's retain_model with the extension's
+own tuple and limits, then runs the existing generate. So the two lines give the same IR and the same Rust,
+and the golden tests compare both lines with one recorded output.
+
+
MEP-4. Refusals
+
The SDK session refuses an unagreed tuple, a wrong envelope and a failed integrity check before the
+extension runs. The module checks the tuple again, because the native trait methods can be called without a
+session, and refuses with InvalidParams. A required guarantee gives a failed
+GenerateResult with diagnostic RS_UNSUPPORTED_GUARANTEE; the session already refuses it
+first. A frontend failure is CompileModelResult::Failed with the frontend's diagnostics.
+
+
MEP-5. Limits
+
The guest advertises 64 MiB encoded, 48 MiB decoded, depth 1024 and 16 Mi nodes, the values of the Gleam
+extension. Hosts take the smaller value of each limit (the MoonBit host offers 2 MiB decoded), so these are
+upper bounds only. The host's WASM memory and instruction budgets still apply.
+
+
REL-1. Release 0.4.0 with the existing workflow
+
.github/extensions.toml sets mep_versions = ["0.1", "0.2.0-draft.1"]; the packager
+already compares it with the claims that it reads from the exact WASM, and the claims carry
+modelTransport. The binding version moves to 0.4.0 (a new minor: a new protocol
+line). The tag extension/rust/v0.4.0 on the merge commit starts the existing release workflow,
+which publishes the WASM, .wasm.sha256 and release.json. The dry run is the same
+resolution, build and asset validation, run locally on the merged commit before the tag.
+
+
Risks and trade-offs
+
+
Risk
Mitigation
+
+
An older host reads 0.2.0-draft.1 or modelTransport in the descriptor claims and
+refuses the bundle.
Run the existing 0.1-only paths: the installed-bundle test of
+morphir-host-native and mise run test:cli-release rust with the pinned finos/morphir
+CLI.
+
A host compares the claims of initialize (narrowed tuples and limits) with the describe
+claims and finds a difference.
Qualify on the MoonBit worker host, native and npm, before release.
+A difference that the SDK causes is fixed in a separate SDK pull request first.
+
A model larger than the host's WASM budgets fails where the legacy line passes.
The payload is
+the same document as the legacy result; the budgets are the host's, not the transport's.
+
+
+
+
Migration plan
+
Additive. Hosts that offer only MEP 0.1 see no change. To roll back, publish nothing more and
+keep the v0.3.0 pins; a released tag is not moved or deleted.
+
+
+
+
diff --git a/openspec/changes/rust-extension-mep-0-2/proposal.md b/openspec/changes/rust-extension-mep-0-2/proposal.md
new file mode 100644
index 00000000..d0f8d6e6
--- /dev/null
+++ b/openspec/changes/rust-extension-mep-0-2/proposal.md
@@ -0,0 +1,47 @@
+# Proposal
+
+## Why
+
+The released Rust extension (`morphir-rust-binding`, `extension/rust/v0.3.0`) declares MEP `0.1` only. Hosts
+that use the MEP `0.2.0-draft.1` model operations, such as the MoonBit CLI worker host, can only run it on the
+legacy line. The Morphir maintainers want every extension on the latest MEP. The Gleam extension
+(`extension/gleam/v0.4.0`) and the Elm extension (`extension/elm/v0.5.0-preview.2`) already declare it, and
+`morphir-extension-sdk` 0.4.0 implements the model operations.
+
+## What Changes
+
+- The Rust extension declares MEP `0.2.0-draft.1` in addition to MEP `0.1`. A host that offers only `0.1`
+ continues to get the `0.1` line with no model transport.
+- The frontend serves `morphir.frontend.compileModel` and the backend serves `morphir.backend.generateModel`
+ for one tuple each: profile `morphir-ir`, codec `morphir-json`, envelope `0.1.0-draft.1`, IR `4.0.0`.
+- The extension does not offer Ion. The released artifact is a WASM guest, and the SDK has no Ion reader for
+ WASM targets.
+- The capability claims (`claimsVersion` `0.1.0-draft.2`) carry the new protocol version and the model
+ tuples. The registry entry in `.github/extensions.toml` declares both MEP versions.
+- Release `extension/rust/v0.4.0` with the same asset shape as `v0.3.0`: the WASM, its `.wasm.sha256` and the
+ version-2 `release.json` descriptor.
+
+## Capabilities
+
+### New Capabilities
+
+- `rust-extension-model-operations`: how the Rust extension negotiates MEP versions and model transport
+ tuples, compiles Rust source into a model envelope, generates Rust from a model envelope, and refuses
+ requests outside the agreed tuple.
+
+### Modified Capabilities
+
+None.
+
+## Beads
+
+- **Tracked by**: `morphir-0xtc`
+- **Related**: None found
+
+## Impact
+
+- `crates/morphir-rust-binding`: new model transport module, capabilities, tests and README.
+- `.github/extensions.toml`: `mep_versions` of the `rust` entry.
+- `CHANGELOG.md`, the binding's version (0.3.0 to 0.4.0) and `Cargo.lock`.
+- No change to `morphir-extension-sdk` or to other repositories. The finos/morphir bundle pins follow in a
+ separate pull request.
diff --git a/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md b/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md
new file mode 100644
index 00000000..20f8c06a
--- /dev/null
+++ b/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md
@@ -0,0 +1,95 @@
+# Spec Delta
+
+## Purpose
+
+Defines how the Rust extension (`morphir-rust`) takes part in MEP `0.2.0-draft.1`: which protocol versions and
+model transport tuples it declares, how it compiles Rust source into a model envelope and generates Rust from
+one, and which requests it refuses.
+
+## ADDED Requirements
+
+### Requirement: Declared protocol versions
+The extension SHALL declare MEP `0.2.0-draft.1` and MEP `0.1` in its describe claims, in its release
+descriptor claims and in its registry entry. It SHALL select `0.2.0-draft.1` only when the host offers that
+version together with a model transport offer, and SHALL select `0.1` otherwise when the host offers `0.1`.
+
+#### Scenario: A host that offers only MEP 0.1
+- **WHEN** a host initializes the extension with protocol versions `["0.1"]`
+- **THEN** the extension selects `0.1`
+- **AND** the initialize result has no `modelTransport` member
+
+#### Scenario: A host that offers MEP 0.2.0-draft.1 with a model transport offer
+- **WHEN** a host initializes the extension with protocol versions `["0.2.0-draft.1", "0.1"]` and a model
+ transport offer that contains the extension's tuples
+- **THEN** the extension selects `0.2.0-draft.1`
+- **AND** the initialize result's `modelTransport` holds the tuples that both peers support, in the host's
+ order, and the smaller of each limit
+
+#### Scenario: A host that offers MEP 0.2.0-draft.1 without a model transport offer
+- **WHEN** a host initializes the extension with protocol versions `["0.2.0-draft.1", "0.1"]` and no model
+ transport offer
+- **THEN** the extension selects `0.1`
+
+### Requirement: Advertised model transport tuples
+The extension SHALL advertise exactly two model transport tuples, both with profile `morphir-ir`, codec
+`morphir-json`, envelope version `0.1.0-draft.1` and IR version `4.0.0`: one for
+`morphir.frontend.compileModel` and one for `morphir.backend.generateModel`. It SHALL NOT advertise an Ion
+tuple and SHALL NOT advertise a guarantee.
+
+#### Scenario: Describe claims list the tuples
+- **WHEN** a host sends `morphir.extension.describe`
+- **THEN** `capabilities.modelTransport.tuples` holds exactly the two tuples
+- **AND** `capabilities.modelTransport.guarantees` is empty
+
+#### Scenario: A host that offers only Ion
+- **WHEN** a host offers MEP `0.2.0-draft.1` with only `morphir-model` / `ion-binary` tuples
+- **THEN** the agreed model transport holds no tuple, so no model operation is available in the session
+
+### Requirement: Compile Rust source into a model envelope
+For an agreed `morphir.frontend.compileModel` tuple, the extension SHALL compile the request's source with the
+same rules as `morphir.frontend.compile` at IR v4. A successful result SHALL carry a model envelope whose
+payload is the IR v4 JSON document that `morphir.frontend.compile` returns for the same source, with the
+envelope's tuple, `modelId` equal to the package name and `revision` equal to `sha256:` and the payload digest.
+
+#### Scenario: A supported source file
+- **WHEN** a host sends `morphir.frontend.compileModel` for one supported `.rs` document
+- **THEN** the result has `success: true`, the compiled module names and a model envelope
+- **AND** the decoded payload equals the recorded golden IR v4 document
+
+#### Scenario: A source file outside the supported subset
+- **WHEN** a host sends `morphir.frontend.compileModel` for a document that the frontend rejects
+- **THEN** the result has `success: false` and the frontend's diagnostics
+- **AND** the result has no `model` member
+
+### Requirement: Generate Rust from a model envelope
+For an agreed `morphir.backend.generateModel` tuple, the extension SHALL decode the model envelope's payload
+and generate Rust with the same rules as `morphir.backend.generate`, for target `rust`.
+
+#### Scenario: A model envelope from compileModel
+- **WHEN** a host sends `morphir.backend.generateModel` with the envelope that `compileModel` returned
+- **THEN** the result has `success: true` and the artifacts that `morphir.backend.generate` returns for the
+ payload's IR
+- **AND** the generated `lib.rs` equals the recorded golden output
+
+### Requirement: Refuse requests outside the agreed tuple
+The extension SHALL refuse a model operation whose tuple was not agreed in the session, whose envelope does
+not match the tuple, or whose payload does not match its length or SHA-256, with a JSON-RPC error and without
+running the frontend or backend. It SHALL refuse a generation that requires a guarantee.
+
+#### Scenario: A tuple that was not agreed
+- **WHEN** a host sends `morphir.backend.generateModel` with an Ion tuple, or with an IR version other than
+ `4.0.0`
+- **THEN** the response is a JSON-RPC error and has no result
+
+#### Scenario: A model envelope with another envelope version
+- **WHEN** a host sends `morphir.backend.generateModel` with a model whose `envelopeVersion` is not
+ `0.1.0-draft.1`
+- **THEN** the response is a JSON-RPC error and has no result
+
+#### Scenario: A model operation on the MEP 0.1 line
+- **WHEN** a session selected MEP `0.1` and the host sends `morphir.frontend.compileModel`
+- **THEN** the response is a JSON-RPC error
+
+#### Scenario: A generation that requires a guarantee
+- **WHEN** a host sends `morphir.backend.generateModel` with a required guarantee
+- **THEN** the extension does not generate artifacts
diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md
new file mode 100644
index 00000000..cfffe941
--- /dev/null
+++ b/openspec/changes/rust-extension-mep-0-2/tasks.md
@@ -0,0 +1,61 @@
+# Tasks
+
+## 1. Model operations in the Rust binding (morphir-0xtc.1)
+
+- [ ] 1.1 Write failing native tests for negotiation: a `0.1`-only host gets `0.1` and no `modelTransport`; a
+ `0.2.0-draft.1` host with an offer gets the agreed tuples and the smaller limits; an offer without
+ `modelTransport` gets `0.1`; an Ion-only offer agrees on no tuple. Verify they fail before 1.3.
+- [ ] 1.2 Write failing native tests for `compileModel` and `generateModel` with golden IR and golden Rust
+ under `crates/morphir-rust-binding/tests/fixtures/model/`, and for the refusals (unagreed tuple, wrong
+ envelope version, payload digest mismatch, model operation on the `0.1` line, required guarantee, frontend
+ failure). Verify they fail before 1.3.
+- [ ] 1.3 Add the `model_transport` module and wire `capabilities()`, `compile_model` and `generate_model`;
+ verify `cargo test --locked -p morphir-rust-binding` passes.
+- [ ] 1.4 Verify the compiled model's payload equals the `morphir.frontend.compile` IR v4 result and the
+ generated `lib.rs` equals the `morphir.backend.generate` result for the same input (test).
+- [ ] 1.5 Verify `cargo clippy --locked -p morphir-rust-binding --all-targets -- -D warnings` and
+ `cargo fmt --check` pass.
+- [ ] 1.6 Document the model operations, the tuples and the absence of Ion in the binding README; verify the
+ README states the claimed protocol versions.
+
+## 2. WASM guest, claims and release descriptor (morphir-0xtc.2)
+
+- [ ] 2.1 Set `mep_versions = ["0.1", "0.2.0-draft.1"]` for `rust` in `.github/extensions.toml`; verify
+ `python3 -m unittest discover -s tests/ci` passes.
+- [ ] 2.2 Extend the ignored WASM host test (`tests/wasm.rs`) to describe the guest, run both model
+ operations through the guest and compare with the native results; verify it passes against the release
+ WASM.
+- [ ] 2.3 Verify the 0.1-only host paths still pass: the installed-bundle test
+ `cargo test -p morphir-host-native --test rust_extension -- --ignored` (through
+ `mise run extension:artifact:rust`) and `mise run test:cli-release rust`.
+- [ ] 2.4 Verify the built `release.json` claims hold `protocolVersions` `0.2.0-draft.1` and `0.1`,
+ `claimsVersion` `0.1.0-draft.2` and the two model tuples.
+- [ ] 2.5 Add a `CHANGELOG.md` entry under `[Unreleased]`; verify `mise run release:changelog-validate`.
+
+## 3. Qualify on the MoonBit worker host (morphir-0xtc.3)
+
+- [ ] 3.1 Build the native and npm CLIs from finos/morphir-moonbit `main` and record the revision.
+- [ ] 3.2 With a contract `0.2.0` pin (WASM and descriptor), run `extension describe`, `extension compile`
+ (model line) and `extension generate` (model envelope) from the native CLI; check the outputs against the
+ golden files and that each process exits by itself.
+- [ ] 3.3 Run the same operations from the npm CLI.
+- [ ] 3.4 Run compile and generate once on the `0.1` line (plain IR) from both CLIs, and record the receipts
+ in the pull request.
+
+## 4. Release extension/rust/v0.4.0 (morphir-0xtc.4)
+
+- [ ] 4.1 Bump `morphir-rust-binding` to `0.4.0`, update `Cargo.lock`, the README version references and the
+ changelog release section; verify `cargo test --locked -p morphir-rust-binding` passes.
+- [ ] 4.2 Open the pull request, get CI green and handle each verified review finding.
+- [ ] 4.3 Dry run on the merge candidate: `.github/scripts/extension_release.py` for the tag,
+ `mise run extension:artifact:rust` from a clean checkout and `select_extension_assets.py
+ --validate-short-id rust`; record the asset digests.
+- [ ] 4.4 After approval, merge and tag `extension/rust/v0.4.0` on the merge commit; verify the release
+ workflow publishes the WASM, `.wasm.sha256` and `release.json`.
+- [ ] 4.5 Verify the published assets: digests equal the dry run, and `extension describe` on the
+ downloaded bundle from the MoonBit native CLI reports MEP `0.1, 0.2.0-draft.1`.
+
+## Workflow follow-up
+
+- Archive the change after the release, and close the epic.
+- Report the release, tag, asset digests and qualification receipts to the main session.
From 2a43eceda4e1838094f43a66f82b16dab4997f67 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Sat, 31 Oct 2026 21:58:34 -0500
Subject: [PATCH 2/7] feat(rust-binding): serve the MEP 0.2.0-draft.1 model
operations
The Rust extension declared MEP 0.1 only, so hosts on the model line
could run it only through the legacy methods. It now advertises one
model transport tuple for each of morphir.frontend.compileModel and
morphir.backend.generateModel: morphir-ir / morphir-json / envelope
0.1.0-draft.1 / IR 4.0.0. The SDK session adds MEP 0.2.0-draft.1 to the
claims, keeps MEP 0.1 for hosts that do not offer model transport, and
refuses unagreed tuples and bad envelopes before the extension runs.
Both operations reuse the legacy compile and generate paths, so the model
payload is the IR v4 document that compile returns and generateModel
returns the same lib.rs as generate. There is no Ion tuple: the released
guest is WASM and the SDK has no Ion reader for WASM targets.
Tests cover negotiation (0.1-only host, model host, offer without model
transport, Ion-only offer), golden outputs for both operations and the
refusals. Bead morphir-0xtc.1.
---
Cargo.lock | 1 +
crates/morphir-rust-binding/Cargo.toml | 1 +
crates/morphir-rust-binding/README.md | 41 +-
crates/morphir-rust-binding/src/lib.rs | 20 +
.../src/model_transport.rs | 191 +++++++
.../tests/fixtures/model/compiled-ir-v4.json | 508 ++++++++++++++++++
.../tests/fixtures/model/generated-lib.rs | 24 +
.../tests/fixtures/model/models.rs | 28 +
.../tests/model_operations.rs | 287 ++++++++++
.../tests/support/model.rs | 236 ++++++++
.../changes/rust-extension-mep-0-2/tasks.md | 12 +-
11 files changed, 1342 insertions(+), 7 deletions(-)
create mode 100644 crates/morphir-rust-binding/src/model_transport.rs
create mode 100644 crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json
create mode 100644 crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs
create mode 100644 crates/morphir-rust-binding/tests/fixtures/model/models.rs
create mode 100644 crates/morphir-rust-binding/tests/model_operations.rs
create mode 100644 crates/morphir-rust-binding/tests/support/model.rs
diff --git a/Cargo.lock b/Cargo.lock
index 80e1757c..07624a7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3778,6 +3778,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
+ "serde",
"serde_json",
"syn 3.0.6",
"tempfile",
diff --git a/crates/morphir-rust-binding/Cargo.toml b/crates/morphir-rust-binding/Cargo.toml
index 30f52b47..1e47f638 100644
--- a/crates/morphir-rust-binding/Cargo.toml
+++ b/crates/morphir-rust-binding/Cargo.toml
@@ -23,6 +23,7 @@ indexmap = "2"
[dev-dependencies]
tempfile.workspace = true
+serde = "1"
cucumber = "0.23"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
diff --git a/crates/morphir-rust-binding/README.md b/crates/morphir-rust-binding/README.md
index 91965a5d..bcbe07cc 100644
--- a/crates/morphir-rust-binding/README.md
+++ b/crates/morphir-rust-binding/README.md
@@ -3,7 +3,8 @@
`morphir-rust-binding` implements the `morphir-rust` extension. It provides MEP
`frontend/compile` and `backend/generate` natively and as an Extism WASM guest.
Both capabilities advertise IR versions `3` and `4`, language/target `rust` and
-source suffix `.rs`. Syn parses source without running rustc, macros or build
+source suffix `.rs`. The extension speaks MEP `0.1` and MEP `0.2.0-draft.1`; on
+the second line it also serves the [model operations](#model-operations-on-mep-020-draft1). Syn parses source without running rustc, macros or build
scripts. The shared `morphir-core` codecs and typed migration handle the IR.
The frontend and backend translate types and a small subset of pure
@@ -322,6 +323,44 @@ their declared order. The consumer supplies external crates and implementations.
Mappings do not synthesize opaque representations. Unknown types without a
mapping, wrong arities, invalid names and Rust name collisions fail generation.
+## Model operations on MEP 0.2.0-draft.1
+
+The extension declares MEP `0.2.0-draft.1` and MEP `0.1`. It selects
+`0.2.0-draft.1` only when the host offers that version with a model transport
+offer. A host that offers only `0.1`, or no model transport, gets `0.1` and the
+legacy `compile` and `generate` methods, unchanged.
+
+On `0.2.0-draft.1` the extension serves one tuple for each operation:
+
+| Operation | Profile | Codec | Envelope | IR |
+| --- | --- | --- | --- | --- |
+| `morphir.frontend.compileModel` | `morphir-ir` | `morphir-json` | `0.1.0-draft.1` | `4.0.0` |
+| `morphir.backend.generateModel` | `morphir-ir` | `morphir-json` | `0.1.0-draft.1` | `4.0.0` |
+
+It offers no Ion tuple: the released guest is WASM, and the SDK has no Ion reader
+for WASM targets. It offers no IR 3 tuple; IR 3 remains available on MEP `0.1`.
+The legacy `compile` and `generate` methods also stay available on
+`0.2.0-draft.1`.
+
+`compileModel` compiles with the same rules as `compile` at IR v4: one document,
+no dependencies. A request with dependencies fails with `RS_DEPENDENCIES`. The
+model payload is the IR v4 JSON document that `compile` returns for the same
+source. `modelId` is the package name and `revision` is `sha256:` followed by the
+payload digest. A source that the frontend rejects gives `success: false`, the
+diagnostics, and no model.
+
+`generateModel` admits the envelope against its tuple, checks the payload's
+length and SHA-256, and generates the same `lib.rs` as `generate` does for the
+payload's IR, for target `rust`. A request with a required guarantee generates
+nothing; the extension checks no guarantee.
+
+The SDK session refuses, with a JSON-RPC error and before the extension runs, a
+tuple that the session did not agree, an envelope that does not match its tuple
+and a payload that fails its integrity checks. The guest's limits are 64 MiB
+encoded, 48 MiB decoded, depth 1024 and 16 Mi nodes; a host takes the smaller
+value of each. [The model operation tests](tests/model_operations.rs) compare
+both operations with golden files in `tests/fixtures/model/`.
+
## Native usage and tests
The public entry point is `RustExtension`, implementing the SDK's `Extension`,
diff --git a/crates/morphir-rust-binding/src/lib.rs b/crates/morphir-rust-binding/src/lib.rs
index d2074953..e3c48332 100644
--- a/crates/morphir-rust-binding/src/lib.rs
+++ b/crates/morphir-rust-binding/src/lib.rs
@@ -1,6 +1,10 @@
//! Rust type and pure-function frontend and backend for Morphir IR v3 and v4.
//! Explicit native and external function bindings are extracted into v4 value definitions.
//!
+//! The extension speaks MEP `0.1` and MEP `0.2.0-draft.1`. On the second line it also serves the
+//! model operations `morphir.frontend.compileModel` and `morphir.backend.generateModel` for the
+//! tuple `morphir-ir` / `morphir-json` / envelope `0.1.0-draft.1` / IR `4.0.0`.
+//!
//! ```
//! use morphir_extension_sdk::{Extension, NativeExtension};
//! use morphir_rust_binding::RustExtension;
@@ -11,6 +15,7 @@
mod backend;
mod frontend;
mod functions;
+mod model_transport;
mod patterns;
mod values;
@@ -52,6 +57,7 @@ impl Extension for RustExtension {
ir_versions: vec!["3".into(), "4".into()],
generate: true,
}),
+ model_transport: Some(model_transport::support()),
..Default::default()
}
}
@@ -72,6 +78,13 @@ impl Frontend for RustExtension {
)
}
+ fn compile_model(
+ &self,
+ request: morphir_extension_sdk::model::CompileModelRequest,
+ ) -> Result {
+ model_transport::compile_model(request)
+ }
+
fn supported_languages() -> Vec {
vec!["rust".into()]
}
@@ -92,6 +105,13 @@ impl Backend for RustExtension {
})
}
+ fn generate_model(
+ &self,
+ request: morphir_extension_sdk::model::GenerateModelRequest,
+ ) -> Result {
+ model_transport::generate_model(request)
+ }
+
fn target_languages() -> Vec {
vec!["rust".into()]
}
diff --git a/crates/morphir-rust-binding/src/model_transport.rs b/crates/morphir-rust-binding/src/model_transport.rs
new file mode 100644
index 00000000..0f489757
--- /dev/null
+++ b/crates/morphir-rust-binding/src/model_transport.rs
@@ -0,0 +1,191 @@
+//! MEP `0.2.0-draft.1` model operations for one tuple per operation: profile `morphir-ir`, codec
+//! `morphir-json`, envelope `0.1.0-draft.1` and IR `4.0.0`.
+//!
+//! Both operations reuse the MEP `0.1` compile and generate paths. The model payload is the IR v4
+//! JSON document that `morphir.frontend.compile` returns. The SDK session checks tuples, limits and
+//! payload integrity before and after each call; this module checks the tuple again because the
+//! trait methods can also be called without a session.
+//!
+//! The extension offers no Ion tuple: the released guest is WASM, and the SDK has no Ion reader for
+//! WASM targets.
+
+use crate::RustExtension;
+use morphir_extension_sdk::model::{
+ CompileModelRequest, CompileModelResult, GenerateModelRequest, MODEL_ENVELOPE_VERSION,
+ ModelCodec, ModelOperation, ModelPayload, ModelTransportLimits, ModelTransportSupport,
+ ModelTransportTuple, MorphirModel, retain_model,
+};
+use morphir_extension_sdk::prelude::*;
+
+/// The model-content profile that this extension reads and writes.
+const PROFILE: &str = "morphir-ir";
+/// The semantic IR version of the model payload.
+const IR_VERSION: &str = "4.0.0";
+/// The IR version option of the MEP `0.1` compile path that produces the payload.
+const LEGACY_IR_VERSION: &str = "4";
+
+/// The complete tuple of one operation.
+fn tuple(operation: ModelOperation) -> ModelTransportTuple {
+ ModelTransportTuple {
+ operation,
+ profile: PROFILE.into(),
+ codec: ModelCodec::MorphirJson,
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ ir_version: IR_VERSION.into(),
+ }
+}
+
+/// The guest's own upper bounds. A host takes the smaller value of each limit.
+fn limits() -> ModelTransportLimits {
+ ModelTransportLimits {
+ max_encoded_bytes: 64 * 1024 * 1024,
+ max_decoded_bytes: 48 * 1024 * 1024,
+ max_depth: 1024,
+ max_nodes: 16 * 1024 * 1024,
+ }
+}
+
+/// The model transport that the extension advertises.
+pub(crate) fn support() -> ModelTransportSupport {
+ ModelTransportSupport {
+ tuples: vec![
+ tuple(ModelOperation::CompileModel),
+ tuple(ModelOperation::GenerateModel),
+ ],
+ limits: limits(),
+ guarantees: vec![],
+ }
+}
+
+/// Compile one Rust document into a Morphir IR v4 model envelope.
+pub(crate) fn compile_model(request: CompileModelRequest) -> Result {
+ if request.output != tuple(ModelOperation::CompileModel) {
+ return Err(ExtensionError::InvalidParams(
+ "Unsupported model output tuple".into(),
+ ));
+ }
+ // The frontend does not compile against dependencies, so their models are never decoded.
+ if !request.dependencies.is_empty() {
+ return Ok(CompileModelResult::Failed {
+ diagnostics: vec![crate::error(
+ "RS_DEPENDENCIES",
+ "External dependencies are not supported",
+ )],
+ });
+ }
+ let package_name = request.package.name.clone();
+ let mut options = request.options;
+ options.ir_version = LEGACY_IR_VERSION.into();
+ let result = RustExtension.compile(CompileRequest {
+ language_id: request.language_id,
+ sources: request.sources,
+ package: request.package,
+ dependencies: vec![],
+ options,
+ baseline: None,
+ })?;
+ let Some(ir) = result.ir.filter(|_| result.success) else {
+ return Ok(CompileModelResult::Failed {
+ diagnostics: result.diagnostics,
+ });
+ };
+ Ok(CompileModelResult::Compiled {
+ model: Box::new(encode(&package_name, &ir)?),
+ diagnostics: result.diagnostics,
+ modules: result.modules,
+ })
+}
+
+/// Generate Rust from a Morphir IR v4 model envelope.
+pub(crate) fn generate_model(request: GenerateModelRequest) -> Result {
+ let input = tuple(ModelOperation::GenerateModel);
+ if request.input != input {
+ return Err(ExtensionError::InvalidParams(
+ "Unsupported model input tuple".into(),
+ ));
+ }
+ if let Some(required) = request.required_guarantees.first() {
+ return Ok(GenerateResult {
+ success: false,
+ artifacts: vec![],
+ diagnostics: vec![crate::error(
+ "RS_UNSUPPORTED_GUARANTEE",
+ format!(
+ "The Rust backend cannot check guarantee '{}'",
+ required.guarantee
+ ),
+ )],
+ });
+ }
+ // Admission checks the envelope against the tuple, the payload's length and SHA-256, and the
+ // limits, before the bytes are read as IR.
+ let retained = retain_model(&request.model, &input, &limits())
+ .map_err(|error| ExtensionError::InvalidParams(format!("Model not admitted: {error}")))?;
+ let ir = serde_json::from_slice(retained.bytes())?;
+ RustExtension.generate(GenerateRequest {
+ ir,
+ target: request.target,
+ options: request.options,
+ })
+}
+
+fn encode(package_name: &str, ir: &serde_json::Value) -> Result {
+ let bytes = serde_json::to_vec(ir)?;
+ let payload = ModelPayload::from_bytes(ModelCodec::MorphirJson, &bytes)
+ .map_err(|error| ExtensionError::ExecutionFailed(format!("Model payload: {error}")))?;
+ Ok(MorphirModel {
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ model_id: package_name.into(),
+ revision: format!("sha256:{}", payload.sha256),
+ ir_version: IR_VERSION.into(),
+ profile: PROFILE.into(),
+ payload,
+ evidence: vec![],
+ critical: vec![],
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_advertised_support_is_valid_for_this_target() {
+ let support = support();
+ let agreed =
+ morphir_extension_sdk::model::NegotiatedModelTransport::select(&support, &support)
+ .unwrap();
+ assert_eq!(agreed.support(), &support);
+ assert!(
+ support
+ .tuples
+ .iter()
+ .all(|tuple| tuple.codec == ModelCodec::MorphirJson)
+ );
+ }
+
+ #[test]
+ fn dependencies_fail_compilation_without_decoding_their_models() {
+ let request: CompileModelRequest = serde_json::from_value(serde_json::json!({
+ "languageId": "rust",
+ "sources": {"documents": [
+ {"uri": "models.rs", "languageId": "rust", "version": 1, "text": "pub type A = i64;"}
+ ]},
+ "package": {"name": "acme/example"},
+ "dependencies": [{"packageName": "acme/other", "model": {
+ "envelopeVersion": "0.1.0-draft.1", "modelId": "acme/other", "revision": "r",
+ "irVersion": "4.0.0", "profile": "morphir-ir",
+ "payload": {"codec": "morphir-json", "layout": "single-document",
+ "decodedByteLength": 1, "sha256": "x", "content": "not base64"}
+ }}],
+ "options": {"irVersion": "4.0.0", "typesOnly": false},
+ "contextDigest": "0".repeat(64),
+ "output": tuple(ModelOperation::CompileModel),
+ }))
+ .unwrap();
+ let CompileModelResult::Failed { diagnostics } = compile_model(request).unwrap() else {
+ panic!("dependencies must fail compilation");
+ };
+ assert_eq!(diagnostics[0].code.as_deref(), Some("RS_DEPENDENCIES"));
+ }
+}
diff --git a/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json b/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json
new file mode 100644
index 00000000..78f1f4ab
--- /dev/null
+++ b/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json
@@ -0,0 +1,508 @@
+{
+ "formatVersion": 4,
+ "distribution": {
+ "Library": {
+ "packageName": "acme/example",
+ "dependencies": {},
+ "def": {
+ "modules": {
+ "models": {
+ "Public": {
+ "types": {
+ "person": {
+ "Public": {
+ "doc": "A person in the domain.",
+ "TypeAliasDefinition": {
+ "typeParams": [],
+ "typeExp": {
+ "Record": {
+ "fields": {
+ "name": {
+ "Reference": {
+ "fqname": "morphir/SDK:string#string"
+ }
+ },
+ "age": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "decision": {
+ "Public": {
+ "doc": "The state of a decision.",
+ "CustomTypeDefinition": {
+ "typeParams": [],
+ "access": "Public",
+ "constructors": {
+ "pending": [],
+ "accepted": [
+ [
+ "field-0",
+ {
+ "Reference": {
+ "fqname": "acme/example:models#person"
+ }
+ }
+ ]
+ ],
+ "rejected": [
+ [
+ "reason",
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:string#string"
+ }
+ }
+ ]
+ ]
+ }
+ }
+ }
+ },
+ "score": {
+ "Public": {
+ "doc": "",
+ "TypeAliasDefinition": {
+ "typeParams": [],
+ "typeExp": {
+ "Tuple": {
+ "elements": [
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:string#string"
+ }
+ },
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "values": {
+ "select": {
+ "Public": {
+ "doc": "The larger of two values, or zero when they are equal.",
+ "ExpressionBody": {
+ "inputTypes": {
+ "a": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "b": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "outputType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "body": {
+ "IfThenElse": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "condition": {
+ "Apply": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ },
+ "function": {
+ "Apply": {
+ "attributes": {
+ "inferredType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ }
+ }
+ },
+ "function": {
+ "Reference": {
+ "attributes": {
+ "inferredType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "fqname": "morphir/SDK:basics#equal"
+ }
+ },
+ "argument": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "a"
+ }
+ }
+ }
+ },
+ "argument": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "b"
+ }
+ }
+ }
+ },
+ "then": {
+ "Literal": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "literal": {
+ "IntegerLiteral": 0
+ }
+ }
+ },
+ "else": {
+ "IfThenElse": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "condition": {
+ "Apply": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ },
+ "function": {
+ "Apply": {
+ "attributes": {
+ "inferredType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ }
+ }
+ },
+ "function": {
+ "Reference": {
+ "attributes": {
+ "inferredType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Function": {
+ "parameterType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "returnType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#bool"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "fqname": "morphir/SDK:basics#greater-than"
+ }
+ },
+ "argument": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "a"
+ }
+ }
+ }
+ },
+ "argument": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "b"
+ }
+ }
+ }
+ },
+ "then": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "a"
+ }
+ },
+ "else": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "b"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "amount": {
+ "Public": {
+ "doc": "",
+ "ExpressionBody": {
+ "inputTypes": {
+ "value": {
+ "Reference": {
+ "fqname": "morphir/SDK:maybe#maybe",
+ "args": [
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "outputType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ },
+ "body": {
+ "PatternMatch": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "value": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:maybe#maybe",
+ "args": [
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "name": "value"
+ }
+ },
+ "cases": [
+ {
+ "pattern": {
+ "ConstructorPattern": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:maybe#maybe",
+ "args": [
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "fqname": "morphir/SDK:maybe#just",
+ "patterns": [
+ {
+ "AsPattern": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "pattern": {
+ "WildcardPattern": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ }
+ }
+ },
+ "name": "morphir-local-0"
+ }
+ }
+ ]
+ }
+ },
+ "body": {
+ "Variable": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "name": "morphir-local-0"
+ }
+ }
+ },
+ {
+ "pattern": {
+ "ConstructorPattern": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:maybe#maybe",
+ "args": [
+ {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "fqname": "morphir/SDK:maybe#nothing",
+ "patterns": []
+ }
+ },
+ "body": {
+ "Literal": {
+ "attributes": {
+ "inferredType": {
+ "Reference": {
+ "fqname": "morphir/SDK:basics#int"
+ }
+ }
+ },
+ "literal": {
+ "IntegerLiteral": 0
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "doc": "Golden source for the MEP 0.2.0-draft.1 model operation tests."
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs b/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs
new file mode 100644
index 00000000..761fb3a2
--- /dev/null
+++ b/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs
@@ -0,0 +1,24 @@
+pub mod models {
+ ///The state of a decision.
+ pub enum Decision {
+ Pending,
+ Accepted(crate::models::Person),
+ Rejected(::std::string::String),
+ }
+ ///A person in the domain.
+ pub struct Person {
+ pub name: ::std::string::String,
+ pub age: i64,
+ }
+ pub type Score = (::std::string::String, i64);
+ ///The larger of two values, or zero when they are equal.
+ pub fn select(a: i64, b: i64) -> i64 {
+ if (a) == (b) { 0i64 } else { if (a) > (b) { a } else { b } }
+ }
+ pub fn amount(value: ::std::option::Option) -> i64 {
+ match value {
+ ::std::option::Option::Some(__morphir_binding_1) => __morphir_binding_1,
+ ::std::option::Option::None => 0i64,
+ }
+ }
+}
diff --git a/crates/morphir-rust-binding/tests/fixtures/model/models.rs b/crates/morphir-rust-binding/tests/fixtures/model/models.rs
new file mode 100644
index 00000000..d78017da
--- /dev/null
+++ b/crates/morphir-rust-binding/tests/fixtures/model/models.rs
@@ -0,0 +1,28 @@
+//! Golden source for the MEP 0.2.0-draft.1 model operation tests.
+
+/// A person in the domain.
+pub struct Person {
+ pub name: String,
+ pub age: i64,
+}
+
+/// The state of a decision.
+pub enum Decision {
+ Pending,
+ Accepted(Person),
+ Rejected { reason: String },
+}
+
+pub type Score = (String, i64);
+
+/// The larger of two values, or zero when they are equal.
+pub fn select(a: i64, b: i64) -> i64 {
+ if a == b { 0 } else if a > b { a } else { b }
+}
+
+pub fn amount(value: Option) -> i64 {
+ match value {
+ Some(amount) => amount,
+ None => 0,
+ }
+}
diff --git a/crates/morphir-rust-binding/tests/model_operations.rs b/crates/morphir-rust-binding/tests/model_operations.rs
new file mode 100644
index 00000000..1c43c1b4
--- /dev/null
+++ b/crates/morphir-rust-binding/tests/model_operations.rs
@@ -0,0 +1,287 @@
+//! MEP `0.2.0-draft.1` negotiation and model operations through native JSON-RPC dispatch.
+
+#[path = "support/model.rs"]
+mod model;
+
+use model::*;
+use morphir_extension_sdk::{
+ model::{
+ CompileModelResult, MODEL_ENVELOPE_VERSION, MODEL_MEP_VERSION, ModelGuaranteeRequirement,
+ ModelOperation, ModelTransportSupport, MorphirModel,
+ },
+ prelude::*,
+ protocol::{DescribeParams, methods},
+};
+use morphir_rust_binding::RustExtension;
+use serde_json::{Value, json};
+
+fn compiled_model(session: &Session) -> MorphirModel {
+ let response = session.call(methods::COMPILE_MODEL, a_compile_model_request());
+ assert!(response.error.is_none(), "{:?}", response.error);
+ match serde_json::from_value(response.result.unwrap()).unwrap() {
+ CompileModelResult::Compiled { model, .. } => *model,
+ CompileModelResult::Failed { diagnostics } => panic!("compile failed: {diagnostics:?}"),
+ }
+}
+
+fn agreed(session: &Session) -> ModelTransportSupport {
+ serde_json::from_value(session.result()["capabilities"]["modelTransport"].clone()).unwrap()
+}
+
+// ---- negotiation ------------------------------------------------------------------------------
+
+#[test]
+fn describe_claims_both_protocol_lines_and_exactly_the_json_tuples() {
+ let session = Session::start(&[], None);
+ let response = session.call(
+ methods::DESCRIBE,
+ DescribeParams {
+ protocol_versions: vec!["0.1".into()],
+ },
+ );
+ let claims = response.result.expect("describe result");
+ let mut versions: Vec =
+ serde_json::from_value(claims["protocolVersions"].clone()).unwrap();
+ versions.sort();
+ assert_eq!(versions, ["0.1", MODEL_MEP_VERSION]);
+ let support: ModelTransportSupport =
+ serde_json::from_value(claims["capabilities"]["modelTransport"].clone()).unwrap();
+ assert_eq!(
+ support.tuples,
+ [
+ a_json_tuple(ModelOperation::CompileModel),
+ a_json_tuple(ModelOperation::GenerateModel)
+ ]
+ );
+ assert!(support.guarantees.is_empty());
+}
+
+#[test]
+fn a_host_that_offers_only_mep_0_1_gets_0_1_without_model_transport() {
+ let session = Session::on_0_1();
+ assert_eq!(session.result()["protocolVersion"], "0.1");
+ assert!(
+ session.result()["capabilities"]
+ .get("modelTransport")
+ .is_none(),
+ "{:#}",
+ session.result()
+ );
+}
+
+#[test]
+fn a_model_host_gets_the_agreed_json_tuples_in_host_order_and_its_smaller_limits() {
+ let session = Session::on_model_line();
+ assert_eq!(session.result()["protocolVersion"], MODEL_MEP_VERSION);
+ let support = agreed(&session);
+ assert_eq!(
+ support.tuples,
+ [
+ a_json_tuple(ModelOperation::CompileModel),
+ a_json_tuple(ModelOperation::GenerateModel)
+ ]
+ );
+ assert_eq!(support.limits, moonbit_host_limits());
+}
+
+#[test]
+fn a_draft_host_without_a_model_transport_offer_gets_0_1() {
+ let session = Session::start(&[MODEL_MEP_VERSION, "0.1"], None);
+ assert!(session.initialized.error.is_none());
+ assert_eq!(session.result()["protocolVersion"], "0.1");
+}
+
+#[test]
+fn an_ion_only_offer_agrees_on_no_tuple_and_no_model_operation() {
+ let session = Session::start(&[MODEL_MEP_VERSION, "0.1"], Some(an_ion_only_offer()));
+ assert!(session.initialized.error.is_none());
+ assert!(agreed(&session).tuples.is_empty());
+ let response = session.call(methods::COMPILE_MODEL, a_compile_model_request());
+ assert!(response.result.is_none() && response.error.is_some());
+}
+
+// ---- compileModel and generateModel -----------------------------------------------------------
+
+#[test]
+fn compile_model_returns_the_golden_ir_v4_envelope() {
+ let session = Session::on_model_line();
+ let response = session.call(methods::COMPILE_MODEL, a_compile_model_request());
+ let result = response.result.expect("compileModel result");
+ assert_eq!(result["success"], true);
+ assert_eq!(result["modules"], json!(["Models"]));
+ let model: MorphirModel = serde_json::from_value(result["model"].clone()).unwrap();
+ assert_eq!(model.envelope_version, MODEL_ENVELOPE_VERSION);
+ assert_eq!(model.profile, "morphir-ir");
+ assert_eq!(model.ir_version, "4.0.0");
+ assert_eq!(model.model_id, "acme/example");
+ assert_eq!(model.revision, format!("sha256:{}", model.payload.sha256));
+ assert!(model.evidence.is_empty() && model.critical.is_empty());
+ let payload = payload_json(&model, ModelOperation::CompileModel);
+ assert_golden(
+ "compiled-ir-v4.json",
+ &format!("{}\n", serde_json::to_string_pretty(&payload).unwrap()),
+ );
+}
+
+#[test]
+fn compile_model_payload_is_the_legacy_ir_v4_result() {
+ let session = Session::on_model_line();
+ let payload = payload_json(&compiled_model(&session), ModelOperation::CompileModel);
+ let legacy = RustExtension.compile(a_legacy_compile_request()).unwrap();
+ assert!(legacy.success, "{:?}", legacy.diagnostics);
+ assert_eq!(legacy.ir_version.as_deref(), Some("4"));
+ assert_eq!(payload, legacy.ir.unwrap());
+}
+
+#[test]
+fn generate_model_from_the_compiled_envelope_returns_the_golden_rust() {
+ let session = Session::on_model_line();
+ let model = compiled_model(&session);
+ let response = session.call(methods::GENERATE_MODEL, a_generate_model_request(model));
+ assert!(response.error.is_none(), "{:?}", response.error);
+ let generated: GenerateResult = serde_json::from_value(response.result.unwrap()).unwrap();
+ assert!(generated.success, "{:?}", generated.diagnostics);
+ assert_eq!(generated.artifacts.len(), 1);
+ assert_eq!(generated.artifacts[0].path, "lib.rs");
+ assert_golden("generated-lib.rs", &generated.artifacts[0].content);
+}
+
+#[test]
+fn generate_model_returns_the_legacy_generate_result() {
+ let session = Session::on_model_line();
+ let model = compiled_model(&session);
+ let ir = payload_json(&model, ModelOperation::CompileModel);
+ let response = session.call(methods::GENERATE_MODEL, a_generate_model_request(model));
+ let rich: Value = response.result.unwrap();
+ let legacy = RustExtension
+ .generate(GenerateRequest {
+ ir,
+ target: "rust".into(),
+ options: Default::default(),
+ })
+ .unwrap();
+ assert_eq!(rich, serde_json::to_value(legacy).unwrap());
+}
+
+#[test]
+fn compile_model_failure_has_diagnostics_and_no_model() {
+ let session = Session::on_model_line();
+ let request = a_compile_model_request_for("models.rs", "pub struct Broken { pub field: &str }");
+ let response = session.call(methods::COMPILE_MODEL, request);
+ let result = response.result.expect("a failed compilation is a result");
+ assert_eq!(result["success"], false);
+ assert!(result.get("model").is_none(), "{result:#}");
+ assert!(!result["diagnostics"].as_array().unwrap().is_empty());
+}
+
+#[test]
+fn compile_model_takes_the_module_from_a_relative_directory_uri() {
+ let session = Session::on_model_line();
+ let request = a_compile_model_request_for("src/domain/models.rs", "pub type Id = String;");
+ let response = session.call(methods::COMPILE_MODEL, request);
+ let result = response.result.expect("compileModel result");
+ assert_eq!(result["success"], true, "{result:#}");
+ assert_eq!(result["modules"], json!(["Models"]));
+}
+
+// ---- refusals ---------------------------------------------------------------------------------
+
+fn assert_refused(response: morphir_extension_sdk::protocol::ExtensionResponse) {
+ assert!(response.result.is_none(), "{:?}", response.result);
+ assert!(response.error.is_some());
+}
+
+#[test]
+fn generate_model_refuses_an_unagreed_ion_tuple() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.input = an_ion_tuple(ModelOperation::GenerateModel);
+ assert_refused(session.call(methods::GENERATE_MODEL, request));
+}
+
+#[test]
+fn generate_model_refuses_an_unagreed_ir_version() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.input.ir_version = "3.0.0".into();
+ request.model.ir_version = "3.0.0".into();
+ assert_refused(session.call(methods::GENERATE_MODEL, request));
+}
+
+#[test]
+fn compile_model_refuses_an_unagreed_output_tuple() {
+ let session = Session::on_model_line();
+ let mut request = a_compile_model_request();
+ request.output = an_ion_tuple(ModelOperation::CompileModel);
+ assert_refused(session.call(methods::COMPILE_MODEL, request));
+}
+
+#[test]
+fn generate_model_refuses_another_envelope_version() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.model.envelope_version = "0.1.0-draft.2".into();
+ assert_refused(session.call(methods::GENERATE_MODEL, request));
+}
+
+#[test]
+fn generate_model_refuses_a_payload_that_fails_its_digest() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.model.payload.sha256 = "0".repeat(64);
+ assert_refused(session.call(methods::GENERATE_MODEL, request));
+}
+
+#[test]
+fn model_operations_are_refused_on_the_0_1_line() {
+ let session = Session::on_0_1();
+ assert_refused(session.call(methods::COMPILE_MODEL, a_compile_model_request()));
+}
+
+#[test]
+fn generate_model_refuses_a_required_guarantee() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.required_guarantees = vec![ModelGuaranteeRequirement {
+ guarantee: "morphir.typed".into(),
+ scope: "acme/example".into(),
+ }];
+ let response = session.call(methods::GENERATE_MODEL, request);
+ let generated = response
+ .result
+ .map(|result| serde_json::from_value::(result).unwrap());
+ assert!(
+ generated.is_none_or(|result| !result.success && result.artifacts.is_empty()),
+ "a required guarantee must not generate artifacts"
+ );
+}
+
+// ---- the trait methods without a session ------------------------------------------------------
+
+#[test]
+fn the_trait_methods_refuse_an_unagreed_tuple_without_a_session() {
+ let mut compile = a_compile_model_request();
+ compile.output = an_ion_tuple(ModelOperation::CompileModel);
+ assert!(RustExtension.compile_model(compile).is_err());
+
+ let session = Session::on_model_line();
+ let mut generate = a_generate_model_request(compiled_model(&session));
+ generate.model.profile = "morphir-model".into();
+ assert!(RustExtension.generate_model(generate).is_err());
+}
+
+#[test]
+fn the_trait_method_refuses_a_required_guarantee_without_a_session() {
+ let session = Session::on_model_line();
+ let mut request = a_generate_model_request(compiled_model(&session));
+ request.required_guarantees = vec![ModelGuaranteeRequirement {
+ guarantee: "morphir.typed".into(),
+ scope: "acme/example".into(),
+ }];
+ let result = RustExtension.generate_model(request).unwrap();
+ assert!(!result.success && result.artifacts.is_empty());
+ assert_eq!(
+ result.diagnostics[0].code.as_deref(),
+ Some("RS_UNSUPPORTED_GUARANTEE")
+ );
+}
diff --git a/crates/morphir-rust-binding/tests/support/model.rs b/crates/morphir-rust-binding/tests/support/model.rs
new file mode 100644
index 00000000..588f0a8a
--- /dev/null
+++ b/crates/morphir-rust-binding/tests/support/model.rs
@@ -0,0 +1,236 @@
+//! Object mothers for MEP `0.2.0-draft.1` model operation tests.
+
+#![allow(dead_code)]
+
+use morphir_extension_sdk::{
+ NativeExtension,
+ model::{
+ CompileModelRequest, GenerateModelRequest, MODEL_ENVELOPE_VERSION, MODEL_MEP_VERSION,
+ ModelCodec, ModelOperation, ModelTransportLimits, ModelTransportSupport,
+ ModelTransportTuple, MorphirModel,
+ },
+ prelude::*,
+ protocol::{ExtensionRequest, ExtensionResponse, InitializeParams, PeerInfo, methods},
+};
+use morphir_rust_binding::RustExtension;
+use serde_json::Value;
+use std::path::PathBuf;
+
+/// The source compiled by the golden tests.
+pub const SOURCE: &str = include_str!("../fixtures/model/models.rs");
+
+/// The directory of the recorded golden outputs.
+pub fn fixtures() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/model")
+}
+
+/// Compare `actual` with a recorded golden file. Set `MORPHIR_BLESS=1` to record it again.
+pub fn assert_golden(name: &str, actual: &str) {
+ let path = fixtures().join(name);
+ if std::env::var_os("MORPHIR_BLESS").is_some() {
+ std::fs::write(&path, actual).unwrap();
+ }
+ let expected = std::fs::read_to_string(&path)
+ .unwrap_or_else(|error| panic!("cannot read golden {}: {error}", path.display()));
+ assert_eq!(expected, actual, "golden {name} differs");
+}
+
+/// The tuple of `operation` that the extension advertises.
+pub fn a_json_tuple(operation: ModelOperation) -> ModelTransportTuple {
+ ModelTransportTuple {
+ operation,
+ profile: "morphir-ir".into(),
+ codec: ModelCodec::MorphirJson,
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ ir_version: "4.0.0".into(),
+ }
+}
+
+/// The Ion tuple of `operation`, which the extension does not advertise.
+pub fn an_ion_tuple(operation: ModelOperation) -> ModelTransportTuple {
+ ModelTransportTuple {
+ profile: "morphir-model".into(),
+ codec: ModelCodec::IonBinary,
+ ..a_json_tuple(operation)
+ }
+}
+
+/// The limits that the MoonBit CLI worker host offers.
+pub fn moonbit_host_limits() -> ModelTransportLimits {
+ ModelTransportLimits {
+ max_encoded_bytes: 2_796_204,
+ max_decoded_bytes: 2 * 1024 * 1024,
+ max_depth: 128,
+ max_nodes: 100_000,
+ }
+}
+
+/// A host offer like the MoonBit CLI's: Ion first, then Morphir JSON, for both operations.
+pub fn a_moonbit_like_offer() -> ModelTransportSupport {
+ ModelTransportSupport {
+ tuples: vec![
+ an_ion_tuple(ModelOperation::CompileModel),
+ an_ion_tuple(ModelOperation::GenerateModel),
+ a_json_tuple(ModelOperation::CompileModel),
+ a_json_tuple(ModelOperation::GenerateModel),
+ ],
+ limits: moonbit_host_limits(),
+ guarantees: vec![],
+ }
+}
+
+/// A host offer with only Ion tuples.
+pub fn an_ion_only_offer() -> ModelTransportSupport {
+ ModelTransportSupport {
+ tuples: vec![
+ an_ion_tuple(ModelOperation::CompileModel),
+ an_ion_tuple(ModelOperation::GenerateModel),
+ ],
+ ..a_moonbit_like_offer()
+ }
+}
+
+/// A native extension and the result of its `morphir.initialize`.
+pub struct Session {
+ pub extension: NativeExtension,
+ pub initialized: ExtensionResponse,
+ next_id: std::cell::Cell,
+}
+
+impl Session {
+ /// Initialize a native Rust extension with `versions` and an optional model transport `offer`.
+ pub fn start(versions: &[&str], offer: Option) -> Self {
+ let extension = NativeExtension::frontend_backend(RustExtension).unwrap();
+ let initialized = extension.protocol().handle(
+ ExtensionRequest::new(
+ methods::INITIALIZE,
+ InitializeParams {
+ model_transport: offer,
+ protocol_versions: versions.iter().map(|v| (*v).into()).collect(),
+ host: PeerInfo {
+ kind: Default::default(),
+ name: "model-test".into(),
+ version: "1.0.0".into(),
+ },
+ },
+ 0,
+ )
+ .unwrap(),
+ );
+ Self {
+ extension,
+ initialized,
+ next_id: std::cell::Cell::new(1),
+ }
+ }
+
+ /// A session on MEP `0.1`.
+ pub fn on_0_1() -> Self {
+ let session = Self::start(&["0.1"], None);
+ assert!(
+ session.initialized.error.is_none(),
+ "{:?}",
+ session.initialized.error
+ );
+ session
+ }
+
+ /// A session on MEP `0.2.0-draft.1` with the MoonBit-like offer.
+ pub fn on_model_line() -> Self {
+ let session = Self::start(&[MODEL_MEP_VERSION, "0.1"], Some(a_moonbit_like_offer()));
+ assert!(
+ session.initialized.error.is_none(),
+ "{:?}",
+ session.initialized.error
+ );
+ session
+ }
+
+ /// The initialize result as JSON.
+ pub fn result(&self) -> &Value {
+ self.initialized.result.as_ref().expect("initialize result")
+ }
+
+ /// Send one request and return its response.
+ pub fn call(&self, method: &str, params: impl serde::Serialize) -> ExtensionResponse {
+ let id = self.next_id.get();
+ self.next_id.set(id + 1);
+ self.extension
+ .protocol()
+ .handle(ExtensionRequest::new(method, params, id).unwrap())
+ }
+}
+
+/// A `compileModel` request for the golden source.
+pub fn a_compile_model_request() -> CompileModelRequest {
+ a_compile_model_request_for("models.rs", SOURCE)
+}
+
+/// A `compileModel` request for one document.
+pub fn a_compile_model_request_for(uri: &str, text: &str) -> CompileModelRequest {
+ CompileModelRequest {
+ language_id: "rust".into(),
+ sources: SourceSet {
+ root: None,
+ documents: vec![SourceDocument {
+ uri: uri.into(),
+ language_id: "rust".into(),
+ version: 1,
+ text: text.into(),
+ }],
+ },
+ package: CompilePackage {
+ name: "acme/example".into(),
+ exposed_modules: None,
+ },
+ dependencies: vec![],
+ options: CompileOptions {
+ types_only: false,
+ ir_version: "4.0.0".into(),
+ ..Default::default()
+ },
+ context_digest: "0".repeat(64),
+ output: a_json_tuple(ModelOperation::CompileModel),
+ }
+}
+
+/// The legacy `compile` request for the same source at IR v4.
+pub fn a_legacy_compile_request() -> CompileRequest {
+ CompileRequest {
+ language_id: "rust".into(),
+ sources: a_compile_model_request().sources,
+ package: CompilePackage {
+ name: "acme/example".into(),
+ exposed_modules: None,
+ },
+ options: CompileOptions {
+ types_only: false,
+ ir_version: "4".into(),
+ ..Default::default()
+ },
+ ..Default::default()
+ }
+}
+
+/// A `generateModel` request for `model`.
+pub fn a_generate_model_request(model: MorphirModel) -> GenerateModelRequest {
+ GenerateModelRequest {
+ model,
+ input: a_json_tuple(ModelOperation::GenerateModel),
+ target: "rust".into(),
+ context_digest: "1".repeat(64),
+ required_guarantees: vec![],
+ options: Default::default(),
+ }
+}
+
+/// Admit a compiled model envelope for `operation` and decode its payload as JSON.
+pub fn payload_json(model: &MorphirModel, operation: ModelOperation) -> Value {
+ let retained = morphir_extension_sdk::model::retain_model(
+ model,
+ &a_json_tuple(operation),
+ &moonbit_host_limits(),
+ )
+ .unwrap();
+ serde_json::from_slice(retained.bytes()).unwrap()
+}
diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md
index cfffe941..1484d4ab 100644
--- a/openspec/changes/rust-extension-mep-0-2/tasks.md
+++ b/openspec/changes/rust-extension-mep-0-2/tasks.md
@@ -2,20 +2,20 @@
## 1. Model operations in the Rust binding (morphir-0xtc.1)
-- [ ] 1.1 Write failing native tests for negotiation: a `0.1`-only host gets `0.1` and no `modelTransport`; a
+- [x] 1.1 Write failing native tests for negotiation: a `0.1`-only host gets `0.1` and no `modelTransport`; a
`0.2.0-draft.1` host with an offer gets the agreed tuples and the smaller limits; an offer without
`modelTransport` gets `0.1`; an Ion-only offer agrees on no tuple. Verify they fail before 1.3.
-- [ ] 1.2 Write failing native tests for `compileModel` and `generateModel` with golden IR and golden Rust
+- [x] 1.2 Write failing native tests for `compileModel` and `generateModel` with golden IR and golden Rust
under `crates/morphir-rust-binding/tests/fixtures/model/`, and for the refusals (unagreed tuple, wrong
envelope version, payload digest mismatch, model operation on the `0.1` line, required guarantee, frontend
failure). Verify they fail before 1.3.
-- [ ] 1.3 Add the `model_transport` module and wire `capabilities()`, `compile_model` and `generate_model`;
+- [x] 1.3 Add the `model_transport` module and wire `capabilities()`, `compile_model` and `generate_model`;
verify `cargo test --locked -p morphir-rust-binding` passes.
-- [ ] 1.4 Verify the compiled model's payload equals the `morphir.frontend.compile` IR v4 result and the
+- [x] 1.4 Verify the compiled model's payload equals the `morphir.frontend.compile` IR v4 result and the
generated `lib.rs` equals the `morphir.backend.generate` result for the same input (test).
-- [ ] 1.5 Verify `cargo clippy --locked -p morphir-rust-binding --all-targets -- -D warnings` and
+- [x] 1.5 Verify `cargo clippy --locked -p morphir-rust-binding --all-targets -- -D warnings` and
`cargo fmt --check` pass.
-- [ ] 1.6 Document the model operations, the tuples and the absence of Ion in the binding README; verify the
+- [x] 1.6 Document the model operations, the tuples and the absence of Ion in the binding README; verify the
README states the claimed protocol versions.
## 2. WASM guest, claims and release descriptor (morphir-0xtc.2)
From ec535ae1c497e004bd282acb75fbf5d882ec2b6e Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Sat, 31 Oct 2026 22:06:25 -0500
Subject: [PATCH 3/7] feat(rust-binding): declare MEP 0.2.0-draft.1 in the
release registry
The packager compares the registry's mep_versions with the claims read
from the exact WASM, so the rust entry now lists 0.1 and 0.2.0-draft.1.
The built descriptor's claims (claimsVersion 0.1.0-draft.2) carry both
protocol versions and the two model tuples.
The ignored WASM host test now describes the guest, negotiates the model
line and runs both model operations through the guest against the native
results and the golden Rust. The 0.1-only host paths still pass: the
installed-bundle test of morphir-host-native and the released Morphir CLI
0.4.0-beta.8 (mise run test:cli-release rust). Bead morphir-0xtc.2.
---
.github/extensions.toml | 2 +-
CHANGELOG.md | 9 ++
crates/morphir-rust-binding/README.md | 4 +-
crates/morphir-rust-binding/tests/wasm.rs | 94 +++++++++++++++++++
.../changes/rust-extension-mep-0-2/tasks.md | 10 +-
5 files changed, 112 insertions(+), 7 deletions(-)
diff --git a/.github/extensions.toml b/.github/extensions.toml
index 4f59039a..b60d95e4 100644
--- a/.github/extensions.toml
+++ b/.github/extensions.toml
@@ -3,7 +3,7 @@ package = "morphir-rust-binding"
artifact = "morphir-rust-binding"
extension_id = "morphir-rust"
name = "Morphir Rust"
-mep_versions = ["0.1"]
+mep_versions = ["0.1", "0.2.0-draft.1"]
targets = ["rust"]
languages = [{ id = "rust", file_extensions = [".rs"] }]
ir_versions = ["3", "4"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0a703b6..05a473ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- The Rust extension (`morphir-rust-binding`) speaks MEP `0.2.0-draft.1` as
+ well as MEP `0.1`. On the new line it serves `morphir.frontend.compileModel`
+ and `morphir.backend.generateModel` for the tuple `morphir-ir` /
+ `morphir-json` / envelope `0.1.0-draft.1` / IR `4.0.0`; the model payload is
+ the IR v4 document that `compile` returns. It offers no Ion tuple, because the
+ released guest is WASM and the SDK has no Ion reader for WASM targets. Hosts
+ that offer only MEP `0.1`, or no model transport, get the `0.1` line
+ unchanged. The bundle's capability claims and `.github/extensions.toml`
+ declare both protocol versions.
- SDK 0.4.0 adds canonical numeric/string `RequestId` wire identity and explicit
constructors that preserve string IDs through native and generated guest
dispatch, including lifecycle errors. Numeric convenience constructors retain
diff --git a/crates/morphir-rust-binding/README.md b/crates/morphir-rust-binding/README.md
index bcbe07cc..ffd96d7f 100644
--- a/crates/morphir-rust-binding/README.md
+++ b/crates/morphir-rust-binding/README.md
@@ -445,7 +445,9 @@ read from the exact WASM being packaged and checked against
later; older CLIs cannot read this descriptor. The versioned WASM and
`.wasm.sha256` names are unchanged. The downloaded descriptor is still named
`-.release.json`; rename it to `release.json` before
-publishing. WASM installs show `Claims: unchecked`.
+publishing. WASM installs show `Claims: unchecked`. The claims list MEP `0.1`
+and `0.2.0-draft.1` and, under `capabilities.modelTransport`, the two model
+tuples and the guest's limits.
See the [packaging contract](../../docs/extensions/capability-claims.md#packaging-wasm-extensions)
for the descriptor shape and provenance rules.
diff --git a/crates/morphir-rust-binding/tests/wasm.rs b/crates/morphir-rust-binding/tests/wasm.rs
index 359b7504..9efeee4f 100644
--- a/crates/morphir-rust-binding/tests/wasm.rs
+++ b/crates/morphir-rust-binding/tests/wasm.rs
@@ -32,6 +32,9 @@ mod pattern;
#[path = "support/functions.rs"]
mod functions;
+#[path = "support/model.rs"]
+mod model;
+
#[test]
#[ignore = "requires a built Rust WASM guest"]
fn wasm_compiles_and_generates_functions_and_lambdas_in_both_versions() {
@@ -250,6 +253,8 @@ fn initialize(plugin: &mut Plugin) {
.unwrap();
let initialized: InitializeResult = serde_json::from_value(result(plugin, request)).unwrap();
assert_eq!(initialized.protocol_version, MEP_VERSION);
+ // A host that offers only MEP 0.1 gets no model transport.
+ assert!(initialized.capabilities.model_transport.is_none());
assert_eq!(initialized.extension.id, "morphir-rust");
assert_eq!(
initialized.extension.types,
@@ -402,3 +407,92 @@ fn wasm_returns_protocol_errors_and_remains_usable() {
);
assert_eq!(pong, json!({ "ok": true }));
}
+
+#[test]
+#[ignore = "requires a built Rust WASM guest; see this file's module documentation"]
+fn wasm_serves_the_model_operations_like_native() {
+ use morphir_extension_sdk::{
+ model::{MODEL_MEP_VERSION, ModelOperation, ModelTransportSupport},
+ protocol::DescribeParams,
+ };
+ let mut plugin = guest();
+ let claims = result(
+ &mut plugin,
+ ExtensionRequest::new(
+ methods::DESCRIBE,
+ DescribeParams {
+ protocol_versions: vec![MEP_VERSION.into()],
+ },
+ 1,
+ )
+ .unwrap(),
+ );
+ let mut versions: Vec =
+ serde_json::from_value(claims["protocolVersions"].clone()).unwrap();
+ versions.sort();
+ assert_eq!(versions, [MEP_VERSION, MODEL_MEP_VERSION]);
+ let json_tuples = [
+ model::a_json_tuple(ModelOperation::CompileModel),
+ model::a_json_tuple(ModelOperation::GenerateModel),
+ ];
+ let advertised: ModelTransportSupport =
+ serde_json::from_value(claims["capabilities"]["modelTransport"].clone()).unwrap();
+ assert_eq!(advertised.tuples, json_tuples);
+
+ let initialized: InitializeResult = serde_json::from_value(result(
+ &mut plugin,
+ ExtensionRequest::new(
+ methods::INITIALIZE,
+ InitializeParams {
+ model_transport: Some(model::a_moonbit_like_offer()),
+ protocol_versions: vec![MODEL_MEP_VERSION.into(), MEP_VERSION.into()],
+ host: PeerInfo {
+ kind: Default::default(),
+ name: "rust-binding-integration-test".into(),
+ version: "0.1.0".into(),
+ },
+ },
+ 2,
+ )
+ .unwrap(),
+ ))
+ .unwrap();
+ assert_eq!(initialized.protocol_version, MODEL_MEP_VERSION);
+ let agreed = initialized.capabilities.model_transport.unwrap();
+ assert_eq!(agreed.tuples, json_tuples);
+ assert_eq!(agreed.limits, model::moonbit_host_limits());
+
+ let native = model::Session::on_model_line();
+ let compiled = result(
+ &mut plugin,
+ ExtensionRequest::new(methods::COMPILE_MODEL, model::a_compile_model_request(), 3).unwrap(),
+ );
+ let expected = native
+ .call(methods::COMPILE_MODEL, model::a_compile_model_request())
+ .result
+ .unwrap();
+ assert_eq!(compiled, expected);
+ let envelope = serde_json::from_value(compiled["model"].clone()).unwrap();
+
+ let generated = result(
+ &mut plugin,
+ ExtensionRequest::new(
+ methods::GENERATE_MODEL,
+ model::a_generate_model_request(envelope),
+ 4,
+ )
+ .unwrap(),
+ );
+ let generated: GenerateResult = serde_json::from_value(generated).unwrap();
+ assert!(generated.success, "{:?}", generated.diagnostics);
+ model::assert_golden("generated-lib.rs", &generated.artifacts[0].content);
+
+ let mut unagreed =
+ model::a_generate_model_request(serde_json::from_value(compiled["model"].clone()).unwrap());
+ unagreed.input = model::an_ion_tuple(ModelOperation::GenerateModel);
+ let refused = call(
+ &mut plugin,
+ ExtensionRequest::new(methods::GENERATE_MODEL, unagreed, 5).unwrap(),
+ );
+ assert!(refused.result.is_none() && refused.error.is_some());
+}
diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md
index 1484d4ab..70ba6928 100644
--- a/openspec/changes/rust-extension-mep-0-2/tasks.md
+++ b/openspec/changes/rust-extension-mep-0-2/tasks.md
@@ -20,17 +20,17 @@
## 2. WASM guest, claims and release descriptor (morphir-0xtc.2)
-- [ ] 2.1 Set `mep_versions = ["0.1", "0.2.0-draft.1"]` for `rust` in `.github/extensions.toml`; verify
+- [x] 2.1 Set `mep_versions = ["0.1", "0.2.0-draft.1"]` for `rust` in `.github/extensions.toml`; verify
`python3 -m unittest discover -s tests/ci` passes.
-- [ ] 2.2 Extend the ignored WASM host test (`tests/wasm.rs`) to describe the guest, run both model
+- [x] 2.2 Extend the ignored WASM host test (`tests/wasm.rs`) to describe the guest, run both model
operations through the guest and compare with the native results; verify it passes against the release
WASM.
-- [ ] 2.3 Verify the 0.1-only host paths still pass: the installed-bundle test
+- [x] 2.3 Verify the 0.1-only host paths still pass: the installed-bundle test
`cargo test -p morphir-host-native --test rust_extension -- --ignored` (through
`mise run extension:artifact:rust`) and `mise run test:cli-release rust`.
-- [ ] 2.4 Verify the built `release.json` claims hold `protocolVersions` `0.2.0-draft.1` and `0.1`,
+- [x] 2.4 Verify the built `release.json` claims hold `protocolVersions` `0.2.0-draft.1` and `0.1`,
`claimsVersion` `0.1.0-draft.2` and the two model tuples.
-- [ ] 2.5 Add a `CHANGELOG.md` entry under `[Unreleased]`; verify `mise run release:changelog-validate`.
+- [x] 2.5 Add a `CHANGELOG.md` entry under `[Unreleased]`; verify `mise run release:changelog-validate`.
## 3. Qualify on the MoonBit worker host (morphir-0xtc.3)
From 05ad3c7ecdc8f010c5e964065bda2d76644ba97c Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Sat, 31 Oct 2026 22:08:08 -0500
Subject: [PATCH 4/7] chore(release): prepare morphir-rust extension v0.4.0
Bump morphir-rust-binding to 0.4.0 for extension/rust/v0.4.0, the first
release that declares MEP 0.2.0-draft.1. A new minor version, because the
release adds a protocol line. Update the inventory table, the binding
README and the changelog. Bead morphir-0xtc.4.
---
CHANGELOG.md | 2 +-
Cargo.lock | 2 +-
README.md | 2 +-
crates/morphir-rust-binding/Cargo.toml | 2 +-
crates/morphir-rust-binding/README.md | 8 ++++----
openspec/changes/rust-extension-mep-0-2/tasks.md | 2 +-
6 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05a473ea..50971b43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
released guest is WASM and the SDK has no Ion reader for WASM targets. Hosts
that offer only MEP `0.1`, or no model transport, get the `0.1` line
unchanged. The bundle's capability claims and `.github/extensions.toml`
- declare both protocol versions.
+ declare both protocol versions. Released as `extension/rust/v0.4.0`.
- SDK 0.4.0 adds canonical numeric/string `RequestId` wire identity and explicit
constructors that preserve string IDs through native and generated guest
dispatch, including lifecycle errors. Numeric convenience constructors retain
diff --git a/Cargo.lock b/Cargo.lock
index 07624a7a..18c516aa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3768,7 +3768,7 @@ dependencies = [
[[package]]
name = "morphir-rust-binding"
-version = "0.3.0"
+version = "0.4.0"
dependencies = [
"cucumber",
"extism",
diff --git a/README.md b/README.md
index 5f57af6e..d0e020f2 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ This table lists independently releasable extensions. Registrations come from
| Extension | Package | Version | Description |
| --- | --- | --- | --- |
| `morphir-elm-native` | `morphir-elm-binding` | `0.2.0` | Compiles Elm type declarations to IR v3 and v4 and generates Elm back; incremental. |
-| `morphir-rust` | `morphir-rust-binding` | `0.3.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4. |
+| `morphir-rust` | `morphir-rust-binding` | `0.4.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4; MEP 0.1 and 0.2.0-draft.1 model operations. |
The Python extension (crate `morphir-python-binding`, bundle `morphir-python`) is now
maintained and released from [finos/morphir-python](https://github.com/finos/morphir-python).
diff --git a/crates/morphir-rust-binding/Cargo.toml b/crates/morphir-rust-binding/Cargo.toml
index 1e47f638..ed36aeff 100644
--- a/crates/morphir-rust-binding/Cargo.toml
+++ b/crates/morphir-rust-binding/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "morphir-rust-binding"
-version = "0.3.0"
+version = "0.4.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
diff --git a/crates/morphir-rust-binding/README.md b/crates/morphir-rust-binding/README.md
index ffd96d7f..34338f29 100644
--- a/crates/morphir-rust-binding/README.md
+++ b/crates/morphir-rust-binding/README.md
@@ -383,7 +383,7 @@ For a distributable guest, build with `--release`. Its artifact is
`MORPHIR_RUST_GUEST` to that artifact's absolute path when running the host tests. Tests load it with
WASI disabled and compare native and guest results for both IR versions.
-The guest reports ID `morphir-rust`, name `Morphir Rust`, version `0.3.0`,
+The guest reports ID `morphir-rust`, name `Morphir Rust`, version `0.4.0`,
frontend language `rust`, suffix `.rs`, backend target `rust`, and
`irVersions: ["3", "4"]` on both capabilities.
@@ -406,9 +406,9 @@ artifact, its SHA-256 checksum and `release.json`. A clean checkout builds from
an archived HEAD and records that commit in the descriptor. A dirty checkout
can produce a local test bundle, without release provenance.
-Independent releases use tags such as `extension/rust/v0.3.0`. Download the
-assets from the [Rust v0.3.0 release](https://github.com/finos/morphir-rust/releases/tag/extension/rust/v0.3.0).
-The published descriptor is named `morphir-rust-binding-0.3.0.release.json`;
+Independent releases use tags such as `extension/rust/v0.4.0`. Download the
+assets from the [Rust v0.4.0 release](https://github.com/finos/morphir-rust/releases/tag/extension/rust/v0.4.0).
+The published descriptor is named `morphir-rust-binding-0.4.0.release.json`;
rename it to `release.json` alongside the WASM and checksum before publishing
the directory to a local extension repository.
diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md
index 70ba6928..cae64ba5 100644
--- a/openspec/changes/rust-extension-mep-0-2/tasks.md
+++ b/openspec/changes/rust-extension-mep-0-2/tasks.md
@@ -44,7 +44,7 @@
## 4. Release extension/rust/v0.4.0 (morphir-0xtc.4)
-- [ ] 4.1 Bump `morphir-rust-binding` to `0.4.0`, update `Cargo.lock`, the README version references and the
+- [x] 4.1 Bump `morphir-rust-binding` to `0.4.0`, update `Cargo.lock`, the README version references and the
changelog release section; verify `cargo test --locked -p morphir-rust-binding` passes.
- [ ] 4.2 Open the pull request, get CI green and handle each verified review finding.
- [ ] 4.3 Dry run on the merge candidate: `.github/scripts/extension_release.py` for the tag,
From 83656b030ac71b36de7c8e88077e30a4a0eb0a72 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Sat, 31 Oct 2026 22:11:11 -0500
Subject: [PATCH 5/7] docs(openspec): record the MoonBit worker host
qualification
Tick group 3 (bead morphir-0xtc.3). The MoonBit host compiles on the model
line whenever an extension claims compileModel, so the MEP 0.1 compile ran
on the 0.1-only hosts of task 2.3.
---
openspec/changes/rust-extension-mep-0-2/tasks.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md
index cae64ba5..7b6f8edf 100644
--- a/openspec/changes/rust-extension-mep-0-2/tasks.md
+++ b/openspec/changes/rust-extension-mep-0-2/tasks.md
@@ -34,13 +34,14 @@
## 3. Qualify on the MoonBit worker host (morphir-0xtc.3)
-- [ ] 3.1 Build the native and npm CLIs from finos/morphir-moonbit `main` and record the revision.
-- [ ] 3.2 With a contract `0.2.0` pin (WASM and descriptor), run `extension describe`, `extension compile`
+- [x] 3.1 Build the native and npm CLIs from finos/morphir-moonbit `main` and record the revision.
+- [x] 3.2 With a contract `0.2.0` pin (WASM and descriptor), run `extension describe`, `extension compile`
(model line) and `extension generate` (model envelope) from the native CLI; check the outputs against the
golden files and that each process exits by itself.
-- [ ] 3.3 Run the same operations from the npm CLI.
-- [ ] 3.4 Run compile and generate once on the `0.1` line (plain IR) from both CLIs, and record the receipts
- in the pull request.
+- [x] 3.3 Run the same operations from the npm CLI.
+- [x] 3.4 Run generate once on the `0.1` line (plain IR) from both CLIs, and record the receipts in the pull
+ request. The MoonBit host compiles on the model line whenever the extension claims `compileModel`, so the
+ `0.1` compile runs on the 0.1-only hosts of task 2.3.
## 4. Release extension/rust/v0.4.0 (morphir-0xtc.4)
From 93c529ec1c9b0a8503784ee1019e9e6f893043f6 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Sat, 31 Oct 2026 22:20:10 -0500
Subject: [PATCH 6/7] fix(rust-binding): check the IR version option outside a
session
The trait method compile_model overwrote options.irVersion with 4, so a
direct call with irVersion 3 and the IR 4.0.0 output tuple returned an IR
v4 model. The SDK session already refuses that combination; the method
now refuses it too.
Make two tests check what their names say: the no-session tuple test now
changes the input tuple, and a new negotiation test uses a host order and
a limit that differ from the guest's. Correct the README inventory row:
model operations exist only on MEP 0.2.0-draft.1.
---
README.md | 2 +-
.../src/model_transport.rs | 10 ++++-
.../tests/model_operations.rs | 42 +++++++++++++++++--
3 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index d0e020f2..372c4240 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ This table lists independently releasable extensions. Registrations come from
| Extension | Package | Version | Description |
| --- | --- | --- | --- |
| `morphir-elm-native` | `morphir-elm-binding` | `0.2.0` | Compiles Elm type declarations to IR v3 and v4 and generates Elm back; incremental. |
-| `morphir-rust` | `morphir-rust-binding` | `0.4.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4; MEP 0.1 and 0.2.0-draft.1 model operations. |
+| `morphir-rust` | `morphir-rust-binding` | `0.4.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4; MEP 0.1, and MEP 0.2.0-draft.1 with model operations. |
The Python extension (crate `morphir-python-binding`, bundle `morphir-python`) is now
maintained and released from [finos/morphir-python](https://github.com/finos/morphir-python).
diff --git a/crates/morphir-rust-binding/src/model_transport.rs b/crates/morphir-rust-binding/src/model_transport.rs
index 0f489757..e85a9721 100644
--- a/crates/morphir-rust-binding/src/model_transport.rs
+++ b/crates/morphir-rust-binding/src/model_transport.rs
@@ -3,8 +3,8 @@
//!
//! Both operations reuse the MEP `0.1` compile and generate paths. The model payload is the IR v4
//! JSON document that `morphir.frontend.compile` returns. The SDK session checks tuples, limits and
-//! payload integrity before and after each call; this module checks the tuple again because the
-//! trait methods can also be called without a session.
+//! payload integrity before and after each call; this module checks the tuple, the IR version
+//! option and the envelope again because the trait methods can also be called without a session.
//!
//! The extension offers no Ion tuple: the released guest is WASM, and the SDK has no Ion reader for
//! WASM targets.
@@ -64,6 +64,12 @@ pub(crate) fn compile_model(request: CompileModelRequest) -> Result
Date: Sat, 31 Oct 2026 22:36:28 -0500
Subject: [PATCH 7/7] fix(rust-binding): advertise the model depth that the
decoder reads
The guest advertised a model depth of 1024, but generateModel decodes the
admitted payload with serde_json, whose default recursion limit reads at
most 128 levels. A host that agreed a depth above 128 could admit a
payload that the decoder then refused. Advertise depth 128 instead: model
admission and serde_json count depth the same way, so every admitted
payload is decodable. The recursion limit stays, so a deep document
cannot overflow the WASM guest's stack. Found by the Codex review.
---
crates/morphir-rust-binding/README.md | 5 ++-
.../src/model_transport.rs | 39 ++++++++++++++++++-
.../rust-extension-mep-0-2/design.html | 10 +++--
3 files changed, 47 insertions(+), 7 deletions(-)
diff --git a/crates/morphir-rust-binding/README.md b/crates/morphir-rust-binding/README.md
index 34338f29..45cd6a65 100644
--- a/crates/morphir-rust-binding/README.md
+++ b/crates/morphir-rust-binding/README.md
@@ -357,8 +357,9 @@ nothing; the extension checks no guarantee.
The SDK session refuses, with a JSON-RPC error and before the extension runs, a
tuple that the session did not agree, an envelope that does not match its tuple
and a payload that fails its integrity checks. The guest's limits are 64 MiB
-encoded, 48 MiB decoded, depth 1024 and 16 Mi nodes; a host takes the smaller
-value of each. [The model operation tests](tests/model_operations.rs) compare
+encoded, 48 MiB decoded, depth 128 and 16 Mi nodes; a host takes the smaller
+value of each. Depth 128 is the deepest document that the JSON decoder reads, so
+every admitted payload can be decoded. [The model operation tests](tests/model_operations.rs) compare
both operations with golden files in `tests/fixtures/model/`.
## Native usage and tests
diff --git a/crates/morphir-rust-binding/src/model_transport.rs b/crates/morphir-rust-binding/src/model_transport.rs
index e85a9721..e941f771 100644
--- a/crates/morphir-rust-binding/src/model_transport.rs
+++ b/crates/morphir-rust-binding/src/model_transport.rs
@@ -35,12 +35,17 @@ fn tuple(operation: ModelOperation) -> ModelTransportTuple {
}
}
-/// The guest's own upper bounds. A host takes the smaller value of each limit.
+/// The deepest document that `serde_json` decodes with its default recursion limit, counted as
+/// model admission counts depth (the root at depth one).
+const MAX_DEPTH: u32 = 128;
+
+/// The guest's own upper bounds. A host takes the smaller value of each limit. The depth bound is
+/// the decoder's, so every payload that admission accepts can be decoded.
fn limits() -> ModelTransportLimits {
ModelTransportLimits {
max_encoded_bytes: 64 * 1024 * 1024,
max_decoded_bytes: 48 * 1024 * 1024,
- max_depth: 1024,
+ max_depth: MAX_DEPTH,
max_nodes: 16 * 1024 * 1024,
}
}
@@ -170,6 +175,36 @@ mod tests {
);
}
+ fn nested(depth: u32) -> String {
+ let levels = depth as usize - 1;
+ format!("{}0{}", "[".repeat(levels), "]".repeat(levels))
+ }
+
+ fn admit(text: &str, max_depth: u32) -> std::result::Result<(), &'static str> {
+ let model = MorphirModel {
+ payload: ModelPayload::from_bytes(ModelCodec::MorphirJson, text.as_bytes()).unwrap(),
+ ..encode("acme/example", &serde_json::json!({})).unwrap()
+ };
+ let limits = ModelTransportLimits {
+ max_depth,
+ ..limits()
+ };
+ retain_model(&model, &tuple(ModelOperation::GenerateModel), &limits)
+ .map(|_| ())
+ .map_err(|error| error.code())
+ }
+
+ #[test]
+ fn every_depth_that_admission_accepts_is_decodable() {
+ // Admission refuses the deeper document for its depth, and refuses the deepest allowed
+ // one only for its profile, so it accepts that depth. The decoder reads exactly that depth.
+ assert_eq!(admit(&nested(MAX_DEPTH + 1), MAX_DEPTH), Err("depth_limit"));
+ assert_ne!(admit(&nested(MAX_DEPTH), MAX_DEPTH), Err("depth_limit"));
+ assert!(serde_json::from_str::(&nested(MAX_DEPTH)).is_ok());
+ assert!(serde_json::from_str::(&nested(MAX_DEPTH + 1)).is_err());
+ assert_eq!(support().limits.max_depth, MAX_DEPTH);
+ }
+
#[test]
fn dependencies_fail_compilation_without_decoding_their_models() {
let request: CompileModelRequest = serde_json::from_value(serde_json::json!({
diff --git a/openspec/changes/rust-extension-mep-0-2/design.html b/openspec/changes/rust-extension-mep-0-2/design.html
index e9cccabf..452ddc01 100644
--- a/openspec/changes/rust-extension-mep-0-2/design.html
+++ b/openspec/changes/rust-extension-mep-0-2/design.html
@@ -97,9 +97,13 @@
MEP-4. Refusals
first. A frontend failure is CompileModelResult::Failed with the frontend's diagnostics.
MEP-5. Limits
-
The guest advertises 64 MiB encoded, 48 MiB decoded, depth 1024 and 16 Mi nodes, the values of the Gleam
-extension. Hosts take the smaller value of each limit (the MoonBit host offers 2 MiB decoded), so these are
-upper bounds only. The host's WASM memory and instruction budgets still apply.
+
The guest advertises 64 MiB encoded, 48 MiB decoded, depth 128 and 16 Mi nodes. Hosts take the smaller value
+of each limit (the MoonBit host offers 2 MiB decoded), so these are upper bounds only. The host's WASM memory
+and instruction budgets still apply. Depth 128 is the deepest document that serde_json decodes
+with its default recursion limit, counted as admission counts depth, so every payload that the session admits
+can be decoded. The Gleam extension's depth 1024 was not taken: a host that agreed a depth above 128 could
+admit a payload that the decoder then refuses. Disabling the recursion limit was not chosen either, because a
+deep document could then overflow the WASM guest's stack.
REL-1. Release 0.4.0 with the existing workflow
.github/extensions.toml sets mep_versions = ["0.1", "0.2.0-draft.1"]; the packager