OpenSpec change · Bead morphir-5hxy · 2026-10-28 · Proposed; no process or new-draft implementation
+
OpenSpec change · Bead morphir-5hxy · 2026-10-28 · Applying; published SDK prerequisite in progress
Context
See proposal.md for motivation and the two spec deltas for requirements. PythonExtension already implements native Rust frontend/backend traits. The published SDK 0.3.0 exposes NativeExtension::frontend_backend, open_protocol, model methods and admission helpers. Its wire request type still uses a numeric u64 ID, while the canonical contract admits numeric and string IDs; adopting helpers alone is not full draft conformance. The Python implementation does not opt into model transport and its manifest declares MEP 0.1 only. finos/morphir c552dac801f23f2b2b566d29e08780723498ce36 defines exact MEP 0.2.0-draft.1 and model envelope 0.1.0-draft.1. These versions are independent of SDK 0.3.0, compiler 0.4.2, claims format and IR version.
Goals and non-goals
Add a native Rust process guest and current-draft model adapters while retaining WASM and legacy behavior. Keep language semantics shared. Initial model tuples use morphir-ir/morphir-json and supported Library IR 3.0.0 and draft 4.0.0. Rich Ion profiles, unsupported distribution kinds, additional Python syntax, cancellation/progress and performance claims are not introduced. A Python CLI stdio wrapper can follow separately; the selected CLI installation remains mise/pipx.
-
Decisions
RNT-1. Add a thin native Rust guest
Add a process guest crate/entry point, depending on the maintained compiler and published SDK, with no PyO3 or CPython requirement. The proposed command is:
morphir-python-extension --stdio
Reuse the SDK's session endpoint and dispatch. Keep model adaptation in the shared PythonExtension so native, WASM and process paths agree. Put stdio effects in the guest layer. A Python CLI wrapper would reuse the engine but retain an interpreter/launcher dependency; a frozen Python CLI would bundle an unnecessary runtime for this guest.
Boundary
Current release
Proposed change
MEP
Legacy 0.1
Retain 0.1 and add exact 0.2.0-draft.1
Model envelope
Not advertised
0.1.0-draft.1, admitted JSON profile
Extension runtime
WASM
WASM and native process
SDK
0.3.0, numeric-only wire IDs
Qualified published pin covering the canonical wire contract
Semantic IR
Supported v3 and draft v4 Library models
Same semantic scope, explicit admitted tuples
SDK-1. Resolve concrete SDK wire gaps before claiming conformance
Pin a published SDK that supports canonical numeric and string request IDs and the required draft behavior. SDK 0.3.0 currently has numeric-only ExtensionRequest.id, so link and qualify an upstream SDK update if no suitable release exists. Do not vendor a sibling checkout or silently coerce wire IDs. Both WASM and process must pass the same ID-preservation vectors before the new draft is advertised. Related upstream work is MEP protocol type generation, issue 241; its existence is not evidence that the wire gap is fixed.
MEP-1. Negotiate the new draft explicitly
Retain legacy 0.1 operations and exact 0.2.0-draft.1 offers. Opt in only after supported model methods and executable conformance pass. Reuse SDK model admission and session rules; method constants alone are not support. Never enable model methods under legacy negotiation. Keep optional flags false until implemented.
MOD-1. Start with the explicit JSON model profile
Implement compile_model/generate_model over the existing compile/generate core. Admit complete operation/profile/codec/envelope/IR tuples and their bilateral limits. The initial profile is morphir-ir/morphir-json with envelope 0.1.0-draft.1. Use the canonical base64 payload, exact byte length/hash and critical-field rules. Reject unadvertised Ion profiles, guarantees and unsupported semantic forms. This achieves bounded new-draft support without implying rich Ion preservation or silently converting rich input.
IO-1. Treat framing and lifetime as a bounded server boundary
Use Content-Length JSON-RPC framing, byte-based lengths, an 8 KiB header ceiling and an initial 16 MiB complete-body ceiling. Advertised decoded/encoded model limits must fit those outer bounds, including envelope overhead. Enforce local bounds even if the peer advertises more. Check partial input, duplicate lengths, integer overflow, invalid UTF-8/JSON and EOF before unbounded allocation. Reserve stdout for frames and flush responses. Follow initialization, capability locks, shutdown and exit; do not respond to notifications. Host supervision owns deadlines and descendant cleanup.
RLS-1. Qualify dual-runtime distribution
Retain the portable WASM guest and publish native process executables for the advertised targets, with --stdio locked as an argument. Use a canonical host-readable descriptor with runtime/platform/mode/digest/claims bound per artifact. Validate the staged descriptor with pinned host tooling; do not assume that either the existing WASM-only packager or native-only bundle command can package a mixed release unchanged. Preserve historical assets. Release exact native/CLI package pairs if shared compiler changes affect them.
HST-1. Qualify hosts independently
The Rust host provides ProcessLaunch/ProcessChannel and verified-byte staging. It clears the child environment, favoring a directly executable Rust guest. Qualify a pinned conforming Rust host and test each documented MoonBit route independently. Use a pinned source build when a released host lacks the new draft, and state that prerequisite. If a route needs upstream work, record that dependency and label it unsupported until it passes. Existing MoonBit process wrappers demonstrate framing, not Python extension activation.
+
Decisions
RNT-1. Add a thin native Rust guest
Add a process guest crate/entry point, depending on the maintained compiler and published SDK, with no PyO3 or CPython requirement. The proposed command is:
morphir-python-extension --stdio
Reuse the SDK's session endpoint and dispatch. Keep model adaptation in the shared PythonExtension so native, WASM and process paths agree. Put stdio effects in the guest layer. A Python CLI wrapper would reuse the engine but retain an interpreter/launcher dependency; a frozen Python CLI would bundle an unnecessary runtime for this guest.
Boundary
Current release
Proposed change
MEP
Legacy 0.1
Retain 0.1 and add exact 0.2.0-draft.1
Model envelope
Not advertised
0.1.0-draft.1, admitted JSON profile
Extension runtime
WASM
WASM and native process
SDK
0.3.0, numeric-only wire IDs
Qualified published pin covering the canonical wire contract
Semantic IR
Supported v3 and draft v4 Library models
Same semantic scope, explicit admitted tuples
SDK-1. Resolve concrete SDK wire gaps before claiming conformance
Pin a published SDK that supports canonical numeric and string request IDs and the required draft behavior. SDK 0.3.0 currently has numeric-only ExtensionRequest.id, so link and qualify an upstream SDK update if no suitable release exists. Do not vendor a sibling checkout or silently coerce wire IDs. Both WASM and process must pass the same ID-preservation vectors before the new draft is advertised. The concrete prerequisite is request ID support, issue 316, tracked as morphir-4wrx. A candidate SDK 0.4.0 is under qualification and is not yet published. Related upstream work is MEP protocol type generation, issue 241; its existence is not evidence that the wire gap is fixed.
MEP-1. Negotiate the new draft explicitly
Retain legacy 0.1 operations and exact 0.2.0-draft.1 offers. Opt in only after supported model methods and executable conformance pass. Reuse SDK model admission and session rules; method constants alone are not support. Never enable model methods under legacy negotiation. Keep optional flags false until implemented.
MOD-1. Start with the explicit JSON model profile
Implement compile_model/generate_model over the existing compile/generate core. Admit complete operation/profile/codec/envelope/IR tuples and their bilateral limits. The initial profile is morphir-ir/morphir-json with envelope 0.1.0-draft.1. Use the canonical base64 payload, exact byte length/hash and critical-field rules. Reject unadvertised Ion profiles, guarantees and unsupported semantic forms. This achieves bounded new-draft support without implying rich Ion preservation or silently converting rich input.
IO-1. Treat framing and lifetime as a bounded server boundary
Use Content-Length JSON-RPC framing, byte-based lengths, an 8 KiB header ceiling and an initial 16 MiB complete-body ceiling. Advertised decoded/encoded model limits must fit those outer bounds, including envelope overhead. Enforce local bounds even if the peer advertises more. Check partial input, duplicate lengths, integer overflow, invalid UTF-8/JSON and EOF before unbounded allocation. Reserve stdout for frames and flush responses. Follow initialization, capability locks, shutdown and exit; do not respond to notifications. Host supervision owns deadlines and descendant cleanup.
RLS-1. Qualify dual-runtime distribution
Retain the portable WASM guest and publish native process executables for the advertised targets, with --stdio locked as an argument. Use a canonical host-readable descriptor with runtime/platform/mode/digest/claims bound per artifact. Validate the staged descriptor with pinned host tooling; do not assume that either the existing WASM-only packager or native-only bundle command can package a mixed release unchanged. Preserve historical assets. Release exact native/CLI package pairs if shared compiler changes affect them.
HST-1. Qualify hosts independently
The Rust host provides ProcessLaunch/ProcessChannel and verified-byte staging. It clears the child environment, favoring a directly executable Rust guest. Qualify a pinned conforming Rust host and test each documented MoonBit route independently. Use a pinned source build when a released host lacks the new draft, and state that prerequisite. If a route needs upstream work, record that dependency and label it unsupported until it passes. Existing MoonBit process wrappers demonstrate framing, not Python extension activation.
Risks and trade-offs
Risk
Mitigation
Draft, SDK and runtime support are confused
Exact independent version table, live negotiation and candidate-bound conformance before claims.
Model content or guarantees are lost
Admit only explicit JSON tuples initially. Refuse unsupported profiles, critical fields and guarantees; no silent fallback.
Malformed input exhausts resources
Bound headers, wire bodies and decoded bytes before allocation; test boundary and partial cases.
Process mode is mistaken for a sandbox
Document ordinary OS privileges and the host's explicit supervision, distinct from WASM isolation.
Packaging or host support lags
Pin descriptor readers and host builds, qualify installed bytes, record upstream blockers and make only verified support claims.
Native execution is assumed faster
Measure startup and representative compile/generate workloads before making a performance claim.
Migration and rollout
Add negotiation and bounded JSON model adapters with tests and compatibility documentation.
Add the Rust process server with framing and lifecycle tests; preserve the existing WASM path.
Build and qualify runtime artifacts and descriptors with matching claims and source receipts.
Run pinned MEP guest conformance and real host installation/operation cases, including legacy hosts and explicit refusals.
After maintainer approval, publish a new extension version and document runtime selection. Existing users can stay on the prior WASM release; never replace its bytes.
The current proposal does not change protocol claims or release a guest. Every process task remains unstarted.
Repository evidence and references
rust/crates/morphir-python-binding/src/lib.rs: shared PythonExtension traits and truthful current capabilities.
rust/crates/morphir-python-native/src/lib.rs: current private two-function JSON bridge; it is not a full MEP transport.
Published morphir-extension-sdk 0.3.0: native.rs, traits.rs and model admission helpers.
finos/morphir-rust 26d5e707e5ec94d16421db6380edbdc865d85c04: process launch/channel and stdio frontend fixture. Fixture framing must not be copied as a hardened server.
finos/morphir-moonbit bd3adc52e6b8f6368fdaee50f76535799732f8d6: portable MEP embedding wrapper. No claim of Python host compatibility follows.
Related work: morphir-bj9r owns the selected CLI installation route; morphir-ukvn.5.98 tracks the earlier MoonBit extension qualification context.
diff --git a/openspec/changes/add-process-mep-extension/tasks.md b/openspec/changes/add-process-mep-extension/tasks.md
index 3d78eb74..871bce00 100644
--- a/openspec/changes/add-process-mep-extension/tasks.md
+++ b/openspec/changes/add-process-mep-extension/tasks.md
@@ -1,6 +1,6 @@
# Tasks
-This change is proposed. No process guest or newer-MEP implementation has started.
+This change is being applied. The published SDK prerequisite is tracked in morphir-4wrx and finos/morphir-rust#316. Python process/model implementation tasks remain incomplete.
## 1. Negotiate current and legacy MEP (morphir-5hxy.1)
diff --git a/openspec/changes/add-documentation-site/.openspec.yaml b/openspec/changes/archive/2026-10-28-add-documentation-site/.openspec.yaml
similarity index 100%
rename from openspec/changes/add-documentation-site/.openspec.yaml
rename to openspec/changes/archive/2026-10-28-add-documentation-site/.openspec.yaml
diff --git a/openspec/changes/add-documentation-site/design.html b/openspec/changes/archive/2026-10-28-add-documentation-site/design.html
similarity index 87%
rename from openspec/changes/add-documentation-site/design.html
rename to openspec/changes/archive/2026-10-28-add-documentation-site/design.html
index 52df927b..c0afa75c 100644
--- a/openspec/changes/add-documentation-site/design.html
+++ b/openspec/changes/archive/2026-10-28-add-documentation-site/design.html
@@ -13,16 +13,16 @@
Code goes in
(or json, toml, bash, diff). Give sections an id and
list them in nav.toc.
-->
-
-
-
-
-
+
+
+
+
+
add-documentation-site
-
OpenSpec change · Bead morphir-ukvn.9 · 2026-10-28 · Published foundation; MoonBit visual alignment in progress
+
OpenSpec change · Bead morphir-ukvn.9 · 2026-10-28 · Published and verified; MoonBit visual alignment complete
diff --git a/openspec/changes/add-process-mep-extension/proposal.md b/openspec/changes/add-process-mep-extension/proposal.md
index 9bb7cf18..6275950b 100644
--- a/openspec/changes/add-process-mep-extension/proposal.md
+++ b/openspec/changes/add-process-mep-extension/proposal.md
@@ -11,7 +11,6 @@ The Python compiler currently exposes a WASM MEP guest advertising legacy `0.1`,
- Add exact `0.2.0-draft.1` negotiation and bilateral model transport admission, initially for the `morphir-ir`/`morphir-json` profile with the compiler's supported IR v3/v4 Library models.
- Implement bounded, integrity-checked model envelopes and compileModel/generateModel adapters for the admitted tuples. Refuse unsupported profiles and guarantees explicitly; rich Ion transport is not advertised by this initial increment.
- Publish and qualify both runtime choices, with accurate platform/runtime descriptors and host compatibility documentation. Qualify each documented host route independently.
-- Keep this change proposed: no process guest or newer-MEP implementation has been added by PR #52.
## Capabilities
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index a7898a12..7df8a3f9 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -1465,6 +1465,7 @@ dependencies = [
"cucumber",
"morphir-core",
"morphir-extension-sdk",
+ "morphir-json",
"num-bigint 0.5.1",
"ruff_python_ast",
"ruff_python_codegen",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 60c90e72..afb5aa52 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -13,6 +13,7 @@ homepage = "https://github.com/finos/morphir-python"
[workspace.dependencies]
# Published Morphir crates. Never use sibling-checkout paths here.
morphir-core = "=0.2.0"
+morphir-json = "=0.2.0"
morphir-extension-sdk = "=0.3.0"
num-bigint = "0.5"
# Ruff component crates evolve together; update this group as one unit.
diff --git a/rust/crates/morphir-python-binding/Cargo.toml b/rust/crates/morphir-python-binding/Cargo.toml
index 81ff6904..5e04cc85 100644
--- a/rust/crates/morphir-python-binding/Cargo.toml
+++ b/rust/crates/morphir-python-binding/Cargo.toml
@@ -15,6 +15,7 @@ crate-type = ["rlib", "cdylib"]
[dependencies]
num-bigint.workspace = true
morphir-core.workspace = true
+morphir-json.workspace = true
morphir-extension-sdk.workspace = true
ruff_python_parser.workspace = true
ruff_python_ast.workspace = true
diff --git a/rust/crates/morphir-python-binding/src/lib.rs b/rust/crates/morphir-python-binding/src/lib.rs
index 77c08d98..46f3cbfd 100644
--- a/rust/crates/morphir-python-binding/src/lib.rs
+++ b/rust/crates/morphir-python-binding/src/lib.rs
@@ -26,6 +26,7 @@
mod backend;
mod frontend;
mod ir;
+mod model_transport;
mod modules;
mod names;
mod values;
@@ -72,6 +73,13 @@ impl Extension for PythonExtension {
}
impl Frontend for PythonExtension {
+ fn compile_model(
+ &self,
+ request: morphir_extension_sdk::model::CompileModelRequest,
+ ) -> Result {
+ model_transport::compile(self, request)
+ }
+
fn compile(&self, request: CompileRequest) -> Result {
let diagnostics = if frontend::parse_stage_requested(&request) {
vec![Diagnostic {
@@ -118,6 +126,13 @@ impl Frontend for PythonExtension {
}
impl Backend for PythonExtension {
+ fn generate_model(
+ &self,
+ request: morphir_extension_sdk::model::GenerateModelRequest,
+ ) -> Result {
+ model_transport::generate(self, request)
+ }
+
fn generate(&self, request: GenerateRequest) -> Result {
Ok(match backend::generate(&request) {
Ok(artifacts) => GenerateResult {
diff --git a/rust/crates/morphir-python-binding/src/model_transport.rs b/rust/crates/morphir-python-binding/src/model_transport.rs
new file mode 100644
index 00000000..a349eae5
--- /dev/null
+++ b/rust/crates/morphir-python-binding/src/model_transport.rs
@@ -0,0 +1,178 @@
+//! Explicit JSON-model admission around the shared compiler, without transcoding.
+use crate::{PythonExtension, error};
+use morphir_extension_sdk::model::*;
+use morphir_extension_sdk::prelude::*;
+
+pub(crate) fn support() -> ModelTransportSupport {
+ ModelTransportSupport {
+ tuples: [ModelOperation::CompileModel, ModelOperation::GenerateModel]
+ .into_iter()
+ .flat_map(|operation| {
+ ["3.0.0", "4.0.0"].map(|version| ModelTransportTuple {
+ operation,
+ profile: "morphir-ir".into(),
+ codec: ModelCodec::MorphirJson,
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ ir_version: version.into(),
+ })
+ })
+ .collect(),
+ // Base64 payloads leave at least 4 MiB for envelope/source metadata in
+ // the process transport's 16 MiB complete-message bound.
+ limits: ModelTransportLimits {
+ max_encoded_bytes: 12 * 1024 * 1024,
+ max_decoded_bytes: 8 * 1024 * 1024,
+ max_depth: 128,
+ max_nodes: 100_000,
+ },
+ guarantees: vec![],
+ }
+}
+
+fn invalid(error: impl std::fmt::Display) -> ExtensionError {
+ ExtensionError::InvalidParams(format!("MEP model admission: {error}"))
+}
+
+fn admit(method: &str, value: &serde_json::Value) -> Result<()> {
+ let support = support();
+ let admitted = NegotiatedModelTransport::select(&support, &support).map_err(invalid)?;
+ let mut capabilities = PythonExtension::capabilities();
+ capabilities.model_transport = Some(support);
+ validate_model_invocation(&admitted, &capabilities, method, value).map_err(invalid)
+}
+
+pub(crate) fn compile(
+ extension: &PythonExtension,
+ request: CompileModelRequest,
+) -> Result {
+ admit(
+ "morphir.frontend.compileModel",
+ &serde_json::to_value(&request)?,
+ )?;
+ if !request.dependencies.is_empty() {
+ return Ok(CompileModelResult::Failed {
+ diagnostics: vec![error("PY001", "Expected Python and no dependencies")],
+ });
+ }
+ let model_id = request.package.name.clone();
+ let result = extension.compile(CompileRequest {
+ language_id: request.language_id,
+ sources: request.sources,
+ package: request.package,
+ dependencies: vec![],
+ options: request.options,
+ baseline: None,
+ })?;
+ if !result.success {
+ return Ok(CompileModelResult::Failed {
+ diagnostics: result.diagnostics,
+ });
+ }
+ let ir = result
+ .ir
+ .ok_or_else(|| invalid("successful compiler result is missing IR"))?;
+ let bytes = bounded_json(&ir, support().limits.max_decoded_bytes)?;
+ let payload = ModelPayload::from_bytes(ModelCodec::MorphirJson, &bytes).map_err(invalid)?;
+ let model = MorphirModel {
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ model_id,
+ revision: format!("sha256:{}", payload.sha256),
+ ir_version: request.output.ir_version.clone(),
+ profile: request.output.profile.clone(),
+ payload,
+ evidence: vec![],
+ critical: vec![],
+ };
+ retain_model(&model, &request.output, &support().limits).map_err(invalid)?;
+ Ok(CompileModelResult::Compiled {
+ model: Box::new(model),
+ diagnostics: result.diagnostics,
+ modules: result.modules,
+ })
+}
+
+pub(crate) fn generate(
+ extension: &PythonExtension,
+ request: GenerateModelRequest,
+) -> Result {
+ admit(
+ "morphir.backend.generateModel",
+ &serde_json::to_value(&request)?,
+ )?;
+ let retained =
+ retain_model(&request.model, &request.input, &support().limits).map_err(invalid)?;
+ let limits = support().limits;
+ let source = std::str::from_utf8(retained.bytes()).map_err(invalid)?;
+ let ir = morphir_json::read(
+ source,
+ &mut JsonBudget {
+ remaining: limits.max_nodes,
+ depth: limits.max_depth as usize,
+ string_bytes: limits.max_decoded_bytes as usize,
+ },
+ )
+ .map_err(invalid)?;
+ extension.generate(GenerateRequest {
+ ir,
+ target: request.target,
+ options: request.options,
+ })
+}
+
+/// Stop JSON serialization before extending the payload buffer beyond its bound.
+fn bounded_json(value: &serde_json::Value, limit: u32) -> Result> {
+ struct Buffer {
+ bytes: Vec,
+ limit: usize,
+ }
+ impl std::io::Write for Buffer {
+ fn write(&mut self, bytes: &[u8]) -> std::io::Result {
+ if self.bytes.len().saturating_add(bytes.len()) > self.limit {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "model payload exceeds decoded limit",
+ ));
+ }
+ self.bytes.extend_from_slice(bytes);
+ Ok(bytes.len())
+ }
+ fn flush(&mut self) -> std::io::Result<()> {
+ Ok(())
+ }
+ }
+ let mut buffer = Buffer {
+ bytes: Vec::new(),
+ limit: limit as usize,
+ };
+ serde_json::to_writer(&mut buffer, value).map_err(invalid)?;
+ Ok(buffer.bytes)
+}
+
+// Materialize only after SDK admission, using the published literal-JSON reader
+// so serde-private-looking keys cannot acquire a different scalar meaning.
+struct JsonBudget {
+ remaining: u32,
+ depth: usize,
+ string_bytes: usize,
+}
+impl morphir_json::ReadBudget for JsonBudget {
+ type Error = &'static str;
+ fn before_value(
+ &mut self,
+ depth: usize,
+ _: morphir_json::ValueKind,
+ ) -> std::result::Result<(), Self::Error> {
+ if depth >= self.depth {
+ return Err("model depth limit");
+ }
+ self.remaining = self.remaining.checked_sub(1).ok_or("model node limit")?;
+ Ok(())
+ }
+ fn before_string(&mut self, value: &str) -> std::result::Result<(), Self::Error> {
+ if value.len() > self.string_bytes {
+ Err("model string limit")
+ } else {
+ Ok(())
+ }
+ }
+}
diff --git a/rust/crates/morphir-python-binding/tests/model_transport.rs b/rust/crates/morphir-python-binding/tests/model_transport.rs
new file mode 100644
index 00000000..b9066d2d
--- /dev/null
+++ b/rust/crates/morphir-python-binding/tests/model_transport.rs
@@ -0,0 +1,228 @@
+//! The model boundary retains supported semantics and refuses unsupported admission.
+use morphir_extension_sdk::model::*;
+use morphir_extension_sdk::prelude::*;
+use morphir_python_binding::PythonExtension;
+use serde_json::{Value, json};
+
+fn a_request(version: &str) -> CompileModelRequest {
+ CompileModelRequest {
+ language_id: "python".into(),
+ sources: SourceSet {
+ root: None,
+ documents: vec![SourceDocument {
+ uri: "model.py".into(), language_id: "python".into(), version: 1,
+ text: "from dataclasses import dataclass\n@dataclass(frozen=True)\nclass Box:\n value: int\n".into(),
+ }],
+ },
+ package: CompilePackage { name: "acme".into(), exposed_modules: None },
+ dependencies: vec![],
+ options: CompileOptions { ir_version: version.into(), ..Default::default() },
+ context_digest: "a".repeat(64),
+ output: ModelTransportTuple {
+ operation: ModelOperation::CompileModel, profile: "morphir-ir".into(),
+ codec: ModelCodec::MorphirJson, envelope_version: MODEL_ENVELOPE_VERSION.into(), ir_version: version.into(),
+ },
+ }
+}
+
+fn a_model(version: &str) -> MorphirModel {
+ match PythonExtension.compile_model(a_request(version)).unwrap() {
+ CompileModelResult::Compiled { model, .. } => *model,
+ CompileModelResult::Failed { diagnostics } => panic!("{diagnostics:?}"),
+ }
+}
+
+fn generation(model: MorphirModel) -> GenerateModelRequest {
+ GenerateModelRequest {
+ input: ModelTransportTuple {
+ operation: ModelOperation::GenerateModel,
+ profile: "morphir-ir".into(),
+ codec: ModelCodec::MorphirJson,
+ envelope_version: MODEL_ENVELOPE_VERSION.into(),
+ ir_version: model.ir_version.clone(),
+ },
+ model,
+ target: "python".into(),
+ context_digest: "a".repeat(64),
+ required_guarantees: vec![],
+ options: Default::default(),
+ }
+}
+
+#[test]
+fn json_models_compile_and_generate_with_the_legacy_semantics() {
+ for version in ["3.0.0", "4.0.0"] {
+ let model = a_model(version);
+ assert_eq!(model.envelope_version, MODEL_ENVELOPE_VERSION);
+ assert_eq!(model.ir_version, version);
+ assert_eq!(model.model_id, "acme");
+ let request = generation(model);
+ let retained = retain_model(
+ &request.model,
+ &request.input,
+ &ModelTransportLimits {
+ max_encoded_bytes: 1024 * 1024,
+ max_decoded_bytes: 1024 * 1024,
+ max_depth: 128,
+ max_nodes: 100_000,
+ },
+ )
+ .unwrap();
+ let ir: Value = serde_json::from_slice(retained.bytes()).unwrap();
+ let legacy = PythonExtension
+ .generate(GenerateRequest {
+ ir,
+ target: "python".into(),
+ options: Default::default(),
+ })
+ .unwrap();
+ let generated = PythonExtension.generate_model(request).unwrap();
+ assert!(generated.success, "{:?}", generated.diagnostics);
+ let mut recompile = a_request(version);
+ recompile.sources.documents = generated
+ .artifacts
+ .iter()
+ .map(|artifact| SourceDocument {
+ uri: artifact.path.clone(),
+ language_id: "python".into(),
+ version: 1,
+ text: artifact.content.clone(),
+ })
+ .collect();
+ let reparsed = PythonExtension.compile_model(recompile).unwrap();
+ let CompileModelResult::Compiled {
+ model: reparsed, ..
+ } = reparsed
+ else {
+ panic!("generated model should recompile")
+ };
+ assert_eq!(reparsed.payload, a_model(version).payload);
+ assert_eq!(
+ serde_json::to_value(generated).unwrap(),
+ serde_json::to_value(legacy).unwrap()
+ );
+ }
+}
+
+#[test]
+fn model_compilation_preserves_language_diagnostics() {
+ let mut request = a_request("4.0.0");
+ request.sources.documents[0].text = "import os\n".into();
+ let legacy = PythonExtension
+ .compile(CompileRequest {
+ language_id: request.language_id.clone(),
+ sources: request.sources.clone(),
+ package: request.package.clone(),
+ dependencies: vec![],
+ options: request.options.clone(),
+ baseline: None,
+ })
+ .unwrap();
+ assert!(!legacy.success);
+ let CompileModelResult::Failed { diagnostics } =
+ PythonExtension.compile_model(request).unwrap()
+ else {
+ panic!("unsupported source must fail")
+ };
+ assert_eq!(
+ serde_json::to_value(diagnostics).unwrap(),
+ serde_json::to_value(legacy.diagnostics).unwrap()
+ );
+}
+
+#[test]
+fn malformed_envelopes_guarantees_and_unadvertised_forms_are_refused() {
+ let baseline = generation(a_model("4.0.0"));
+ let mut value = serde_json::to_value(&baseline).unwrap();
+ value["model"]["critical"] = json!([]);
+ for (path, invalid) in [
+ ("/model/payload/sha256", json!("0".repeat(64))),
+ ("/model/payload/decodedByteLength", json!(1)),
+ ("/model/payload/content", json!("not canonical base64")),
+ ("/model/critical", json!(["future.required"])),
+ ("/model/irVersion", json!("3.0.0")),
+ ("/input/codec", json!("ion-binary")),
+ ("/input/profile", json!("rich-model")),
+ ("/input/operation", json!("morphir.frontend.compileModel")),
+ (
+ "/requiredGuarantees",
+ json!([{"guarantee":"example.typed","scope":"model:acme"}]),
+ ),
+ ] {
+ let mut invalid_request = value.clone();
+ *invalid_request.pointer_mut(path).unwrap() = invalid;
+ let request = serde_json::from_value(invalid_request).unwrap();
+ assert!(
+ PythonExtension.generate_model(request).is_err(),
+ "accepted {path}"
+ );
+ }
+ let mut request = a_request("4.0.0");
+ request.output.ir_version = "3.1.0".into();
+ assert!(PythonExtension.compile_model(request).is_err());
+}
+
+#[test]
+fn model_round_trip_retains_large_integer_precision_and_v3_refuses_it() {
+ let decimal = "1234567890123456789012345678901234567890";
+ for version in ["3.0.0", "4.0.0"] {
+ let mut request = a_request(version);
+ request.options.types_only = false;
+ request.sources.documents[0].text = format!("def number() -> int:\n return {decimal}\n");
+ match PythonExtension.compile_model(request).unwrap() {
+ CompileModelResult::Compiled { model, .. } => {
+ assert_eq!(version, "4.0.0");
+ let generated = PythonExtension.generate_model(generation(*model)).unwrap();
+ assert!(generated.success, "{:?}", generated.diagnostics);
+ assert!(
+ generated
+ .artifacts
+ .iter()
+ .any(|artifact| artifact.content.contains(decimal))
+ );
+ }
+ CompileModelResult::Failed { diagnostics } => {
+ assert_eq!(version, "3.0.0");
+ assert!(
+ diagnostics
+ .iter()
+ .any(|item| item.message.contains("64-bit"))
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn aggregate_nodes_and_dependency_support_are_bounded() {
+ let mut request = generation(a_model("4.0.0"));
+ let retained = retain_model(
+ &request.model,
+ &request.input,
+ &ModelTransportLimits {
+ max_encoded_bytes: 1024 * 1024,
+ max_decoded_bytes: 1024 * 1024,
+ max_depth: 128,
+ max_nodes: 100_000,
+ },
+ )
+ .unwrap();
+ let mut ir: Value = serde_json::from_slice(retained.bytes()).unwrap();
+ ir["extra"] = json!(vec![0; 100_001]);
+ request.model.payload =
+ ModelPayload::from_bytes(ModelCodec::MorphirJson, &serde_json::to_vec(&ir).unwrap())
+ .unwrap();
+ assert!(PythonExtension.generate_model(request).is_err());
+
+ let mut request = a_request("4.0.0");
+ request.dependencies.push(CompileModelDependency {
+ package_name: "dep".into(),
+ model: a_model("4.0.0"),
+ });
+ let CompileModelResult::Failed { diagnostics } =
+ PythonExtension.compile_model(request).unwrap()
+ else {
+ panic!("dependencies are unsupported")
+ };
+ assert_eq!(diagnostics[0].code.as_deref(), Some("PY001"));
+}
From 22c7a8311b274745f2f86ee6e2af7774c68ebddc Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:04:20 -0500
Subject: [PATCH 04/26] feat(extension): serve standalone MEP sessions
---
.../add-process-mep-extension/tasks.md | 4 +-
rust/Cargo.lock | 6 +
rust/Cargo.toml | 1 +
.../morphir-python-extension/Cargo.toml | 6 +
.../crates/morphir-python-extension/README.md | 27 +++++
.../morphir-python-extension/src/lib.rs | 2 +
.../morphir-python-extension/src/main.rs | 24 ++++
.../morphir-python-extension/src/server.rs | 114 ++++++++++++++++++
.../morphir-python-extension/tests/framing.rs | 13 ++
.../morphir-python-extension/tests/session.rs | 109 +++++++++++++++++
10 files changed, 304 insertions(+), 2 deletions(-)
create mode 100644 rust/crates/morphir-python-extension/README.md
create mode 100644 rust/crates/morphir-python-extension/src/main.rs
create mode 100644 rust/crates/morphir-python-extension/src/server.rs
create mode 100644 rust/crates/morphir-python-extension/tests/session.rs
diff --git a/openspec/changes/add-process-mep-extension/tasks.md b/openspec/changes/add-process-mep-extension/tasks.md
index 871bce00..2ace45e8 100644
--- a/openspec/changes/add-process-mep-extension/tasks.md
+++ b/openspec/changes/add-process-mep-extension/tasks.md
@@ -16,8 +16,8 @@ This change is being applied. The published SDK prerequisite is tracked in morph
## 3. Serve native stdio sessions (morphir-5hxy.3)
-- [ ] 3.1 Add the thin Rust process guest and documented `morphir-python-extension --stdio` entry point; verify it builds and executes without CPython/PyO3 or a user Rust toolchain.
-- [ ] 3.2 Implement bounded Content-Length framing around the SDK session; test Unicode byte lengths, partial/truncated messages, duplicate/invalid lengths, overflow, invalid UTF-8/JSON and header/body limits.
+- [x] 3.1 Add the thin Rust process guest and documented `morphir-python-extension --stdio` entry point; verify it builds and executes without CPython/PyO3 or a user Rust toolchain.
+- [x] 3.2 Implement bounded Content-Length framing around the SDK session; test Unicode byte lengths, partial/truncated messages, duplicate/invalid lengths, overflow, invalid UTF-8/JSON and header/body limits.
- [ ] 3.3 Preserve request IDs, notification silence, fixed session capabilities, shutdown/exit and protocol-only stdout; verify a real multi-request process test and document stderr and supervision behavior.
## 4. Distribute both runtime choices (morphir-5hxy.4)
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 7df8a3f9..5fbc6a77 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -1479,6 +1479,12 @@ dependencies = [
[[package]]
name = "morphir-python-extension"
version = "0.1.0"
+dependencies = [
+ "morphir-extension-sdk",
+ "morphir-python-binding",
+ "serde",
+ "serde_json",
+]
[[package]]
name = "morphir-python-native"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index afb5aa52..6c1c135c 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -22,6 +22,7 @@ ruff_python_ast = "=0.0.15"
ruff_python_codegen = "=0.0.15"
ruff_text_size = "=0.0.15"
serde_json = "1.0"
+serde = { version = "1.0", features = ["derive"] }
tempfile = "3"
[profile.release]
diff --git a/rust/crates/morphir-python-extension/Cargo.toml b/rust/crates/morphir-python-extension/Cargo.toml
index a7947eed..84009521 100644
--- a/rust/crates/morphir-python-extension/Cargo.toml
+++ b/rust/crates/morphir-python-extension/Cargo.toml
@@ -8,3 +8,9 @@ license.workspace = true
repository.workspace = true
description = "Native stdio MEP guest for the Morphir Python compiler"
publish = false
+
+[dependencies]
+morphir-python-binding = { path = "../morphir-python-binding", version = "0.4.2" }
+morphir-extension-sdk.workspace = true
+serde.workspace = true
+serde_json = { workspace = true, features = ["raw_value"] }
diff --git a/rust/crates/morphir-python-extension/README.md b/rust/crates/morphir-python-extension/README.md
new file mode 100644
index 00000000..7b094043
--- /dev/null
+++ b/rust/crates/morphir-python-extension/README.md
@@ -0,0 +1,27 @@
+# Morphir Python process guest
+
+This crate runs the shared Python compiler as a native MEP process. It has no
+CPython or PyO3 dependency, and the executable needs no user Rust toolchain.
+Source is parsed statically and is never imported or executed.
+
+Development build and entry point:
+
+```bash
+cargo build --manifest-path rust/Cargo.toml --release -p morphir-python-extension
+./rust/target/release/morphir-python-extension --stdio
+```
+
+In stdio mode stdout contains only Content-Length-framed UTF-8 JSON-RPC.
+Headers are bounded to 8 KiB and complete bodies to 16 MiB. Lengths count bytes;
+unknown headers are ignored and duplicate lengths are refused. A damaged frame
+ends the connection. Invalid JSON in an otherwise valid frame gets a parse-error
+response and the next framed request can recover. Notifications get no response;
+`morphir.exit` ends the process. Each process owns its SDK protocol session.
+
+The host owns deadlines and process cleanup. A native process has ordinary OS
+rights; choosing it does not provide a WASM sandbox.
+
+The current development build advertises legacy MEP 0.1. Exact-draft discovery
+and model transport wait for the qualified published SDK prerequisite and
+conformance checks. Platform archives and installation recipes are still under
+development. The retained WASM release remains the qualified installation route.
diff --git a/rust/crates/morphir-python-extension/src/lib.rs b/rust/crates/morphir-python-extension/src/lib.rs
index ea0e7d8b..aa2df2e5 100644
--- a/rust/crates/morphir-python-extension/src/lib.rs
+++ b/rust/crates/morphir-python-extension/src/lib.rs
@@ -1,2 +1,4 @@
//! Bounded stdio transport for the Morphir Python process guest.
pub mod framing;
+mod server;
+pub use server::serve;
diff --git a/rust/crates/morphir-python-extension/src/main.rs b/rust/crates/morphir-python-extension/src/main.rs
new file mode 100644
index 00000000..e4cc81c3
--- /dev/null
+++ b/rust/crates/morphir-python-extension/src/main.rs
@@ -0,0 +1,24 @@
+//! Standalone process entry point; the compiler never imports or executes Python.
+use morphir_python_extension::serve;
+
+fn main() {
+ let mut arguments = std::env::args_os().skip(1);
+ let argument = arguments.next();
+ if arguments.next().is_some() {
+ eprintln!("Usage: morphir-python-extension --stdio");
+ std::process::exit(2);
+ }
+ match argument.as_deref() {
+ Some(value) if value == "--stdio" => {
+ if let Err(error) = serve(&mut std::io::stdin().lock(), &mut std::io::stdout().lock()) {
+ eprintln!("morphir-python-extension: {error}");
+ std::process::exit(1);
+ }
+ }
+ Some(value) if value == "--help" => println!("Usage: morphir-python-extension --stdio"),
+ _ => {
+ eprintln!("Usage: morphir-python-extension --stdio");
+ std::process::exit(2);
+ }
+ }
+}
diff --git a/rust/crates/morphir-python-extension/src/server.rs b/rust/crates/morphir-python-extension/src/server.rs
new file mode 100644
index 00000000..1017d056
--- /dev/null
+++ b/rust/crates/morphir-python-extension/src/server.rs
@@ -0,0 +1,114 @@
+//! Framed stdio sessions around the SDK's native protocol endpoint.
+use crate::framing::{MAX_BODY_BYTES, read_frame, write_frame};
+use morphir_extension_sdk::{
+ NativeExtension,
+ protocol::{ExtensionRequest, error_codes, methods},
+};
+use morphir_python_binding::PythonExtension;
+use serde::{Deserialize, Deserializer, Serialize};
+use serde_json::value::RawValue;
+use std::io::{self, BufRead, Write};
+
+#[derive(Deserialize)]
+struct MessageHead {
+ jsonrpc: String,
+ method: String,
+ // Explicit null is a malformed request ID, not a notification.
+ #[serde(default, deserialize_with = "present_id")]
+ id: Option>,
+}
+
+fn present_id<'de, D: Deserializer<'de>>(reader: D) -> Result
diff --git a/openspec/changes/add-process-mep-extension/tasks.md b/openspec/changes/add-process-mep-extension/tasks.md
index 9d7eea6c..6520b064 100644
--- a/openspec/changes/add-process-mep-extension/tasks.md
+++ b/openspec/changes/add-process-mep-extension/tasks.md
@@ -35,5 +35,5 @@ This change is being applied. The published SDK prerequisite is tracked in morph
## Workflow follow-up
-- Implementation requires separately applying this proposal after review.
+- Implementation is in progress under the active apply goal. SDK publication and a separate upstream Python MCK profile decision are pending; the new draft remains unadvertised.
- Merge, tags and publication require maintainer approval; sync/archive only after implementation and qualification are complete.
diff --git a/rust/crates/morphir-python-extension/README.md b/rust/crates/morphir-python-extension/README.md
index 7b094043..0d1f220e 100644
--- a/rust/crates/morphir-python-extension/README.md
+++ b/rust/crates/morphir-python-extension/README.md
@@ -11,6 +11,10 @@ cargo build --manifest-path rust/Cargo.toml --release -p morphir-python-extensio
./rust/target/release/morphir-python-extension --stdio
```
+With no arguments the executable also serves stdio, matching the canonical
+native bundler and installed process launcher. `--stdio` is an explicit alias;
+`--help` prints usage and exits.
+
In stdio mode stdout contains only Content-Length-framed UTF-8 JSON-RPC.
Headers are bounded to 8 KiB and complete bodies to 16 MiB. Lengths count bytes;
unknown headers are ignored and duplicate lengths are refused. A damaged frame
@@ -25,3 +29,31 @@ The current development build advertises legacy MEP 0.1. Exact-draft discovery
and model transport wait for the qualified published SDK prerequisite and
conformance checks. Platform archives and installation recipes are still under
development. The retained WASM release remains the qualified installation route.
+
+## Stage a development bundle
+
+From a clean candidate checkout, build the release executable and run the pinned
+Morphir CLI's native bundler through the packaging helper:
+
+```bash
+python -m scripts.process_extension \
+ --executable rust/target/release/morphir-python-extension \
+ --output .dev/process-bundle \
+ --commit "$(git rev-parse HEAD)" \
+ --report .dev/qualification/process-package.json \
+ --host-check
+```
+
+On Windows the executable path ends in `.exe`. The helper selects an explicit
+supported target, verifies the pinned CLI archive and stages a versioned raw
+executable, its checksum and canonical `release.json`. It records the canonical
+default argument list, explicit `--stdio` alias, executable mode, source commit,
+Cargo lock digest, bundler identity, exact artifact bytes and live claims in the
+separate report. Receipts are not additional members of the canonical bundle.
+
+`--host-check` requires the development Python environment with the matching
+native compiler. It publishes into a temporary local repository, installs into
+an isolated Morphir home, deletes the repository and runs offline v3/v4
+compile/generate/recompile comparisons against the Python API. This legacy route
+check does not qualify the new MEP draft. Platform release builds, new-draft
+qualification and promotion gates remain in progress.
diff --git a/rust/crates/morphir-python-extension/src/main.rs b/rust/crates/morphir-python-extension/src/main.rs
index e4cc81c3..03e22589 100644
--- a/rust/crates/morphir-python-extension/src/main.rs
+++ b/rust/crates/morphir-python-extension/src/main.rs
@@ -8,13 +8,16 @@ fn main() {
eprintln!("Usage: morphir-python-extension --stdio");
std::process::exit(2);
}
- match argument.as_deref() {
- Some(value) if value == "--stdio" => {
- if let Err(error) = serve(&mut std::io::stdin().lock(), &mut std::io::stdout().lock()) {
- eprintln!("morphir-python-extension: {error}");
- std::process::exit(1);
- }
+ // Canonical native bundles launch their executable without arguments.
+ // Keep --stdio as an explicit alias for host configurations and manual use.
+ if argument.as_deref().is_none_or(|value| value == "--stdio") {
+ if let Err(error) = serve(&mut std::io::stdin().lock(), &mut std::io::stdout().lock()) {
+ eprintln!("morphir-python-extension: {error}");
+ std::process::exit(1);
}
+ return;
+ }
+ match argument.as_deref() {
Some(value) if value == "--help" => println!("Usage: morphir-python-extension --stdio"),
_ => {
eprintln!("Usage: morphir-python-extension --stdio");
diff --git a/rust/crates/morphir-python-extension/tests/session.rs b/rust/crates/morphir-python-extension/tests/session.rs
index 73d2fd60..10e59615 100644
--- a/rust/crates/morphir-python-extension/tests/session.rs
+++ b/rust/crates/morphir-python-extension/tests/session.rs
@@ -87,8 +87,13 @@ fn malformed_frames_fail_without_writing_non_protocol_output() {
#[test]
fn standalone_process_needs_no_python_or_rust_environment() {
+ standalone(&["--stdio"]);
+ standalone(&[]);
+}
+
+fn standalone(arguments: &[&str]) {
let mut child = Command::new(env!("CARGO_BIN_EXE_morphir-python-extension"))
- .arg("--stdio")
+ .args(arguments)
.env_clear()
.stdin(Stdio::piped())
.stdout(Stdio::piped())
diff --git a/rust/extension.toml b/rust/extension.toml
index 6f3dcf62..e978c300 100644
--- a/rust/extension.toml
+++ b/rust/extension.toml
@@ -32,3 +32,5 @@ release = "https://github.com/finos/morphir/releases/download/v0.4.0-beta.10"
"aarch64-unknown-linux-gnu" = "76a92a11030cd254d2283e98363cf203f86aaecd583fa480bdf6f508c86710f6"
"aarch64-apple-darwin" = "98635ec74fbdc3c2cef1d4ae184bb1c5ed9e2960575f3d8abb4b97e0e4da363f"
"x86_64-apple-darwin" = "4080dcfb04f6e1c902bce2780756c6af44320ac0dd6ff6bd953ca74a0da8e128"
+
+"x86_64-pc-windows-msvc" = "c178002d570f4cb8c9ceed4bf7b51f04d4ebec2458765935839c90ac1cc01176"
diff --git a/scripts/extension_bundle.py b/scripts/extension_bundle.py
index 53f9699e..bc41bd99 100644
--- a/scripts/extension_bundle.py
+++ b/scripts/extension_bundle.py
@@ -32,6 +32,7 @@
import tempfile
import tomllib
import urllib.request
+import zipfile
from pathlib import Path
from typing import Any
@@ -220,9 +221,11 @@ def receipt(bundle: Path, *, version: str | None = None) -> dict[str, object]:
def _target() -> str:
machine = platform.machine().lower()
arch = {"arm64": "aarch64", "amd64": "x86_64"}.get(machine, machine)
- system = {"Darwin": "apple-darwin", "Linux": "unknown-linux-gnu"}.get(
- platform.system()
- )
+ system = {
+ "Darwin": "apple-darwin",
+ "Linux": "unknown-linux-gnu",
+ "Windows": "pc-windows-msvc",
+ }.get(platform.system())
if system is None:
raise BundleError(f"no pinned Morphir CLI for {platform.system()}")
return f"{arch}-{system}"
@@ -236,8 +239,10 @@ def morphir_cli() -> tuple[Path, str]:
if expected is None:
raise BundleError(f"no pinned Morphir CLI digest for {target}")
directory = CACHE / f"morphir-{host['version']}-{target}"
- binary = directory / "morphir"
- archive_name = f"morphir-{host['version']}-{target}.tgz"
+ is_windows = target.endswith("windows-msvc")
+ binary = directory / ("morphir.exe" if is_windows else "morphir")
+ extension = "zip" if is_windows else "tgz"
+ archive_name = f"morphir-{host['version']}-{target}.{extension}"
if not binary.is_file():
with urllib.request.urlopen(
f"{host['release']}/{archive_name}", timeout=120
@@ -246,24 +251,35 @@ def morphir_cli() -> tuple[Path, str]:
if sha256(data) != expected:
raise BundleError(f"{archive_name} digest differs from the pin")
directory.mkdir(parents=True, exist_ok=True)
- with tarfile.open(fileobj=io.BytesIO(data)) as archive:
- member = next(
- m for m in archive.getmembers() if Path(m.name).name == "morphir"
- )
- extracted = archive.extractfile(member)
- if extracted is None:
- raise BundleError(f"{archive_name} has no morphir executable")
- binary.write_bytes(extracted.read())
+ if is_windows:
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
+ zip_member = next(
+ m for m in archive.namelist() if Path(m).name == "morphir.exe"
+ )
+ binary.write_bytes(archive.read(zip_member))
+ else:
+ with tarfile.open(fileobj=io.BytesIO(data)) as archive:
+ member = next(
+ m for m in archive.getmembers() if Path(m.name).name == "morphir"
+ )
+ extracted = archive.extractfile(member)
+ if extracted is None:
+ raise BundleError(f"{archive_name} has no morphir executable")
+ binary.write_bytes(extracted.read())
binary.chmod(0o755)
return binary, expected
def host_check(bundle: Path, *, version: str | None = None) -> dict[str, object]:
"""Install the bundle with the pinned CLI and round trip offline."""
+ return host_round_trips(bundle, receipt(bundle, version=version))
+
+
+def host_round_trips(bundle: Path, described: dict[str, Any]) -> dict[str, object]:
+ """Qualify an admitted runtime bundle through installed, offline CLI routes."""
from morphir_tools.python import native_compiler
bundle = bundle.resolve()
- described = receipt(bundle, version=version)
cli, cli_digest = morphir_cli()
with tempfile.TemporaryDirectory(prefix="python-bundle-host-") as scratch:
work = Path(scratch)
diff --git a/scripts/process_extension.py b/scripts/process_extension.py
new file mode 100644
index 00000000..26df824a
--- /dev/null
+++ b/scripts/process_extension.py
@@ -0,0 +1,178 @@
+"""Stage process extensions with the pinned Morphir CLI's canonical bundler.
+
+Receipts live outside the canonical three-file bundle. They bind its source,
+platform, launch convention, executable mode and exact bytes. Publication and
+installed-host qualification are separate steps.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import stat
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Any
+
+from scripts import extension_bundle as bundle
+
+PLATFORMS = (
+ "x86_64-unknown-linux-gnu",
+ "aarch64-unknown-linux-gnu",
+ "aarch64-apple-darwin",
+ "x86_64-apple-darwin",
+ "x86_64-pc-windows-msvc",
+)
+
+
+def artifact_name(version: str, platform: str) -> str:
+ """Return the raw executable name for an explicitly supported platform."""
+ if platform not in PLATFORMS:
+ raise bundle.BundleError(f"unsupported process platform {platform}")
+ suffix = ".exe" if platform.endswith("windows-msvc") else ""
+ return f"morphir-python-extension-{version}-{platform}{suffix}"
+
+
+def receipt(directory: Path, commit: str, platform: str) -> dict[str, Any]:
+ """Refuse a candidate whose descriptor, bytes, identity or mode disagree."""
+ if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
+ raise bundle.BundleError("source commit must be a full lowercase Git SHA")
+ version = bundle.crate_version()
+ name = artifact_name(version, platform)
+ expected = {name, f"{name}.sha256", "release.json"}
+ if {p.name for p in directory.iterdir()} != expected:
+ raise bundle.BundleError("process bundle has missing or extra files")
+ if any(p.is_symlink() or not p.is_file() for p in directory.iterdir()):
+ raise bundle.BundleError("process bundle files must be regular files")
+ try:
+ descriptor = json.loads((directory / "release.json").read_text())
+ (artifact,) = descriptor["artifacts"]
+ claims = artifact["claims"]
+ digest = bundle.sha256((directory / name).read_bytes())
+ if not (
+ descriptor["schemaVersion"] == "2.0.0-draft.2"
+ and descriptor["extensionId"] == "morphir-python"
+ and descriptor["shortId"] == "python"
+ and descriptor["version"] == version
+ and descriptor["gitCommit"] == commit
+ and artifact["runtime"] == "process"
+ and artifact["platform"] == platform
+ and artifact["filename"] == name
+ and artifact["sha256"] == digest
+ and claims["extension"]["id"] == descriptor["extensionId"]
+ and claims["extension"]["version"] == version
+ and claims["protocolVersions"]
+ == bundle.declaration()["extension"]["mep_versions"]
+ ):
+ raise bundle.BundleError("process descriptor disagrees with candidate")
+ if (directory / f"{name}.sha256").read_text() != f"{digest} {name}\n":
+ raise bundle.BundleError("process checksum disagrees with executable")
+ except (KeyError, ValueError, TypeError) as error:
+ raise bundle.BundleError(f"invalid process descriptor: {error}") from error
+ mode = stat.S_IMODE((directory / name).stat().st_mode)
+ if os.name != "nt" and mode & stat.S_IXUSR == 0:
+ raise bundle.BundleError("process artifact lacks owner executable mode")
+ return {
+ "schemaVersion": 1,
+ "sourceCommit": commit,
+ "runtime": "process",
+ "platform": platform,
+ "version": version,
+ "arguments": [],
+ "explicitArguments": ["--stdio"],
+ "executable": True,
+ "mode": oct(mode),
+ "files": {
+ name: bundle.sha256((directory / name).read_bytes())
+ for name in sorted(expected)
+ },
+ "claims": claims,
+ }
+
+
+def package(executable: Path, output: Path, commit: str) -> dict[str, Any]:
+ """Run the pinned native bundler on a uniquely named copy of exact bytes."""
+ platform = bundle._target()
+ name = artifact_name(bundle.crate_version(), platform)
+ if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
+ raise bundle.BundleError("source commit must be a full lowercase Git SHA")
+ if output.exists() or output.is_symlink():
+ raise bundle.BundleError(f"bundle directory {output} already exists")
+ if executable.is_symlink() or not executable.is_file():
+ raise bundle.BundleError("process executable must be a regular file")
+ cli, archive_digest = bundle.morphir_cli()
+ with tempfile.TemporaryDirectory(prefix="python-process-bundle-") as temporary:
+ copied = Path(temporary) / name
+ shutil.copyfile(executable, copied)
+ copied.chmod(0o700)
+ subprocess.run(
+ [
+ str(cli.resolve()),
+ "--no-banner",
+ "extension",
+ "bundle",
+ "--executable",
+ str(copied),
+ "--extension-id",
+ "morphir-python",
+ "--short-id",
+ "python",
+ "--version",
+ bundle.crate_version(),
+ "--platform",
+ platform,
+ "--output",
+ str(output.resolve()),
+ ],
+ check=True,
+ )
+ # gitCommit is a canonical optional descriptor member. All runtime fields
+ # and claims come from the upstream writer and its live describe probe.
+ descriptor_path = output / "release.json"
+ descriptor = json.loads(descriptor_path.read_text())
+ descriptor["gitCommit"] = commit
+ descriptor_path.write_text(json.dumps(descriptor, indent=2) + "\n")
+ result = receipt(output, commit, platform)
+ result["bundler"] = {
+ "version": bundle.declaration()["host"]["version"],
+ "archiveSha256": archive_digest,
+ "binarySha256": bundle.sha256(cli.read_bytes()),
+ }
+ result["cargoLockSha256"] = bundle.sha256(
+ (bundle.ROOT / "rust/Cargo.lock").read_bytes()
+ )
+ return result
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Write a source-bound process receipt outside the canonical bundle."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--executable", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--commit", required=True)
+ parser.add_argument("--report", type=Path, required=True)
+ parser.add_argument(
+ "--host-check",
+ action="store_true",
+ help="install offline and compare v3/v4 with the matching Python API",
+ )
+ args = parser.parse_args(argv)
+ if args.report.resolve().is_relative_to(args.output.resolve()):
+ parser.error("the report must be outside the canonical bundle")
+ try:
+ result = package(args.executable, args.output, args.commit)
+ if args.host_check:
+ result["installedHost"] = bundle.host_round_trips(args.output, result)
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+ args.report.write_text(json.dumps(result, indent=2) + "\n")
+ except (bundle.BundleError, OSError, subprocess.CalledProcessError) as error:
+ parser.exit(1, f"process bundle refused: {error}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/unit/test_extension_bundle.py b/tests/unit/test_extension_bundle.py
index c4d81cee..e692a2d3 100644
--- a/tests/unit/test_extension_bundle.py
+++ b/tests/unit/test_extension_bundle.py
@@ -2,7 +2,9 @@
from __future__ import annotations
+import io
import json
+import zipfile
from typing import TYPE_CHECKING
import pytest
@@ -51,3 +53,46 @@ def test_bundle_requires_multi_document_claim(
else:
with pytest.raises(bundle.BundleError, match="claims lack"):
bundle.receipt(tmp_path)
+
+
+def test_windows_cli_archive_is_verified_and_only_the_binary_is_extracted(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(bundle.platform, "system", lambda: "Windows")
+ monkeypatch.setattr(bundle.platform, "machine", lambda: "AMD64")
+ monkeypatch.setattr(bundle, "CACHE", tmp_path)
+ stream = io.BytesIO()
+ with zipfile.ZipFile(stream, "w") as archive:
+ archive.writestr("release/morphir.exe", b"verified CLI")
+ archive.writestr("../../unwanted", b"do not extract")
+ archive_bytes = stream.getvalue()
+ digest = bundle.sha256(archive_bytes)
+ monkeypatch.setattr(
+ bundle,
+ "declaration",
+ lambda: {
+ "host": {
+ "version": "test",
+ "release": "https://example.invalid/releases",
+ "sha256": {"x86_64-pc-windows-msvc": digest},
+ }
+ },
+ )
+ urls: list[str] = []
+
+ def download(url: str, *, timeout: int) -> io.BytesIO:
+ assert timeout > 0
+ urls.append(url)
+ return io.BytesIO(archive_bytes)
+
+ monkeypatch.setattr(bundle.urllib.request, "urlopen", download)
+ executable, actual_digest = bundle.morphir_cli()
+ assert actual_digest == digest
+ assert executable.name == "morphir.exe"
+ assert executable.read_bytes() == b"verified CLI"
+ assert urls == [
+ "https://example.invalid/releases/morphir-test-x86_64-pc-windows-msvc.zip"
+ ]
+ assert sorted(
+ p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*") if p.is_file()
+ ) == ["morphir-test-x86_64-pc-windows-msvc/morphir.exe"]
diff --git a/tests/unit/test_process_extension.py b/tests/unit/test_process_extension.py
new file mode 100644
index 00000000..39cdd087
--- /dev/null
+++ b/tests/unit/test_process_extension.py
@@ -0,0 +1,114 @@
+"""Process release receipts bind canonical descriptors and exact executable bytes."""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import TYPE_CHECKING
+
+import pytest
+
+from scripts import extension_bundle as wasm
+from scripts import process_extension as process
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def candidate(directory: Path) -> str:
+ version = wasm.crate_version()
+ name = process.artifact_name(version, "aarch64-apple-darwin")
+ binary = directory / name
+ binary.write_bytes(b"executable fixture")
+ binary.chmod(0o700)
+ digest = wasm.sha256(binary.read_bytes())
+ (directory / f"{name}.sha256").write_text(f"{digest} {name}\n")
+ (directory / "release.json").write_text(
+ json.dumps(
+ {
+ "schemaVersion": "2.0.0-draft.2",
+ "extensionId": "morphir-python",
+ "shortId": "python",
+ "version": version,
+ "gitCommit": "a" * 40,
+ "platformDifferences": "none",
+ "artifacts": [
+ {
+ "runtime": "process",
+ "platform": "aarch64-apple-darwin",
+ "filename": name,
+ "sha256": digest,
+ "claimCheck": "unchecked",
+ "claims": {
+ "protocolVersions": ["0.1"],
+ "extension": {"id": "morphir-python", "version": version},
+ "capabilities": {},
+ },
+ }
+ ],
+ }
+ )
+ )
+ return name
+
+
+def test_process_receipt_binds_source_bytes_launch_and_mode(tmp_path: Path) -> None:
+ name = candidate(tmp_path)
+ receipt = process.receipt(tmp_path, "a" * 40, "aarch64-apple-darwin")
+ assert receipt["sourceCommit"] == "a" * 40
+ assert receipt["arguments"] == []
+ assert receipt["explicitArguments"] == ["--stdio"]
+ assert receipt["runtime"] == "process"
+ assert receipt["files"][name] == wasm.sha256((tmp_path / name).read_bytes())
+ assert receipt["executable"] is True
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "bytes",
+ "digest",
+ "source",
+ "platform",
+ "runtime",
+ "claims",
+ "missing",
+ "extra",
+ "mode",
+ ],
+)
+def test_process_receipt_refuses_mismatched_candidates(
+ tmp_path: Path, change: str
+) -> None:
+ name = candidate(tmp_path)
+ descriptor = json.loads((tmp_path / "release.json").read_text())
+ if change == "bytes":
+ (tmp_path / name).write_bytes(b"changed")
+ elif change == "digest":
+ (tmp_path / f"{name}.sha256").write_text("wrong")
+ elif change == "source":
+ descriptor["gitCommit"] = "b" * 40
+ elif change == "platform":
+ descriptor["artifacts"][0]["platform"] = "x86_64-pc-windows-msvc"
+ elif change == "runtime":
+ descriptor["artifacts"][0]["runtime"] = "wasm"
+ elif change == "claims":
+ descriptor["artifacts"][0]["claims"]["extension"]["version"] = "0.0.0"
+ elif change == "missing":
+ (tmp_path / name).unlink()
+ elif change == "extra":
+ (tmp_path / "unqualified").touch()
+ elif change == "mode":
+ if os.name == "nt":
+ pytest.skip("POSIX executable mode")
+ (tmp_path / name).chmod(0o600)
+ (tmp_path / "release.json").write_text(json.dumps(descriptor))
+ with pytest.raises(wasm.BundleError):
+ process.receipt(tmp_path, "a" * 40, "aarch64-apple-darwin")
+
+
+def test_process_platforms_are_explicit_and_windows_uses_exe() -> None:
+ assert len(process.PLATFORMS) == 5
+ assert process.artifact_name("0.4.2", "x86_64-pc-windows-msvc").endswith(".exe")
+ with pytest.raises(wasm.BundleError, match="unsupported process platform"):
+ process.artifact_name("0.4.2", "riscv64-unknown-linux-gnu")
From 6d8f2915d71a6126e48b588186eb62a668c687de Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:22:40 -0500
Subject: [PATCH 07/26] test(extension): verify isolated process sessions
---
.../morphir-python-extension/tests/session.rs | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
diff --git a/rust/crates/morphir-python-extension/tests/session.rs b/rust/crates/morphir-python-extension/tests/session.rs
index 10e59615..e2cf3583 100644
--- a/rust/crates/morphir-python-extension/tests/session.rs
+++ b/rust/crates/morphir-python-extension/tests/session.rs
@@ -112,3 +112,96 @@ fn standalone(arguments: &[&str]) {
assert_eq!(replies.len(), 1);
assert_eq!(replies[0]["result"]["extension"]["id"], "morphir-python");
}
+
+#[test]
+fn invalid_request_ids_do_not_become_notifications_or_poison_the_session() {
+ let mut messages = Vec::new();
+ for id in [
+ Value::Null,
+ json!(true),
+ json!([]),
+ json!({}),
+ json!(-1),
+ json!(1.5),
+ ] {
+ messages.push(json!({"jsonrpc":"2.0","id":id,"method":"morphir.ping"}));
+ }
+ messages.push(json!({"jsonrpc":"2.0","id":77,"method":"morphir.ping","params":{}}));
+ let mut output = Vec::new();
+ serve(&mut Cursor::new(wire(&messages)), &mut output).unwrap();
+ let replies = responses(output);
+ assert_eq!(replies.len(), 7);
+ for reply in &replies[..6] {
+ assert_eq!(reply["id"], Value::Null);
+ assert_eq!(reply["error"]["code"], -32600);
+ }
+ assert_eq!(replies[6]["id"], 77);
+ assert_eq!(replies[6]["result"]["ok"], true);
+}
+
+#[test]
+fn independent_stream_sessions_cannot_share_initialization_state() {
+ let mut first = Vec::new();
+ serve(&mut Cursor::new(wire(&[initialize()])), &mut first).unwrap();
+ assert_eq!(responses(first)[0]["result"]["protocolVersion"], "0.1");
+ let mut second = Vec::new();
+ serve(
+ &mut Cursor::new(wire(&[
+ json!({"jsonrpc":"2.0","id":2,"method":"morphir.extension.info","params":{}}),
+ ])),
+ &mut second,
+ )
+ .unwrap();
+ assert_eq!(responses(second)[0]["error"]["code"], -32014);
+}
+
+#[test]
+fn truncated_input_exits_with_diagnostics_only_on_stderr() {
+ let mut child = Command::new(env!("CARGO_BIN_EXE_morphir-python-extension"))
+ .env_clear()
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ child
+ .stdin
+ .take()
+ .unwrap()
+ .write_all(b"Content-Length: 10\r\n\r\n{}")
+ .unwrap();
+ let result = child.wait_with_output().unwrap();
+ assert_eq!(result.status.code(), Some(1));
+ assert!(result.stdout.is_empty());
+ assert!(
+ String::from_utf8(result.stderr)
+ .unwrap()
+ .contains("morphir-python-extension:")
+ );
+}
+
+#[test]
+fn description_is_fixed_across_initialization_and_reinitialization_refusal() {
+ let describe = |id| json!({"jsonrpc":"2.0","id":id,"method":"morphir.extension.describe","params":{"protocolVersions":["0.1"]}});
+ let mut second_init = initialize();
+ second_init["id"] = json!(3);
+ second_init["params"]["protocolVersions"] = json!(["0.2.0-draft.99"]);
+ let mut output = Vec::new();
+ serve(
+ &mut Cursor::new(wire(&[
+ describe(0),
+ initialize(),
+ describe(2),
+ second_init,
+ describe(4),
+ ])),
+ &mut output,
+ )
+ .unwrap();
+ let replies = responses(output);
+ assert_eq!(replies.len(), 5);
+ assert_eq!(replies[0]["result"]["protocolVersions"], json!(["0.1"]));
+ assert_eq!(replies[0]["result"], replies[2]["result"]);
+ assert_eq!(replies[2]["result"], replies[4]["result"]);
+ assert_eq!(replies[3]["error"]["code"], -32600);
+}
From 6945b7fcf34d194073bcb5f6a7d3f76e58c75696 Mon Sep 17 00:00:00 2001
From: Damian Reeves <957246+DamianReeves@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:31:54 -0500
Subject: [PATCH 08/26] ci(extension): qualify portable process artifacts
---
.github/workflows/ci.yml | 38 ++++++++++++
.github/workflows/release.yml | 61 ++++++++++++++++++-
.../add-process-mep-extension/design.html | 20 +++++-
3 files changed, 116 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2fcadc99..6954981f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -110,6 +110,11 @@ jobs:
rust-toolchain: '1.98.1'
manylinux: '2_28'
target: x86_64-unknown-linux-gnu
+ # Build the standalone guest in the same glibc 2.28 container. Its
+ # shared compiler objects are then reused by the Python wheel build.
+ before-script-linux: >-
+ cargo build --manifest-path ../../rust/Cargo.toml --locked --release
+ -p morphir-python-extension --target x86_64-unknown-linux-gnu
args: --release --locked --sdist --out ../../dist/native
- name: Audit the Linux baseline and bundled libraries
@@ -130,6 +135,13 @@ jobs:
path: dist/native/
if-no-files-found: error
+ - name: Upload the process executable from the portable build
+ uses: actions/upload-artifact@v6
+ with:
+ name: process-linux-x86_64
+ path: rust/target/x86_64-unknown-linux-gnu/release/morphir-python-extension
+ if-no-files-found: error
+
test:
name: Test
needs: [changes, native-wheel]
@@ -218,9 +230,35 @@ jobs:
run: |
MORPHIR_NATIVE_WHEEL="$(ls dist/native/*.whl)" mise run install
+ - name: Download the portable process executable
+ uses: actions/download-artifact@v7
+ with:
+ name: process-linux-x86_64
+ path: dist/process/
+
- name: Package and qualify the bundle offline
run: mise run extension:bundle
+ - name: Package and qualify the process guest offline
+ run: |
+ mkdir -p .morphir/build/extensions .dev/qualification
+ uv run --no-sync python -m scripts.process_extension \
+ --executable dist/process/morphir-python-extension \
+ --output .morphir/build/extensions/python-process \
+ --commit "$GITHUB_SHA" \
+ --report .dev/qualification/process-x86_64-unknown-linux-gnu.json \
+ --host-check
+
+ - name: Upload the qualified process bundle and receipt
+ uses: actions/upload-artifact@v6
+ with:
+ name: morphir-python-process-x86_64-unknown-linux-gnu
+ path: |
+ .morphir/build/extensions/python-process/
+ .dev/qualification/process-x86_64-unknown-linux-gnu.json
+ include-hidden-files: true
+ if-no-files-found: error
+
- name: Upload the extension bundle
uses: actions/upload-artifact@v6
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a4f72300..d0bf7959 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,7 +34,8 @@ jobs:
if: >-
github.event_name == 'workflow_dispatch' ||
startsWith(github.ref, 'refs/tags/native-v') ||
- startsWith(github.ref, 'refs/tags/python-v')
+ startsWith(github.ref, 'refs/tags/python-v') ||
+ startsWith(github.ref, 'refs/tags/extension/python/v')
strategy:
fail-fast: false
matrix:
@@ -89,6 +90,11 @@ jobs:
rust-toolchain: '1.98.1'
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux || 'off' }}
+ # Process guests share the wheel's Linux baseline and compiled core.
+ before-script-linux: >-
+ ${{ (github.event_name == 'workflow_dispatch' ||
+ startsWith(github.ref, 'refs/tags/extension/python/v')) &&
+ format('cargo build --manifest-path ../../rust/Cargo.toml --locked --release -p morphir-python-extension --target {0}', matrix.target) || '' }}
args: --release --locked --out ../../dist/native
- name: Set up Python 3.14
@@ -159,6 +165,57 @@ jobs:
path: dist/mise-install.json
if-no-files-found: error
+ # Linux x86_64 already produced and qualified this exact guest in qualify.
+ # The other producers use their matching native wheel for API comparison.
+ - name: Build the process executable on macOS and Windows
+ if: >-
+ matrix.manylinux == '' &&
+ (github.event_name == 'workflow_dispatch' ||
+ startsWith(github.ref, 'refs/tags/extension/python/v'))
+ shell: bash
+ env:
+ MACOSX_DEPLOYMENT_TARGET: ${{ matrix.target == 'aarch64-apple-darwin' && '11.0' || '10.12' }}
+ run: >-
+ cargo +1.98.1 build --manifest-path rust/Cargo.toml --locked --release
+ -p morphir-python-extension --target '${{ matrix.target }}'
+
+ - name: Qualify the process bundle against the matching Python API
+ if: >-
+ !matrix.reuse &&
+ (github.event_name == 'workflow_dispatch' ||
+ startsWith(github.ref, 'refs/tags/extension/python/v'))
+ shell: bash
+ run: |
+ uv venv .dev/process-check --python 3.14
+ qualifier=.dev/process-check/bin/python
+ executable=rust/target/${{ matrix.target }}/release/morphir-python-extension
+ if [[ "${{ runner.os }}" == Windows ]]; then
+ qualifier=.dev/process-check/Scripts/python.exe
+ executable="$executable.exe"
+ fi
+ uv pip install --python "$qualifier" dist/pure/*.whl dist/native/*.whl
+ mkdir -p .morphir/build/extensions .dev/qualification
+ "$qualifier" -m scripts.process_extension \
+ --executable "$executable" \
+ --output .morphir/build/extensions/python-process \
+ --commit "$GITHUB_SHA" \
+ --report .dev/qualification/process-${{ matrix.target }}.json \
+ --host-check
+
+ - name: Upload the qualified process bundle and receipt
+ if: >-
+ !matrix.reuse &&
+ (github.event_name == 'workflow_dispatch' ||
+ startsWith(github.ref, 'refs/tags/extension/python/v'))
+ uses: actions/upload-artifact@v6
+ with:
+ name: morphir-python-process-${{ matrix.target }}
+ path: |
+ .morphir/build/extensions/python-process/
+ .dev/qualification/process-${{ matrix.target }}.json
+ include-hidden-files: true
+ if-no-files-found: error
+
- name: Upload the qualified wheel
uses: actions/upload-artifact@v6
with:
@@ -239,7 +296,7 @@ jobs:
extension-candidate:
name: Verify the extension bundle candidate
- needs: qualify
+ needs: [qualify, native-wheels]
if: startsWith(github.ref, 'refs/tags/extension/python/v')
runs-on: ubuntu-latest
steps:
diff --git a/openspec/changes/add-process-mep-extension/design.html b/openspec/changes/add-process-mep-extension/design.html
index 4bed3902..d9d59200 100644
--- a/openspec/changes/add-process-mep-extension/design.html
+++ b/openspec/changes/add-process-mep-extension/design.html
@@ -23,12 +23,30 @@
add-process-mep-extension
OpenSpec change · Bead morphir-5hxy · 2026-10-28 · Applying; published SDK prerequisite in progress
See proposal.md for motivation and the two spec deltas for requirements. PythonExtension already implements native Rust frontend/backend traits. The published SDK 0.3.0 exposes NativeExtension::frontend_backend, open_protocol, model methods and admission helpers. Its wire request type still uses a numeric u64 ID, while the canonical contract admits numeric and string IDs; adopting helpers alone is not full draft conformance. The Python implementation does not opt into model transport and its manifest declares MEP 0.1 only. finos/morphir c552dac801f23f2b2b566d29e08780723498ce36 defines exact MEP 0.2.0-draft.1 and model envelope 0.1.0-draft.1. These versions are independent of SDK 0.3.0, compiler 0.4.2, claims format and IR version.
Goals and non-goals
Add a native Rust process guest and current-draft model adapters while retaining WASM and legacy behavior. Keep language semantics shared. Initial model tuples use morphir-ir/morphir-json and supported Library IR 3.0.0 and draft 4.0.0. Rich Ion profiles, unsupported distribution kinds, additional Python syntax, cancellation/progress and performance claims are not introduced. A Python CLI stdio wrapper can follow separately; the selected CLI installation remains mise/pipx.
Decisions
RNT-1. Add a thin native Rust guest
Add a process guest crate/entry point, depending on the maintained compiler and published SDK, with no PyO3 or CPython requirement. The proposed command is:
morphir-python-extension --stdio
No-argument launches also select stdio, matching the canonical native bundler and installed process launcher. Reuse the SDK's session endpoint and dispatch. Keep model adaptation in the shared PythonExtension so native, WASM and process paths agree. Put stdio effects in the guest layer. A Python CLI wrapper would reuse the engine but retain an interpreter/launcher dependency; a frozen Python CLI would bundle an unnecessary runtime for this guest.
Boundary
Current release
Proposed change
MEP
Legacy 0.1
Retain 0.1 and add exact 0.2.0-draft.1
Model envelope
Not advertised
0.1.0-draft.1, admitted JSON profile
Extension runtime
WASM
WASM and native process
SDK
0.3.0, numeric-only wire IDs
Qualified published pin covering the canonical wire contract
Semantic IR
Supported v3 and draft v4 Library models
Same semantic scope, explicit admitted tuples
SDK-1. Resolve concrete SDK wire gaps before claiming conformance
Pin a published SDK that supports canonical numeric and string request IDs and the required draft behavior. SDK 0.3.0 currently has numeric-only ExtensionRequest.id, so link and qualify an upstream SDK update if no suitable release exists. Do not vendor a sibling checkout or silently coerce wire IDs. Both WASM and process must pass the same ID-preservation vectors before the new draft is advertised. The concrete prerequisite is request ID support, issue 316, tracked as morphir-4wrx. SDK 0.4.0 is merged in PR 317 as d51daa8aea7ae09ae295ff254d72b27770c64104. All CI and the committed-source publication dry run pass. Crate-tag publication approval is pending; the SDK is not yet published. Related upstream work is MEP protocol type generation, issue 241; its existence is not evidence that the wire gap is fixed.
MEP-1. Negotiate the new draft explicitly
Retain legacy 0.1 operations and exact 0.2.0-draft.1 offers. Opt in only after supported model methods and executable conformance pass. Reuse SDK model admission and session rules; method constants alone are not support. Never enable model methods under legacy negotiation. Keep optional flags false until implemented.
MOD-1. Start with the explicit JSON model profile
Implement compile_model/generate_model over the existing compile/generate core. Admit complete operation/profile/codec/envelope/IR tuples and their bilateral limits. The initial profile is morphir-ir/morphir-json with envelope 0.1.0-draft.1. Use the canonical base64 payload, exact byte length/hash and critical-field rules. After SDK admission, materialize JSON through published morphir-json 0.2.0 with finite depth/node/string budgets, preserving numeric lexemes and literal private-looking keys. Bound outgoing JSON before extending payload buffers. Reject unadvertised Ion profiles, guarantees and unsupported semantic forms. This achieves bounded new-draft support without implying rich Ion preservation or silently converting rich input.
IO-1. Treat framing and lifetime as a bounded server boundary
Use Content-Length JSON-RPC framing, byte-based lengths, an 8 KiB header ceiling and an initial 16 MiB complete-body ceiling. Advertised decoded/encoded model limits must fit those outer bounds, including envelope overhead. Enforce local bounds even if the peer advertises more. Check partial input, duplicate lengths, integer overflow, invalid UTF-8/JSON and EOF before unbounded allocation. Reserve stdout for frames and flush responses. Follow initialization, capability locks, shutdown and exit; do not respond to notifications. Host supervision owns deadlines and descendant cleanup.
RLS-1. Qualify dual-runtime distribution
Retain the portable WASM guest and publish native process executables for the advertised targets, with --stdio locked as an argument. Use a canonical host-readable descriptor with runtime/platform/mode/digest/claims bound per artifact. Validate the staged descriptor with pinned host tooling; do not assume that either the existing WASM-only packager or native-only bundle command can package a mixed release unchanged. Preserve historical assets. Release exact native/CLI package pairs if shared compiler changes affect them.
HST-1. Qualify hosts independently
The Rust host provides ProcessLaunch/ProcessChannel and verified-byte staging. It clears the child environment, favoring a directly executable Rust guest. Qualify a pinned conforming Rust host and test each documented MoonBit route independently. Use a pinned source build when a released host lacks the new draft, and state that prerequisite. If a route needs upstream work, record that dependency and label it unsupported until it passes. Existing MoonBit process wrappers demonstrate framing, not Python extension activation.
Risks and trade-offs
Risk
Mitigation
Draft, SDK and runtime support are confused
Exact independent version table, live negotiation and candidate-bound conformance before claims.
Model content or guarantees are lost
Admit only explicit JSON tuples initially. Refuse unsupported profiles, critical fields and guarantees; no silent fallback.
Malformed input exhausts resources
Bound headers, wire bodies and decoded bytes before allocation; test boundary and partial cases.
Process mode is mistaken for a sandbox
Document ordinary OS privileges and the host's explicit supervision, distinct from WASM isolation.
Packaging or host support lags
Pin descriptor readers and host builds, qualify installed bytes, record upstream blockers and make only verified support claims.
Native execution is assumed faster
Measure startup and representative compile/generate workloads before making a performance claim.
The pinned executable MCK currently accepts only moonbit-model-bool-library-v1, with mandatory rich Ion semantics and MoonBit artifact inventories. Python cannot qualify against that profile. Approval is pending for a separate upstream Python JSON profile; no existing MoonBit check will be relaxed and no unit-test result substitutes for executable qualification.
Migration and rollout
Add negotiation and bounded JSON model adapters with tests and compatibility documentation.
Add the Rust process server with framing and lifecycle tests; preserve the existing WASM path.
Build and qualify runtime artifacts and descriptors with matching claims and source receipts.
Run pinned MEP guest conformance and real host installation/operation cases, including legacy hosts and explicit refusals.
After maintainer approval, publish a new extension version and document runtime selection. Existing users can stay on the prior WASM release; never replace its bytes.
Implementation is underway on the process delivery branch. Protocol discovery remains legacy-only until the published SDK prerequisite and candidate-bound conformance pass. All five task groups remain incomplete.
+
Build and release assessment
+
The personal build-master skill applies because this repository has no local copy. Process delivery belongs to the existing compiler artifact producers. Build the Linux x86_64 executable in the wheel producer's manylinux 2.28 container and qualify it beside WASM in the existing bundle job. The release/manual five-platform wheel matrix owns the remaining native builds, using its matching wheel for installed Python API comparison. Reuse the already qualified Linux x86_64 process artifact. No new scheduled workflow or separate platform matrix is introduced.
+
Risk and deadline
Execution and owner
Evidence and failure response
+
Incorrect shared compiler or process bytes, before merge
Existing Linux PR bundle category, repository maintainers
Canonical bundle probe, isolated installation, deleted source repository, offline v3/v4 comparison with the exact native wheel; failure blocks the selected aggregate.
+
Platform-specific executable or wheel failure, before promotion
Existing five-platform release matrix, explicit manual qualification and extension tag events
Native target and compiler/toolchain identity, exact digest/claims/source receipts and installed host comparisons. Missing, failed or skipped selected producers block extension publication through its needs graph.
+
New protocol claim without independent proof, before promotion
The Python MCK profile and published SDK 0.4.0 remain pending. Platform packaging does not establish draft qualification. Promotion assurance must consume and recheck the candidate-bound executable MCK reports before the change is complete.
+
+
The existing Maturin action provides before-script-linux; its pinned Rust toolchain and manylinux selection apply to that hook. Linux builds reuse shared Cargo output between process and Python artifacts. macOS and Windows use the same native target and deployment configuration as their wheel producer. The canonical Morphir CLI owns descriptor generation and live probing; the project helper adds the canonical source commit member and writes a separate byte-bound receipt. WASM and process retain separate descriptors because canonical readers refuse mixed-runtime bundles.
+
Criterion
Weight
Current assessment
+
Risk and coverage fit
25
3: focused local refusal tests and installed macOS legacy evidence pass; matrix results pending.
+
Feedback and placement
20
3: one Linux PR path; broader work stays manual/release. Added CI latency not yet measured.
+
Release evidence and trust
20
U: current-draft MCK prerequisite and final promotion assembly pending.
+
Execution efficiency
15
U: shared producer/output design inspected, actual CI reuse and elapsed time pending measurement.
+
Ownership and structure
10
4: existing bundle and native-wheel categories, explicit needs, repository maintainers own failures.
+
Operability and recovery
10
2: candidate refusal tests pass; platform rerun/transfer and final promotion refusals pending.
+
+
Total withheld while 35 weighted points remain unknown. This is a provisional workflow assessment, not release eligibility or measured performance. Local full Python/Rust checks and actionlint validate source changes; real matrix runs must supply platform and timing evidence. No native speed claim follows from this build design.
+
Repository evidence and references
rust/crates/morphir-python-binding/src/lib.rs: shared PythonExtension traits and truthful current capabilities.
rust/crates/morphir-python-native/src/lib.rs: current private two-function JSON bridge; it is not a full MEP transport.
Published morphir-extension-sdk 0.3.0: native.rs, traits.rs and model admission helpers.
finos/morphir-rust 26d5e707e5ec94d16421db6380edbdc865d85c04: process launch/channel and stdio frontend fixture. Fixture framing must not be copied as a hardened server.
finos/morphir-moonbit bd3adc52e6b8f6368fdaee50f76535799732f8d6: portable MEP embedding wrapper. No claim of Python host compatibility follows.