Skip to content

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.nix

flake.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:

SurfacePythonTypeScriptWhat 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(): pass

mvmforge 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.url is the pinned mvm flake (override with MVMFORGE_MVM_FLAKE_URL for local-checkout dev).
  • inputs.nixpkgs.follows = "mvm/nixpkgs" — mvm chooses the nixpkgs revision.
  • mvmforge.metadata carries ir_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 + b

The 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’s mkGuest bakes 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 — a mkGuest-compatible service entry that mvm dispatches per mvmctl 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 VM

mvmforge 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:

  1. Append to mvmforge_ir::SUPPORTED_LANGUAGES (the validator allowlist).
  2. Add nix/factories/mk<Lang>FunctionService.nix that produces the { extraFiles, servicePackages, service } triple consumed by mkGuest.
  3. Add a Language variant in crates/mvmforge-runtime/src/config.rs (interpreter + dispatch filename).
  4. Update crates/mvmforge/src/flake.rs dispatch to import the new factory.
  5. Add a corpus entry under tests/corpus/function-app-<lang>/ exercising the language end-to-end.

No mvm change. No IR schema bump.