VM budgets: how fuel and maxHeapBytes stay true

AJS runs untrusted code inside the host's JavaScript process, so its budgets are security promises. Fuel bounds work; maxHeapBytes bounds memory. Both broke the same way, five review rounds in a row during 0.14.0-rc.2: each round found one more place where guest code could allocate or hold memory that no budget saw. Each patch closed that place and the next review found another. That is the signature of a missing invariant, not of a missing patch. This document states the invariants, names every door they guard, and says how each is checked. The checks are tests that probe behaviour, not lists someone has to remember to update.

The invariants

I1. Nothing allocates before it is charged. Every operation whose allocation depends on runtime data computes an upper bound on the bytes it will allocate from its inputs, before allocating. It then charges fuel in proportion and passes the heap gate. 'x'.repeat(5e8) used to allocate 1GB and only then charge fuel. Array.from({ length: 3e8 }) charged nothing at all. The heap ceiling cannot help with either, because a budget checked after the allocation has already lost.

The bounded exceptions are the crossings (rounds 39–42). The capability membrane BUILDS its copy as it reads (what is checked must be what is forwarded), so the copy's size is not known before it exists. It is built within a budget fixed beforehand: membraneMaxBytes (4MB by default, set by the host) and, outbound, what the remaining fuel can pay. It is charged when complete. The heap ceiling applies where the value LANDS: outbound at allocate (in admit), inbound at the bind. So a crossing can allocate up to membraneMaxBytes before its charge, and no more. Run arguments are the same: admitted before the run exists, capped by argsMaxBytes and the fuel, with the ceiling applied at the bind. (Rounds 40–41 also bounded the crossing by the heap ceiling. They measured it in the membrane's byte scale, not the heap's, so it refused values that fit, and they reconciled the whole heap on every crossing. Removed in round 42, cumulative review 16, Tonio.)

