Skip to content

Latest commit

 

History

History
262 lines (221 loc) · 11.7 KB

facade.md

File metadata and controls

262 lines (221 loc) · 11.7 KB

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.

Bind

// 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

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

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.

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
Loading

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.

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.

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

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

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

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.

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
Loading