In this section: How UMA Works

WASM Microservices Tutorial: Rust

Build a portable WebAssembly microservice in Rust using the UMA pattern. This tutorial walks through the Feature Flag Evaluator from Chapter 4: pure service core, WASI adapter, contract definition, and runtime validation.

What you will build

This tutorial covers building wasm microservices in Rust from first principles. The Feature Flag Evaluator is a service that decides whether a named flag is enabled for a given user context. It reads a flag definition and a context object, evaluates rules in first-match-wins order, and returns a deterministic decision: which flag, whether it is enabled, and which rule matched.

The evaluator core is written in pure Rust with no I/O dependencies. It compiles to a single .wasm binary. That binary runs via wasmtime on the command line, in a browser via a JavaScript WASI polyfill, and in a Node-based edge or cloud adapter. same binary, same output, across every host. The core never changes between deployments. Only the adapter changes.

This is the UMA portability claim made concrete: business logic that is deterministic by construction, not by convention. The rollout hash, the rule language, and the default fallback all live inside the portable core. No adapter can deviate from them.

Prerequisites

  • Rust toolchain via rustup. Verify with cargo --version.
  • The wasm32-wasip1 compilation target: rustup target add wasm32-wasip1
  • wasmtime on your PATH. On macOS: brew install wasmtime. On Linux: download a release from the Wasmtime releases page and add it to your PATH. Verify with wasmtime --version.
  • jq on your PATH (used by the lab scripts for output comparison).
  • Node.js 20 or newer and npm for the TypeScript parity path. Verify with node --version.

The repo also ships a pinned wasmtime copy at .bin/ used by CI. If wasmtime is not on your global PATH, the scripts will fall back to that copy.

Project structure

The chapter directory follows the canonical UMA service anatomy. Every directory has a single, non-negotiable responsibility:

  • core/: pure business logic. A Rust library crate (ff_eval_core) with no dependencies beyond the standard library. This is the only code that gets compiled to WASM. It has no I/O, no network access, no filesystem access. Deterministic by construction.
  • contracts/: typed boundary. JSON Schema files (input.schema.json, output.schema.json) that define what the evaluator accepts and what it promises to return. The contract is the stable surface. everything else can change.
  • adapters/: runtime translation. Thin host-specific wrappers that know how to feed input to the WASM module and read its output. Three adapters are provided: browser/ (JavaScript WASI polyfill), edge/ (Node-based Cloudflare-style worker), cloud/ (Node-based Lambda-style handler). Each adapter is less than 100 lines. None owns any rule semantics.
  • wasi-app/: the WASI CLI host. A second Rust crate (ff_eval_wasi_app) that reads JSON from stdin, calls the core evaluation function, and writes JSON to stdout. This is the binary that compiles to ff_eval_wasi_app.wasm.

The separation matters because it is the architectural proof. If rule semantics ever leaked into an adapter, the TypeScript parity tests would diverge. The contract enforces the boundary. the directory structure makes the boundary visible.

The service core

All evaluation logic lives in core/src/lib.rs. The rule language supports ==, !=, <, <=, >, >=, in, &&, and ||. Identifiers resolve from the context object. String and numeric literals are supported. A built-in rollout(p) function performs a deterministic FNV-1a hash of flag.key + ":" + context.userId and returns true if the hash normalized to [0, 1) is less than p.

Rollout decisions are sticky: the same flag key and user ID always produce the same bucket. This is not a runtime property: it is baked into the hash algorithm in the core. No adapter can change it. No deployment configuration can change it. That stickiness guarantee is what makes the rollout trustworthy at scale.

The core has no Cargo.toml dependencies beyond the standard library. No serde, no tokio, no runtime. This is deliberate. Every dependency added to the core is a dependency that must compile to WASM, a potential source of non-determinism, and a reason for binary size to grow. The WASI app crate is where serde and serde_json live. on the boundary, not in the logic.

Building and running

The validated Chapter 4 reader path is two commands:

# Run core unit tests (native host, no WASM runtime needed)
cargo test --locked -p ff_eval_core

# Full smoke path: build WASM, run Rust tests, run TS parity tests,
# execute JSON vector suite, compare outputs across all four labs
./scripts/smoke_flag_labs.sh

# Build the WASM binary directly
cargo build --release --target wasm32-wasip1 -p ff_eval_wasi_app
# Output: target/wasm32-wasip1/release/ff_eval_wasi_app.wasm
# Install dependencies for the TypeScript parity implementation
cd ts && npm install