I2. Every byte that outlives the step that allocated it is charged where it becomes reachable. That covers a bind, an in-place insertion (push, fill, a Set's add), a memoize store, and a holder's push (map's results).

I3. Everything that holds guest values across nested guest execution is a heap root. That covers scope states, memo caches, run arguments, and the containers loop atoms keep in JS locals.

How the heap is measured

A bind ends a step's in-flight bytes. When a step binds its value, everything else it allocated is garbage and the value is now charged as bound, so the step's frame is cleared at the bind. Otherwise let a = Array.from({ length: 1e5 }) would count twice and be refused at half the cap.

What this deliberately over-counts (fails closed):

What it deliberately does not count: garbage. A value no root reaches and no active step holds is the JS collector's to reclaim. Peak reachable memory is the promise.

Refused, not charged: implicit coercion

JavaScript converts an object to a string or number wherever it needs a primitive: arr < 5, arr * 2, obj[arr], 'abc'.includes(arr), Math.max(arr), parseInt(arr), a sort's default comparator. For an array that is its whole string form, recursively, so every one of those was an allocation door the sixth rc.2 review found outside the gate. AJS refuses them (Tonio, 2026-10-02) instead of estimating them, because no agent program means them and refusing keeps the doors enumerable:

Say what you mean instead: arr.join(','), JSON.stringify(obj), String(n) on a number.

Exact operand types

Refusing coercion closed one door class; the seventh rc.2 review found the general one: a bound computed from a different VIEW of an operand than the native method then reads. int('1e8') is 0 to a bound and 100,000,000 to repeat; a method exempt from argument checks still converts its second argument; a model of replace's output counted one $' per match where the template had fifty. So the method table is TYPED: for each receiver kind, each method declares the exact type of every argument position (num, str, pattern, any = used as a value, …), and its bound reads those same, validated operands. A count must be a number — 'x'.repeat('1e8') is refused, not modelled. Arguments past the signature are refused. A method another kind has is not callable on this one.

The VM's own regex engine

Guest regexes never run on the host's backtracking engine. src/vm/regex.ts is a Pike VM: threads advance in lockstep and are deduplicated per position, so a match is O(input × pattern) whatever the pattern, and every step is charged as fuel. Exponential ((a+)+$) and polynomial (a*a*c) shapes are ordinary work. It supports classes, ., anchors, \b, groups (capturing, non-capturing, named), alternation and every quantifier, greedy and lazy, with flags gimsuy; it refuses backreferences, lookaround, \p{…} and the d/v flags. It is held to native RegExp by a differential test (a corpus plus a grammar fuzz — 152,400 comparisons, zero mismatches when it landed). replace, replaceAll, match, search and split are implemented by the VM over it (string-methods.ts) and charge exactly what they build. A schema's pattern would run on the host's engine when it validates, so a guest-supplied one is refused (the library's own are allowed).

Linear is not bounded unless the work is charged (eighth rc.2 review). The first version charged one step per thread per input position and did the rest for free: following zero-width instructions, copying a thread's capture array at every save, testing a character against a 400k-entry class, expanding (?:){1e12} during compilation, allocating per call. Each ran for seconds on a few fuel. The engine is now metered by construction:

Correctness rides on the same structure: thread deduplication is keyed on the pc and on which enclosing optional quantifiers began at the current position, because JavaScript's empty-iteration check makes those part of a thread's future (pc alone diverged on nested quantifiers over empty-matchable bodies). Case folding uses equivalence classes derived from the host engine (regex-folds.ts, generated and freshness-tested). Predicates compiled by compilePredicate use the same engine, through a RegExp-protocol adapter (src/lang/predicate-regex.ts).

The doors

Door What allocates Gate
Expression evaluator: methodCall, call, + builtin methods and statics, global builtins, concatenation the typed method table (methodGate): argument types, then allocate() with a bound from those operands
Regular expressions guest patterns and their work the VM's own linear engine (regex.ts), fuel per step; replace/split/… charged exactly
Implicit coercion (operators, computed keys, primitive-taking methods) the string form of an object refused (above)
Atoms data atoms (v1 ops only; see below), VM services data atoms delegate to the same gated primitives; every atom is listed in a ratchet table with its allocation story
Capability returns io atoms the membrane (membraneMaxBytes), then I2 at the bind
Capability inputs (the outbound membrane) the deep copy of what an io atom hands a capability egressValue: walk budgeted by remaining fuel and membraneMaxBytes, copy BUILT by the walk itself from what it read (JSON plus Date; no structuredClone, so what is checked is what is forwarded) inside membraneMaxBytes and a budget the remaining fuel can pay (I1's bounded exception, above), then charged through allocate(), where the heap ceiling applies; a REFUSED walk is billed the bytes it walked (a required field of every refusal) less what the crossing already spent, never more than the fuel present. Every io atom is on it, per call site (egress-doors.test.ts); its input schema is a frozen JSON copy admitted at definition (io-atom-schema.test.ts). A refusal ENDS THE RUN (membrane-halt.test.ts), so it can cost at most one walk per run
Binds and insertions setStateVar, accountMutation, memo stores, holder pushes I2
Literals ([...], {...}) O(AST size), bounded by admission none needed: the AST is already budgeted

The method table

Every method guest code may call, on every receiver kind (string, array, object, number, the guest Set and Date, and the builtin namespaces Array, Object, JSON, String, Math, Number), declares an allocation class:

A method with no entry is not guest-callable. The allowlist used to be "every method on these prototypes", which admitted amplifiers by default. Now admission requires a declared bound.

Checked by probing. vm-budgets.test.ts calls every table entry on receivers and arguments at several scales, including the amplifying dimensions: large counts, long separators, long replacements and nested arrays. It fails if any result exceeds its declared bound. A table that is only consistent with itself proves nothing, which is how push is the only mutator stayed true in a comment and false in the code.

Atoms: fewer, thinner

Data atoms (split, join, push, keys, pick, omit, merge, template, len, …) duplicated what the evaluator does through methodCall. The transpiler emitted one or the other depending on syntax: arr.push(x) as a statement was an atom, inside an expression it was a method call. Two implementations meant two budget gates to get right. The statement form escaped maxHeapBytes; the atom returned the array where JavaScript returns the length.

Consequence: costOverrides and quotas keyed on a data op such as split apply to v1 ASTs only. For v2 code a method call is an expression and costs what the evaluator charges.

Why not just ask the runtime?

process.memoryUsage() and its browser cousins cannot do this job, and the reason is not asynchrony:

The runtime's real role is the floor under these budgets. A host running untrusted code at scale should also give each run an isolation boundary with a hard limit, such as a Node Worker with resourceLimits: { maxOldGenerationSizeMb }. That limit is enforced by the engine, so it holds even where an estimate is wrong. What it cannot do is fail gracefully: the worker dies. maxHeapBytes is the budget that fails the RUN, with an error naming the operation, and leaves the host and the rest of the program intact. Use both. The native VM direction (docs/ajs-native-vm.md) eventually merges them, because a wasm instance's linear memory is a hard, synchronous, per-run cap.

Checked by

History

The rc.2 review series is in docs/reviews/0.14.0-rc.2-*.md. Read it before changing anything here. Every invariant above exists because its absence shipped.