Skip to content

The flare-dispatch substrate — execution environment for agentic work

The substrate is where FractalBox’s agentic work runs: containers under a deny-all egress policy, admission that owns the account’s Containers ceiling, artifacts on R2, and metered model access. It is a component of this repo (apps/substrate, its own worker — wrangler name flare-dispatch-substrate, frozen at the first BYOC deploy), consumed by the dispatcher in-repo and by fractalbot (the Slack conversational agent) from its own repo — and by any later consumer. The substrate executes; it never decides what to run, never renders an outcome, and never holds conversation or CI semantics.

The substrate is a policy-and-audit layer over first-party Cloudflare primitives, not sandbox machinery of its own. Cloudflare Sandboxes (GA 2026-04-13) provide deny-by-default egress, dynamic per-instance policy, TLS interception, and outbound-handler credential injection; Browser Rendering provides the browser fleet. The substrate adds what the platform does not express: request-level grants asserted against inputs no model authored, redirect re-policing, one enforced admission path, per-execution and per-consumer budgets, approval attestation, and the audit trail — see the ADRs for each decision.

Consumers and the substrate boundary Flowchart, top to bottom. 9 nodes, 8 edges. Consumers: flare-dispatch RunWorkflow → Facade: WorkerEntrypoint over service binding Consumers: fractalbot TaskWorkflow → Facade: WorkerEntrypoint over service binding Consumers: future surfaces → Facade: WorkerEntrypoint over service binding substrate worker: Facade: WorkerEntrypoint over service binding → Admission: per-class pools, ticket-gated boot substrate worker: Admission: per-class pools, ticket-gated boot → Container DOs: lean, browser, agent, task substrate worker: Container DOs: lean, browser, agent, task — Egress: deny-all, grant profiles, credential injection; — R2 artifacts and backups; — Metered model proxy: per-execution and per-consumer substrate worker: Egress: deny-all, grant profiles, credential injection substrate worker: R2 artifacts and backups substrate worker: Metered model proxy: per-execution and per-consumer Consumers flare-dispatch RunWorkflow fractalbot TaskWorkflow future surfaces substrate worker Facade: WorkerEntrypointover service binding Admission: per-class pools,ticket-gated boot Container DOs: lean, browser,agent, task Egress: deny-all, grantprofiles, credential injection R2 artifacts and backups Metered model proxy:per-execution andper-consumer
Consumers and the substrate boundary
Diagram source
flowchart TB
accTitle: Consumers and the substrate boundary
subgraph consumers[Consumers]
rw[flare-dispatch RunWorkflow]
tw[fractalbot TaskWorkflow]
later[future surfaces]
end
subgraph sub[substrate worker]
facade["Facade: WorkerEntrypoint over service binding"]
pool["Admission: per-class pools, ticket-gated boot"]
box["Container DOs: lean, browser, agent, task"]
eg["Egress: deny-all, grant profiles, credential injection"]
r2[R2 artifacts and backups]
mp["Metered model proxy: per-execution and per-consumer"]
end
rw --> facade
tw --> facade
later -.-> facade
facade --> pool --> box
box --- eg
box --- r2
box --- mp

Consumers reach the substrate only through the facade (ADR-0003): ensureSandbox / execUnderGrant / checkpoint / abort, plus admission.enqueue/attempt/release and poolStatus(). The boundary speaks plain structural types; Effect layers wrap consumer-side. Verdicts, triggers, sinks, planners, and conversation state stay with the consumers (ADR-0008).

The stricter of the two consumers’ threat models — hostile cloned code; every control holds without model cooperation — is the floor for all workloads, CI included:

  • Deny-all egress with named grant profiles; grants derive only from reviewed code, never dispatch inputs (ADR-0005).
  • No long-lived credential reachable from inside a container — writes leave via Worker-side writeback or handler-injected credentials; the per-execution model-proxy token is the one sanctioned carve-out (ADR-0006).
  • Irreversible commands require an approval attestation at the exec surface (ADR-0007).
  • Execution tier and image class are policy-selected, never model- or payload-visible (ADR-0010).
  • A process that outlives the exec fence holds no grant (ADR-0012).
  • The @cloudflare/sandbox + @cloudflare/containers pin is a security surface with a deploy-time canary (ADR-0011).

Every command a consumer sends crosses the exec fence (src/engine/exec-fence.ts), which holds the grant open only for the command’s lifetime:

