Consuming the facade
The facade is the only way into the substrate (ADR-0003). A consumer holds no Durable Object binding, no container class, no D1 — one service binding, thirteen methods, plain structural types. Every type named below is documented in the generated reference; this page is the sequence and the handling patterns the reference does not carry.
// the consumer's wrangler.jsonc"services": [ { "binding": "SUBSTRATE", "service": "flare-dispatch-substrate", "entrypoint": "FractalbotFacade" }]The entrypoint name is the consumer identity. DispatcherFacade and FractalbotFacade are
separate WorkerEntrypoint classes over the same implementation; which one a binding targets decides
the pool an execution lands in, the namespace its sandbox keys live in, and the ceiling its spend is
counted against. No runtime field carries identity and the substrate never trusts one
(ADR-0009) — a consumer cannot claim to be
another by any value it sends. Adding a consumer means adding an entrypoint class in a reviewed PR.
Declare the binding as the contract interface:
import type { SubstrateFacade, SubstrateRecipe,} from "@fractalboxdev/flare-dispatch-substrate-contract";
interface Env { /** Service binding to `flare-dispatch-substrate`, entrypoint `FractalbotFacade`. */ readonly SUBSTRATE: SubstrateFacade;}The contract has no dependencies and imports nothing, so it never forces a consumer onto a framework.
Effect-side consumers wrap it in a layer and match refusals on kind; the boundary itself stays
Effect-free.
The recipe rides every call
Section titled “The recipe rides every call”SubstrateRecipe is the consumer’s one input to grant derivation, and it is passed on ensureSandbox,
execUnderGrant, admissionEnqueue and admissionAttempt rather than stored. That is deliberate:
the substrate re-derives the pool and the egress grant from the recipe on every call and never reads
state back out of a consumer, so a consumer’s stale or tampered record cannot widen anything.
const recipe: SubstrateRecipe = { version: 3, // bump to force a rebuild repo: { owner: "fractalboxdev", name: "flare-dispatch", ref: "main" },};The security property rides with repo: it must come from an input no model authored. fractalbot
parses it from the human’s message and freezes it; dispatcher runs carry it in reviewed definitions.
A model-chosen repo is a model-chosen egress grant.
version makes restore-or-rebuild decidable without the substrate asking anyone: a call carrying a
higher version than the environment was built at gets a rebuild, and the returned
EnsureResult.generation bumps with rebuilt: true. Compare generation against the last one you
saw to detect that you are no longer on the tree you left.
Two admission shapes
Section titled “Two admission shapes”Wait semantics are the consumer’s choice, because the two consumers need opposite ones (ADR-0004).
Interactive work refuses fast. An interactive task must never silently queue behind CI:
const ensured = await env.SUBSTRATE.ensureSandbox(key, recipe, { mode: "refuse" });if (!ensured.ok) return render(ensured.refusal); // admission-refused carries pool, busy, capensureSandbox refuses on more than admission: a boot that cannot give the container a working
filesystem refuses with sandbox-unavailable. Match on kind.
Batch work queues in the consumer’s own durable machinery. ensureSandbox never blocks on a
queue in either mode — the consumer drives the line and hibernates between attempts, so a wait costs
a durable step rather than a held request:
await env.SUBSTRATE.admissionEnqueue(key, recipe);// ... in a durable step, with the consumer's own backoff:const attempt = await env.SUBSTRATE.admissionAttempt(key, recipe);if (!attempt.admitted) return retryLater(attempt); // position, poolBusy, cap{ mode: "queue" } on ensureSandbox expects that line to have already been driven to admission; it
refuses rather than blocks when it has not. Call admissionRelease when you abandon a wait, and note
that checkpoint and abort release the slot for you.
The ticket never leaves the substrate; every admitting call mints a fresh one and hands it to the execution’s Durable Object, which checks it again before it boots anything.
Diagram source
sequenceDiagram accTitle: Queued admission and the ticket gate participant C as Consumer participant F as Facade participant A as Admission D1 participant S as Sandbox DO C->>F: admissionEnqueue(key, recipe) F->>A: insert a queued row F-->>C: QueuePosition loop durable step, consumer backoff C->>F: admissionAttempt(key, recipe) F->>A: claim a slot, FIFO under the pool cap alt slot free and first in line F->>F: mint a ticket, 10 min TTL F->>S: admit(ticket), verified before it is stored F-->>C: admitted, expiresAt else pool full or not first F-->>C: not admitted, position, poolBusy, cap end end C->>F: ensureSandbox(key, recipe, queue mode) F->>A: row already admitted, heartbeat refreshed F->>S: admit(fresh ticket) F->>S: ensure(recipe) S->>S: ticket gate, else ticket-rejected S-->>F: generation, rebuilt F-->>C: EnsureOutcomeAn admitted ticket expires 10 minutes after it is minted and is refreshed by exec’ing, so an execution that sits idle past that window re-admits on its next call — which can refuse. A resume after a long approval wait is exactly this case.
Exec, and the fence you do not assemble
Section titled “Exec, and the fence you do not assemble”execUnderGrant runs the whole fence inside the substrate: stale-revoke, ensure, apply grant, run,
kill-before-revoke. A consumer supplies a command and gets back facts.
Diagram source
sequenceDiagram accTitle: One execUnderGrant call participant C as Consumer participant F as Facade participant A as Admission D1 participant S as Sandbox DO participant K as Container C->>F: execUnderGrant(key, input) F->>F: recipe check, else recipe-rejected F->>A: enqueue, then one claim attempt alt not admitted F-->>C: admission-refused else admitted F->>S: admit(fresh ticket) F->>S: guardedExec(input) S->>S: approval floor, spend the attestation S->>K: revoke any stale grant S->>S: ensure(recipe) behind the ticket gate S->>K: applyGrant, deny then handlers then allow S->>K: runTaskCommand, deduped on idempotencyKey Note over S,K: in a finally block, even on a throw or timeout S->>K: killFencedProcesses S->>K: revokeGrant S-->>F: receipt, ensured, granted, killed F-->>C: ExecOutcome endconst outcome = await env.SUBSTRATE.execUnderGrant(key, { recipe, command: "pnpm install --frozen-lockfile && pnpm test", idempotencyKey: `${taskId}:${ordinal}`, logPath: `steps/${ordinal}.log`, timeoutMs: 600_000, tailBytes: 8_000,});idempotencyKey is a correctness requirement, not a convenience. It must be stable across retries of
one durable step and distinct across steps: a retried call with the same key joins the in-flight
command or returns its recorded receipt with deduped: true, and never re-runs it. Workflow replays
are the normal case, and a key derived from a timestamp turns every replay into a second execution of
the same command.
command is the one possibly-model-authored value on this path. It is passed through unwrapped —
running it is the point — and the boundary is the egress policy and the credential-free container,
not a shell parser. Commands matching the irreversible floor (git push, wrangler deploy|secret|d1,
terraform apply, kubectl apply|delete, package publishes, gh release) are refused unless the
call carries an ApprovalAttestation
(ADR-0007). The attestation binds to
the exact command text through commandSha256 and to one step through (taskId, ordinal), so an
approval clicked for step 3 cannot satisfy step 7 and cannot be replayed onto a different command.
On success you get an ExecReceipt — exit code, duration, a byte-clamped tail, truncated — plus
the granted host list and the count of processes the pre-revoke kill reported. Full output lives in
artifacts at logPath; the tail is a bounded summary, not the log.
Every failure is a value
Section titled “Every failure is a value”The substrate never throws across the boundary. Both outcome types are a discriminated union on ok,
and a refusal is a SubstrateRefusal a consumer renders directly:
if (!outcome.ok) { switch (outcome.refusal.kind) { case "admission-refused": return busy(outcome.refusal); // pool, poolBusy, cap, retryAfterMs case "approval-required": return askHuman(outcome.refusal); // the rule that matched, never the command case "attestation-rejected":return rejected(outcome.refusal); case "budget-stop": return spent(outcome.refusal); // scope + meter state case "recipe-rejected": return badInput(outcome.refusal); case "ticket-rejected": return retry(outcome.refusal); case "sandbox-unavailable": return infra(outcome.refusal); }}Handle every kind. SubstrateRefusal is the substrate’s whole vocabulary for things a consumer must
act on, and a default branch that logs “something failed” throws away the one field that made the
refusal actionable. Widening this union is a breaking contract change for exactly this reason — see
the versioning policy.
abort is the exception: it never refuses. It kills what can be killed, releases the slot, reports
the count, and is idempotent on an already-gone container.
What you do not get, on purpose
Section titled “What you do not get, on purpose”- No pool or image class input. Both are policy-selected inside the substrate from
(consumer, recipe) (ADR-0010).
PoolNameappears only in refusals andpoolStatus(), as observability. An execution backend a model can name is an egress posture a model can choose. - No grant vocabulary. Grants derive from profiles reviewed inside the substrate. A recipe may
select among named profiles through
profiles; it can never define one. See authoring a grant profile. - No admission ticket. It is minted and verified inside the substrate; consumers never carry it.
- No verdicts. Exit codes, durations, generations, meter state, denial counts — execution facts only, permanently (ADR-0008). Mapping facts onto “passed”, “needs review” or “awaiting human” is consumer semantics.
Ending an execution
Section titled “Ending an execution”checkpoint(key, reason) snapshots the workspace, stops the container and releases the pool slot; the
next ensureSandbox or execUnderGrant restores from it. abort(key) skips the snapshot — the
off-switch. Neither is optional in practice: an admitted slot stops counting only once its heartbeat
goes 10 minutes stale, so a consumer that walks away from an execution parks a slot for that long,
and the default caps (lean 6, browser 3, agent 3, task 4) are small enough to feel it.
Diagram source
stateDiagram-v2 accTitle: An execution's pool slot [*] --> Queued : admissionEnqueue or any admitting call Queued --> Admitted : under the pool cap and first in line Queued --> [*] : admissionRelease, or a refuse-mode refusal Queued --> Lapsed : 100 s with no attempt Admitted --> Admitted : exec or attempt refreshes the heartbeat Admitted --> [*] : checkpoint, abort or admissionRelease Admitted --> Lapsed : heartbeat 10 min stale Lapsed : stops counting against the cap or the line Lapsed --> [*] : swept by any release once 10 min stale