Changelog

All notable changes to tjs-lang are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

[0.14.0-rc.10] — 2026-10-31

On the rc dist-tag. Mostly what tosijs-native found building natively in .tjs from the first line: typed parameters with defaults, class methods that validate, .d.ts that compiles across files, a Bun.build plugin and an optional shared runtime per bundle. Pin 0.14.0-rc.10 exactly to test it.

Read this first: what changes since rc.9

Added since rc.9

Fixed since rc.9

[0.14.0-rc.9] — 2026-10-28

On the rc dist-tag. A VM security fix (#3531), destructured and array-pattern parameters checked as the one argument they are at any position, wrap() working with emitted metadata, :? returns checked like parameters, tosijs proxies seen through everywhere, Type values as defaults, and sharper error messages. Pin 0.14.0-rc.9 exactly to test it.

Read this first: what changes since rc.8

Security since rc.8

Fixed since rc.8

[0.14.0-rc.8] — 2026-10-20

On the rc dist-tag. A type error now carries what you passed (err.received), destructured parameters are checked as the one argument they are (in both the TJS and the TypeScript spelling), converted TypeScript gets native TJS for them, and runtime errors name the right line again after a declaration block. Pin 0.14.0-rc.8 exactly to test it.

Read this first: what changes since rc.7

Added since rc.7

Fixed since rc.7

[0.14.0-rc.7] — 2026-10-19

On the rc dist-tag. TJS now works with tosijs models (typed functions, ==, truthiness and given see through tosijs proxies), live pages check signature examples, list parameters check their elements, and the TJS book's Part I, "Learn to Program", is complete. Pin 0.14.0-rc.7 exactly to test it.

Read this first: what changes since rc.6

Fixed since rc.6

Added since rc.6

Documentation since rc.6

[0.14.0-rc.6] — 2026-10-17

On the rc dist-tag. One breaking change: || in a type is a compile error (a union is spelled |). Plus a fix for annotations on nested functions, and documentation whose every example now runs its tests. Pin 0.14.0-rc.6 exactly to test it.

Read this first: what breaks since rc.5

Changed since rc.5

Fixed since rc.5

Documentation since rc.5

[0.14.0-rc.5] — 2026-10-09

On the rc dist-tag. == and != now group their operands as JavaScript does (the rewrite is an AST pass), tjs() returns its runtime prelude separately for REPL hosts, and npm links to the documentation site. Pin 0.14.0-rc.5 exactly to test it. This section is what changed since rc.4; the notes for 0.14.0 as a whole, rc.1 to rc.4, are under the 0.14.0 heading below.

Read this first: what changes since rc.4

Added since rc.4

Fixed since rc.4

0.14.0 — unreleased

Release candidate: 0.14.0-rc.4 (2026-10-07), on the rc dist-tag. WASM blocks mean what they say (a block that cannot compile is a compile error, and the WASM path and the fallback give the same answer), predicate => is removed, and the deprecated unsafe prefix no longer escapes var or eval. Read "what breaks since rc.3" first. Pin 0.14.0-rc.4 exactly to test it.

Read this first: what breaks since rc.3

Each of these breaks toward correctness; none has a compatibility switch.

Changed since rc.3 — a wasm {} block means what its fallback {} means

Found by reviewing the WASM QuickStart, whose first example printed undefined (it promised 7). Each is pinned by src/lang/wasm-semantics.test.ts, which runs functions through WASM and through the fallback and requires the same answer.

Fixed since rc.3

Added since rc.3

Removed since rc.3

Release candidate: 0.14.0-rc.3 (2026-10-06), on the rc dist-tag. It adds the doc-site work below: every playground example runs on tjs.tosijs.net, and four library fixes it found. 0.14.0 final follows once the books (the site's four volumes) are reviewed as self-hosted tutorials. Pin 0.14.0-rc.3 exactly to test it.

Found by sweeping every page of the doc site (tjs.tosijs.net) with a headless checker; the full account is docs/reviews/0.14.0-site-readiness.md.

Fixed since rc.2

Added since rc.2

Release candidate: 0.14.0-rc.2 (2026-10-05), on the rc dist-tag (npm i tjs-lang@rc). It carries everything since rc.1 below: the membrane rebuilt to construct its own copy (JSON data plus Date, no structuredClone), capability-boundary refusals that end the run, IO atoms that must declare an input schema, the method table, the linear regex engine, and the per-atom timer changes. Pin 0.14.0-rc.2 exactly to test it.

Release candidate: 0.14.0-rc.1 (2026-09-26), on the rc dist-tag (npm i tjs-lang@rc); latest stays on 0.13 until 0.14.0 is final. It carries everything below, including the admission work from the final re-reviews (the 8KB source cap, the deprecation of vm.run(source), and every run option read once and checked). ^0.14.0 does NOT admit a prerelease, so pin 0.14.0-rc.1 exactly to test it. Published through the OIDC workflow, vouched for by release-attestation.json.

Since rc.1 — tosijs-schema floor raised to ^1.12.0 (#58). tjs-lang unwrapped schema builders by duck-typing (x?.schema ?? x, 'schema' in x) in eight places, so a value that merely HAD a schema key was read as a builder: a Type example such as { label: '', schema: { version: 0 } } had its schema field used as the type, and a JSON Schema with a stray schema: true became accept-all. The same check sat in the VM's guest-facing Schema.isValid / Schema.response. tosijs-schema 1.12 brands builders (isBuilder); every site now asks it. The old ^1.5.1 range was also floating fresh installs onto 1.12 (four breaking validation releases later) while the code assumed 1.5, so the range now names the version the code is written against. The LLM batteries' output schema is back to named fields ({ role?, content?, tool_calls? }) via .open, still admitting provider additions such as reasoning_content, where s.record(s.any) rejected nothing at all.

Since rc.1 — AJS AST format v2: a bare string is always a literal (board #1860). v1 stored a literal string and a variable reference the same way, and the VM read it as a reference when a variable by that name was in scope: let x = 5; const s = 'x' made s 5, ['x', 'z'] became [5, 'z'], and input that chose variable names could redirect a literal. The transpiler now writes $ajs: 2, where every reference is an explicit node and a bare string is data; an object literal shaped like a VM node ({ $expr: … }) stays data too. A while body is now a block, so a const in it no longer fails on the second iteration (#2343). Compatibility: v1 ASTs — every stored procedure, everything the fluent builder writes (it still writes v1; its strings mean references) — keep v1 meaning, read by the same VM; an older VM refuses a v2 AST by version, by name. Frozen by golden fixtures for both versions.

Since rc.1 — custom atoms receive VALUES. An atom defined with defineAtom and written like the documented example (async ({ url }) => fetch(url)) received the variable's NAME when a program passed one — echo({ v: local }) got "local" — in every earlier release. The VM now resolves inputs before calling it, and runs it under a context where resolveValue is the identity: an atom written the old way, calling resolveValue on its own inputs, keeps working and cannot re-evaluate guest data shaped like { $expr: … } as code. Dynamic cost and timeoutMs functions see the same resolved input — so a cost function written against raw nodes (testing for $expr, or for a variable name) now receives values and must be updated. Steps an atom builds and runs itself — on the context it received, or on a scope from createChildScope(ctx) — run normally under either setting: the VM never runs a step under the resolved-inputs context. Running them on a COPY of that context, or after replacing its state, is refused by name rather than silently writing the caller's scope. An atom that takes steps AS INPUT must pass { resolveInputs: false }, or its steps arrive evaluated as values.

Since rc.1 — the VM's budgets are invariants, not patches (docs/vm-budgets.md). Five rc.2 review rounds in a row found one more place where guest code could allocate or hold memory that neither fuel nor maxHeapBytes saw — each fix closed one place and the next review found another. They are now three rules, each checked by a test that probes behaviour:

  • Nothing allocates before it is charged. Every operation whose allocation depends on runtime data bounds it from its INPUTS and charges fuel and heap first. 'x'.repeat(5e8) built 1GB and then charged; Array.from({ length: 3e8 }) charged nothing — any guest could take the host's memory for under one unit of fuel, no capability needed.
  • Every byte that outlives its step is charged where it lands — a bind, an in-place insertion (push, fill, a Set's add), a memoize store, a loop's results.
  • Everything holding guest values while guest steps run is measured — scopes, memo caches, arguments, and the arrays a loop holds. Before a run fails, the VM measures the live heap from those (each object once, every slot a pointer) plus the in-flight allocations of the steps still running, and fails only if THAT is over; the error reports it.

The guest method allowlist is now a table, and a method without a declared allocation bound is not callable. Gone (breaking, though none of them could be used well): the legacy HTML string methods (bold, link, …), callback methods that guest code has no functions to pass to (forEach, every, flatMap, … — use for…of or map/filter/find/reduce), matchAll and the array keys/values/entries (they returned host iterators), the __lookupGetter__ family (it returned a host function), and toString on a builtin function — Date.toString() returned the host's SOURCE CODE. A long string held under several names still counts once per name (fails closed). With everything since rc.1 below, the VM grew ~30%: tjs-lang/vm-ast is 37 KB gzipped, tjs-lang/vm 88 KB (68 KB at rc.1); the regex engine is about 7 KB of it, the closed schema dialect and data-only Schema about 2 KB.

Since rc.1 — $predicate runs only source the host trusts (breaking). A predicate compiles to native JavaScript, and a syntactic verifier cannot make hostile JavaScript safe: one review round found five more routes from a "verified" predicate to the host's regex engine (['(a+)+$',''].reduce(RegExp), a method passed as a callback, a tagged template, a class shadowing a name, overwriting a field). So the $predicate paths — createPredicateEvaluator (what tjs-lang/schema installs), compilePredicateSchema, validatePredicateSchema — run a source only if the host registered it with trustPredicate(source) (tjs-lang/css's schema builders register theirs), or passed trustAllPredicates: true. Anything else fails closed, saying why. Migration: wrap your own $predicate sources in trustPredicate(…); never trust a source that arrived as data. Running untrusted predicates on the AJS VM, whose surface is a closed table, is planned and will lift this. A source trusted after a first refusal runs at once (the evaluator checks trust on every call); its cache is bounded. In predicates, a string-literal pattern (s.search('@')) runs on the metered engine rather than being refused.

Since rc.1 — a Set, a Date and a regex are DATA (breaking). A guest Set is an array of its items (s.size is its length, by every route; Array.isArray(s) is true), and a Date is a frozen object of its fields — value (ISO), timestamp, year, month (1–12), day, hours, minutes, seconds, dayOfWeek — all in UTC, so a program's output does not depend on the host's time zone (fields, add and format used local time). A Date has ONE form: guest JSON.stringify, a capability's input and the run result all see the object, where rc.1 wrote the ISO string — write d.value for that. Date(x) takes every form a date takes: a guest Date, a stored one (an object with a numeric timestamp, which is what a Date becomes after JSON, a store or a capability), a number of ms within the Date range, or a string that parses; Date.parse is the same door. format's keywords ('ISO', 'date', 'time') are case-insensitive. A regex is an object of its source and flags. Their methods are the VM's, dispatched by kind, so a value never carries a function: the sealed wrappers and the run-result conversion that kept their methods away from guest code are gone, with the four defects the seventeenth re-review found in them (a recursive conversion that a deep or self-containing Set crashed, a __proto__ key, an uncharged walk of an Object.assign target, conversion only at the result). push and Object.assign into a Set are refused (use its methods), and reading a method as a value (const f = s.add) is refused as it is on a string. An in-place insertion is now checked and charged BEFORE it happens, so a refused value is never left behind for a catch to reach. agentRun hands a sub-agent exactly what it was passed: it resolved the input twice (a string that named a caller variable became that variable's value) and evaluated an inline sub-agent's expressions in the CALLER's scope. Sub-agent and runCode arguments are null-prototype, like vm.run's. pick again copies any own data property (pick(arr, ['length'])), never a function. An AST's inputSchema is validated before the run's timer exists, so it is now capped on the product — 1M argument paths and 1e8 schema-node × path steps (about 0.6s) — and refused past either: validate in the host, or pass smaller arguments. A guest-program fuzzer (src/vm/guest-fuzz.test.ts) now checks the invariants over random programs.

Since rc.1 — httpFetch follows no redirects and admits only known headers; the VM never writes to your capabilities (security, breaking). In allowlist mode httpFetch compared only the hostname, so on Bun or Deno file://<allowed-host>/etc/hosts read a host file. Every request is now admitted by one rule: the scheme must be http: or https: in both modes, then the allowlist, and without an allowlist nothing is fetched.

  • No redirects are followed. A redirect (301, 302, 303, 307 or 308 with a Location) comes back to the agent as data, { redirect: true, status, location }, with location resolved to an absolute URL, and fetching it is a new request, admitted like any other with its headers and body chosen afresh. A 304 Not Modified, or any other 3xx, is an ordinary response. In a browser location is null, because the browser hides it. Four review rounds each found another redirect rule the VM had re-implemented wrong (credentials re-sent, then the body re-sent on 307/308, then on 301/302 for PUT, PATCH and DELETE), so the built-in client stopped following redirects. A host that wants them followed supplies a fetch capability.
  • Guest request headers are a closed list: Accept, Accept-Language, Authorization, Cache-Control, Content-Language, Content-Type, the If-* validators, Range, User-Agent, X-API-Key, X-Request-Id, X-Correlation-Id and X-Requested-With. A host adds names for a run with context.allowedRequestHeaders, as it does with allowedFetchDomains. Anything else is refused by name, never dropped, and X-Agent-Depth is refused even if a host lists it. A guest Host was sent as given, and on Bun behind a Host-routing proxy it reached virtual hosts outside the allowlist. A denylist kept growing, and so did an "X- except the routing ones" rule (X-Envoy-Original-Dst-Host, X-Originating-IP, …), so neither survived.
  • A host's own fetch capability receives the agent's request after the same admission. The request's SHAPE is admitted on every path, always: http:/https: only (file:// read a host file through the documented safeFetch on Bun), the method and the headers. Its DESTINATION (allowedFetchDomains, with ports) is enforced on every path when set; without it, a host fetch owns the destination. Redirects and credentials are then the host's; the documented safeFetch refuses redirects and omits credentials.
  • Eval and SafeFunction take a host-only fetchPolicy ({ domains, methods, headers }), forwarded to the run and never visible to guest code. Their context option is guest variables, so before this they could not allow a POST at all.
  • Migration, hosts with their own fetch: an agent's POST, PUT or DELETE now needs context.allowedFetchMethods (or fetchPolicy.methods), and headers outside the closed list need context.allowedRequestHeaders (or fetchPolicy.headers).
  • The built-in httpFetch is a demonstration capability, highly constrained by default (Tonio). A real deployment supplies its own fetch. Methods: GET and HEAD by default; a host enables more for a run with context.allowedFetchMethods. CONNECT, TRACE and TRACK are never admitted. Ports: an allowedFetchDomains entry admits the scheme's default port only, unless it names one (api.example.com:8443); matching the hostname alone admitted every port of an allowed host. One admission, every path: the agent's method and headers are admitted the same way when a host's own fetch sends the request, so a host that needs Cookie or PROPFIND declares it.
  • No browser credentials: cookies and HTTP auth are not sent (credentials: 'omit'); a custom fetch can opt in.
  • Bounded body: the body is read under membraneMaxBytes (a larger declared Content-Length is refused before reading) and aborted once the cap is exceeded, not buffered whole first.

Separately, vm.run used to write its default in-memory store INTO the capabilities object you passed, so two runs sharing that object shared the store, and a frozen object made the run throw. The VM now uses your capabilities object exactly as passed: it is never copied, wrapped or written, so a class instance with private fields or getters works as it always did.

  • The default store belongs to the run and is used only when your object has no store.
  • Custom atoms: read the store through storeOf(ctx), not ctx.capabilities.store, which is now the host's own object and undefined when the host gave none.
  • Each run, nested runs included, has its own default store.
  • A non-object capabilities is refused.

In AJS, an atom input named op, result, resultConst or resultAssign is refused at transpile time: it was silently read as a step control field.

Image URLs follow the same rule as a fetch, in llmVision and in llmPredictBattery message arrays: the model server may fetch an http(s) image_url itself. Inline base64 image data passes (data:image/… or data:application/octet-stream, parameters allowed, which is what httpFetch's dataUrl returns). http(s) passes only against the run's allowedFetchDomains, and is refused without one. What reaches the model server is the ADMITTED, normalised URL: a backslash-userinfo URL that one parser reads as an allowed host and another as 169.254.169.254 is never forwarded as written. llmPredictBattery takes a string or a message array, which the VM REBUILDS from a closed shape ({ role, content }, content a string or text/image_url parts; any other part type, such as video_url, is refused) and forwards with the admitted image URLs; the shipped battery sends it as the conversation itself. The { text, images } vision form is refused there (use llmVision). With an allowlist set, a fetch URL that is not absolute is refused, because it names no host to check. A NETWORK-PATH reference (//host/x and its backslash forms) is refused on every path, since it borrows the page's scheme. A host's fetch always receives the ADMITTED, normalised URL, never the raw input. A policy list that is not an array of strings is refused (a string was read one character at a time). Rebinding a value already in scope (a per-iteration let p = o) costs one pointer again: in rc.2's heap accounting it re-measured the whole value, so a loop was quadratic in fuel where rc.1 was linear.

Since rc.1 — an outbound membrane on capability calls (security, breaking for some custom atoms). What an IO atom hands a capability is now a budgeted deep COPY of its resolved input, checked against the input schema the atom declares (Tonio). The inbound membrane already did this for what a capability returns. The outbound direction kept being found one site at a time:

  • a system or prompt that was really an array of video_url parts;
  • tools and responseFormat forwarded live, so a guest that caught a timeout changed them before a slow host serialised them;
  • raw URLs.

It applies to every core, battery and custom IO atom whose inputs the VM resolves, and fails by refusing ('<op>': its input does not have the shape the atom declares). Embedders: a custom IO atom now receives plain-data copies, and is called only with inputs matching its declared schema; a storeGet key must be the string it declares. An IO atom with resolveInputs: false should pass what it hands a capability through the newly exported egressValue. agentRun's capability route and cache now do (both handed the host live guest objects), and a cache key must be a string.

The copy is paid for. Its walk is budgeted by the run's remaining fuel and by membraneMaxBytes, which now caps both directions (it was documented for returns only). The copy is charged as an allocation before it is made, and a call that is refused is charged for the walk it caused. The first version of this membrane charged nothing, so a loop over a large value used about 100× the CPU for the same fuel (cumulative review 5).

cache and memoize require a key (breaking). A key-less block derived its key by calling the hash atom's step wrapper, which returns nothing, so every key-less block was stored under undefined and returned ANOTHER block's result; a cache entry is shared across runs for 24 hours by default. Keying by a digest of the steps is no better: the steps are the same when the data they read is not. Only the author knows what a result depends on, so the builder's key is now required and the VM refuses a block without one. A store written by an earlier version may hold a stale cache:undefined entry; it is safe to delete. A key larger than membraneMaxBytes is refused like any other capability input, and a result too large to cross fails the step rather than being returned uncached.

IO atoms declare their inputs (breaking for embedders). defineAtom refuses an IO atom (effects: 'io', the default) with no input schema (use s.object({}) for none), or whose input schema contains pattern/patternProperties: the outbound membrane validates every call against that schema, so a pattern there ran on the host's regex engine over guest-chosen strings, outside fuel. Check patterns in the atom's body. (The JSON Schema direction is Pattern: docs/pattern.md.) The schema is admitted as a frozen plain-JSON COPY when the atom is defined, and every call is validated against that copy: a pattern is refused whatever its value (a RegExp included), any other non-JSON value is refused rather than converted, and changing atom.inputSchema afterwards changes nothing. An atom tagged io after definition is admitted at its first call. Migration:

defineAtom('ping', undefined, s.any, fn)               // refused
defineAtom('ping', s.object({}), s.any, fn)            // takes nothing
defineAtom('lookup', s.object({ id: s.string.pattern('^[a-z]+$') }), …) // refused:
defineAtom('lookup', s.object({ id: s.string }), …)    // check the id in the body

A refused outbound copy is billed for the work its walk did, whatever refused it, never more than the fuel present, and keeps its own error unless fuel genuinely ran out. Round 31 billed only a refusal for size (a depth refusal after a megabyte was free); round 32 billed the whole budget on top of what the copy had already been charged (cumulative reviews 6 and 7).

A refusal at the capability boundary that is a bug ends the run (breaking). An atom call whose input does not match the atom's declared schema, a value refused on its way out to a capability (too large, too deep, not plain data, over the heap ceiling or the fuel), and a capability's RETURN that JSON could not express (a function, a getter, a class instance: the host's contract broken) can no longer be caught by try/catch. A return that is merely too large or too deep (things JSON can do), or does not match the atom's declared output, is the world's doing and stays catchable (Tonio), and every inbound copy is billed in fuel so a retry loop pays. A value an atom THROWS reaches a guest catch as a capped string: its message (read once, so a DOMException's works), or String(value) for a thrown primitive; never an object (a thrown { message: obj } used to hand over a live host object). A typed array over a SharedArrayBuffer, or any subclass of a typed array, DataView or Date, is refused at the boundary: structuredClone would SHARE that memory with the guest, not copy it. Refusals that end the run: no catch block, later step or parent-agent step runs, and the run reports the refusal. This includes a cache entry or key that cannot cross, and persisted ASTs or builder programs that wrapped such a call in try. Ordinary failures (a fetch that fails, a store that throws) are caught as before. Four review rounds in a row (cumulative reviews 5 to 8) each found a refusal path billed short of the work it did, and each mattered only because a guest could catch the refusal and retry it in a loop. A refusal that ends the run costs at most one bounded walk (Tonio).

The membrane builds its copy: JSON plus Date (breaking). Every value crossing the capability boundary (capability returns, IO-atom inputs, run arguments) is now a copy the membrane builds from exactly what it read, instead of a structuredClone of the original. structuredClone copies internal slots the check never saw, so a typed array over a SharedArrayBuffer with a shadowed buffer, a prototype-swapped buffer, or a 1-byte view over 50MB was checked as one thing and forwarded as another (cumulative review 13). What crosses is plain objects, arrays, strings, numbers, booleans, null/undefined, and Date (by its internal time). Map, Set, typed arrays, ArrayBuffer, RegExp and Error no longer cross: a capability returning one, or a run argument containing one, is refused. Convert them to JSON first (a Map to an object or an array of pairs). Shared references and cycles are preserved, as before. A Date crosses only when it is exactly an intrinsic Date (a subclass, or one with own fields, would arrive thinned). Cost, measured: a 300-document RAG return copies in ~1.9ms (3.5ms before); a 400k-number array in ~143ms (~127ms before), where the per-element descriptor read that keeps getters from running dominates, as it did.

The membrane's copy is built safely, and the VM loads in any page. Copies are built with a null prototype and given their real one when complete, so a polluted Object.prototype setter never runs on capability data and frozen intrinsics (SES) do not refuse ordinary JSON. A crossing is bounded by membraneMaxBytes and (outbound) the remaining fuel; the heap ceiling applies where the value lands. A refusal for size names the limit that set it. Fixed in the same rounds: rc.2 builds briefly read SharedArrayBuffer at module scope, which threw on import in any browser page that is not cross-origin isolated (every bundle is now load-tested without optional globals). An array's length is read from its own descriptor and validated: a Proxy whose length trap returned an object could put an unwalked host object into the copy (cumulative review 17). Object copies are built on their real prototype with each key defined, so they stay in V8's fast mode. An Array subclass, like any class instance, is refused rather than thinned to a plain array (its prototype getters would read as undefined), and so is an array or object from another realm (an iframe, a node:vm context): pass plain data, e.g. through a JSON round-trip.

Faster hot loops: a per-atom timer is armed only for a call still pending. Every atom step used to arm a setTimeout and a Promise.race, though a call whose body completes synchronously can never be overtaken by its timer. A 20,000-iteration loop went from 54ms to 32ms, and from 40,004 timers to 1 (the run's deadline), at the same fuel. An atom that awaits real work is timed as before, with its deadline counted from the call on a monotonic clock (performance.now()), so a wall-clock step cannot time it out early.

Persisted ASTs: a stored AST (procedureStore, a saved builder program) containing a key-less memoize or cache now fails when it runs, with "a key is required". Add a key.

A fetch URL that is relative is decided by the URL parser itself (resolved against an http and a file: sentinel). It passes only as a plain path naming no host, and contains no tab/LF/CR/backslash a parser would rewrite. A hand-written "two leading slashes" check missed /\t/evil.com/x.

Since rc.1 — AsyncJS checks every atom call against the atom's declared inputs (breaking). A call to an atom is a single object literal, storeSet({ key, value }), or no arguments, random(). The transpiler now refuses, with a message naming what the atom takes:

  • a positional call (storeSet('k', v), foo(opts));
  • a spread (foo({ ...opts }), which was silently dropped);
  • an input the atom does not declare (httpFetch({ url, cache }); four playground examples asked for a cache that httpFetch never had);
  • a missing required input.

These all used to compile and run the atom with inputs undefined, reporting success. Core atoms are checked from a table generated from their schemas (src/vm/core-atom-inputs.ts). Pass your own atoms to check calls to them too: transpile(src, { atoms: vm.atoms }). Every entry point that holds a VM does this itself (vm.run(source), createAgent, Eval, SafeFunction), so a host atom that overrides a core name is checked against its own inputs. Without atoms, a refusal on a core name says to pass them. An open schema (no additionalProperties: false) still requires what it lists. An AST persisted under rc.1 with a positional atom call (e.g. in procedureStore) never delivered its inputs; re-transpile it. Local functions and Error('message') keep positional arguments. The VM does not check parameters; it runs an AST as written. Its job is to be correct and safe; preventing bad code is the transpiler's job (Tonio). An AST built by hand or by another front end is that producer's responsibility. Four review rounds tried to make the VM decide what a call meant from each atom's schema, and each broke a call that worked. Embedders: an atom you called positionally (an args input) is now called foo({ args: [a, b] }).

Since rc.1 — the AsyncJS value domain is closed (breaking). A guest value is data — JSON values, with the VM's own Set, Date and regex as data (above) — and never a host function or a builtin namespace. It is checked where values ENTER guest state: the walk that charges every bind, insertion and reconcile now refuses a function or namespace anywhere in the value, so no producer (an expression, an argument read, an atom result such as pick) can bypass it. Arguments are a null-prototype object read by own key (args.constructor was the host's Object, and varsImport could hand a capability Object.prototype). A Set or Date in the run's RESULT is plain data, so a structuredClone at a worker or process boundary keeps it (wrapper methods had reduced a Set to { size }); a host that read wrapper methods off the result reads the data instead. Validation costs about 0.002 fuel per schema-node × data-node step (some 0.11 fuel per 6-field row): raise fuel, or validate at your own edge, for large payloads. Earlier holes closed in this round of review: const g = parseInt, Object.values(Math), { toJSON: encodeURIComponent }, [Set].join('') (which printed VM source) and a dot-path 's.add' all handed out host functions, and a stolen Set.add called through a guest object's own hasOwnProperty property grew a Set past maxHeapBytes. Method calls now dispatch to the receiver kind's INTRINSIC, never to a property the guest owns; a namespace can no longer reach the host's result (where JSON.stringify threw). llmPredict options are allowlisted (model, temperature, maxTokens, topP, stop, seed, responseFormat, tools) and tools must be { type: 'function', function: { name, description?, parameters?, strict? } }. Validation is charged at the VM's own rate (it bought ~38× more host time per fuel), and an AST's inputSchema counts its arguments per path, as validation walks them.

Since rc.1 — in AsyncJS, Schema is data, and a method is not a value (breaking). Guest code held live tosijs-schema builders, whose methods are host closures: a validate read as a value and spliced next to a harmless schema validated against a pattern smuggled in through meta (51s on Node, at fuel 50). Now Schema.string, Schema.email, … are frozen plain JSON schemas, and Schema.object, array, record, union, tuple, enum, const, fromExample, response and isValid are VM-implemented, returning plain JSON built only from admitted parts. Migration: builder chaining is gone — write the keyword: .int → type: 'integer'; .min/.max → minimum/maximum (or minLength/maxLength, minItems/maxItems); .step → multipleOf; .title/.describe → title/description; .default → default; .open → additionalProperties: true; Schema.pattern → refused (use regexMatch). .optional → a nullable type, { type: ['string', 'null'] }, which Schema.object leaves out of required — note that a nullable key may be absent; a present key must be the type or null. And reading a method as a value (const f = s.trim, 'a'.toUpperCase) is refused: call it. Validation is charged as schema size × data size, before it runs, at every door (an anyOf of 3,300 branches over a 97×97 array ran 630ms for 63 fuel when charged as a sum), including the AST's inputSchema, paid at admission. Every LLM door — the core llmPredict too — admits its responseFormat (shape allowlisted: json_schema, json_object, text) and every tool's parameters schema.

Since rc.1 — guest schemas are a closed dialect (breaking). Every schema that comes from guest code or the guest AST — Schema.* and filter arguments (after an example is converted), an AST's inputSchema, a return step's schema, an LLM responseFormat — must be a plain JSON tree using only the keywords the VM admits, each with exactly its value type: type, properties, items, additionalProperties, anyOf, oneOf, required, enum, const, the numeric bounds, format (only the formats tosijs-schema enforces, all linear), title, description, default, examples. Anything else is refused, naming its path — including pattern (except the library's own, e.g. Schema.emoji), patternProperties and $predicate. A denylist failed: tosijs-schema compiles pattern with new RegExp(value), so an ARRAY or a regex object walked past a string-only check and ran on the host's backtracking engine, at the inputSchema door before fuel or the timeout existed. Migration: a builder or persisted AST whose .take()/.return() schema uses .pattern(), patternProperties or $predicate (the tjs-lang/css schemas included) is now refused by the VM; validate those on the host after vm.run, or match inside the program with regexMatch. Also refused, and failing at vm.run for a persisted AST: $schema, $id, $ref, definitions, uniqueItems, prefixItems, nullable, additionalItems, any other keyword, an accessor, a non-plain object, a non-finite number, more than 10,000 nodes or 64 levels. An object with a string type field (or an array of type names) is read as a schema, not an example. The predicate trust registry moved from a string global to a Symbol.for slot, so bundles of different 0.14 prereleases in one realm do not share trust.

Since rc.1 — every resource a regex uses is charged. Everything a regex costs is now a function of its program size and its input. A compiled regex is charged its program's size against maxHeapBytes as the program is built, and counted while it is held (it used to keep its matching table for as long as it lived: 1000 held regexes kept ~690MB under an 8MB ceiling). Regex literals in one source share a compile budget of max(250k, 200 × source bytes) steps, every byte compiling allocates counted as work, with each distinct literal compiled once; an over-budget source is refused with "The regexes in this source are too large to compile". That holds in AJS helper functions too, and for $predicate sources. In predicates, __fuel and __rx are reserved identifiers, and the pattern passed to match/search/matchAll must be a regex or string literal (or a const bound to one): JavaScript compiles a string passed there as a regex on its own engine, which no fuel can see inside. A string LITERAL is compiled by the metered engine instead; any other non-regex argument is refused (replace/split take a string literally and are unaffected).

Since rc.1 — the regex engine is metered by construction, and predicates use it too. A linear algorithm is not a bounded one unless its work is CHARGED: the first version charged one step per thread per position, and the work it did not see (following zero-width instructions, copying capture arrays, testing a 400k-character class, expanding (?:){1e12} at compile time) ran for seconds on a few fuel. Every unit of engine work is now charged — each instruction visited, each capture copy in proportion to its width, each class probe, compilation itself — and every pattern-sized quantity is capped (counts over {10000}, nesting, program size, capture slots). Classes are merged range tables, binary-searched. Case-insensitive matching uses equivalence classes DERIVED from the host engine (generated, freshness-tested), so i and iu agree with JavaScript on every code point (Dž ~ DŽ, ſ ~ s under u, dotless ı). A long-standing divergence on nested quantifiers over empty-matchable bodies is fixed: dedup on the program counter alone let a path whose empty check would fail block one whose check would pass. replace charges each substitution at its exact length BEFORE building it. Unsupported regex literals are a transpile error at their source location. Predicates (compilePredicate, $predicate, tjs-lang/css) run regex literals on the same engine, charged to the predicate's fuel — /a*a*c/, which the old star-height screen certified, took 13s on 6k characters inside a $predicate that travels as data. A Type predicate with a regex is no longer emitted as a verified guard (standalone output cannot carry the engine; it still runs, unverified). That means a warning from tjs check, so a --max-warnings 0 gate fails on such a file. Write the check with string methods (includes, indexOf, startsWith, split) to keep it verified — the Email example in the declarations guide now does. Cost: tjs-lang/css validates a theme in 7.3ms (0.20ms on the host's engine, measured — ~36× slower; ~15× per value), still inside a frame. Making this fast is card #2767, and Pattern (docs/pattern.md) is the longer-term answer. Also: the ASI guard recognises a control header from its parenthesis structure, so a header spanning lines, label: for (…) and for await (…) keep their brace-less bodies.

Since rc.1 — native TJS accepts a brace-less control body that starts with [ or (. The ASI guard put a ; in front of such a line — which, after if (…) / while (…) / for (…) / else, BECAME the statement's body: if (c)\n [a, b] = …\nelse … orphaned the else, so valid JavaScript was refused (a TJS ⊇ JS violation, found when the VM's regex parser entered the dogfood corpus). A bare header now keeps its body; a complete one-line if (a) foo(b) still separates the next line, as before.

Since rc.1 — guest regexes run on the VM's own engine; methods take exactly-typed arguments (breaking). Guest regexes (literals, regexMatch, match/search/replace/replaceAll/ split) no longer run on the host's backtracking engine: a linear-time Pike VM, fuel per step, makes ReDoS ordinary work instead of screening for it (the screen missed polynomial shapes — /a*a*c/ over 6k characters ran 12.9s for 2.7 fuel). Unsupported, and refused when the regex is compiled (a literal, at transpile time): backreferences, lookahead/lookbehind, \p{…}, the d/v flags. A schema pattern supplied by guest code is refused (it would validate on the host's engine) — use regexMatch. Methods now take arguments of exactly the type they read: a count is a number ('x'.repeat('1e8') is refused; it built 191MB under a 1MB cap), positions are typed (arr.indexOf(v, obj) converted obj), and extra arguments are refused. The regex input cap (100k characters) is gone — input is charged, not capped. Also: a Set's remove(NaN) removed the last element; JSON.stringify is bounded by its escaped size; printers charge their walk as they walk.

Since rc.1 — AJS refuses implicit coercion of objects and arrays (breaking). Operators (+ - * / % ** < > <= >=, unary +/-), computed keys, and methods that take primitives now refuse an object/array operand instead of converting it to its string form, recursively and for free: arr < 5, 'x' + arr, `${arr}`, obj[arr], 's'.includes(arr), Math.max(arr), parseInt(arr), [[2],[1]].sort(). Write the conversion: arr.join(','), JSON.stringify(obj). Equality still compares objects, by identity. Printers (join, JSON.stringify, flat, toString) are now bounded by the TREE they print — a shared-reference DAG of a few KB joined to 400M characters for 2.5 fuel. Regex literals are now data in the AST ({ $expr: 'regex' }), built by the VM after the ReDoS screen; they were host RegExp objects the transpiler built from guest source, never screened, and serialized as {}. match/search with a string pattern are screened too (one was 634ms past a 50ms timeout), and a RegExp runs only over inputs up to the existing 100k-character cap. Also: a Set's has/intersection/diff are O(1)/O(n+m) (they were linear/quadratic for a linear charge); slice/substring/split are charged for what they select; a helper's extra arguments are dropped, not held unbound.

Since rc.1 — one implementation per operation (v2). Method calls compile to the VM's gated method call and template literals to its gated +, instead of push/split/join/ template atoms with their own budgets and their own semantics: let n = arr.push(x) is now the new length (it was the array), `${null}` is 'null' (it was ''), and an unknown method is a transpile error (it became a call to an atom named after the method). An expression statement compiles to a new evaluate step — it bound _, clobbering a guest's own _, and refused a program with const _. v1 ASTs keep the data atoms, now thin wrappers over the same gate; the template atom reads only its own vars ({{constructor}} printed Object's source). costOverrides/quotas keyed on a data op apply to v1 ASTs only. Hosts running untrusted code at scale should still give each run a hard engine-enforced floor (a Node Worker with resourceLimits) — see "Why not just ask the runtime?" in the budgets doc.

Since rc.1 — every v2 block is a scope. if/else, try and catch blocks now scope their declarations, as while bodies already did and as JavaScript does: sibling blocks may each declare const t, a block let x may shadow an outer const x, and catch (e) shadows an outer e (with errorOp, bound in the catch block). v1 ASTs keep unscoped blocks. A raw atom ({ op, exec }) that writes ctx.state[step.result] itself now declares in the innermost block — use defineAtom, which honours assignment (resultAssign). A default parameter is now filled by assignment, not a declaration in the if that tests it. var is function-scoped, as in JavaScript: it is declared once at function entry and var x = v assigns it, in any block (lowering it like let lost if (c) { var q = 5 } silently); a callback's vars are the callback's own. for (var x of …) and for (x of …) are refused with a pointer to const/let: the loop variable is bound in the loop's scope, so they cannot mean what JavaScript means (assign an outer x), and they silently did not. errorOp, like the catch parameter, is bound in the catch block and is not visible after it.

Since rc.1 — const follows the scope a write lands in. One rule, enforced where every scope write passes: a write may not land on a const of the scope it writes. A block let x beside an outer const x was refused as a reassignment — legal JavaScript rejected — while varsImport, varsLet and catch checked nothing and could overwrite a const.

Since rc.1 — guest code cannot set an object's prototype. __proto__ is refused as an object key — at transpile time, in object literals, in resolved values, in atom inputs, and in the guest Object.assign (which used the native one, and so the __proto__ setter), and in every atom that builds an object from guest keys (pick, omit, varsExport, agentRun's input, the return projection). Set as a key, it replaced the object's prototype, hiding whatever hung off it from maxHeapBytes (~19MB held under an 8MB cap). Every computed-key write in the VM now goes through one guarded function, and a source scan (guest-key-writes.test.ts) fails on any new one. pick also reads own properties only: pick(o, ['constructor']) returned the inherited Object function, a live host function in guest hands.

Since rc.1 — AJS assignment means what it means in JavaScript (#59). Found by tosijs-platform; every one of these was a SILENT wrong answer:

  • Every compound assignment stored only its right-hand side: n -= 2 gave 2, s += 'b' gave 'b', n *= 3 gave 3. They now lower to x = x op y.
  • i++ / i-- / ++i as a statement were dropped (a warning, then nothing). They now assign. Used as a VALUE (j = i++) they are refused, as is any expression AJS does not support — the transpiler used to turn an unrecognised expression into null and carry on.
  • Assigning an outer variable inside a for…of body was lost (s = s + w left s empty): assignment and declaration compiled to the same step, which wrote the loop body's own scope. Assignment now compiles to a new varAssign step that writes the scope that OWNS the variable, as JavaScript does, charged to that scope's heap ledger. Declarations are unchanged, and ASTs already stored keep their meaning. An AST from this transpiler is format v2 (below), so an older VM refuses it by version, never silently.

Since rc.1 — runCode is its own program. Breaking. Code run by runCode sees only the args it is given; it no longer sees the caller's variables or local helpers. It ran in a child of the caller's scope, so model-written source could read every caller binding (keys, tokens), reassign them (stopped at a scope boundary in the first rc.2 fix), and — what that boundary could not stop — mutate them in place (allowed.push('evil')). A fresh scope has nothing on the other side to reach. Symptom: a caller variable that runCode source used to read now reads as undefined. Pass what the code needs in args; what it is given it holds by reference, as a function holds its arguments. Likewise agentRun now runs a sub-agent's OWN local helpers — it was handed the caller's, so a call by name ran the wrong body.

Since rc.1 — two exports removed. Technically breaking; neither had a real use.

  • setTranspiler is no longer exported from any entry. It lived in vm.ts, which every entry re-exports, so it was public everywhere — including tjs-lang/vm-ast, whose whole promise is that it contains no parser, while setTranspiler let any importer arm one in the sandbox's process. It is now internal; tjs-lang/vm, the main entry and tjs-lang/eval still wire the transpiler in exactly as before. If you called it to give vm-ast a parser, import AgentVM from tjs-lang/vm instead — that is what it is for.
  • The tjs-lang/src export is gone. It pointed at src/index.ts under every condition, so in Node (with type stripping) it loaded TypeScript whose relative imports have no extension and failed; every other src/ path is behind the bun condition. Nothing we could find imported it. Use tjs-lang (the bun condition already resolves it to source).

The syntax reference is now TJS-SYNTAX.md (was CLAUDE-TJS-SYNTAX.md), so its page on the doc site is /TJS-SYNTAX/. The old file stays as a one-line pointer.

Since rc.1 — httpFetch no longer breaks cross-origin requests in a browser. The default fetch added X-Agent-Depth to every request; in a browser that is a non-simple header, so any cross-origin API that does not allow it failed preflight with "Failed to fetch" (found running the AJS weather example live on the doc site). In a browser the header now goes only to the page's own origin, where agent endpoints live; servers send it everywhere as before, and a custom fetch capability still receives the depth.

Packaging (rc.1): typescript is now a declared OPTIONAL peer (^5) — tjs-lang/lang/from-ts imports it, and it was only a devDependency, so a Node consumer of that entry without it installed failed at import. Dev-only scripts (bin/*.ts, editors/build-*.ts, the CodeMirror demo component) and unreachable editor .ts sources are no longer shipped; every exported path is unchanged. Found by the new publish workflow's release-doctor, not by a user.

Published first as 0.14.0-rc.0 on the rc dist-tag, so tosijs-ui could verify against it before its peer range admitted 0.14 (tosijs-ui#182). It did: tosijs-ui 1.15.2 declares tjs-lang: ^0.13.1 || ^0.14.0, so npm consumers of both resolve cleanly. That rc verified PEER RESOLUTION; the Type-example and TjsStrict changes below came after it, through several review rounds (docs/reviews/0.14.0-*).

Untrusted AJS source is capped at 8KB by default (was 64KB) — and the real answer is to not accept source at all. maxSourceBytes for Eval, SafeFunction, vm.run(source), runCode and transpileCode now defaults to 8KB. Nine review rounds in this release each found another input shape that the AJS preprocessor or acorn parses super-linearly — nested destructuring took 19s at 60KB, a function head followed by whitespace 2.9s — and patching them did not converge. A quadratic cost shrinks with the square of the cap: at 8KB the worst known shape is ~455ms (densely nested destructuring, measured 2026-09-26). Raise it per call for trusted source, and transpile(source, { maxSourceBytes }) (new, opt-in) caps an in-process transpile. For source the GUEST builds (runCode/transpileCode), vm.run's maxSourceBytes can only lower the 8KB cap, never raise or disable it: that text can come from llmPredict output, and one option cannot speak for two trust domains. If you run agents from untrusted callers, take an AST instead: transpile on the caller's side (tjs-lang/browser or tjs-lang/lang) or in a worker or process you can afford to lose, send the AST, and run it with tjs-lang/vm-ast, which has no parser in it at all — the parse cost then lands on whoever sent the source, and what remains on the host is linear. (This project's own hosted endpoints still accept source; they get the 8KB cap when functions/ is bumped to 0.14 and redeployed — a 0.x minor is outside ^0.13.x — and moving them to ASTs is next.) ajs, createAgent, tjs and the exported parser functions are for TRUSTED source and take no cap.

Deprecated: vm.run(source) — the VM parsing AJS. It still works (capped at 8KB) and notes itself once in the flight recorder. The VM should never be the thing that parses: transpile separately (transpile from tjs-lang/lang, in a worker, another process, or on the caller's machine) and run the AST with tjs-lang/vm-ast, which contains no parser — so a bad payload can only take down the step that parsed it, never the endpoint. Eval and SafeFunction keep taking source: they are for source you trust, or a process you can afford to lose.

A language release. The tosijs-ui-hosted site was what 0.14.0 was originally reserved for; that work is real but lands separately, as a non-breaking change to build tooling that does not touch the published surface. The version number follows the narrative rather than a name reserved in advance.

If you only read one line: a runtime type now reports typeof === 'function' instead of 'object'. .check(), isRuntimeType(), Object.keys() and spread are unchanged, and serialisation improved from throwing to working — so unless you branch on typeof, that one moves nothing under you.

What may newly RUN — error propagation is decided at the type check (native .tjs and converted code). A MonadicError passed where it does not fit a parameter's type is still returned unchanged without running the body — including through overloads, arrays and options bags. But every validated function used to begin with a pre-check that returned ANY Error from ANY parameter, whatever its declared type:

What may newly REJECT something — each is a fix, and each can turn a value that used to pass into a returned MonadicError:

Changed

Added

Fixed

Added

0.13.13 — 2026-09-13

Read this first if you use unsafe

A patch release normally cannot take anything away. This one can, so it is named here rather than left for you to discover. var and eval were reachable in a .tjs file behind the unsafe marker; they are now refused outright, so source that compiled under 0.13.12 can fail under 0.13.13:

unsafe var x = 1      // 0.13.12: compiled     0.13.13: refused
unsafe eval(src)      // 0.13.12: compiled     0.13.13: refused

The full reasoning is under Changed below; briefly, eval could not be given a named replacement even in principle, because direct eval reads the CALLER's scope and any wrapper would silently be a different operation. unsafe itself is only deprecated — it warns and keeps working.

It is a patch deliberately: the version line stays on 0.13.x until the tosijs-ui migration and tjs doc land, and 0.14.0 is reserved for that release. Pinning ~0.13.12 will not protect you from this; pin exactly if you rely on either escape.

Added

Changed

Fixed

Security

0.13.12 — 2026-09-06

Two security-relevant fixes against published 0.13.11, and the end of a defect class.

Eval/SafeFunction returned plausible wrong values (#52) — spread was silently dropped and a dotted read came back as its own source text — and in the reference RBAC layer that inverted into a grant (#54). Both were reported by a consumer running against a known-good oracle, which is the only way either could have been seen: every wrong value had the right shape, so structural checks, typeof and length checks all passed. Separately, verifyPredicate was certifying impure functions as pure, so "verified" meant less than the badge claimed.

The rest is one class of defect, finally closed. Eight scanners that misread code merely MENTIONING the syntax they scan for — a test block quoted as data being executed and deleted, a comment containing export consuming the real one, a template's ${…} desynchronising the shared literal scanner, a quoted wasm function being compiled, a quoted Is operator being transformed. Each was found somewhere other than where it did its damage.

The dogfood behaviour gate went from 108 broken tests to zero. Every test suite we ship now converts to TJS, runs, and preserves every assertion — all three 1.0 self-hosting gates at zero, both ratchets pinned at 1.0. Roughly two thirds of that distance was defects in the gate rather than in the language, which is recorded at the baseline so the next bad number there is read as a question about the apparatus first.

Fixed — silent wrong values in Eval/SafeFunction

Security

Fixed — the emitted runtime and the TypeScript bridge

Self-hosting

Changed — the dogfood gate stopped discarding its own evidence

Fixed — the literal-blindness class

Added

Fixed — the dogfood gate itself (not shipped code)

Changed — cleanups

0.13.11 — 2026-09-04

Three findings from the 0.13.10 pre-release review, all pre-existing, none introduced by 0.13.10. Each was reproduced before being fixed and is pinned by a test.

Security

Fixed

0.13.10 — 2026-09-03

Changed — AJS parses through its own core, not parse() with a flag

The structural fix behind 0.13.7's test-block RCE. That vulnerability was one missing !options.vmTarget among ~30 source transforms of which exactly two checked the flag, so ~28 TJS transforms ran when compiling AJS. A gate fails open, and this one had for months.

AJS now parses through parseAgentSource() in the new src/lang/parser-agent.ts — four steps (hashbang, line comments, colon shorthand, param markers), because that is what AJS is: a JavaScript subset plus typed signatures. Adding a TJS transform means editing parser.ts, which AJS does not call, so there is nothing left to remember.

All seven previously-leaking TJS constructs — bang access, Is, inline wasm function, Type, Generic, extend, FunctionPredicate — are rejected on the AJS path now, closed as a group rather than one at a time. They were inert, but accepting syntax the language does not have is how the last one arrived.

vmTarget is removed from ParseOptions and PreprocessOptions. It was internal (both AJS entry points set it themselves) and is not part of the documented API, so no supported usage changes. parse() is TJS's parser and says so.

One visible behaviour change: two same-name top-level functions in AJS source are now rejected by acorn (Identifier 'x' has already been declared) rather than by TJS's polymorphic-merge pass (ambiguous signatures). Same rejection, from the JavaScript rule that actually governs a JavaScript subset.

Guarded two ways in src/lang/eval-no-transpile-execution.test.ts: an acorn-parsed pin on the AJS pipeline's import set, so a new transform fails at the import rather than waiting for someone to think of a construct that exercises it; and the existing behavioural ratchet, whose known-leak list is empty.

Scope of that claim, stated honestly. parser-params.ts is shared with TJS's parser, so "absent by construction" is true of the transform list, not of every behaviour reachable through it: TJS safety markers (!/? on params, :!/:? on returns) are still accepted and silently discarded on the AJS path. No execution, no capability, no fuel bypass — but an author writing function main(!apiKey: '') gets the opposite of what the marker documents. Tracked in TODO.md.

Shipped as a patch, with the breakage named. Three things could bite:

Neither the flag nor the constructs are a documented API, which is why this is a patch rather than a minor.

Fixed

[0.13.9] — 2026-09-03

SECURITY — a TJS example value is parsed, never executed

Second transpile-time escape, and a different path from 0.13.7's. A return-type annotation containing = was evaluated rather than parsed:

function f(a: 0): { x = (globalThis.PWNED = 1) } { return { x: a } }

tjs check on that file ran it. So did tjs emit, the bun .tjs plugin, the module loader and the playground — anything that transpiles source it did not write. Eight emitter sites used new Function(\return ${text}`)()` to turn an example into a value.

This is the ordinary tjs() path, so 0.13.7's vmTarget gate did not touch it. The AJS path rejects the carrier, so the VM and the hosted endpoints were not affected — the blast radius is developer and CI machines transpiling untrusted .tjs.

Fix: src/lang/literal-value.ts parses examples with acorn and accepts only literals — strings, numbers, booleans, null, regex, arrays, plain objects, signed numbers, and non-interpolated templates. No allowlist of dangerous names and no sanitising of source text: anything that can compute is not an example, and that is decidable from the AST. Ordinary return defaults are unaffected.

Found by the post-remediation review (reviews/0.13.8-post-remediation-review.md).

Fixed

Changed

Known

tjs-lang <= 0.13.8 all carry at least one transpile-time execution path. No GitHub advisory has been filed and prior versions are not deprecated: this project has no known consumers, so the automated-notification machinery would be ceremony rather than protection. Revisit if that changes.

[0.13.8] — 2026-09-03

SECURITY — 0.13.7 shipped the fix in src/ but not in dist/

If you installed 0.13.7 from npm and use it under Node, upgrade. 0.13.7's security fix (a VM-target transpile no longer executes the code it is transpiling) was real in source and absent from the published bundles: dist/ had been built 35 minutes before the fix landed.

Bun resolves this package to src/, so every local check passed. Node resolves it to dist/, so every Node consumer of 0.13.7 got the vulnerable build — including anyone importing tjs-lang/eval. Verified by fresh-installing the published tarball and running the exploit against it, which is the only check that would have caught it.

No source change from 0.13.7. This release is the same code, correctly built.

Added

[0.13.7] — 2026-09-02

SECURITY — a VM-target transpile no longer executes the code it is transpiling

If you call Eval, SafeFunction, or expose either over a network, upgrade.

parse() ran every test '…' { … } block with new Function(body)(). Eval and SafeFunction transpile the submitted string before vm.run, so the payload executed with full ambient authority before fuel, timeout, capabilities and the membrane existed:

Eval({ code: "test 'x' { globalThis.__PWNED__ = true } return 1", fuel: 10, timeoutMs: 1 })
// -> { result: 1, fuelUsed: 0.2 }     and __PWNED__ === true

timeoutMs: 1 was irrelevant and 0.2 fuel was charged, because the whole sandbox is downstream of transpilation.

It was a category error before it was a vulnerability. AJS has never had test blocks — they are a TJS feature, and the AJS path inherited them only by sharing parse(), where every other TJS-only transform is gated on vmTarget and this one was not. An agent language whose premise is that code travels as data and runs with no ambient authority was calling new Function on submitted source.

What changed: a VM-target transpile rejects test blocks as the syntax error they are. TJS inline tests are unaffected — no .tjs behaviour changed, and the global runTests default was deliberately not touched, because that would have altered documented TJS behaviour to fix a bug that only ever existed on the other path.

Verified two ways: every TJS construct a VM-target transpile still accepts was probed for transpile-time execution (none executes), and a source-level test asserts that test extraction is the only dynamic-execution site in the entire parse path.

Still open and tracked: a VM-target transpile accepts seven TJS constructs AJS does not have (inert, ratcheted). The structural fix — an AJS core that TJS wraps, rather than one shared function with flags — is planned. A gate fails open; layering fails closed.

Fixed

Changed

Added

Compatibility

Every file in zod, effect, kysely, radash, superstruct and ts-pattern now converts and parses — 1973/1973, up from 1951. All six projects' own test suites pass against our output.

[0.13.6] — 2026-08-26

BREAKING — defineAtom now defaults to effects: 'io'

If you define custom atoms, read this. It ships as a patch, deliberately, and the reasoning is worth stating because the obvious call is the wrong one.

Gating a security correctness fix behind a version bump means every adopter on ^0.13.x keeps the hole until they choose to move. Here the failure mode is the silent absence of protection, so the person who never upgrades is the one who stays exposed — while the person who does gets, at worst, a loud error telling them their atom was handing live host references to guest code. There are no bad surprises in that trade, only good ones, and they should arrive automatically. Breaking toward correctness, loudly, is a bugfix.

So: no bad surprises, but not silent ones either — hence this entry, the BREAKING marker, and the migration notes below.

effects defaulted to 'pure', and 'pure' skips the capability membrane. Not a lighter check — membraneValue has exactly one call site, inside if (atom.effects === 'io'), so an atom that didn't opt in bypassed the boundary entirely: host objects reached guest scope by reference, getters intact, with methodCall standing right there.

One default was serving two populations with opposite needs. Core atoms (len, jsonStringify, map) work on data already inside the VM, so 'pure' is right for them. Atoms defined through the public defineAtom exist to bring host data in — Firestore snapshots, Elasticsearch hits, SDK responses — which is precisely the data the membrane exists to sanitise, and precisely the shape that carries accessors. The default served the first and silently disabled the boundary for the second, whose authors are outside our audit surface.

It failed quietly, which is what settles it: nothing warned, nothing broke, the atom worked and the hardening was absent. snowfox-app upgraded specifically for the 0.12.0 prototype-strip and later found all four of its custom atoms untagged (#38). When the people who read the release note and acted on it still don't get the protection, documentation is not a control.

What changes for you. An atom you define without effects now has its return deep-copied through structuredClone before it reaches guest state. Three consequences:

If your atom really is pure, say so and nothing changes:

defineAtom('slugify', inSchema, outSchema, fn, { effects: 'pure' })

That is the honest fix, not a workaround — and it is now an explicit claim rather than something you get by forgetting. Core atoms are classified the same way, by an explicit sweep in both directions, so their class no longer depends on which default is in force.

Fixed

Added

Fixed (same change)

[0.13.5] — 2026-08-25

Fixes two defects in 0.13.4's asCompared, both found by a nine-lens review run after that release was published. If you use asCompared, upgrade. If you do not, the second one still affects you — the projection table is emitted into every file that uses ==, Is or truthiness, whether or not it declares a projection.

0.13.4 also went out without its tag being pushed, so the full-suite pre-push gate never ran for it. That is how it shipped.

Fixed

Changed

Documentation

[0.13.4] — 2026-08-25

Added

Fixed

[0.13.3] — 2026-08-24

Found by an ecosystem security sweep and by playing with the deployed playground — neither by a test here. Both defects were in published 0.13.2.

Fixed

Changed

[0.13.2] — 2026-08-21

A ninth pass then blocked this one on three more (report), two of them sibling-site misses in the fixes above: the symlink guard protected only the LEAF, so a symlinked output DIRECTORY still escaped and destroyed a file outside the named tree; and moving the #! line to the file-write seam fixed -o while silently regressing tjs emit bin.tjs > bin.js, which is the first example in --help. Both fixed here, with containment (a symlinked -o ROOT stays legal — that is a normal setup) and with stdout treated as the artifact sink it is. A fourth finding — that port.test.ts is red — was checked and refuted: isOurServer rejects the test runner under both relative and absolute argv.

An eighth review pass, run after 0.13.1 shipped (report), found that 0.13.1's hashbang fix had been applied at the wrong seam — breaking tjsx, a published bin. Six of eight rounds have now found a defect introduced by the previous round's fixes. That is the argument for the round, not against it.

⚠️ Upgrade from 0.13.1

Fixed

[0.13.1] — 2026-08-21

0.13.0 was published by mistake. It was meant to be a release candidate and the version was labelled 0.13.0; the publish also ran from the working tree rather than a pushed tag, so the full-suite gate in .githooks/pre-push never fired for it. v0.13.0 has since been tagged retroactively at the commit the registry actually has, and 0.13.0 will be deprecated on npm in favour of this release.

A seventh pass then reviewed this patch before it shipped and blocked it on three more, all introduced by the fixes below (report): tjs check had started silently SKIPPING symlinked source files (green because it did not look), emit/convert still followed symlinks and could write output derived from outside the tree they were given, and tjs emit silently stripped the #! line this release advertises as newly supported. All three are fixed here. Five of seven rounds have now found a defect introduced by the previous round's fixes — which is the argument for the round, not against it.

A sixth review pass, run AFTER publication (report), found the blocker below plus every major fixed here. Reviewing after shipping is not the plan; it is what caught this.

⚠️ Upgrade from 0.13.0

Fixed

Performance

Internal

Compatibility

[0.13.0] — 2026-08-19

Reviewed FIVE times before tagging, each pass over the full diff since v0.13.0-beta.1. The first (report) returned BLOCK on five blockers; the second (report) BLOCKed on two more that the first round of fixes had introduced; the third (report) BLOCKed on a parenthesised-arrow emit bug; the fourth (report) BLOCKed on four, two of them regressions introduced by the third round's own fixes — a maxHeapBytes bypass and a build failure that would have shipped a stale bundle; the fifth (report) BLOCKed on two — a let arrow with :? that crashed at module load (a regression from the fourth round's own fixes) and the shipped tjs binary hard-failing for anyone without the TypeScript compiler. All are fixed, along with every major and most minors each pass confirmed.

That pattern — a fix round introducing the next blocker — happened in FOUR of the five rounds, and is the honest argument for reviewing again after fixing rather than treating the last green run as the answer.

Links are absolute because docs/reviews/ is deliberately excluded from the npm package ("!docs/reviews" in files): relative links would be dead on npm and unpkg.

⚠️ Upgrading — read this first

11 changes alter behaviour. Most produce no type error, so recompiling does not catch them; one of them — a Type block that declares no example, predicate or default — is a hard compile error on source that used to transpile. All but the VM-budget change affect code that ran under 0.12.0.

(The count and the "the last one" pointer were both stale: bullets were appended to this list after it was written, so the positional reference had drifted off the item it named. Positional references into a list that grows are a standing trap; the item is named now.)

Two bodies of work. First, the language stabilised in its own direction: the guiding rule became a form that parses must mean something, and every construct that parsed while validating nothing was either built or removed. Second (below, from "Everything below came out of the full pre-release review"), the pre-release review of 0.13.0-beta.1.

The through-line is worth stating because it explains why so many entries are small: the language now claims less by accident and checks more on purpose, and everything it claims is asserted by something that runs.

Added

Changed

Fixed

Performance

Documentation

Everything below came out of the full pre-release review of 0.13.0-beta.1. Seven findings were blockers; the review also caught that the beta's own changelog entry had omitted four VM security fixes entirely (now written up in their own section, below, since they shipped there).

Changed

Fixed

Security

Fixed

Changed

[0.13.0-beta.1] — 2026-08-03

Beta. The language changed shape: all nine mode directives are gone and the file extension is the only gate. Escapes are per-construct, so an accidental use is still caught. Published as a beta because that shape change deserves real use before it is called stable.

Removed — BREAKING

Security

The four items immediately below shipped in 0.13.0-beta.1 and were omitted from this entry at the time — found by the pre-release review of the beta. They are recorded here, under the version that actually contains them, rather than backdated into [Unreleased].

Security / Chore

Added

Changed

Fixed

0.12.0 — 2026-07-20

Minor bump with breaking changes — see Changed. Lands the TjsDictDefaults mode and five VM-security fixes from a two-round adversarial review. Closes zero open GitHub issues (this release is security + dict-defaults, both internally driven); the per-mode opt-out those security/dict-defaults changes make more acute is tracked as #7, still open.

Security

Affected versions: the SSRF, ReDoS, capability-membrane, methodCall, and scope-name fixes below address vulnerabilities present in 0.11.0 and all earlier releases. Pinned consumers (VM embedders) should upgrade.

Added

Changed

0.11.0 — 2026-07-18

Minor bump — two new entry points (./import-resolver, ./import-resolver/worker), no breaking changes. This is the release tosijs-ui's doc system builds against.

Added

Documentation

0.10.1 — 2026-07-17

Patch — a critical fix, no API changes. One behavior change (Is() on cyclic graphs now answers instead of crashing), noted under Changed.

Fixed

Changed

0.10.0 — 2026-07-16

Minor bump — additive features and fixes, no breaking changes.

Added

Changed

Fixed

Performance

Documentation

0.9.1 — 2026-07-11

No breaking changes.

Added

Changed

0.9.0 — 2026-07-06

Added

Fixed

Changed (mildly breaking)

0.8.7 — 2026-07-01

Fixed

0.8.6 — 2026-06-30

Fixed

0.8.5 — 2026-06-30

Added

Fixed

0.8.4 — 2026-06-26

Added

Changed

(No 0.8.3 was tagged — the version was skipped.)

0.8.2 — 2026-06-24

Added

Fixed

0.8.1 — 2026-06-10

Fixed

Changed

0.8.0 — 2026-05-14

Added

Entries below 0.8.0 are backfilled coarsely from the git tags and log (they predated this changelog). Only v0.2.0, v0.7.6, v0.7.7, v0.7.8 were tagged before 0.8.0, so the long 0.2.0 → 0.7.6 span is summarized as one entry rather than split across untagged versions.

0.7.8 — 2026-04-30

Added / Fixed

0.7.7 — 2026-04-27

Fixed

0.7.6 — 2026-04-26

The long feature-accretion phase (202 commits since 0.2.0, no intermediate tags — coarse summary):

Added

Changed

0.2.0 — 2026-01-29

The foundational release: the TJS→JS transpiler (runtime type metadata), the TypeScript→TJS converter, the AJS gas-metered VM (fuel metering, capability injection, monadic errors), the builder API, stored procedures (AST-as-token), Eval() safe eval, proportional fuel charging for memory-allocating ops, and the playground + editor integrations (Monaco / CodeMirror / Ace, linter, autocomplete POC).

Changed