The exec fence around one command Flowchart, top to bottom. 9 nodes, 9 edges. execUnderGrant command + recipe + idempotency key → Irreversible command? Irreversible command? → Refused typed refusal, nothing runs [yes, no valid attestation]; → Stale revoke clear any grant a prior call left [no, or attestation spent once] Stale revoke clear any grant a prior call left → ensure admitted ticket? ensure admitted ticket? → Refused typed refusal, nothing runs [no]; → Apply grant profile hosts, method and path rules [yes] Apply grant profile hosts, method and path rules → Run command dedupe on key, output to artifacts Run command dedupe on key, output to artifacts → Kill fenced processes always, even on throw or timeout declared detached ones spared Kill fenced processes always, even on throw or timeout declared detached ones spared → Revoke grant container back to deny-all yes, no valid attestation no, or attestation spent once no yes execUnderGrantcommand + recipe + idempotencykey Irreversible command? Refusedtyped refusal, nothing runs Stale revokeclear any grant a prior call left ensureadmitted ticket? Apply grantprofile hosts, method and path rules Run commanddedupe on key, output to artifacts Kill fenced processesalways, even on throw or timeoutdeclared detached ones spared Revoke grantcontainer back to deny-all
The exec fence around one command
Diagram source
flowchart TB
accTitle: The exec fence around one command
cmd["**execUnderGrant**<br/>command + recipe + idempotency key"] --> floor{"Irreversible command?"}
floor -->|"yes, no valid attestation"| refuse["**Refused**<br/>typed refusal, nothing runs"]
floor -->|"no, or attestation spent once"| stale["**Stale revoke**<br/>clear any grant a prior call left"]
stale --> ensure{"**ensure**<br/>admitted ticket?"}
ensure -->|no| refuse
ensure -->|yes| apply["**Apply grant**<br/>profile hosts, method and path rules"]
apply --> run["**Run command**<br/>dedupe on key, output to artifacts"]
run --> kill["**Kill fenced processes**<br/>always, even on throw or timeout<br/>declared detached ones spared"]
kill --> revoke["**Revoke grant**<br/>container back to deny-all"]
class refuse danger
class run accent
class revoke ok

Accepted residuals (inherited from fractalbot’s ADR-0005, restated so consumers start from documented gaps): DNS exfiltration is uncovered; git-upload-pack is a bounded exfiltration sink; anything a fenced command backgrounds survives the fence’s kill, which reaches only the container’s startProcess registry — what bounds such a child is the revoke that follows and the container’s lifetime. A process a run declares detached is spared deliberately and holds no grant of its own, but shares whatever grant a later fence opens while it is open (ADR-0012).

Never store, never log: Slack bot tokens (never reach the substrate), GitHub installation tokens, secret values, capability-token values, raw prompts beyond metering metadata. Authenticated clone URLs are scrubbed from git remotes immediately post-clone; .git config is redacted at the artifact/checkpoint capture chokepoint. Every egress denial is recorded as a per-execution event {host, method, path, reason, count} retrievable with the execution’s artifacts — and never surfaced into the container.

Per-org BYOC, unchanged: an org deploys its dispatcher and its the substrate worker (the substrate first — the dispatcher’s service binding requires it), each from its own operator overlay under one upstream pin. fractalbot remains a separate single-workspace deployable binding to the FractalBox org’s substrate.

One org's BYOC deployment Flowchart, top to bottom. 4 nodes, 4 edges. Upstream pin one commit, two overlays → substrate worker container classes, D1, R2 [deploys first]; → dispatcher worker binds DispatcherFacade [deploys second] dispatcher worker binds DispatcherFacade → substrate worker container classes, D1, R2 [service binding] fractalbot binds FractalbotFacade → substrate worker container classes, D1, R2 [service binding, FractalBox org] deploys first deploys second service binding service binding, FractalBoxorg Upstream pinone commit, two overlays dispatcher workerbinds DispatcherFacade fractalbotbinds FractalbotFacade substrate workercontainer classes, D1, R2
One org's BYOC deployment
Diagram source
flowchart TB
accTitle: One org's BYOC deployment
pin["**Upstream pin**<br/>one commit, two overlays"]
disp["**dispatcher worker**<br/>binds `DispatcherFacade`"]
fb["**fractalbot**<br/>binds `FractalbotFacade`"]
sub["**substrate worker**<br/>container classes, D1, R2"]
pin -->|deploys first| sub
pin -->|deploys second| disp
disp -->|service binding| sub
fb -.->|"service binding, FractalBox org"| sub
class sub accent

Deploy blast radius is the structural reason the substrate is its own worker: container DO classes live in whichever worker defines them, and deploying that worker churns running containers. Product iteration on the dispatcher or fractalbot must never kill long-lived work mid-run.

Patch distribution — the floor is only as good as the version an org runs: the substrate reports its version on the health surface; security releases declare a minimum supported version on an advisory channel; a substrate-only bump path exists so a security patch never queues behind a product release (deploy.yml, workflow_dispatch with target: substrate — it stops after the canary).

deploy.yml runs migrations → substrate → canary → dispatcher, and the canary is a gate rather than a report: the dispatcher is a consumer, and ADR-0011 requires the floor to be proven on the running build before consumer traffic reaches it.

