ADR 0009: TypeScript escape-hatch runtime

Status: accepted, 2026-10-06

Context

Steps that need a loop, a library or a one-off API call are TypeScript scripts a repository ships beside its ship.ks (ADR 0003). Issue #10 asks what runs them, under constraints the earlier decisions have already set: such steps run in a separate runtime behind the ScriptHost port, not in-process, so keepshipping stays one static binary (ADR 0013); the runtime is one adapter crate under adapters/, not a core dependency (ADR 0002); check is budgeted at cold 1 s and warm p95 100 ms and CI fails a blown budget (crates/cli/benches/check.rs); and CI runs keepshipping check under unshare --net (.github/workflows/ci.yml), so checking a workflow cannot need the network.

The port is already written and tested: crates/core/src/ports/script_host.rs defines schema(&ScriptRef) returning a Result<StepSchema, ScriptHostError> without running the script, which is what check inspects, and run(&ScriptRef, &Values, &SecretBundle) returning a Result<Values, ScriptHostError> — both async, with secrets travelling on a dedicated file descriptor and never in argv, the environment or stdio. ks-testing ships the fake and the conformance kit (crates/testing/src/script_host.rs) and the adapter has to pass it. StepSchema has no secret kind: a gap this ADR records, not one it closes.

Spike

Deno 2.9.7 (V8 15.0.245.2), measured 2026-10-06 on 6 vCPU AMD EPYC, Linux x86_64, 10 reps, median/p90. The extractor loads npm:typescript@5.9.3 through the compiler API, builds a program for the step file and reads types only — inputs off the default export's step({...}) type, outputs off run's return type with Promise unwrapped. It never executes the step and never sees a secret.

medianp90
cold first run (empty DENO_DIR, npm download)1656 ms
warm extraction, fresh process per call127.5 ms137.1 ms
same, full TypeScript lib instead of the minimal one338.7 ms354.8 ms
deno compiled extractor (128 MB binary)101.5 ms107.1 ms
run side (import step, stdin, fd secret, JSON lines)17.7 ms
process floor (deno run of an empty script)12.5 ms
long-lived daemon holding the program0.37 ms unchanged file, ~8 ms after an edit

The budget this decision needed was under 200 ms warm; 127.5 ms meets it, and the dominant win was the minimal noLib declaration file, not a compiled binary. Full diagnostics cost about 12 ms more.

The spike also settled three things the decision depends on. Extraction is type-directed: the site example yields {"inputs":{"db":{"kind":"secret"}},"outputs":{"applied":{"type":"number"}}}, widening the return to {applied: 3, skipped: 0} adds skipped: number, and returning "3" yields string. A step with a type error still extracts, but the poisoned field degrades to any, so diagnostics must be collected and an errored schema treated as untrusted. Permissions are the reason Deno wins: the host runs with --allow-read for the workspace and /proc/self/fd/3, no env and no net, and under it Deno.env.get and fetch throw NotCapable; the extractor additionally needs read and the TSC_* variables TypeScript reads itself. And the secret descriptor does not work the obvious way: Deno 2.x cannot read an inherited descriptor at all — no Deno.readSync, Deno.FsFile refuses the rid, node:fs.readSync(3) returns EBADF — so the route is to reopen /proc/self/fd/3, which means the parent must dup2 a pipe onto that fixed descriptor in pre_exec, since Deno does not renumber inherited descriptors and uses 3, 4, 5 itself. A regular file behind fd 3 is refused, so it has to be a pipe. That is Linux-only; macOS (/dev/fd/N) and Windows (no Unix descriptor inheritance) are unverified. The spike code is not committed.

Decision

Deno, pinned, as a subprocess behind ScriptHost, in a new adapter crate adapters/deno-script-host. This ratifies the adapter README.md and docs/ARCHITECTURE.md already name as a "deno script host".

Consequences