# Run the TypeScript unit tests
npm test

# Run a specific lab against the TypeScript implementation
cd .. && ./scripts/run_lab.sh --impl ts lab2-rollout-match

# Prove behavioral equivalence across all labs
./scripts/compare_impls.sh

The first Rust command compiles the core library and runs its unit test suite. Tests cover equality comparisons, rollout bucketing, the in operator, numeric comparisons, logical operators, and the default fallback. All tests run on the native host. no WASM runtime required at this step.

The smoke script is the full acceptance path. It builds the WASI evaluator (cargo build --release --target wasm32-wasip1 -p ff_eval_wasi_app), runs the Rust unit tests, installs the TypeScript parity implementation dependencies, runs the TypeScript parity tests, executes the JSON vector suite, and compares Rust and TypeScript outputs across all four guided labs. If the smoke gate passes, the chapter is verified end-to-end.

Running the labs

Four labs exercise progressively richer evaluation scenarios. Run them in order:

./scripts/run_lab.sh lab1-country-match
./scripts/run_lab.sh lab2-rollout-match
./scripts/run_lab.sh lab3-default-fallback
./scripts/run_lab.sh lab4-rule-language
  • lab1-country-match: a direct equality rule: country == 'CA'. First-match-wins returns rule 0. The simplest possible evaluation path.
  • lab2-rollout-match: the rollout(0.20) function. The country rule misses. the rollout rule hits for this specific user ID. Demonstrates sticky bucketing.
  • lab3-default-fallback: no rule matches. The evaluator returns the flag's default value. matchedRule is null.
  • lab4-rule-language: demonstrates the in operator, numeric comparison, and logical && and || in a single flag definition.

Every lab produces the same output whether run via the Rust WASI binary, the TypeScript implementation, or fed directly through wasmtime. That invariant is what the labs are designed to surface. not the output itself, but the fact that the output is identical regardless of host.

To see the available labs: ./scripts/list_labs.sh

The TypeScript parity path

A parallel TypeScript implementation lives in the ts/ directory. It implements the same contract, the same rule language semantics, and the same FNV-1a rollout hash. It is maintained as a parity implementation, not as a reference. The Rust WASI binary is the validated default path.

To run a lab against the TypeScript implementation:

./scripts/run_lab.sh --impl ts lab2-rollout-match

To prove behavioral equivalence across all labs:

./scripts/compare_impls.sh

This script runs every lab through both implementations and diffs the outputs. If they diverge, the parity contract is broken and the diff shows exactly where. If they match, you have a cross-language proof that the contract is the behavior. not the implementation.

This is the portability proof. Two implementations in different languages, compiled independently, producing bit-for-bit identical outputs because the contract defines the semantics and neither implementation owns them.

What this demonstrates

The WASM binary compiled from core/ and wasi-app/ runs identically in three contexts without recompilation:

  • wasmtime CLI: pipe JSON to stdin, read JSON from stdout. The host is a native process. Full WASI 0.1 capability surface.
  • Browser: the adapters/browser/ff.js polyfill provides a minimal WASI implementation in JavaScript. The same .wasm file fetched over HTTP, stdin and stdout wired through JavaScript buffers.
  • Edge or cloud: Node's built-in wasi module instantiates the module in a Cloudflare Worker-style or Lambda-style handler. The adapter is the only code that knows it is running in Node.

In every case the core is identical. The rule evaluation, the rollout hash, and the output schema do not change. What changes is the thin adapter layer: how input arrives, how output is returned, and which WASI capabilities the host provides.

This is the architectural argument for UMA in practice. The durable behavior (the logic worth maintaining, testing, and versioning) lives in a single artifact. The runtime context is a deployment detail. Changing where the service runs does not require changing what the service does.

Rust core

A library crate with no I/O dependencies. Implements the rule language, the FNV-1a rollout hash, and the first-match-wins evaluation loop. Compiles to WASM. Has no knowledge of stdin, stdout, HTTP, or any host capability. Tested on the native host via cargo test.

WASM host

Any process that can run a WASI module: wasmtime on the command line, Node's built-in wasi module in an edge worker, a JavaScript WASI polyfill in a browser. The host provides the I/O surface (stdin, stdout, clocks) and nothing else. It does not interpret flag rules. It does not own rollout semantics.

Source code and further reading

The complete Chapter 4 source is in the UMA code examples repository under chapter-04-feature-flag-evaluator/. For the architectural context behind the WASM execution model and the WASI system interface, see the WebAssembly Microservices Architecture page.