deploy.yml job order Flowchart, left to right. 6 nodes, 5 edges. Substrate D1 migrations → Deploy substrate Deploy substrate → Canary HTTPS to unlisted host dies 520 Canary HTTPS to unlisted host dies 520 → Stop dispatcher never deploys [fail]; → Dispatcher migrations, then deploy [pass]; → Dogfood facade round trip [pass] fail pass pass Substrate D1migrations Deploysubstrate CanaryHTTPS to unlisted host dies 520 Stopdispatcher never deploys Dispatchermigrations, then deploy Dogfoodfacade round trip
deploy.yml job order
Diagram source
flowchart LR
accTitle: deploy.yml job order
mig["**Substrate D1**<br/>migrations"] --> sub["**Deploy**<br/>substrate"]
sub --> can{"**Canary**<br/>HTTPS to unlisted host dies 520"}
can -->|fail| stop["**Stop**<br/>dispatcher never deploys"]
can -->|pass| disp["**Dispatcher**<br/>migrations, then deploy"]
can -->|pass| dog["**Dogfood**<br/>facade round trip"]
class stop danger
class can accent
Surface What it answers
POST /canary A container HTTPS fetch to an unlisted host dies 520 — interception is engaged and the image trusts the interception CA on this build (ADR-0011). An http-only 520 is a diagnosis, not a pass.
POST /dogfood The facade round trip: ensure → exec → replay the same idempotency key → checkpoint → abort, against a real container and a real public clone.
GET /health Version, deployment id, pool caps against the ceiling, and the canary verdict. 503 unverified until a fresh passing canary exists for the running build.

Verdicts are keyed by deployment id (version_metadata), not by the semver: the SDK internals the deny-all posture rests on can move without the semver moving. The record doubles as the rate limit that lets the probe endpoints stay credential-free — a fresh verdict is served from D1 instead of re-probing, so an anonymous caller costs at most one container boot per deployment per re-verify window.

apps/substrate/scripts/verify-deploy.sh <base-url> <canary|dogfood|health> is the same check by hand — the BYOC health check an operator runs against their own deployment. A deferred answer (pool full, nothing ran) is retried; a failed canary is decided on the first answer.

The container image is pinned per worker: infra/Dockerfile.substrate tracks the substrate’s @cloudflare/sandbox version, separately from the dispatcher’s infra/Dockerfile.sandbox. The DO is the client and the image is the server for one protocol, so a mismatched pair fails at exec rather than at deploy — invisible until real work runs.

Stage Ordering constraint What
1 None — additive; the substrate not yet involved Slack batch path lands in flare-dispatch (classification at fractalbot’s ingress → HMAC re-dispatch) with enforced controls: secrets: [] on slack origin, run allowlist excluding payload-command runs, config-pinned target repo.
2 Before untrusted or write-capable workloads share the fleet The substrate is born: worker + facade + ticket-gated admission + the egress engine ported from fractalbot (src/egress.ts, src/exec.ts, src/sandbox-policy.ts — zero Cloudflare imports). flare-dispatch adopts the substrate: drain in-flight runs, then delete its container DO classes (state in the moving classes is disposable by design — leases live in D1, backups are cache) and consume the facade. SDK pair reconciled + canary live before consumer traffic. Deliverables: grant-profile catalog covering the run catalog, grant-authoring guide, generated facade API reference, BYOC upgrade runbook. Exit: every run’s credentials ride writeback or proxy injection (worker-deploy is the acceptance case); every run graduated legacy → report → enforce.
3 After the facade API freezes fractalbot binds: deletes sandbox.ts, image config, backup plumbing, its sandbox DO binding; keeps driver, planner, budgets, approval UX. Both its exec paths route through the substrate’s approval check.

Engineering ships at 10x; stages are risk-ordered, not effort-ordered — 1 and 2 run concurrently, and 3 starts when the facade contract review lands.

Stage 2’s operator sequence is adoption-runbook.md: deploy with the binding, flip SUBSTRATE_BACKEND, open a report window per run, graduate to enforce, then drain, delete the dispatcher’s classes and raise the caps. It also names what still blocks the drain — the facade serves no preview-URL or container-artifact surface, and while it does serve detached processes (startDetached / detachedStatus / stopDetached, ADR-0012), the runtime adapter is not wired to them and the run bodies still call sandbox.runDetached. So the three runs that need one stay on the dispatcher’s fleet until those move over.

  • A container boot without an admitted ticket fails closed (tested by calling ensure() directly); no containers stanza exists in any wrangler config except the substrate’s.
  • Zero secrets in container env across the consumer catalogs; worker-deploy ships on handler injection.
  • Dogfood round-trips: a Slack mention triggers a dispatcher run whose verdict lands in-thread; a fractalbot task executes on the substrate with a mid-run approval.
  • Docs shipped at stage-2 exit: facade API reference, grant-authoring guide, BYOC upgrade runbook.
  • May BYOC operators author custom grant profiles, and behind what review gate (ADR-0005 defers this).
  • Whether in-flight Workflows instances resume on new-deploy code (unverified platform behavior; affects consumer bake periods, not the substrate’s contract).