Skip to content

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.

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, cap

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

Queued admission and the ticket gate Sequence diagram. 4 participants, 16 messages. Participants: Consumer (C), Facade (F), Admission D1 (A), Sandbox DO (S). 1. Consumer → Facade: admissionEnqueue(key, recipe) 2. Facade → Admission D1: insert a queued row 3. Facade --> Consumer: QueuePosition loop durable step, consumer backoff: 4. Consumer → Facade: admissionAttempt(key, recipe) 5. Facade → Admission D1: claim a slot, FIFO under the pool cap alt slot free and first in line: 6. Facade → Facade: mint a ticket, 10 min TTL 7. Facade → Sandbox DO: admit(ticket), verified before it is stored 8. Facade --> Consumer: admitted, expiresAt else pool full or not first: 9. Facade --> Consumer: not admitted, position, poolBusy, cap 10. Consumer → Facade: ensureSandbox(key, recipe, queue mode) 11. Facade → Admission D1: row already admitted, heartbeat refreshed 12. Facade → Sandbox DO: admit(fresh ticket) 13. Facade → Sandbox DO: ensure(recipe) 14. Sandbox DO → Sandbox DO: ticket gate, else ticket-rejected 15. Sandbox DO --> Facade: generation, rebuilt 16. Facade --> Consumer: EnsureOutcome loopdurable step, consumerbackoff altslot free and first in linepool full or not first ConsumerConsumer FacadeFacade Admission D1Admission D1 Sandbox DOSandbox DO admissionEnqueue(key,recipe) insert a queued row QueuePosition admissionAttempt(key,recipe) claim a slot, FIFO under thepool cap mint a ticket, 10 min TTL admit(ticket), verified before itis stored admitted, expiresAt not admitted, position,poolBusy, cap ensureSandbox(key, recipe,queue mode) row already admitted,heartbeat refreshed admit(fresh ticket) ensure(recipe) ticket gate, elseticket-rejected generation, rebuilt EnsureOutcome
Queued admission and the ticket gate
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: 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.

One execUnderGrant call Sequence diagram. 5 participants, 15 messages. Participants: Consumer (C), Facade (F), Admission D1 (A), Sandbox DO (S), Container (K). 1. Consumer → Facade: execUnderGrant(key, input) 2. Facade → Facade: recipe check, else recipe-rejected 3. Facade → Admission D1: enqueue, then one claim attempt alt not admitted: 4. Facade --> Consumer: admission-refused else admitted: 5. Facade → Sandbox DO: admit(fresh ticket) 6. Facade → Sandbox DO: guardedExec(input) 7. Sandbox DO → Sandbox DO: approval floor, spend the attestation 8. Sandbox DO → Container: revoke any stale grant 9. Sandbox DO → Sandbox DO: ensure(recipe) behind the ticket gate 10. Sandbox DO → Container: applyGrant, deny then handlers then allow 11. Sandbox DO → Container: runTaskCommand, deduped on idempotencyKey Note over Sandbox DO,Container: in a finally block, even on a throw or timeout 12. Sandbox DO → Container: killFencedProcesses 13. Sandbox DO → Container: revokeGrant 14. Sandbox DO --> Facade: receipt, ensured, granted, killed 15. Facade --> Consumer: ExecOutcome altnot admittedadmitted ConsumerConsumer FacadeFacade Admission D1Admission D1 Sandbox DOSandbox DO ContainerContainer in a finally block, even on athrow or timeout execUnderGrant(key, input) recipe check, elserecipe-rejected enqueue, then one claimattempt admission-refused admit(fresh ticket) guardedExec(input) approval floor, spend theattestation revoke any stale grant ensure(recipe) behind theticket gate applyGrant, deny thenhandlers then allow runTaskCommand, dedupedon idempotencyKey killFencedProcesses revokeGrant receipt, ensured, granted,killed ExecOutcome
One execUnderGrant call
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
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). PoolName appears only in refusals and poolStatus(), 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.

An execution's pool slot State diagram, top to bottom. 5 states, 8 transitions. start → Queued [admissionEnqueue or any admitting call] Queued → Admitted [under the pool cap and first in line]; → end [admissionRelease, or a refuse-mode refusal]; → stops counting against the cap or the line [100 s with no attempt] Admitted → Admitted [exec or attempt refreshes the heartbeat]; → end [checkpoint, abort or admissionRelease]; → stops counting against the cap or the line [heartbeat 10 min stale] stops counting against the cap or the line → end [swept by any release once 10 min stale] admissionEnqueue or anyadmitting call under the pool cap and first inline admissionRelease, or arefuse-mode refusal 100 s with no attempt exec or attempt refreshes theheartbeat checkpoint, abort oradmissionRelease heartbeat 10 min stale swept by any release once 10min stale Queued Admitted stops counting against thecap or the line
An execution's pool slot
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