ADR-0023: The workflow engine is in-app
Status: Proposed · Date: 2026-08-11 (revises the 2026-08-06 celld draft)
Work in progress — this ADR may be modified and iterated on freely until implementation starts, and it is explicitly gated on the Phase 2 spike checklist in the design notes: a passed spike flips it Active; a failed spike re-opens the substrate question toward Restate (Option B). Revision note: the original 2026-08-06 draft chose celld as the substrate; a 2026-08-11 dependency-weight review demoted celld and then Restate in the same pass — the reasoning chain is recorded in the design notes’ decision log.
References
- ADR-0022: Automations run as Deno subprocesses — the sibling decision; run children execute through its runner, bridge pattern, permission model, and Runs panel, and its local scripts import this ADR’s step primitives
- Inbox draft ADR-0002: Restate for durable workflows — the prior durable-execution decision; its options survey is carried forward, and Restate is this ADR’s named graduation path
- Restate Cloud Deployment Management — the live Restate environments from that stack
- Triggers (Engine drafts) — the documented
@tranquil/sdksurface this ADR honors as the contract - Automations v2 — Design Notes — research digest, decision log, spike checklist, phased roadmap
- Automations v2 — Security Review — the design-time security assessment covering both ADRs; this ADR carries only the engine-side deltas
Context
The “Engine” is a promissory note. The docs describe @tranquil/sdk workflows — workflow('name', w => w.trigger(webhook({ path }) | schedule('cron') | event('name') | manual())), w.emit(), tranquil run <name>, stable webhook URLs, “runs headless on the Engine” — and tranquil-examples ships “coming soon” placeholders. No SDK or Engine code exists anywhere.
The 2026-08-06 draft of this ADR adopted celld (Deno Land’s self-hosted distributed Durable Objects daemon) as the substrate. A 2026-08-11 review re-weighed that choice on dependency cost and owned risk, and three verifications reframed it:
- Restate’s license and distribution are not blockers — BUSL-1.1 whose Additional Use Grant
forbids only offering a “Public Restate Platform Service”4; bundling
restate-serverfor users’ own workflows behind our SDK is permitted production use, and v1.7.3 publishes ~40 MB compressed macOS/Linux server archives with checksums (no Windows artifacts)5. So the fallback was real — which sharpened the question of what any server buys us. - The local engine needs none of the distributed machinery. Restate’s and celld’s genuinely hard problems — replicated logs, partitions, leases, exactly-once across networks — do not exist for a single-user, single-writer, sequential-steps engine on one machine. What remains (a journal table, memoized replay, retry/backoff rows, a timer scan with catch-up on launch) is a few hundred lines of boring, testable logic on SQLite.
- Electron’s own Node cannot replace Deno as the sandbox (the zero-external-runtime option):
Node 22’s now-stable permission model has filesystem scoping but no network permission at
all6; a boolean
--allow-netonly exists from around Node 257 — far beyond the Node 20 in Electron 30 or the Node 22 in Electron ~35–37 — and nothing matches Deno’s per-host--allow-net=host:port8. So the runner stays Deno, and the engine question becomes: what justifies any second runtime or daemon beside it? Answer: nothing does.
The constraint carried from the first draft still frames everything: Tranquil is individual-client, local-first. The engine must run on the user’s machine with zero accounts before any shared infrastructure exists. The constraint this revision adds: every external binary and daemon is install friction and a support surface — the engine has to earn each one, and a local single-writer engine earns none.
Decision
The Engine is not a server. It is an in-app component: a scheduler and durable-execution
journal (SQLite) living in the app’s main process, which executes each workflow run as a Deno
subprocess through the ADR-0022 runner — same permission model, same bridge pattern, same Runs
panel. The documented @tranquil/sdk trigger surface (webhook | schedule | event | manual) and step surface (step.do / step.sleep / step.waitForEvent, TerminalError,
journaled ctx.now()/ctx.random()) are preserved unchanged, with Restate-class
durable-execution semantics1 in a minimal v1, and the engine stays swappable beneath the step
surface — Restate is the named graduation path. Zero daemons. Packaged builds bundle a pinned
Deno binary, so the whole automations-and-workflows stack ships in the box.
┌──────────────────────────── Tranquil app ────────────────────────────┐
│ main process — the engine │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ registry · schedules · event inbox · journal (SQLite) │ │
│ │ one timer scan (cron fires, retries, timeouts, catch-up) │ │
│ │ webhook listener 127.0.0.1:<port> │ │
│ └────┬──────────────────────────────────────────────┬───────┘ │
│ │ spawn per run (ADR-0022 runner: │ classic IPC │
│ │ bundled deno + computed permission flags) ▼ (ADR-0015) │
│ ▼ ┌──────────────────┐ │
│ ┌──────────────┐ step protocol │ windows │ │
│ │ deno child │ 127.0.0.1:<port> + token │ Runs panel │ │
│ │ (one per run)│ ◀────────────────────────▶│ host capability │ │
│ └──────┬───────┘ capability steps routed │ layer (tabs, ui)│ │
│ │ via engine → a window └────────┬─────────┘ │
└────────┼─────────────────────────────────────────────┼───────────────┘
└── CDP ws://127.0.0.1:9222 ◀── browser steps run live in the child What a workflow is, and where it executes. A
workflow()file is plain Deno-dialect TypeScript. There is no deploy pipeline: “deploying” a workflow is the engine registering its file path and parsed triggers in the registry. The Workers dialect, Wrangler bundles, esbuild-on-PATH, and endpoint-host process of the earlier drafts all disappear. Workflow code executes in a Deno child per run — spawned, permission-flagged, and killable exactly like an ADR-0022 script, with the step-primitives module layered on top. Consent is never unattended: a workflow’s@permissionsdeclaration is approved by the user at registration; if the declaration on disk differs at fire time, the engine blocks the run (stateblocked, notify, re-approval required) — it never auto-approves and never raises a prompt while nobody is at the keyboard. Two substrates total (host renderer for the app; Deno children for scripts and runs), one TypeScript dialect, one toolchain.Engine placement. The engine lives in the main process because it is app-global by nature: schedules span windows, runs survive window closes, and catch-up on launch is natural. Its work is async SQLite plus one timer scan — heavy work happens in run children, and the engine must never block the UI thread. Engine↔renderer traffic is classic IPC per ADR-0015 (the cz-init forwarding pattern); no new RPC principal appears in main and ADR-0015 is untouched. Runs surface in the shared Runs panel (one Runs surface, two run kinds — ADR-0022 specific 4) and in the CLI:
tranquil runs list | describe | retry | cancel. Terminal failures notify, click-through to the panel.Trigger mapping. The documented SDK surface maps onto engine primitives:
SDK surface (already documented) Engine primitive webhook({ path, validate })Engine-owned loopback HTTP listener; stable local URL /hooks/<wf>;validateruns in the engine before a run spawns. Loopback-only as a rule —127.0.0.1binds also avoid the Windows Firewall dialog. Loopback binding is not authentication — any local process can POST to a bare local port and trigger a consented workflow, so hook URLs carry an unguessable secret segment (or token header) minted at registration; same honesty as the:9222stance in the security review. Port strategy is an open question.schedule('cron')Next-fire rows in SQLite + the timer scan. Catch-up on launch covers schedules that came due while nothing was open — the everyday case under Mode A — and a powerMonitorresume hook re-scans after sleep12.event('name')/w.emit()A durable SQLite inbox drained by the scheduler — retries ride the same scan. manual()/tranquil run <wf> --inputCommand palette / CLI → engine. Durable execution model — Restate-class semantics, minimal v1. The programming surface is unchanged from the first draft (deliberately the common subset of Cloudflare Workflows’ steps3 and Restate’s
ctx.run/ctx.sleep/ awakeables2):step.do(name, opts?, fn)— a journaled side effect: the result is persisted before the run advances; on resume it is replayed, not re-executed.step.sleep(name, duration)— a durable timer that survives crash and restart.step.waitForEvent(name, { timeout? })— a durable promise (awakeable): journaled pending, resolved externally, result journaled.TerminalError— non-retryable; fails the run immediately. Everything else thrown is transient by default.- Journaled
ctx.now()/ctx.random()for nondeterminism the orchestrator itself needs. - The determinism rule, documented loudly: code between steps replays on resume, so it
must be deterministic — all I/O and nondeterminism goes inside
step.do. v1 runs steps sequentially (parallel steps deferred).
Failure semantics. A transient error triggers automatic retry with exponential backoff under per-step
{ retries, backoff, timeout }; retry scheduling and per-attempt timeouts ride the engine’s timer scan (the scan doubles as watchdog — a dead child past its timeout is a failed attempt). An app quit or crash mid-step means: on next launch the engine re-spawns the run child and the SDK replays from the journal — finished steps return their recorded results; the interrupted step re-executes. Step bodies are therefore at-least-once while step results are effectively-once — side effects inside steps should be idempotent, and the docs must say so. Exhausted retries move the run tofailed, retained for manualretryorcancel.Journal schema (SQLite9, WAL):
(run_id, seq, step_name, status, attempt, result_json, error, started_at, ended_at, next_retry_at). Engine-internal — workflow code may never depend on it. SQLite arrives as a bundled native module (better-sqlite3 via electron-rebuild; Electron’s ownnode:sqliteis a later option once its Node reaches it).The upgrade path is the design constraint. The step surface is the contract; engines swap beneath it: (a) v1 — this journal; (b) Restate behind the same SDK if the engine is outgrown (the retarget is near-mechanical:
step.do→ctx.run,step.sleep→ctx.sleep,step.waitForEvent→awakeables —TerminalErroris literally Restate’s own class2). Nothing in workflow authoring may depend on journal internals.The run channel, and how a workflow reaches the browser. Run children speak a small step protocol to the engine over a localhost TCP socket authenticated by a one-time token — the pattern ADR-0022’s bridge already proves, inheriting its auth rules verbatim (single-use tokens delivered via env, never in URLs; unclaimed expiry; close on any auth failure before protocol frames flow), and the only shape portable to Windows (extra inherited fds are POSIX-flavored with no clean Deno API; Unix domain sockets have no Deno-on-Windows story). Not stdio (protocol frames would mix with user
console.log), and not capnweb (no function stubs are needed engine-side, and RPC never reaches main per ADR-0015). Frames: step begin/result, awakeable park/resolve, heartbeat, cancel — cancel is protocol-level with a grace window so the child can finish journaling, with process-kill as the backstop on every OS (Windows has no graceful SIGTERM phase).Browser and user-context work inside a
step.doruns live in the child, exactly like a script — thetabsAPI plus direct CDP against a host-resolved target — and the step’s result is what gets journaled. The engine’s job is capability routing and parking: a capability step journals an awakeable, the engine picks an eligible open window (session partition and affinity decide eligibility) and grants the child access to that window’s runner bridge; with no eligible window the awakeable parks durably and the run suspends — parked, not failed — waking when one appears. Affinity is also capability containment: a run granted one window’s bridge reaches only that window’s tabs and session partition, never another window’s — parking must not be short-circuited by routing to a wrong-partition window. The celld draft’s ClientHub cell collapses into this router plus a parked-awakeables table.User-edit replay safety. At run start the engine snapshots the workflow source under
~/.tranquil/engine/runs/<runId>/; resume and replay always execute the snapshot, never the live file. The registry tracks the current file separately, so editing a workflow mid-run can never diverge a journal — the everyday hazard for user-authored workflows in an editor, solved locally without deployment versioning. Snapshot GC rides journal retention.Mode A / Mode B. Mode A (product default): the engine lives in the running app — up whenever a Tranquil window is open, stopped on quit, nothing external to supervise at all. Schedules due while everything was closed fire via catch-up on next launch. Mode B (deferred): the same engine headless under per-OS user-level supervision — LaunchAgent / systemd user service / Task Scheduler logon task — running via
ELECTRON_RUN_AS_NODEon the app’s own binary: still zero new dependencies. Adopt only when proven needed.Dependencies: the bundled Deno. Packaged builds bundle a pinned Deno at
Contents/Resources/bin/deno(per-OS equivalent), spawned by absolute path — no PATH involvement, no install notification, no managed download; scripts and workflows work offline, out of the box. This overturns ADR-0022’s “bundling rejected for now” (that ADR carries the amendment): Deno now carries the entire automations and workflows story, so zero-install outweighs the size. Resolution order: config override (power users) → bundled binary (packaged default) → PATH (dev mode only —yarn startkeeps today’s behavior). Children spawn withDENO_DIR=~/.tranquil/deno-cacheso the bundled runtime’s module cache never collides with a user’s own Deno install. Costs, stated plainly: ~100 MB per arch on disk (~40 MB compressed) riding every update; the Deno version revs with app releases — which is also the support story (scripts run on the exact runtime the app was tested with, and the bundled binary can servedeno lspfor authoring). Deno is MIT-licensed10; ship the license text in acknowledgements.Cross-platform notes — macOS is the implementation target; all three OSes are the product target, so nothing here may be signal- or path-shaped:
- Portability headline: Deno ships all three OSes and its permission flags are OS-agnostic8; the engine is in-app JS + SQLite — a clean tri-OS story (Restate publishes no Windows server artifacts5; celld+MinIO were two more per-OS binary problems).
- Cancel is protocol-level (specific 5) because Windows has no graceful SIGTERM.
- macOS packaging — the sharp edge: notarization signs nested binaries with the hardened
runtime, and V8’s JIT means the bundled
denoneeds JIT entitlements (com.apple.security.cs.allow-jit, likelyallow-unsigned-executable-memory)11 or it crashes only in signed builds — an explicit verify item at the first notarized build. Deno has no universal binary: per-arch app builds bundle their own (a universal DMG bundles both and picks at runtime). - Windows/Linux packaging is mundane: sign
deno.exealong with the installer (Defender may slow the first spawn); Linux packages just include the binary. PATH discovery survives only as the dev-mode fallback. - SQLite: electron-rebuild per OS/arch; the engine data dir must be guaranteed-local —
WAL does not work over network filesystems9 (a Windows roaming-profile
%USERPROFILE%is the failure case; verify item). - Sleep/wake: hook Electron
powerMonitor’sresume(supported on all three OSes)12 to trigger the catch-up scan. - Hygiene: permission-flag paths are built with native separators;
--allow-run=gitresolvesgit.exevia PATHEXT on Windows unaided.
Options considered
The field was weighed for the healthcare-era backend in inbox draft ADR-0002 and again in this ADR’s 2026-08-06 celld draft; both surveys carry forward. What changed in this revision is the weighting — dependency count and owned risk, not capability lists.
- Option A: in-app engine + Deno run children (chosen). Zero daemons, zero installs, one runtime, one dialect; the engine is a feature of the app, not a service beside it. The correctness we own — journal, replay, retries, timers — is bounded: local, single-writer, sequential, with none of the distributed problems the server substrates exist to solve. Scripts and workflows share the runner, the bridge pattern, the permission model, and the Runs panel.
- Option B: Restate — the incumbent with in-house experience; the graduation path. Verified
2026-08-11: BUSL-1.1 whose Additional Use Grant forbids only a “Public Restate Platform
Service” — bundling
restate-serverfor users’ own workflows behind our SDK is permitted production use, converting to Apache-2.0 four years per release4; v1.7.3 ships ~40 MB compressed macOS/Linux archives with sha256s and a Homebrew formula — and no Windows artifacts5. Why it is not the engine here: even bundled, it is a server binary plus a supervised endpoint-host process — two children — whose distributed machinery a local single-writer engine never exercises; its versioned-deployment lifecycle must be automated around every user edit (solved locally by per-run snapshots, specific 6); and Windows is blocked upstream. Graduation triggers: cross-device execution becomes a near-term bet; parallel or high-volume runs outgrow the v1 journal; or the journal’s correctness burden proves heavier in practice than supervising servers. The retarget stays near-mechanical behind the step surface (specific 4). - Option C: celld — the 2026-08-06 choice, demoted. Single-binary distributed Durable Objects: per-cell SQLite, alarms, hibernatable WebSockets, replicate-before-ack to an S3-compatible bucket13 — and the one genuinely unique capability in the field: any-device lease takeover of a failed run with zero accounts and no server cluster. Demoted because it is pre-1.0 with patches-by-email governance; it requires MinIO (AGPL-3.0, upstream archived15) and esbuild; workflow code would be Workers-dialect, not Deno14; and its v1 still required hand-building this same journal on top of adopting the substrate — all of that for a Phase-4 cross-device story that is not a near-term bet. Re-evaluate when its native “Workflows (durable execution)” ships14, a bucket backend without MinIO exists, and cross-device becomes near-term.
- Option D: bespoke Deno engine server per the original Engine docs — still rejected: a daemon with an API surface, lifecycle, and port to own, re-implementing scheduling and durability beside the app instead of inside it. The chosen option keeps the healthy kernel (the client is the engine) without a second process.
- Option E: all-Node — zero external runtime. Attractive on its face: the app already ships
V8 twice, and an
ELECTRON_RUN_AS_NODEchild gives the same fault isolation, kill, and timeout. Rejected on verified facts: Electron 30 bundles Node 20 (permission model still experimental, fs-only); Node 22’s stable model has no network permission at all6; a boolean--allow-netappears only around Node 257 — so for the entire Electron horizon a Node child cannot scope network egress, and ADR-0022’s@permissions net=api.github.comcontract would become policy rather than enforcement. It also loses native TypeScript and the integrated toolchain. Wrong trade for a product whose scripts are meant to be shared. - Option F: run workflows in a hidden window / webview — carried forward from the first draft, still rejected for execution: a renderer dies with the app (no durability), Chromium throttles hidden-renderer timers, and workflow code would inherit ambient renderer privileges, violating the capability model.
- Option G: do nothing — the Engine docs remain fiction, and the docs debt compounds.
Consequences
| Risk | Mitigation |
|---|---|
| We own replay/retry/timer correctness — the exact class of code Restate hardens | Bounded scope: local, single-writer, sequential; the spike’s kill tests (items 1, 2, 10) are the gate; the step surface keeps the Restate retarget open; the journal schema stays engine-internal |
| Main-process engine work could jank the UI | The engine is async SQLite + one timer scan; all heavy work happens in run children; spike item 9 measures scan behavior under load |
| Journal rows can hold sensitive step results (scraped content, tokens) | Carry forward inbox draft ADR-0002’s journal-encryption concern: encryption at rest and retention/GC settled before any sync or graduation ships |
| better-sqlite3 is a native module | electron-rebuild per OS/arch is routine; node:sqlite is the later exit |
| Bundled Deno grows the app (~100 MB/arch, ~40 MB compressed per update) | Accepted for zero-install; macOS JIT entitlements are an explicit first-notarized-build verify item11 |
| Sequential-only steps in v1 | Deferred by design — one pending timer per run keeps the scan simple; revisit with real demand |
| No any-device takeover (celld’s headline) and no multi-node story | Out of scope for local-first v1; Options B and C record the graduation triggers |
Relationships: ADR-0022 is an independent decision — either ADR stands without the other — but run children ride its runner, bridge pattern, and Runs panel, and its step-primitives import is now literally this ADR’s module in the same runtime (ephemeral in-memory journal in local scripts, durable journal in engine runs). ADR-0003/ADR-0015 are untouched: engine↔renderer is classic IPC, the run channel is the ADR-0022 runner principal, and RPC still never reaches main. Inbox draft ADR-0007 (Hono) no longer carries forward — there is no Worker to host it; its API-surface question returns if a server component ever exists.
Re-evaluate if: the spike fails a go/no-go item (→ Option B, Restate, behind the unchanged SDK surface); cross-device execution becomes a near-term product bet (→ Option B or C per their triggers); or the v1 journal’s correctness burden proves heavier in practice than supervising a server.
- Restate LICENSE — BUSL-1.1; Additional Use Grant: “You may not use the Licensed Work for a Public Restate Platform Service” (a managed service exposing Restate APIs to third parties); Change License Apache-2.0, Change Date four years per release. https://github.com/restatedev/restate/blob/main/LICENSE↩
- Restate releases — v1.7.3 assets:
restate-serverarchives foraarch64/x86_64-apple-darwinand-unknown-linux-musl(~40 MB compressed) with.sha256files and Homebrew formulas; no Windows artifacts (verified 2026-08-11). https://github.com/restatedev/restate/releases↩ - Node.js v22 permission model — stable as of v22.13; flags cover fs read/write (path-scoped), child processes, workers, addons, WASI; no network permission exists. https://nodejs.org/docs/latest-v22.x/api/permissions.html↩
- Node.js current permission model —
--allow-netexists in recent Node (≥ ~v25) as a coarse grant; no per-host scoping is documented. https://nodejs.org/api/permissions.html↩ - Deno security and permissions — per-resource scoping (
--allow-net=host:port,--allow-read=path,--allow-env=NAME,--allow-run=cmd),--no-promptfail-closed; identical flag model on macOS, Linux, and Windows. https://docs.deno.com/runtime/fundamentals/security/↩ - Restate durable execution — the semantic north star: every step journaled; on failure “Restate replays the journal, skipping completed steps and resuming from exactly where it left off.” https://docs.restate.dev/concepts/durable_execution↩
- Electron
powerMonitor—suspend/resumeevents on macOS, Windows, and Linux; the catch-up-scan trigger after sleep. https://www.electronjs.org/docs/latest/api/power-monitor↩ - Cloudflare Workflows —
step.do/step.sleep/step.waitForEvent, automatic retries and state persistence; the API shape the v1 step surface stays compatible with. https://developers.cloudflare.com/workflows/↩ - Restate SDK actions —
ctx.run(“safely wrap any non-deterministic operation … and have Restate persist its result”), durable timers, awakeables,TerminalError. https://docs.restate.dev/foundations/actions↩ - SQLite write-ahead logging — WAL semantics; “WAL does not work over a network filesystem,” hence the guaranteed-local engine data dir. https://www.sqlite.org/wal.html↩
- Deno — MIT-licensed single static binary; official releases for macOS (arm64/x64), Linux, and Windows. https://github.com/denoland/deno↩
- Apple hardened runtime — notarized apps sign nested binaries with the hardened runtime; JIT-compiled runtimes require the
com.apple.security.cs.allow-jit(and related) entitlements. https://developer.apple.com/documentation/security/hardened-runtime↩ - celld documentation — cells as Durable Objects (named, single-threaded, private SQLite), replication to an S3-compatible bucket before acknowledgment, bucket-lease coordination. https://celld.dev/docs/↩
- MinIO — AGPL-3.0; the upstream
minio/miniorepository is archived on GitHub (verified 2026-08-06). https://github.com/minio/minio↩ - celld Cloudflare compatibility — Workers-dialect code; no cron or queues (not planned); “Workflows (durable execution)” planned. https://celld.dev/docs/cloudflare-compat/↩