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 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.
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.
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.
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: EnsureOutcome
An 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.
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.
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
end
const 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.
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.
- 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.
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.
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