Generated artifact
mvmforge compile produces a self-contained artifact directory that mvm can boot. The same directory comes out whether the workload was authored via the decorator surface (@app(...) with command entrypoint) or the runtime surface (@app(..., entrypoint=mv.entrypoint_function(...)) / mv.func({...}, fn)). Both register the same canonical IR; the IR is what feeds the templates.
The artifact
artifact/ flake.nix # rendered Nix template launch.json # canonical-JSON launch plan src/ # bundled user source tree nix/factories/ # bundled per-language Nix factories mkPythonFunctionService.nix # included only for function workloads mkNodeFunctionService.nixflake.nix and launch.json are byte-reproducible: identical IR + identical pin = identical bytes (per ADR-0007 §6).
For command-entrypoint workloads the factory directory is not bundled (the existing mkGuest service path is used directly).
Two surfaces, one IR, one template
Decorator-style and runtime-style authoring aren’t separate pipelines — they’re two ways to register the same IR per ADR-0010 §1:
| Surface | Python | TypeScript | What lands in IR |
|---|---|---|---|
| Long-form decorator (command) | @mv.app(..., entrypoint=mv.entrypoint(...)) | mv.app({ ..., entrypoint: mv.entrypoint(...) }) | Entrypoint::Command |
| Long-form runtime (function) | @mv.app(..., entrypoint=mv.entrypoint_function(...)) | mv.app({ ..., entrypoint: mv.entrypointFunction(...) }) | Entrypoint::Function |
| Short-form runtime | (future: @mv.func(...)) | mv.func({...}, fn) | Entrypoint::Function |
In dev, the runtime SDK call sites also give you a RemoteFunction handle for f.remote(...). In prod (mvmforge emit), only the registration side effect runs — Layer-3 calls raise EmittingContextError per ADR-0010 §2. The IR is identical either way.
Command-entrypoint output
Given:
mv.workload(id="hello")@mv.app(name="hello", source=mv.local_path("src"), image=mv.nix_packages(["python312"]), entrypoint=mv.entrypoint(command=["python", "-m", "hello"]), resources=mv.resources(cpu_cores=1, memory_mb=256, rootfs_size_mb=512))def hello(): passmvmforge compile produces 3 entries:
launch.json
{ "artifact_format_version": "1.0", "entrypoint": { "command": ["python", "-m", "hello"], "env": {}, "kind": "command", "working_dir": "/app" }, "image": { "kind": "nix_packages", "packages": ["python312"] }, "ir_hash": "…", "ir_schema_version": "0.1", "workload_id": "hello", "...": "..."}flake.nix (excerpt)
default = mvm.lib.${system}.mkGuest { name = launch.workload_id; hostname = launch.workload_id; packages = [ appPkg ] ++ imagePackages; services.${launch.workload_id} = { command = buildEntrypoint pkgs; # exec's launch.entrypoint.command at boot preStart = buildPreStart pkgs appPkg; # symlinks appPkg to working_dir env = mergedEnv; };};What to notice:
inputs.mvm.urlis the pinned mvm flake (override withMVMFORGE_MVM_FLAKE_URLfor local-checkout dev).inputs.nixpkgs.follows = "mvm/nixpkgs"— mvm chooses the nixpkgs revision.mvmforge.metadatacarriesir_hash, schema/format/toolchain versions (stable per ADR-0006 §3).
Function-entrypoint output
Given:
mv.workload(id="adder")@mv.app(name="adder", source=mv.local_path("."), image=mv.nix_packages(["python312"]), entrypoint=mv.entrypoint_function(module="adder", function="add"), resources=mv.resources(cpu_cores=1, memory_mb=256, rootfs_size_mb=512), dependencies=mv.no_deps())def add(a, b): return a + bThe same TypeScript workload is mv.func({...}, fn) with language: "python" set explicitly (the TS SDK defaults to language: "node"). Both produce byte-identical IR.
launch.json (function-entrypoint shape)
{ "entrypoint": { "kind": "function", "language": "python", "module": "adder", "function": "add", "format": "json", "working_dir": "/app", "env": {} }, "...": "..."}language is an open string with a mvmforge-side allowlist (per ADR-0010 §4) — adding a language is a one-PR change in mvmforge with no IR schema bump. Today’s allowlist: python, node. The host validator rejects unknown values with E_UNSUPPORTED_LANGUAGE.
flake.nix (function-entrypoint dispatch)
The flake imports the per-language factory bundled at ./nix/factories/:
let factoryArgs = { inherit pkgs workloadId module function format sourcePath; }; factoryService = if launch.entrypoint.language == "python" then import ./nix/factories/mkPythonFunctionService.nix factoryArgs else if launch.entrypoint.language == "node" then import ./nix/factories/mkNodeFunctionService.nix factoryArgs else throw "mvmforge: unsupported language '${launch.entrypoint.language}' …";in { default = mvm.lib.${system}.mkGuest { name = launch.workload_id; packages = factoryService.servicePackages ++ imagePackages; extraFiles = factoryService.extraFiles; # /etc/mvm/runtime.json + /usr/lib/mvm/wrappers/runner services.${launch.workload_id} = factoryService.service; };}What the factory emits:
extraFiles— a{ path = { content; mode; }; }map. mvm’smkGuestbakes these into the rootfs at build time. The factory writes/etc/mvm/runtime.json(the wrapper config) and/usr/lib/mvm/wrappers/runner(the audited wrapper executable).servicePackages— the language interpreter (e.g.python3,nodejs_22) added to the rootfs PATH.service— amkGuest-compatible service entry that mvm dispatches permvmctl invoke.
runtime.json (baked into the rootfs)
{ "language": "python", "module": "adder", "function": "add", "format": "json", "source_path": "/app"}The wrapper (mvmforge-runtime Rust binary, or its in-language v1 mirror) reads this on startup and dispatches module:function per call.
Where the bytes come from
your @mv.app(...) / mv.func({...}, fn) call │ ▼Workload IR (canonical JSON, RFC 8785) │ mvmforge compile (no nix invocation) ▼artifact/ flake.nix ← rendered from a single Rust template (crates/mvmforge/src/flake.rs) launch.json ← serde-serialized launch plan (crates/mvmforge/src/launch.rs) src/ ← copied + reachability-pruned (crates/mvmforge/src/{source,reachability}.rs) nix/factories/← include_str!()-bundled per-language factories (function entrypoints only) │ mvmctl up --flake <artifact> ▼nix build → mvm boots the VMmvmforge compile is fully offline — it doesn’t invoke nix, fetch from the network, or shell out to mvm. Two compiles over the same IR + same pin produce byte-identical artifacts.
Adding a language
Per ADR-0010 §4, adding a third language (e.g. Rust, Go, Ruby) is a one-PR change in mvmforge:
- Append to
mvmforge_ir::SUPPORTED_LANGUAGES(the validator allowlist). - Add
nix/factories/mk<Lang>FunctionService.nixthat produces the{ extraFiles, servicePackages, service }triple consumed bymkGuest. - Add a
Languagevariant incrates/mvmforge-runtime/src/config.rs(interpreter + dispatch filename). - Update
crates/mvmforge/src/flake.rsdispatch to import the new factory. - Add a corpus entry under
tests/corpus/function-app-<lang>/exercising the language end-to-end.
No mvm change. No IR schema bump.
Related
- Workload IR — the v0 field set + validation rules.
- Source bundling — what gets baked into
src/. - Local mvm validation —
just real-mvm-checkandjust real-mvm-up. - Wrapper Security & Threat Model — trust boundaries for the function-entrypoint runner.