Ajo Engine
The artifact
The sealed directory, its manifest, feature evidence and fail-closed loading.
ajo-engine-compiler turns an application into a sealed artifact directory, and ajo-engine loads nothing else. The directory is a contract between the two: it names the exact engine it was written for, every module and asset with its hash, the authority the App declared and what the compiler found in its code. This page describes that contract and what the runtime checks before any application code runs.
Two ways in
The compiler has two modes:
ajo-engine-compiler [--keep-source] <entry.js> <outdir>
ajo-engine-compiler [--keep-source] --input <descriptor.json> --output <outdir>| Mode | Input | Module grants |
|---|---|---|
| Direct | One ESM entry and its static import graph. | Every known runtime module. A development and test convenience. |
| Descriptor | The staging tree from kit build: .ajo/compiler.json, schema 1. | The explicit capability declarations, plus the runtime:* imports the compiler discovers. |
The descriptor is the build handoff: a closed module graph with the client assets, the compiled migrations and the App’s declared authority. Kit writes it on every build, and kit build --compiler runs the compiler on it. In both modes, filesystem roots and FIFO declarations are separate authority checks. A broad module grant does not bypass them.
A Kit App declares that authority in package.json:
{
"kit": {
"engine": {
"env": { "required": ["MAIL_URL"], "optional": [] },
"fs": { "roots": ["/ajo/data"] },
"ipc": { "pipes": [] }
}
}
}Installed ajo-* plugins may add their own kit.engine blocks, which Kit merges into the descriptor. Build and configuration covers that side.
What the directory contains
dist/ajo/
manifest.json the contract described below
chunk-<n>.bin one precompiled bytecode chunk per module
client/ hashed static assets, when the descriptor names them
scan.json the per-module feature-evidence sidecarTreat the directory as one unit. Copy all of it, including the manifest, into the image; a missing or altered file stops the runtime.
manifest.json
The current manifest is schema 3.
| Field | Meaning |
|---|---|
schema | 3. |
engine | The exact engine pin the bytecode was written for. |
entry | The module evaluated first. |
chunks[] | { name, file, sha256 } for every module, with the hash of its chunk file. |
capabilities[] | Declared module grants plus discovered runtime:* imports; every known module in direct mode. |
client | null, or { root, assets: [{ file, size, sha256 }] }. |
env | { required: [], optional: [] }, the App’s declared environment. |
data | { required: bool }, whether the App needs a writable data root. |
fs | { roots: [] }, the only paths runtime:fs will ever resolve. |
ipc | { pipes: [] }, the only FIFOs writePipe will open. |
migrations[] | { name, module }, the compiled migration registry, in order. |
evidence | The aggregate feature scan, described below. |
Engine pin and resealing
Bytecode belongs to one QuickJS-NG build. The manifest’s engine value must equal the runtime’s pin, for example quickjs-ng-0.16.1-bc27, or the runtime refuses to start. When you move to a runtime with a different pin, seal the App again with the matching compiler. A new upstream tag, a bytecode-affecting configuration change or a change to the runtime modules and exports requires a pin update; fixes within an existing surface do not automatically change it.
Source stripping
Source text is stripped by default. Production chunks keep function names and line and column tables, so stack traces stay named and located, but they carry no source. --keep-source produces a source-bearing debug seal. The format does not forbid such a seal, so keep that option out of production packaging.
Feature evidence
The compiler scans every module’s opcodes, globals and imports and sorts each guardable feature family into one of three states: required, absent or uncertain. Dynamic access such as globalThis[name] makes a family uncertain: the scan prefers honesty to optimism. The manifest carries the aggregate; scan.json carries the per-module detail and is pinned by its hash in evidence.sidecar.
These are the families the current compiler records, and whether the current runtime profile supports them:
| Family | Recorded when the code uses | Supported |
|---|---|---|
array-from-async | Array.fromAsync | Yes |
atomics-shared-array-buffer | Atomics or SharedArrayBuffer | Yes |
disposable-stack | DisposableStack, AsyncDisposableStack or using | Yes |
disposal-symbols | Symbol.dispose, Symbol.asyncDispose or using | Yes |
iterator-helpers | The Iterator or AsyncIterator global | Yes |
dynamic-code | eval or the Function constructor | No, never |
weakref-finalization-registry | WeakRef or FinalizationRegistry | No |
The compiler records requirements even when the production profile cannot meet them, so it can successfully seal an artifact that will not run. The runtime rejects an artifact that requires an unsupported family before it opens any bytecode chunk. dynamic-code is never admitted into the parserless runtime.
Fail-closed loading
ajo-engine <artifact> refuses to start when it finds any of these:
- a missing file, or a file whose hash does not match the manifest;
- a schema other than 3, or an engine pin other than its own;
- a capability the runtime cannot provide;
- a required feature family outside the runtime profile;
- an invalid or unavailable declared filesystem root;
- a missing required environment variable;
- no usable data root in
AJO_DATAwhendata.requiredis set.
Bytecode chunks are hashed again at the read used for execution, not only at startup. Each refusal is printed on standard error with the reason:
[ajo] [error] artifact engine mismatch: expected quickjs-ng-0.16.1-bc27
[ajo] [error] artifact requires a valid data root through AJO_DATAIPC timing
FIFO declarations are checked for shape and path at startup. Whether the FIFO exists, is a FIFO and has a reader is checked when writePipe is called. A pipe that is temporarily absent does not by itself prevent the artifact from starting; the write fails and the caller can retry.
What loading does not prove
Validation precedes evaluation of the entry module, but initialization can still fail after the application has begun to cause effects, such as applying migrations. Startup is not a transaction over arbitrary effects.
Hashes prove that the files are consistent with the manifest. They do not prove who published the artifact, and they do not make untrusted bytecode safe to run. The engine’s trust model assumes bytecode from a build and deployment pipeline you control; the security model explains why.
- Seal and runProduce an artifact with kit build and start it.
- Runtime surfaceWhat each declared capability gives the App.
- Security modelTrust, enforced interfaces and documented residuals.
Source of truth: ajo-js docs/artifact.md