Docs

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

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:

  1. 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-server for 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.
  2. 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.
  3. 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-net only 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
  1. 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 @permissions declaration is approved by the user at registration; if the declaration on disk differs at fire time, the engine blocks the run (state blocked, 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.

  2. 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.

  3. 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>; validate runs in the engine before a run spawns. Loopback-only as a rule127.0.0.1 binds 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 :9222 stance 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 powerMonitor resume 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.
  4. 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 to failed, retained for manual retry or cancel.

    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 own node:sqlite is 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.doctx.run, step.sleepctx.sleep, step.waitForEvent→awakeables — TerminalError is literally Restate’s own class2). Nothing in workflow authoring may depend on journal internals.

  5. 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.do runs live in the child, exactly like a script — the tabs API 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.

  6. 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.

  7. 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_NODE on the app’s own binary: still zero new dependencies. Adopt only when proven needed.

  8. 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 start keeps today’s behavior). Children spawn with DENO_DIR=~/.tranquil/deno-cache so 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 serve deno lsp for authoring). Deno is MIT-licensed10; ship the license text in acknowledgements.

  9. 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 deno needs JIT entitlements (com.apple.security.cs.allow-jit, likely allow-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.exe along 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’s resume (supported on all three OSes)12 to trigger the catch-up scan.
    • Hygiene: permission-flag paths are built with native separators; --allow-run=git resolves git.exe via 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-server for 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_NODE child 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-net appears 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.com contract 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

RiskMitigation
We own replay/retry/timer correctness — the exact class of code Restate hardensBounded 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 UIThe 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 moduleelectron-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 v1Deferred 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 storyOut 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.


  1. 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
  2. Restate releases — v1.7.3 assets: restate-server archives for aarch64/x86_64-apple-darwin and -unknown-linux-musl (~40 MB compressed) with .sha256 files and Homebrew formulas; no Windows artifacts (verified 2026-08-11). https://github.com/restatedev/restate/releases
  3. 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
  4. Node.js current permission model — --allow-net exists in recent Node (≥ ~v25) as a coarse grant; no per-host scoping is documented. https://nodejs.org/api/permissions.html
  5. Deno security and permissions — per-resource scoping (--allow-net=host:port, --allow-read=path, --allow-env=NAME, --allow-run=cmd), --no-prompt fail-closed; identical flag model on macOS, Linux, and Windows. https://docs.deno.com/runtime/fundamentals/security/
  6. 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
  7. Electron powerMonitorsuspend/resume events on macOS, Windows, and Linux; the catch-up-scan trigger after sleep. https://www.electronjs.org/docs/latest/api/power-monitor
  8. 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/
  9. 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
  10. 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
  11. Deno — MIT-licensed single static binary; official releases for macOS (arm64/x64), Linux, and Windows. https://github.com/denoland/deno
  12. 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
  13. 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/
  14. MinIO — AGPL-3.0; the upstream minio/minio repository is archived on GitHub (verified 2026-08-06). https://github.com/minio/minio
  15. celld Cloudflare compatibility — Workers-dialect code; no cron or queues (not planned); “Workflows (durable execution)” planned. https://celld.dev/docs/cloudflare-compat/