ADR-0023: celld is the workflow-engine substrate
Status: Proposed · Date: 2026-08-06
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 supersedes it toward the Restate fallback (Option B).
References
- ADR-0022: Automations run as Deno subprocesses — the sibling decision; workflows call back into the same host capability layer, and its scripts can 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 here
- Restate Cloud Deployment Management — the live Restate environments and key strategy from that stack
- Inbox draft ADR-0007: Hono + zod-openapi — the API surface, carried forward inside the celld Worker
- Triggers (Engine drafts) — the documented
@tranquil/sdksurface this ADR honors as the contract - Automations v2 — Design Notes — research digest, spike checklist, phased roadmap
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.
Building it bespoke means building a scheduler, durable state, crash recovery, and replication
before the first workflow runs.
celld[^1] is Deno Land’s self-hosted, distributed Durable Objects + Workers daemon[^2]: a
single binary where every node embeds V8 and executes Wrangler bundles. A cell is a Durable
Object — named, single-threaded, with a private SQLite database — that serves HTTP, holds
hibernatable inbound WebSockets, opens outbound ws:/wss: connections, sets alarms, and makes
outbound fetches[^3]. Writes replicate to an S3-compatible bucket before they are acknowledged
(RPO = 0), and the bucket is the only coordination layer: nodes discover owners and peers from
bucket leases with compare-and-swap — no control plane, no consensus service. Peer HTTP is
HMAC-authenticated and meant for private networks (Tailscale-class overlays)[^2]. Supported:
module Workers, fetch, JS RPC (WorkerEntrypoint/RpcTarget, promise pipelining), service and
DO bindings, static assets. Not supported and not planned: cron triggers, queues, KV, R2, the
Cache API. Planned: D1 and “Workflows (durable execution)”[^3]. It is pre-1.0 with an evolving
compatibility surface, Apache-2.0, and takes contributions as emailed patches only.
Prior art: the team already chose and ran Restate. For the healthcare-era integration backend, inbox draft ADR-0002 (active, 2026-04) adopted Restate — journaled steps replayed after crashes, keyed-workflow dedup, console introspection — with live Restate Cloud sandbox/dev/prod environments (deployment management). The durable-execution bar in this ADR comes from that hands-on use, and the cell taxonomy below deliberately mirrors Restate’s proven three-construct split.
The constraint that reframes the choice: Tranquil is individual-client, local-first. The engine must run on the user’s machine with zero accounts before any shared infrastructure exists.
Decision
The Engine is not built bespoke. Workflows deploy as Wrangler-bundled Workers plus Durable
Object cells onto a celld node — initially a single client-managed node against a local MinIO
bucket — behind a thin @tranquil/sdk facade that preserves the already-documented trigger
surface and provides Restate-class durable-execution semantics from v1 (journaled steps, automatic
retries, durable sleep, awakeables), so the substrate stays swappable. Cells never touch CDP:
browser and user-context steps are capability callbacks to a connected Tranquil client over the
client’s WebSocket, terminating in the same host-renderer capability layer ADR-0022 defines.
What a workflow deploys as. The SDK CLI compiles
workflow()files into one Worker entry (routes — Hono inside, per inbox draft ADR-0007) plus generic DO classes, emitswrangler.jsonc, and runscelld deploy . --bucket …(esbuild on PATH)[^2]. Where workflow code executes, stated plainly to preempt misreads: in the celld daemon’s embedded V8 (the Workers runtime) — a separate OS process, never inside the Tranquil client and never in a webview, headless or otherwise. The client’s webviews execute only the browser portion of capability steps; ADR-0022 scripts execute in Deno subprocesses. Three substrates, one TypeScript toolchain.Trigger mapping. The documented SDK surface maps onto celld primitives:
SDK surface (already documented) celld primitive webhook({ path, validate })Worker fetch route /hooks/<wf>;validateruns in the Worker, then forwards to the workflow’s Coordinator cell. Stable URL = node listen address + route.schedule('cron')No native cron[^3] → the Coordinator stores the expression in SQLite, computes the next fire time, and storage.setAlarm(next)[^4]; the alarm handler re-arms, then starts a run. Missed-while-down semantics are a spike item.event('name')/w.emit()DO-binding JS RPC to subscribers’ Coordinators; reliability is emulated (no queues) with an SQLite inbox table plus alarm-driven drain/retry. manual()/tranquil run <wf> --inputCLI POST to the local node’s Worker route /runs/<wf>.Cell taxonomy — deliberately mirroring the Restate construct split that already worked (
restate.service→ Worker routes,restate.object→ Coordinator,restate.workflow→ Run):Cell class Instance name Owns UserRootuser:<id>(one, in individual mode)workflow registry + versions, CLI/app auth material WorkflowCoordinatorwf:<name>config, schedule alarm, event inbox, run spawning, recent-run index WorkflowRunrun:<wf>:<runId>the durable-execution journal + executor (specific 4); every run its own cell so per-run alarms never contend ClientHubclient:<deviceId>terminates the app’s outbound WebSocket; presence; routes capability requests per their affinity; parks awakeables durably while no eligible client is connected Durable execution model — Restate-class semantics, minimal v1. This is how durable functions are written, and how anyone knows one hit a snag. Restate is the semantic north star[^5]; v1 implements its core subset on the
WorkflowRuncell.Programming surface (in
@tranquil/sdk— deliberately the common subset of Cloudflare Workflows’step.do/step.sleep/step.waitForEvent[^6] and Restate’sctx.run/ctx.sleep/ awakeables[^7]):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, backed by the run cell’s alarm.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; this also keeps one pending timer per run cell).
Failure semantics — how you know it snagged. A transient error (network timeout, any non-terminal throw) triggers automatic retry with exponential backoff under per-step
{ retries, backoff, timeout }options; retry scheduling and the per-attempt timeout both ride the run cell’s alarm (the alarm doubles as watchdog). A daemon or node crash mid-step means the alarm fires on restart, the run re-invokes the workflow function, and it 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 (SQLite in the run cell):
(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.Observability. Run cells answer status queries — current step, attempt count, last error, next retry time — over JS RPC/HTTP. They surface in the client’s Runs panel (the same panel as ADR-0022 local runs: one Runs surface, two run kinds) and in the CLI:
tranquil runs list | describe | retry | cancel. Terminal failures push a notification through the ClientHub to connected clients.Alarm multiplexing. A Durable Object has exactly one alarm —
setAlarmoverwrites[^4] — so each cell multiplexes it across its pending timers: due-times live in SQLite, the alarm is always armed to min(next due), and the handler re-arms before doing work. The Coordinator does the same across cron fires and inbox drains.The upgrade path is the design constraint. The step surface is the contract; engines swap beneath it, in order: (a) v1 — this journal in the run cell; (b) celld’s planned native “Workflows (durable execution)”[^3] when it ships — likely Cloudflare-Workflows-shaped, since celld tracks Cloudflare APIs, making the swap near-mechanical; (c) Restate (or Cloudflare Workflows proper) as the engine behind the same SDK if celld is outgrown — a retarget the team has hands-on experience with (inbox draft ADR-0002). Nothing in workflow authoring may depend on journal internals.
Capability callbacks — how a workflow reaches the browser. Cells never speak CDP:
webhook ─▶ Worker /hooks/<wf> ─▶ WorkflowCoordinator ─▶ WorkflowRun (journals each step) ▲ cron alarm │ "needs browser / user context" CLI ────▶ Worker /runs/<wf> ───────────┘ ▼ ClientHub (client:<device>) ┃ hibernatable WS, runId-correlated frames ┌──────────────────────────┻──────────────────────────┐ │ Tranquil app — Deno WS client → host-renderer │ │ capability layer (ADR-0022): tabs/ui/CDP run local │ └─────────────────────────────────────────────────────┘ all cell writes ── replicate-before-ack ──▶ S3 bucket (MinIO local → R2/Tigris shared)Mechanically these callbacks are
step.waitForEventawakeables: the run journals a pending request, the ClientHub delivers it, and the client’s completion resolves the awakeable with the result journaled. The same shape serves webhook-waits and human-approval steps.Each capability request carries an affinity:
any(served by the first hub with a connected client) ordevice:<id>(pinned — required for steps that need that device’s login sessions or active window, since per-window browser sessions never roam between devices; an authenticated scrape step is de facto pinned). With no eligible client connected, the awakeable parks durably and the run suspends — parked, not failed — waking whenever one connects.The hub↔client protocol is plain runId-correlated frames initially, not capnweb: hibernation drops a cell’s in-memory JS state while keeping the socket, which conflicts with capnweb session state. Running capnweb inside the hub is spike item 9, not the default.
Individual-client mode. Spike: user-run
celld+ MinIO started by adeno task(developers only). Phase 3 product default is Mode A: the client hosts the engine — celld and MinIO spawned and supervised as child processes by the app’s main process. The engine is up whenever a Tranquil client is open on that machine and stops on quit: the Tranquil client is also the engine. Consequence: schedules that came due while nothing was open fire via alarm catch-up on next launch — spike item 3 is load-bearing for exactly this. Mode B (deferred upgrade): celld + MinIO as app-installed user-level background services (macOS LaunchAgent) so schedules and webhooks fire with the editor closed — adopt only when proven needed. In both modes, browser-dependent steps park at the ClientHub until a client connects. Service supervision is a main-process concern — classic IPC per ADR-0015; this ADR needs no RPC-scope change (the app’s hub connection is an outbound client socket, not the guest↔host boundary).Cross-client promotion. Same bundles, shared bucket (R2[^8] or Tigris[^9]), each device a node advertising a Tailscale address[^10].
UserRoot, Coordinators, and Runs become shared-namespace cells;ClientHubstays per-device. The headline capability: any device can pick up a failed run. Every journal write replicates to the bucket before it is acknowledged and ownership is only a lease — so a run orphaned by a dead or offline device is acquired by any other device’s node: lease expiry → CAS takeover → SQLite materialized from the bucket → pending alarm fires on the new owner → replay from the journal. This is a key differentiator over a single localrestate-server, and it depends on alarms surviving ownership migration (verify; spike item 12 tests it locally with two nodes on one machine). Resume rules by step kind: pure/compute steps resume on any node with no client app involved; browser steps resume their orchestration anywhere, but the awaited capability is served per its affinity — any-client requests go to whichever hub has a connected client, device-pinned requests stay durably parked until that device returns. Single-writer implications, stated plainly: a coordinator lives on exactly one node at a time; lease handoff on laptop-close means failover latency — so no interactive or latency-sensitive state belongs in cells.Deployment topologies — same substrate, no new machinery. (a) Solo: one machine, the degenerate hub. (b) Hub-and-spoke — the recommended first cross-client shape: one always-on machine with a Tranquil client left open is the de facto hub. Leases gravitate to whatever is up, so its node accumulates cell ownership, fires schedules reliably, serves as the stable webhook-ingress target, and its open client doubles as the default executor for any-affinity browser steps; laptops are spokes — capability providers and optional nodes. The hub role is operational, not structural: if the hub dies, spokes’ nodes take over its cells exactly per specific 7. (c) Full peer fleet: the general case, no designated hub. Promotion between topologies is pointing more devices at the bucket — never a re-architecture. Verify: whether a non-owner node accepts and proxies requests to a cell’s owner (webhook-via-any-node vs owner-only shapes the hub’s stable-URL story).
Options considered
The field was largely weighed once already, for the healthcare-era integration backend, in inbox draft ADR-0002 — its findings are carried forward here rather than re-litigated.
- Option A: celld (chosen). Durable Objects + per-cell SQLite + alarms + JS RPC + replication for free[^1]; local-first (no accounts, runs offline); cross-client sharing is native — one bucket; Apache-2.0 and forkable; the same vendor as the Deno runtime bet. “Adopt, don’t invent,” echoing ADR-0003’s reasoning for capnweb.
- Option B: Restate — the incumbent with in-house experience; the designated fallback. Inbox
draft ADR-0002 chose Restate and it ran for real: journaled steps replayed from the last
committed entry after a crash; keyed workflows so re-invoking the same workflow ID is a no-op
(dedup); a console showing state, journal, and pending invocations of every workflow; each
integration as three constructs — stateless
restate.service(poller),restate.object(per-tenant cursor/state),restate.workflow(per-record run) — which this ADR’s cell taxonomy deliberately mirrors. Live Restate Cloud sandbox/dev/prod environments exist, with per-env ingress/admin keys and versioned-deployment lifecycle (deployment management). Why it is not the substrate here: that stack is cloud-first — Restate Cloud + AWS Lambda + per-environment accounts and keys — while individual-client mode needs an engine on the user’s machine with zero accounts; and Restate’s cross-machine story is a distributedrestate-servercluster or Restate Cloud (data leaves the machine), versus celld’s share-through-a-bucket. It is the explicit fallback if the spike fails: localrestate-server(single binary; the local-dev pattern that stack already proved) for individual mode, Restate Cloud for shared mode — behind the unchanged SDK step surface. Its semantics are the north star regardless of substrate[^5]. - Option C: bespoke Deno engine per the original Engine docs — full control, one runtime dialect; but it re-implements scheduling, durability, crash recovery, and replication. Exactly the “custom cron + retry tables” status quo inbox 0002 already rejected, one level up.
- Option D: Cloudflare-hosted Workers/DO/Workflows — mature, with real cron and queues; but data leaves the machine, accounts are required, and local-first is forfeited.
- Option E: already rejected in inbox draft ADR-0002 — carried forward, not re-litigated. AWS
SQS + Lambda (manual idempotency keys, no replay after partial execution, opaque mid-flight
state). Step Functions (verbose JSON/YAML state machines, logic outside the definition, AWS
lock-in). XState (in-process; state lost on crash; retries need a second library — the benchmark
onboarding workflow took ~135 lines vs ~70 in Restate). Vercel Workflow DevKit (Vercel-coupled;
the HTTP-hook step model is less composable than
ctx.run). - Option F: run the engine inside the client (hidden window / webview). Superficially attractive — “the client is already running; it’s also the server” — and it matches the intuition that automation code lives in a background webview. Rejected as the execution substrate: a renderer dies with the app (no durability, no journal or replay, no leases); Chromium throttles hidden-renderer timers, so schedules drift or stall; workflow code would inherit the client’s ambient privileges, violating the capability model; and there is no cross-machine story. The healthy kernel survives elsewhere: the client hosts the engine as a supervised child (Mode A, specific 6), and one always-open client is the recommended hub topology (specific 8).
- Option G: do nothing — the Engine docs remain fiction, and the docs debt compounds.
Consequences
| Risk | Mitigation |
|---|---|
| celld is pre-1.0 with an evolving surface, patch-by-email contributions | Pin the binary version; re-run the spike checklist on every upgrade; the thin SDK facade keeps the substrate swappable — the documented SDK surface is the contract, celld an implementation detail. Budget for vendoring/local patches. |
| No cron, no queues[^3] | Alarm-based cron emulation and SQLite-inbox events are correctness we own; watch upstream (queues “if demand appears”). |
| Two daemons per user (celld + MinIO) | One supervised pair under ~/.tranquil/engine/ (Mode A). MinIO is AGPL-3.0 and its upstream repo is now archived[^11] — actively track alternatives and any future celld filesystem-bucket backend; settle this before Mode B ships. |
| Workers API ≠ Deno API for workflow code | Authors write against @tranquil/sdk; Deno tooling still formats/lints/checks the TypeScript; esbuild bundles it. |
| Upstream native “Workflows” may obsolete the run journal | A good outcome — swap beneath the SDK per the upgrade path in specific 4. |
| Run journals replicate to the bucket and can hold sensitive step results (scraped page content, tokens) | Carry forward the journal-encryption concern inbox draft ADR-0002 flags (its ADR-0006): encryption at rest and retention/GC settled before any shared-bucket promotion. |
Relationships: inbox draft ADR-0007 carries forward — Hono runs on Workers, so the API surface lives inside the celld Worker. ADR-0022 is an independent decision: the local runner and the ClientHub are peer channels into the same host capability layer, and either ADR stands without the other. ADR-0003/ADR-0015 are untouched by this ADR — the hub connection is an outbound client socket, not the guest↔host boundary.
Re-evaluate if: the spike fails any go/no-go item (→ Option B, Restate, behind the unchanged SDK surface); celld stalls or relicenses; or Cloudflare-hosted operation becomes acceptable to the product.
[^1]: celld documentation — cells as Durable Objects (named, single-threaded, private SQLite), replication to an S3-compatible bucket before acknowledgment (RPO = 0), bucket-lease coordination with no control plane. https://celld.dev/docs/ [^2]: denoland/celld — “self-hosted, distributed Durable Objects”; single binary, V8 executing Wrangler bundles, celld deploy . --bucket … (esbuild on PATH), HMAC-authenticated peer HTTP for private networks. Announced by Ryan Dahl: “celld = V8 + S3 + SQLite + LTX + Tokio … exactly the Cloudflare Workers/DO JavaScript APIs” (https://x.com/rough__sea/status/2085001943693549887). https://github.com/denoland/celld [^3]: celld Cloudflare compatibility — supported: module Workers, fetch, JS RPC (WorkerEntrypoint/RpcTarget, pipelining), service/DO bindings, DO alarms, hibernatable inbound WebSockets + outbound ws:/wss:, static assets. Not planned: cron triggers, queues, KV, R2, Cache API. Planned: D1, “Workflows (durable execution)“. https://celld.dev/docs/cloudflare-compat/ [^4]: Durable Object alarms — one alarm per object (setAlarm overwrites), getAlarm/deleteAlarm, at-least-once alarm() execution with automatic retries (exponential backoff, up to 6). https://developers.cloudflare.com/durable-objects/api/alarms/ [^5]: Restate durable execution — 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 [^6]: Cloudflare Workflows — durable multi-step applications with step.do, step.sleep/step.sleepUntil, step.waitForEvent, automatic retries and state persistence; the API shape the v1 step surface stays compatible with. https://developers.cloudflare.com/workflows/ [^7]: Restate SDK actions — ctx.run (“safely wrap any non-deterministic operation … and have Restate persist its result”), durable timers, awakeables (“a generated unique ID that an external system can resolve or reject”). https://docs.restate.dev/foundations/actions [^8]: Cloudflare R2 — S3-compatible object storage; a candidate shared bucket for cross-client mode. https://developers.cloudflare.com/r2/ [^9]: Tigris — S3-compatible object storage; the other shared-bucket candidate. https://www.tigrisdata.com/ [^10]: Tailscale — the private-overlay class celld’s peer HTTP expects between nodes. https://tailscale.com/ [^11]: MinIO — the local S3-compatible bucket for the spike; AGPL-3.0, and the upstream minio/minio repository is archived on GitHub (verified 2026-08-06), which makes the alternatives watch a real obligation, not hygiene. https://github.com/minio/minio