# 0007 · QuickJS on wazero for user automations

**Status:** Accepted · 2026-09-22

## Context

Users need continuous behaviour — "whenever a document is tagged `runbook`, share it with ops" — plus
scheduled jobs. The scripts are written by AI agents on request and run inside CoDNA, on behalf of
whoever owns them. They are untrusted code in the security sense even though their author is an
authenticated colleague.

## Decision

Scripts are JavaScript, executed by **QuickJS compiled to WebAssembly and run under wazero**
(`fastschema/qjs`), which keeps the build CGO-free and the single-binary story intact.

Isolation is layered:

| Layer | Provides |
|---|---|
| wazero | No host syscalls. No network stack to reach. No filesystem — WASI is not wired in. |
| QuickJS | Heap cap via `JS_SetMemoryLimit`; bounded stack. |
| wazero | Wall clock, by closing the module when the run's context is cancelled. See the correction below. |
| CoDNA host | The context object is the entire surface. No `fetch`, `require`, `process`, `Date.now`, or `Math.random`. |

Scripts **propose** actions; the host validates each against the automation owner's permissions and
applies the survivors in one transaction.

## Consequences

- A script cannot exceed its author. It has no way to, regardless of what it contains.
- Reads (`codna.find`) run ACL-filtered as the owner, so a scheduled script cannot walk the
  knowledge base.
- Withholding `Date.now` and `Math.random` makes runs reproducible from their `automation_runs`
  record.
- Rejected actions are recorded with reasons, so a silently failing script is discoverable.
- **`fastschema/qjs` is v0.0.6** — young for a component on the security boundary. CoDNA defines its
  own `sandbox.Runtime` interface so the binding is swappable for a hand-rolled wazero host.
- QuickJS is not a modern JS engine. Scripts are small; this has not been a constraint.

## Correction

*2026-09-23* — This record said wall-clock was enforced by an interrupt handler. In `fastschema/qjs`
v0.0.6 the corresponding option, `MaxExecutionTime`, does not stop an infinite loop. The mechanism
that works is wazero's close-on-context-done: cancel the run's context and the module is torn down
mid-call, surfacing as a panic on the executing goroutine, which the sandbox recovers and records as
a breach.

That mechanism is configured on the *compiled* module, which the binding caches globally, so the
first runtime built in the process decides for all of them. A warm-up call that omitted the flag
disabled every timeout in the process. The interface this ADR required — CoDNA's own
`sandbox.Runtime` — is what kept the fix inside one package.

## Alternatives rejected

**A declarative condition→action DSL.** Safer and simpler on day one. Rejected because it accretes:
"unless already shared", "only in wiki", "not on weekends" — and ends up a language with no
debugger, no tests, and no documentation.

**goja** (pure-Go JS interpreter). Far simpler interop and no wasm blob. Rejected on limits: memory
has no hard cap and CPU is stopped by a cooperative watchdog rather than the engine itself.

**Starlark.** The strongest safety story and the least host code. Rejected because it is Python-shaped
— users reviewing what their agent wrote should be reading something they recognize.

**Deno subprocess.** Most capable, OS-level sandboxing. Rejected because it adds a runtime dependency,
which contradicts [0003](https://codna.me/docs/adr/0003-go-single-binary.md).
