VM budgets: fuel, memory and boundary limits
AJS runs untrusted code, often code a model wrote, inside your JavaScript process. Its budgets are therefore security promises, not tuning knobs: a run may use only the work, memory and boundary traffic you allowed it, and when it reaches a limit it fails as a value instead of taking the host down.
Two words recur. The guest is the AJS program and every value it holds: its variables, its
arguments, what its atoms return to it. The host is your JavaScript process: the code that
calls vm.run, the capabilities it passes in, and the atoms it registers.
What to read:
- Embedding the VM: The budgets, When a budget refuses and Why not ask the runtime.
- Writing agents: Refused, not charged and Exact operand types.
- Writing custom atoms: What this means for your atoms.
- Changing the VM: The doors and everything after it, which lists every place a guest program can allocate and how each is checked.
How to call vm.run and pass capabilities is covered in Hosting the VM; this
chapter covers what the budget options mean.
The budgets
Every option is set per run, in the third argument to vm.run.
| Option | Default | What it bounds |
|---|---|---|
fuel |
1,000 | Work. Every step, expression node and allocated byte is charged (rates below). When fuel runs out the run ends with Out of Fuel. Fuel is the time budget for computation. |
timeoutMs |
60s, or twice the slowest registered atom's timeout if that is longer | Wall-clock time, including time spent waiting on a capability, which costs no fuel. The default does not depend on fuel. Whichever of the two runs out first ends the run. |
timeoutOverrides |
each atom's own timeoutMs (1s unless the atom says otherwise) |
One call's wall-clock time, per atom ({ httpFetch: 30_000 }; 0 disables). |
maxHeapBytes |
64MB | Peak live memory: the bytes guest state holds at once. |
membraneMaxBytes |
4MB | One value crossing the capability boundary, in either direction: what an IO atom returns into the VM, and what it hands out to a capability. |
argsMaxBytes |
4MB | The run's arguments, copied in before the run starts. Fuel also bounds them: one unit of fuel pays for 8,000 bytes. |
quotas |
none | Calls per atom ({ llmPredict: 3 }), for work outside the VM that fuel cannot see. An atom not listed is unlimited. |
quotaUsed |
a fresh counter per run | The counter quotas counts against. A quota counts calls within one run, so an agent that can start a nested run gets a fresh allowance there. To hold a cap across nested runs, pass the same quotaUsed object to each. It is trusted host state: never build it from anything a guest can write. |
costOverrides |
each atom's own cost |
The fuel price of an atom ({ httpFetch: 5 }). |
maxSourceBytes belongs to the parser, not the run. The deprecated vm.run(source), Eval and
SafeFunction apply it at 8KB by default; transpile applies it only when you pass it
(transpile(source, { maxSourceBytes })), so set it wherever the source comes from someone else.
See Safe Eval.
What fuel costs:
- an atom call: its
cost, 1 for most atoms; - an expression node (an operator, a name, a member read): 0.01;
- an allocation: 1 per 20,000 bytes;
- the run's arguments: 1 per 8,000 bytes. Copying them in visits every value, and that walk is charged per node at the rate binding the same data costs, which works out higher than a flat allocation.
For sizing: one pass of a small while loop that updates two variables costs about half a unit,
so the default 1,000 runs about 2,000 such iterations. Measure your own agents with fuelUsed
(below) and set the budget with headroom.
Fuel and the heap ceiling answer different questions. Fuel bounds how much a program does over its lifetime; it says nothing about how much it holds at any moment. A run that builds a 4MB string pays about 200 fuel for it, well inside a generous budget, and could still be more memory than you want one agent to hold. So the two are separate limits:
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const { ast } = transpile(`
function big({ n = 1000 }) {
const s = 'x'.repeat(n)
return { len: s.length }
}
`)
const vm = new AgentVM()
const run = await vm.run(ast, { n: 2_000_000 }, {
fuel: 100_000,
maxHeapBytes: 1024 * 1024, // 1MB
})
run.error.message
// 'Heap limit exceeded: guest state holds ~4MB, limit 1MB. Fuel bounds total work;
// this bounds peak memory. Raise it with the maxHeapBytes run option if the workload
// genuinely needs it.'
run.error.op // 'expr.repeat'
The refusal happens before the string exists. repeat computes the size of its result from its
arguments (two million UTF-16 characters, about 4MB) and is refused at the gate: the check,
made before anything is built, that charges fuel for that size and tests it against the heap
ceiling. The figure in the message is what guest state would hold if the allocation went ahead:
current holdings plus the request.
Nothing is thrown into your code. vm.run resolves to { result, error, fuelUsed, … }. On
success error is undefined and result is what the agent returned. On failure both are the error
value, { $error: true, message, op }, where op names the operation that was refused, and
fuelUsed is what the run spent.
Arguments are checked before the run begins. A five-million-character string argument, against the
default argsMaxBytes, returns Capability boundary rejected the run arguments: the value exceeds the 4194304-byte membrane budget. The membrane is the copier every value crossing between host
and guest goes through, and the limit in this message is argsMaxBytes: raise that, not
membraneMaxBytes. A 100,000-character argument with fuel: 10 returns Out of Fuel, because the
run cannot pay to copy it.
When a budget refuses
Some refusals are a value the agent can catch with try/catch; others end the run, even inside a
try. In AJS a caught error is a string (its message, capped), not an Error object, which is why
the examples below assign err directly.
| Refusal | Outcome |
|---|---|
fuel runs out |
ends the run (Out of Fuel) |
timeoutMs passes |
ends the run |
| one atom call passes its own timeout | catchable (Atom 'x' timed out) |
maxHeapBytes would be exceeded |
catchable |
a capability's return is over membraneMaxBytes or too deep |
catchable: the outside world caused it, not a bug in the agent |
| a capability returns something JSON cannot express | ends the run |
| a value going out to a capability is refused (wrong shape, too large, not plain data) | ends the run: the agent built it, so it is the agent's bug |
argsMaxBytes, or arguments the fuel cannot pay to copy |
the run never starts |
a quotas limit is reached |
catchable (Quota exceeded for 'x': 1 call allowed) |
The first example above ends the run only because nothing caught the refusal. The language guide explains the rule behind this split under "Catchable, and run-ending".
Three promises
The budgets rest on three invariants. Tests and source comments refer to them as I1, I2 and I3.
I1. Nothing allocates before it is charged. An operation whose allocation depends on runtime
data computes an upper bound from its inputs first, charges fuel for it, and passes the heap gate.
Only then does it allocate. A limit checked afterwards would be too late: 'x'.repeat(5e8) is one
synchronous native call that asks for 1GB, and no timeout or memory reading can interrupt it once
it starts.
The one bounded exception is a value crossing the capability boundary. The membrane builds its copy as it reads, so that what it checked is exactly what it forwards, which means the copy's size is known only once it exists. Instead the copy is built inside a budget fixed in advance, and charged when complete:
- Inbound (an IO atom's return coming into the VM): the budget is
membraneMaxBytes. - Outbound (an atom's input going out to a capability):
membraneMaxBytes, or what the remaining fuel can pay, whichever is smaller.
So a crossing can allocate up to membraneMaxBytes before it is charged, and no more. Run
arguments are the same, capped by argsMaxBytes and the fuel.
I2. Every byte that outlives its step is charged where it becomes reachable. A step is one node
of the program the VM executes: a statement, an atom call. A byte becomes reachable at a bind
(const x = …), an in-place insertion (push, fill, adding to a Set), a memoize store, and
when an atom adds to a container it holds (the results array of the map atom, which runs a body
once per item).
I3. Everything that holds guest values while guest code runs is a heap root. To measure memory, the VM starts from a known list of holders, the roots, and follows every reference from them; anything held elsewhere is invisible to it. The roots are scope variables, memo caches, run arguments, and the containers loop atoms keep while they run their bodies. A holder that is not a root is memory the ceiling cannot see.
Refused, not charged: implicit coercion
JavaScript converts an object to a string or a number wherever it wants a primitive: arr < 5,
arr * 2, obj[arr], 'abc'.includes(arr), Math.max(arr), a sort's default comparator. For an
array that conversion is its whole string form, built recursively, and each of those sites was an
allocation the budget did not see. AJS refuses them instead of estimating them. No agent
program means them, and refusing keeps the list of places that allocate short enough to check.
function tagLine({ tags = ['red', 'green'] }) {
let refused = ''
try {
const attempt = 'tags: ' + tags // refused: tags is an array
} catch (err) {
refused = err
}
return { refused, line: 'tags: ' + tags.join(', ') }
}
refused is '+' needs a string, number, boolean or null, not an array — convert it explicitly (e.g. arr.join(','), JSON.stringify(obj)), and line is 'tags: red, green'. This error is
catchable; uncaught, it ends the run with that message.
The rules:
- Operators (
+ - * / % ** < > <= >=, unary+and-) take strings, numbers, booleans and null. Equality (==,===) never converts in AJS, so it may compare objects, by identity:arr == arris true andarr == [1, 2]is false. - Computed keys (
obj[k]) are primitives. - Method arguments are primitives, except where the method uses an argument as a value: it
stores it, compares it by identity, or copies it.
arr.includes(obj),arr.concat(other)andObject.keys(obj)take objects;'s'.includes(arr)andMath.max(arr)do not. The one object a string method accepts is a regex, as a search pattern. - A sort with no comparator compares string forms, so its elements must be primitives:
[3, 1, 2].sort()works,[[3], [1]].sort()is refused.
Say what you mean instead: arr.join(','), JSON.stringify(obj), n.toString() or `${n}`
for a number. (String(n) as a function call is not available in AJS; the String namespace,
String.fromCharCode, is.)
Exact operand types
'x'.repeat('1e8'): as a string, the count '1e8' is three characters long, and repeat reads it
as one hundred million. A budget computed from one reading of an operand, while the method reads
it another way, is no budget. So every method declares, for each receiver kind (the type of the
value before the dot: string, array, object, number, Set, Date), the exact type of each argument,
and its bound is computed from those same checked values.
function stars({ count = 3 }) {
const ok = '*'.repeat(count)
let asText = ''
let extra = ''
try {
const bad = '*'.repeat(count.toString())
} catch (err) {
asText = err
}
try {
const bad = 'stars'.slice(0, 1, 2)
} catch (err) {
extra = err
}
return { ok, asText, extra }
}
Returns { ok: '***', asText: "repeat()'s argument 1 must be a number", extra: 'slice() takes at most 2 argument(s)' }.
- A count is a number.
'x'.repeat('3')is refused, not converted. - Arguments past the method's signature are refused.
- A method of one kind is not callable on another:
['x'].toUpperCase()is'toUpperCase' is not a function. - A method with no entry in the VM's table is not callable at all (see The method table below).
What the heap ceiling counts
The VM keeps a running estimate of live memory and checks it at every gated allocation.
- Bound values. Binds, insertions and memo stores add to the estimate. Nothing subtracts from it, so it is never below what is actually held.
- In-flight values. Each executing step has an allocation frame for the intermediates it builds, which no variable holds yet. The frame is released when the step ends, or cleared when the step binds its result. The result is then counted as bound, and counting it in flight as well would double it, refusing a value just over half the ceiling.
- Reconcile. When the estimate plus in-flight bytes crosses the ceiling, the VM walks every root once, counting shared objects once, and replaces the estimate with that measurement. The run fails only if the measurement, plus in-flight bytes and the request, is over the ceiling, and the error reports that total. The walk is charged as fuel, so a program that hovers near the ceiling and keeps reallocating fails by fuel rather than hanging.
What it over-counts, on purpose (it fails closed):
- A long string held under several names counts once per name. A shared string cannot be told apart from an equal copy, and counting by value would let distinct equal strings count once.
- A concatenation counts its result in full, although JavaScript engines such as V8 build a rope
(a lazy concatenation that points at its parts instead of copying them). The engine may flatten
the rope later, copying it while the parts are still held, and that copy happens outside any
gate. So
s = s.slice(1) + 'a'can peak at about twice the string in real memory, which is why the VM counts the full result. - Printing counts a value as a tree: see
treebounds. A structure that shares sub-arrays is small in memory and enormous when joined, and the join's bound is the printed size.
What it does not count: garbage. A value no root reaches and no running step holds belongs to the JavaScript collector. The promise is about peak reachable memory.
What this means for your atoms
Atoms you define with defineAtom run host code, outside the guarantees above unless you keep
them. How to define and register atoms is in Capabilities and Custom Atoms;
these are the budget rules.
An IO atom (the default, effects: 'io') needs nothing beyond a declared input schema. Its
input is copied out through the membrane and validated against that schema; its return is copied
in, capped by membraneMaxBytes, then charged at the bind like any other value. Return JSON data
plus Date, nothing else. defineAtom refuses an IO atom with no input schema (use s.object({})
for none), and refuses pattern or patternProperties in it, because those would run the host's
regex engine on guest strings.
An oversized return is caused by the outside world, not by a bug in the agent, so the agent can
catch it. (s is from tosijs-schema, which tjs-lang/schema re-exports. The atoms option lets
the transpiler check calls to your atoms: a misspelled input name fails at transpile time instead
of in the run.)
import { transpile } from 'tjs-lang/lang'
import { AgentVM, defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tosijs-schema'
const fetchRows = defineAtom(
'fetchRows',
s.object({ count: s.number }),
s.any,
async ({ count }, ctx) => ctx.capabilities.rows.list(count)
)
const vm = new AgentVM({ fetchRows })
const rows = {
list: async (n) => Array.from({ length: n }, (_, i) => ({ id: i, name: `row ${i}` })),
}
const { ast } = transpile(
`function getRows({ count = 10 }) {
try {
const rows = fetchRows({ count })
return { n: rows.length }
} catch (err) {
return { refused: err }
}
}`,
{ atoms: vm.atoms }
)
const run = await vm.run(ast, { count: 100_000 }, {
fuel: 100_000,
capabilities: { rows },
membraneMaxBytes: 1024 * 1024,
})
run.result.refused
// "Capability boundary rejected the return of 'fetchRows': the value exceeds the
// 1048576-byte membrane budget"
A pure atom that builds a value from its inputs must charge first. With effects: 'pure'
there is no membrane, so your atom's allocation is unguarded, like repeat's, until it charges.
Compute an upper bound in bytes from the inputs (two bytes per string character) and pass it to
allocate before you build anything. allocate charges fuel, checks the heap ceiling, and
throws the run's error if either refuses:
import { AgentVM, allocate, defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tosijs-schema'
const repeatText = defineAtom(
'repeatText',
s.object({ text: s.string, times: s.number }),
s.string,
async ({ text, times }, ctx) => {
allocate(ctx, text.length * times * 2, 'repeatText') // before, not after
return text.repeat(times)
},
{ effects: 'pure' }
)
Called as repeatText({ text: 'ab', times: 1e8 }) under maxHeapBytes: 1024 * 1024, the run
fails with Heap limit exceeded: guest state holds ~381MB, limit 1MB. … and the string is never
built. Allocate the full size of what you build. allocate pays for building the value (I1);
when the agent binds the result, the bind pays for keeping it (I2). Those are two different costs,
and every value pays both.
An atom that runs guest steps must not hold guest values where the heap walk cannot see
them. An atom that takes steps as input (a body to run, like map's) runs them through
createChildScope(ctx) and seq. Anything it keeps in a JavaScript local while those steps run is
invisible to the measurement (I3). Keep it in a variable of the guest scope, or accumulate nothing
until the steps finish.
Why not ask the runtime how much memory is in use?
process.memoryUsage() and its browser cousins cannot do this job:
- It is after the fact.
'x'.repeat(5e8)is one synchronous native call. There is no moment between asking for 1GB and having it when a reading could intervene. Only a bound computed from the inputs, before the call, can refuse it (I1). - It is the whole process, not this run. A server running many agents, or a page hosting the VM, has one heap. A per-run budget cannot be read from it.
- It counts garbage. Dead objects stay in the figure until the collector runs, and forcing a collection stops the world.
- It is not portable. AJS runs in Node, Bun, Deno, workers and browsers, and browsers expose
almost nothing (
performance.memoryis non-standard;measureUserAgentSpecificMemoryis asynchronous, slow, and needs cross-origin isolation).
The engine's own limit is the backstop beneath 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 }. The engine enforces that limit, so it
holds even where an estimate is wrong, but it cannot fail gracefully: the worker dies.
maxHeapBytes fails the run, with an error naming the operation, and leaves the host intact.
Use both. The planned native VM merges the two, because a wasm instance's
linear memory is a hard, synchronous, per-run cap.
The doors
Every place a guest program can allocate, and what guards it. A v2 AST is what the transpiler writes today; v1 ASTs come from older code and the fluent builder (see Atoms: fewer, thinner).
| Door | What allocates | Gate |
|---|---|---|
Expressions: method calls, builtin functions, + |
builtin methods and statics, concatenation | the typed method table: argument types first, then allocate() with a bound computed from those operands |
| Regular expressions | guest patterns and the work of matching them | the VM's own linear engine (src/vm/regex.ts), fuel per step; replace, split and the rest charged exactly |
| Implicit coercion (operators, computed keys, primitive-taking methods) | the string form of an object | refused (above) |
| Atoms | data atoms (v1 ASTs only), VM services | data atoms delegate to the same gated primitives; every atom is listed in a test table saying how its allocation is bounded, and a new atom without an entry fails it |
| Capability returns | IO atoms | the membrane (membraneMaxBytes), then I2 at the bind |
| Capability inputs (outbound) | the copy of what an IO atom hands a capability | the membrane builds the copy as it reads the value (JSON plus Date), inside membraneMaxBytes and what the fuel can pay, then charges it through allocate(). A refused copy is billed the bytes read before the refusal. A refusal ends the run, so it costs at most one copy attempt per run |
| Run arguments | the copy of vm.run's arguments |
argsMaxBytes and the run's fuel, then I2 at the bind |
| Binds and insertions | variables, push and other mutators, memo stores, loop results |
I2 |
Literals ([...], {...}) |
proportional to the AST | none needed: the AST's size is bounded before the run is admitted |
The method table
Every method guest code may call, on every receiver kind (string, array, object, number, the
guest Set and Date, and the namespaces Array, Object, JSON, String, Math, Number),
declares an allocation class:
none: allocates nothing proportional to data (includes,indexOf,Math.max, and in-place mutators, whose growth I2 charges).const: the result is bounded by a constant (toFixed, a number'stoString(2)).shallow: the result is at most a constant times the shallow size of the receiver and arguments (slice,concat,trim,toUpperCase,split,Object.keys). Where arguments select a range they are used:slice(0, 10)of a long string is charged ten characters.tree: printers and flatteners (join,JSON.stringify,flat, an array'stoString, Schema-from-example) are bounded by the value's size as a tree. Every path to a node counts, because printing visits every path.bound(fn): an explicit bound for amplifiers and products (repeat,padStart,padEnd,join,replace,replaceAll,Array.from,Array.of,String.fromCharCode).
join is in both of the last two because its result is the printed tree plus one separator per
element, and a long separator can be the larger part.
A guest Set is held as an array of its items (the VM keeps its membership index). To an agent it
behaves as a Set for has, add and size, and Array.isArray(set) is true and
JSON.stringify(set) prints the items as an array.
Bounds dispatch on the receiver kind wherever one name means different things on different kinds.
The tree class is why this program is refused, although its data is a few hundred bytes:
function wide({ depth = 6 }) {
let row = ['abcdefghij', 'abcdefghij', 'abcdefghij', 'abcdefghij']
let i = 0
while (i < depth) {
row = [row, row, row, row, row, row, row, row, row, row]
i = i + 1
}
try {
return { depth, joined: row.join(',').length }
} catch (err) {
return { depth, refused: err }
}
}
Each level holds ten references to the level below, so memory grows by ten pointers per level
while the printed form grows tenfold. At depth 6 join would print 44 million characters (10⁶
copies of the four-string row, 11 characters each with its comma; about 88MB), and the run returns
refused: 'Heap limit exceeded: …' under the default 64MB ceiling. At depth 2 it returns joined: 4399. Getting to the refusal costs about 3,000 fuel, which the doc site provides; under
vm.run's default of 1,000 the run ends with Out of Fuel first, which is the other budget doing
its job.
A method with no entry is not callable. The list used to be "every method on these prototypes", which admitted amplifiers by default. Now a method is callable only once its bound is declared.
Checked by probing. vm-budgets.test.ts calls every entry on receivers and arguments at
several scales, including the amplifying ones (large counts, long separators, long replacements,
nested arrays), and fails if any result exceeds its declared bound. A table checked only against
itself proves nothing.
The VM's own regex engine
Guest regexes run on the VM's own engine, never the host's. What that means for an agent:
- It supports classes,
., anchors,\b, groups (capturing, non-capturing, named), alternation and every quantifier, greedy and lazy, with flagsgimsuy. It refuses backreferences, lookaround,\p{…}and thedandvflags. - A match cannot hang. Shapes that hang a backtracking engine, exponential (
(a+)+$) and polynomial (a*a*c), are ordinary work, charged as fuel. replace,replaceAll,match,searchandsplitare implemented by the VM over it and charge exactly what they build.- Regex is deprecated in AJS in favour of Pattern.
The engine (src/vm/regex.ts) is a Pike VM: instead of trying one path and backtracking, it
advances every possible match in lockstep and merges duplicates at each position, so a match takes
time proportional to input × pattern whatever the pattern. A test runs it and native RegExp on
the same inputs and requires the same answers.
A schema's pattern keyword would run on the host's engine when the schema validates, so a
guest-supplied one is refused.
How the regex engine is metered (for VM maintainers)
Linear is not bounded unless the work is charged. The first version charged one step per thread
per input position and did everything else for free: following zero-width instructions, copying a
thread's captures at every save, testing a character against a 400k-entry class, expanding
(?:){1e12} during compilation. Each ran for seconds on a few fuel. The engine is now metered by
construction:
- every instruction a thread visits, every capture copy (in proportion to its width), every class
probe (classes are merged range tables, binary-searched) and every compiled instruction and
quantifier iteration goes through the caller's
charge; - every quantity that grows with the pattern is capped: counts (
{10000}), nesting depth, program size, compile work, capture slots, closure states. One regex's compile is capped at 1M steps (about 20ms). Where no run has a fuel budget yet (the transpiler validating literals, the predicate verifier), a source gets one compile budget proportional to its length (RegexCompiler), and each distinct literal is compiled once; - the parser and compiler charge each allocation as they make it, and every byte allocated also counts as compile work, so a pre-run compile budget bounds memory too;
- a compiled regex's program (
regexBytes) is charged throughallocatewhen the regex is built and counted by the heap walk wherever it is held. The matching state table belongs to one operation, never to the regex; - worst-case thread memory (
threadBytes) is charged once per operation; replacecharges each substitution at its exact length, from the template's shape, before building it.
Correctness rides on the same structure. Thread deduplication is keyed on the program counter
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. 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).
Other closed doors
- Guest values are data. A guest value is JSON-shaped: a Set is an array of its items (its membership index is held by the VM), a Date or regex a frozen plain object of its fields. No guest value is a host function or carries a method; method calls dispatch on the receiver's kind. This is checked where values enter guest state, inside the same walk that charges binds, and an insertion is checked before it happens, so a refused value is never left inside a structure.
- A declared
inputSchemais validated before the run, on a capped budget. The transpiler derives an agent'sinputSchemafrom its parameter examples. Validation runs before the run's timer exists, so it is capped at 1M argument paths and 1e8 (schema nodes × paths) steps, about 0.6s, whatever the fuel. Past either, the run is refused withArguments too large to validate against the inputSchema …: validate in the host, or pass smaller arguments. - Guest schemas are data in a closed dialect.
Schema.*returns plain JSON schemas, never a library builder whose methods are host closures, and a member read never returns a host function. A guest schema may use only admitted keywords, each with exactly its value type, and is size-capped. Validation is charged as schema nodes × data nodes before it runs. - Only trusted predicate source runs. A
$predicateis a JSON-Schema keyword whose value is predicate source, a check written as code. A predicate compiles to native JavaScript, which no syntactic check makes safe against hostile code, so the$predicatepaths run only sources the host registered withtrustPredicate.
Atoms: fewer, thinner
Data atoms (split, join, push, keys, pick, omit, merge, template, len, …) used to
duplicate what the expression evaluator does with method calls, and the transpiler emitted one or
the other depending on syntax: arr.push(x) as a statement was an atom, inside an expression a
method call. Two implementations meant two gates to get right, and the atom form escaped
maxHeapBytes.
- v2 ASTs (what the transpiler writes) emit method calls. One implementation, one gate, and JavaScript's semantics.
- v1 ASTs (built with the fluent
Agentbuilder, or saved by older versions and stored as procedures) keep theirop: 'split'steps, as thin wrappers over the same gated primitives. - Atoms remain for what only an atom can do: control flow, scopes, IO and capabilities, and
VM services:
memoize(keep a result in memory),cache(keep a result in the store, with a time limit),agentRun(run another agent) andrunCode(run AJS source through the host'scodecapability).
So costOverrides and quotas keyed on a data op such as split apply to v1 ASTs only. In v2
code a method call is an expression and costs what the evaluator charges.
Checked by
This section and the next are for VM contributors.
src/vm/vm-budgets.test.ts: the method-table probe; every guest-callable method has an entry; the I1 rows (each must fail before it allocates, under a tiny fuel or heap budget).src/vm/rc2-rereview-4.test.ts: the atom ratchet. Every atom that runs nested steps is listed with what it holds.src/vm/heap-reconcile.test.ts,src/vm/heap-roots.test.ts,src/vm/heap-mutation.test.ts: I2, I3 and the list of mutating methods, throughtranspile()output.src/vm/cost-invariant.test.ts: every step charges fuel of at least a constant times its work.src/vm/egress-doors.test.ts,src/vm/io-atom-schema.test.ts,src/vm/membrane-halt.test.ts: every IO atom crosses the outbound membrane, its schema is admitted at definition, and an outbound refusal ends the run.src/vm/guest-values.test.ts,src/vm/guest-fuzz.test.ts: every known route by which a host function could become a guest value, and generated programs checked against the invariants (vm.runnever throws, no function reaches a result or a capability, wall time tracks fuel).
How we got here
Fuel and maxHeapBytes 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 a missing patch, and it is why this chapter states invariants and why the checks
are tests that probe behaviour rather than lists someone must remember to update.
'x'.repeat(5e8)allocated 1GB and only then charged fuel;Array.from({ length: 3e8 })charged nothing (fifth re-review). This is I1.map's results array, held in a JavaScript local, accumulated 94 to 196MB under a 1MB ceiling, because each reconcile lowered the estimate to what the registered roots held (fourth re-review). This is I3.- The sixth review found implicit coercion outside the gate; refusing it was decided on 2026-10-02. The seventh found the general case, a bound computed from a different view of an operand than the method reads, which led to the typed method table.
- The eighth review found the regex engine linear but not charged for most of its work. The ninth found a compiled regex retaining its matching state table (about 690MB for 1000 held regexes under an 8MB ceiling). The eleventh found the regex parser's per-character allocation estimate low by about ten times for class escapes; allocations are now charged where they are made.
- Rounds 13 and 14 closed the guest value domain: sealed method wrappers were replaced by plain
data and per-kind method dispatch, and a stolen schema-builder
validatethat accepted a smuggled pattern led to guest schemas being plain JSON in an allowlisted dialect. The thirteenth had found an array-valuedpatternreachingnew RegExppast a denylist. - The twelfth review led to
$predicaterunning only host-registered source (2026-10-02). - Rounds 39 to 42 settled the crossing exception to I1. Rounds 40 and 41 also bounded each crossing by the heap ceiling, but measured it in the membrane's byte scale rather than the heap's, so it refused values that fit, and it reconciled the whole heap on every crossing. That was removed in round 42 (cumulative review 16).
The review records are in docs/reviews/0.14.0-rc.2-*.md. Read them before changing anything
here: every invariant above exists because its absence shipped.