AJS Native VM (Rust → wasm): the planned 1.0 VM

This is a design note, not a guide: it records the plan for a second AJS VM, written in Rust and compiled to WebAssembly, that is meant to replace the JavaScript VM on the untrusted path for 1.0. Nothing described here ships today.

"The untrusted path" means every place the VM runs code its host did not write: vm.run on a guest AST (tjs-lang/vm and tjs-lang/vm-ast), Eval and SafeFunction (tjs-lang/eval, the safe replacements for eval and new Function), on the client and on the server.

What this means for you today

If you embed the VM, four things follow from this plan:

Relationship to tjs-lang/vm-ast

tjs-lang/vm-ast, shipped in 0.14.0, is the first step of this plan, taken for independent reasons. It is the AST-only VM: no parser and no acorn, 137 KB raw against 324 KB for tjs-lang/vm (dist/tjs-vm-ast.js vs dist/tjs-vm.js, unminified, measured 2026-11-06). It already makes "the VM accepts an AST, not source" a supported contract with real consumers. The Rust VM would implement that contract rather than a new one, which removes the riskiest part of a swap: persuading callers to change how they invoke the VM.

Status

Decided: critical path for 1.0, after 0.14 ships and Pattern lands (Pattern is the planned replacement for regex in AJS, a grammar built as data; see Pattern). The JS VM finishes 0.14; the Rust/wasm VM is the next VM workstream, with the JS VM as its conformance reference until it replaces it on the untrusted path.

Why now: in a VM written in JavaScript and running inside JavaScript, every host builtin a guest can reach does work the VM did not do itself: type coercion, structuredClone, JSON.stringify, the host regex engine. Each one has to be found, given a size limit and charged fuel, one at a time; and guest values that are host objects bring prototypes, getters and live references in both directions. Reviews kept finding one more such place, and each fix exposed a variant of itself (most blockers in the thirty-one 0.14.0-rc.2 review rounds were this one class). That is the second of the triggers below, and it has fired. In wasm the VM owns its allocator and its value representation, so "charged before it allocates" and "nothing live crosses" hold for the whole VM at once, and the VM is isolated from ordinary JavaScript.

Expected side benefit: throughput, because metering stops being a check at every one of those places. To be measured in the phase-1 spike against the JS VM, not assumed: capability crossings and string conversion at the boundary cost something the JS VM does not pay.

Why not go native for TJS too? That would mean forking V8 or JavaScriptCore, and it is decided against. TJS works within the JavaScript ecosystem and influences it; it does not replace it. AJS is different: it is the untrusted-code path, and that is where a second implementation earns its keep.

Thesis

Executing the AST natively, with no JavaScript invoked at all, removes whole classes of sandbox risk rather than mitigating them. A Rust VM compiled to wasm can run in the browser, Bun, Node, wasmtime and Postgres, and can serve as the client-side engine for Eval/SafeFunction. So the end state is one VM, not two, unless a JS fallback is kept for strict CSP (see below).

What changes categorically

What merely moves

Known regression: CSP

WebAssembly.instantiate is blocked under a strict script-src unless 'wasm-unsafe-eval' (or 'unsafe-eval') is granted. Eval exists so that you never have to grant 'unsafe-eval'; the native VM needs the narrower 'wasm-unsafe-eval' instead. Whether to document that requirement or keep the JS VM as a fallback for locked-down contexts is an open question.

Architecture sketch

Estimates

Component Rust lines
AST deserializer + validator 1–2k
Value model, arena, per-key heap accounting 2–3k
Interpreter core, expressions, operator semantics 2–4k
Fuel, quotas, depth caps, tracing, suspend/resume 1–2k
Builtins (Array/Object/String/Number/Math/JSON/Set/Schema) 3–5k
Date subset 1–2k
tosijs-schema core 3–6k
Host boundary, membrane, atom registry 1–2k
Total ~15–30k + tests

Reference: QuickJS is ~55k lines of C for full ES2020. AJS forbids most of what makes engines large.

Binary: 150–300 KB gzipped is realistic, roughly 3–7× today's tjs-vm-ast.js. Today, measured 2026-11-06 with gzip -9 on dist/: 43 KB for tjs-vm-ast.js, 63 KB for tjs-eval.js, 98 KB for tjs-vm.js. The floor is ~120 KB with opt-level = "z", panic = "abort", LTO, wasm-opt, regex-lite, a hand-rolled Date subset and an ASCII fast path for Unicode. Careless choices (full regex, chrono, ICU-style tables) land at 500+ KB. Whether the growth is acceptable is for the phase-1 spike to measure; what it buys is the removal of the risk classes above.

Effort: the interpreter core is weeks. Builtins, the schema validator and conformance are the long tail: months, and mostly specification work.

The two-VM hazard

While both VMs exist, semantic divergence is a security bug, not a compatibility bug. For example: a schema that limits a string's length is checked on the client by one VM and enforced on the server by the other. If the two count length differently (UTF-16 units against code points), a value can pass one check and fail the other, so what the client validated is not what the server enforces. Fuel costs, error shapes, monadic error values, heap accounting and membrane behaviour must agree exactly. Once the Rust VM reaches parity it becomes the reference; the JS VM is either held to the conformance suite or retired from the untrusted path.

