AJS Native VM (Rust → wasm) — the 1.0 VM
Status (Tonio, 2026-10-04): DECIDED, critical path for 1.0, after 0.14 ships and Pattern lands.
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: the "structural to JS-in-JS" trigger below has fired. Most 0.14.0-rc.2 blockers
(thirty-one review rounds) were one class: guest work done by HOST builtins (coercion,
structuredClone, JSON.stringify, the host regex engine) that the VM had to find, bound and
charge door by door, and guest values that are host objects (prototypes, getters, live references
in both directions). Each round closed a door; the next found a branch of the fix. 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 cleanly isolated from ordinary
JavaScript. Expected side benefit: throughput, because metering stops being a guard at every door
(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).
Decided against: a native TJS runtime (forking V8/JSC). TJS works within the JS 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 Eval/SafeFunction engine — so the end
state is one VM, not two.
What changes categorically
- No JS object model in the guest. Guest values live in an arena the host never shares: no
prototype chain, no getters, no
__proto__/constructor/prototypeblocklist. The membrane becomes byte-copying by construction instead of descriptor-walking. - Parser leaves the trust path. The VM accepts only ASTs. The 0.13.4 class of bug (transpile-time execution of untrusted source) cannot recur; attack surface = AST validator + interpreter.
- ReDoS is gone as a category with a linear-time regex engine (Rust
regex/regex-lite), replacing pattern-rejection heuristics. - JS-engine JIT bugs (currently declared out of scope) no longer apply to guest execution.
What merely moves
- Capabilities still cross into a JS host; the boundary shrinks to a byte buffer but remains.
- Timing side channels: still shared-process. Only process isolation addresses these.
- Memory safety is the trade to watch. JS gave it for free. The VM must be Rust with
unsafeforbidden, compiled to wasm so the wasm sandbox catches what the Rust misses. C/C++ would swap JIT bugs for buffer overflows — worse.
Known regression: CSP
WebAssembly.instantiate is blocked under strict script-src unless 'wasm-unsafe-eval' (or
'unsafe-eval') is granted. This is a milder version of the problem Eval exists to avoid.
Decide deliberately: either document the requirement, or keep the JS VM as a fallback for
locked-down contexts (which is two VMs again, with everything that implies below).
Architecture sketch
- Input: versioned JSON AST only. No source. Validation before execution.
- Execution: resumable step machine. On a capability call the VM returns
{ suspended, atom, args }; the host awaits and callsresume. No JSPI/asyncify dependence, identical behaviour on every host, and a suspended VM is serializable — checkpoint, migrate, replay. Fits the flight-recorder design. - Fuel / quotas / heap ceiling / depth cap / trace: ported as-is; semantics must match the JS VM exactly (see conformance).
- Atoms: core atoms remain host capabilities behind schemas. Custom atoms are opaque to the guest — nothing may assume a shared heap.
- tosijs-schema: atom I/O validation moves into Rust. Schemas embed AJS predicates, so the VM hosts the validator which hosts the VM. Natural in Rust, but it means a Rust implementation of tosijs-schema's core comes along. Scope this before starting; it is the largest hidden chunk.
- String semantics: with one VM we define them. UTF-16
.lengthquirks need not survive.
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 cap, 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 realistic (today: 32 KB eval, 66 KB VM). Floor ~120 KB with
opt-level = "z", panic = "abort", LTO, wasm-opt, regex-lite, hand-rolled Date subset,
ASCII-fast-path Unicode. Careless choices (full regex, chrono, ICU-style tables) land at 500+.
Effort: interpreter core is weeks. Builtins, 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: a predicate accepted by one and enforced by the other is a hole. 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.
Audited 2026-09-19 against the current tree — two are already satisfied, two are not, and the ordering of that matters more than the list:
| # | constraint | status |
|---|---|---|
| 1 | Do not document JS builtin behaviour as AJS behaviour | largely satisfied. DOCS-AJS.md promises almost nothing about builtins — the only .length is inside an example, and regex is described by intent ("suspicious patterns are rejected") rather than by flavour. Keep it that way; anything promised about .length, Date formatting, regex flavour or number-to-string becomes a conformance obligation. |
| 2 | Version the AST | ✅ done 0.14.0. Root is {"$ajs":1,"op":"seq",…}; src/vm/ast-version.ts is the single source and the VM refuses a version it cannot read. Enforced at both ends: every producer stamps (ast-version-producers.test.ts — the builder was found unstamped, which would have kept the unversioned population growing) and every boundary refuses (ast-version-boundaries.test.ts). See the correction below on when this would have become expensive. |
| 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 (0.14.0; it used structuredClone until rc.2 round 39) and rejects functions, accessors and every other kind, so nothing can leak a reference that assumes a shared heap. This is a membrane property, not a convention, so it cannot rot. |
| 4 | Accumulate golden fixtures | ✅ started 2026-10-01 with v2: 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, the m[i] regression the rest of the suite missed. Every #52-class fix should add one. |
| 5 | Do not promise that a variable may change type | open — a direction, not yet a rule (Tonio, 2026-10-02; card #2788). A variable fixed in type at its initializer (let n = 0 is a number for its life; a Set of strings holds strings) resolves every method at TRANSPILE time and gives a native VM typed slots instead of tagged values. It needs a json escape type for external data (arguments, capability returns), narrowed by schema validation. Cheap to keep open now: nothing documents re-typing a variable, and the guest value domain is already closed data (round 13). Expensive later: once programs that re-type variables are persisted, the rule can only be a new AST version. |
| 6 | Ban lint-banned JS that costs the VM complexity or surface | open — a direction (Tonio, 2026-10-02; card #2789). Every construct 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. Refuse each with an instructive error naming what to write instead, at the transpiler where it can see the construct and in the VM where it cannot. |
When #2 actually becomes expensive — corrected 2026-09-19. This note first argued that a retrofit "can only say absent means 1, which is exactly the ambiguity a version field exists to prevent". That was wrong, and the correction matters because it relocates the deadline.
"Absent means 1" is a total, unambiguous rule. It maps every AST to exactly one version and is no worse than an explicit field. Adding the field late is perfectly sound — provided the format has not changed in the meantime.
The real hazard is narrow: ship a v2 format without having introduced the field, and ASTs written under v2 also lack it. Only then is absent genuinely ambiguous — v1 or v2, unknowable — and only then is it permanent.
So the window is before the format first changes, not before the first AST is stored, and
$ajs: 1 landed comfortably inside it (0.14.0). Cheap insurance bought early, not a catastrophe
narrowly averted. The obligation this leaves is simply: never change the AST format without
bumping the version.
Phases
- Now (no cost): constraints above; a short AST spec note as the format stabilises.
- Spike: Rust interpreter for the core atoms + expressions, no builtins, driven by the golden fixtures. Measure wasm size and suspend/resume ergonomics. Kill or continue.
- Parity: builtins, Date subset, regex, tosijs-schema core. Full conformance against the JS VM. Resolve string semantics.
- Swap: Rust VM becomes the
Eval/SafeFunctionengine on the client and the VM on the server. JS VM retired from the untrusted path (or kept strictly as CSP fallback under conformance). - Hosts: wasmtime binary; Postgres via pgrx (logic travels to 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.
- Universal endpoints want predicates executed inside Postgres.
- A sandbox issue that is structural to JS-in-JS rather than a bug.
- A consumer needing AJS without a JS host (embedded, edge runtime without JS, agent-local execution).
Open questions
CSP: document
'wasm-unsafe-eval'as a requirement, or keep a JS fallback?Regex engine:
regex-lite(small, linear) vs fullregex(Unicode classes, larger)?Date: which subset is actually promised?
String semantics: code points, UTF-8 bytes, or JS-compatible UTF-16 units?
How much of tosijs-schema must be in Rust for atom I/O — all of it, or a core subset with the rest host-side?
Errors as values instead of
try(Tonio, 2026-10-04; proposed for AST v3). The VM is already monadic inside (ctx.errorskips the remaining steps), andtryis the one node that clears it, which is exception semantics built withoutthrow. The alternative is two clean classes:- a recoverable failure becomes an error VALUE bound to the call's result, and the guest checks
it (
if (isError(r))); - a fatal one (capability-boundary refusal, fuel, heap, timeout) HALTS the run, which 0.14.0 already does for refusals.
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:- a propagation rule for an error value used as an atom input, operand, member or method receiver (propagate, as TJS does, or refuse);
- a new kind in the closed guest value domain (
guest-values.test.ts); - a format version, since v1/v2 ASTs contain
try; - a decision on AJS SOURCE
try/catch: lower it in the transpiler to result checks (the friendly layer) or refuse it with an instructive error. Models write try/catch reflexively, so the grok REPAIR measure (#2809) should decide.
- a recoverable failure becomes an error VALUE bound to the call's result, and the guest checks
it (
Relationship to tjs-lang/vm-ast
Shipped in 0.14.0 and worth noting here, because it is the first step of this plan taken for independent reasons: the AST-only VM (no parser, no acorn, 56 KB against 221 KB) already establishes "the VM accepts an AST, not source" as a supported contract with real consumers. The Rust VM would implement that same contract rather than a new one, which removes the riskiest part of a swap — persuading callers to change how they invoke it.