Constraints on 1.0 (cheap now, expensive later)

These are the only things the initial release must do to keep this option open. Status is as of 0.14.0.

# Constraint Status
1 Do not document JS builtin behaviour as AJS's Partly satisfied
2 Version the AST Done (0.14.0)
3 Keep custom atoms opaque to the guest Satisfied
4 Accumulate golden fixtures Started (0.14.0)
5 Do not promise that a variable may change type Open
6 Refuse JS constructs that cost the VM complexity Open

1. Do not document JS builtin behaviour as AJS behaviour

The language guide promises little about builtins, but it now documents the regex flavour (the VM's linear engine: no backreferences or lookaround, a fixed flag set). That is a conformance obligation the native VM must meet, and a linear Rust engine can. Keep the rest unpromised: anything said about string .length, Date formatting or number-to-string becomes an obligation too.

2. Version the AST

The root carries $ajs, for example {"$ajs":2,"op":"seq",…}. src/vm/ast-version.ts is the single source, and the VM refuses a version it cannot read. The timeline: before 0.14.0 no AST had the field; 0.14.0 introduced it, with "absent means 1" as the rule for older ASTs, and in the same release shipped v2 (bare strings always literal, explicit references, varAssign, block-scoped while). The transpiler writes $ajs: 2; the builder writes an explicit $ajs: 1; the VM reads both by version. Tests check that every producer stamps the field and every boundary refuses an unknown version.

The rule this leaves: never change the AST format without bumping the version. "Absent means 1" is unambiguous only because the field arrived before the format first changed; a format change without a version bump would make an AST's meaning unknowable.

3. Keep custom atoms opaque to the guest

Satisfied structurally. Every effects: 'io' return crosses a membrane that builds its own copy of JSON data plus Date and refuses functions, accessors and every other kind, so nothing can leak a reference that assumes a shared heap. This is a property of the membrane, not a convention, so it cannot rot.

4. Accumulate golden fixtures

src/vm/ast-fixtures/v2.json (source → exact emitted AST → result) and v1.json (hand-written v1 ASTs → the results they must keep producing), checked by src/vm/ast-fixtures.test.ts. They caught, by mutation, an m[i] regression the rest of the suite missed. Every fix to AST semantics should add one.

5. Do not promise that a variable may change type

A direction, not yet a rule. If a variable's type is fixed at its initializer (let n = 0 is a number for its life; a Set of strings holds strings), every method resolves at transpile time and a native VM can store values in slots of known type instead of tagging every value with its type. External data (arguments, capability returns) would need an escape type, json (any JSON value), narrowed by schema validation before use. Cheap to keep open now: nothing documents re-typing a variable, and the guest value domain is already closed data. Expensive later: once programs that re-type variables are persisted, the rule can only arrive as a new AST version. (Card #2788.)

6. Refuse JS constructs that cost the VM complexity or surface

A direction. These are constructs JavaScript linters already ban as bad practice, and every one a VM must implement is conformance surface for the native one: for…in/in (prototype walks), var/hoisting/arguments, accessors and __proto__ in literals, sparse arrays, delete, the comma operator, labels, switch fallthrough. The transpiler, which can see the construct, would refuse each with an error naming what to write instead. (Card #2789.)

Phases

  1. Now (no cost): the constraints above. A short AST specification is to be written as the format stabilises; it does not exist yet.
  2. Spike: Rust interpreter for the core atoms and expressions, no builtins, driven by the golden fixtures. Measure wasm size and suspend/resume ergonomics. Kill or continue.
  3. Parity: builtins, Date subset, regex, tosijs-schema core. Full conformance against the JS VM. Resolve string semantics.
  4. Swap: the Rust VM becomes the Eval/SafeFunction engine on the client and the VM on the server. The JS VM is retired from the untrusted path, or kept strictly as a CSP fallback held to the conformance suite.
  5. Hosts: a wasmtime binary; Postgres via pgrx (the Rust framework for writing Postgres extensions), so logic travels to the data, literally; anything else that appears.

Triggers for making this critical path

The second one fired in 0.14.0-rc.2 (see Status). Kept for the record.

Open questions

Proposal: errors as values instead of try (AST v3, undecided)

Today AJS has try/catch, as the language guide teaches, and nothing here changes that until a decision is made and a new AST version ships.

Inside, the VM is already monadic: once an error is set on the run, the remaining steps are skipped, and try is the one node that clears it. That is exception semantics built without throw. The alternative is two clean classes:

It removes a control construct from the AST and the step machine (no unwinding, no catch scope), makes errors data, and matches TJS's MonadicError. What it needs:

Decision log

The 1.0 critical-path decision, the decision against a native TJS runtime and the errors-as-values proposal are Tonio's, 2026-10-04; constraints 5 and 6 are his, 2026-10-02.