Skip to content

ADR-0004: Admission is enforced by ticket, and the substrate alone owns the ceiling

  • Status: Proposed
  • Date: 2026-08-06
  • Implementation: shipped — src/admission/ticket.ts:71 mint / :87 verify, gated at src/sandbox-do.ts:204,221, minted at src/facade.ts:126. Tests: admission/ticket.test.ts, admission/pools.test.ts, sandbox-do.workers.test.ts.

Both consumers draw on one account-level Containers ceiling with no view of each other. flare-dispatch built a strongly-consistent D1 FIFO counting semaphore with heartbeat decay (packages/core/src/run-admission.ts, container-lease.ts); fractalbot built an advisory check that reads a best-effort projection, fails open on outage, and leans on wrangler max_instances (src/sandbox-policy.ts, src/agent.ts — “admission counts unavailable — admitting”). On a busy CI day one fleet consumes the headroom and the other’s creates fail at the platform with no gate having refused anything. “One admission path” as a convention would not survive: any worker holding a D1 binding by database_id could still write the table, and any binding-holder could still boot a container. Consumers also need opposite wait semantics — a Slack task must refuse fast with an actionable reason; a CI run must queue with visible progress, hibernating in its own durable steps.

  • The substrate mints an admission ticket (HMAC over consumer, key, pool and expiry; 10-minute TTL, re-minted on heartbeat and never extended in place) on admit; a container class refuses to boot without a valid ticket. Enforcement is at the container, not in the caller.
  • One pool per image class — lean, browser, agent, task — each with its own cap; deploy-time validation asserts the cap-sum stays within the account Containers ceiling. That cap partition, not FIFO fairness, is what prevents CI starving interactive tasks.
  • Consumers drive queue waits via admission.enqueue/attempt/release, hibernating in their own durable machinery; ensure() never blocks on a queue. Admission mode is consumer-chosen: {mode:'refuse'} or {mode:'queue', maxQueueAgeMs}. Refusal/timeout is a typed error carrying {pool, poolBusy, cap, position?, queuedForMs?, retryAfterMs?, timedOut?}. A queued execution that has waited maxQueueAgeMs or longer is refused with timedOut: true and loses its queue row; poolStatus() exposes per-pool, per-consumer occupancy.
  • The admission D1 is bound to the substrate worker only. Consumer-side quotas (fractalbot’s per-conversation/per-user caps) stay consumer-side, ahead of the physical gate.

A queued consumer polls from its own durable steps; the container boots only against a ticket the DO verifies itself.

Admission by ticket Sequence diagram. 4 participants, 16 messages. Participants: Consumer (C), Facade (F), Admission D1 (D), Sandbox DO (S). 1. Consumer → Facade: admissionEnqueue(key, recipe) 2. Facade → Admission D1: enqueue — FIFO row in the class's pool loop durable steps, until admitted: 3. Consumer → Facade: admissionAttempt(key, recipe) 4. Facade → Admission D1: claim if the pool is under its cap 5. Admission D1 --> Facade: refused — position, poolBusy 6. Facade --> Consumer: admitted false, position, poolBusy, cap 7. Facade → Facade: mint ticket — HMAC over consumer, key, pool, 10 min TTL 8. Facade → Sandbox DO: admit(consumer, key, ticket) 9. Sandbox DO → Sandbox DO: verify, then store the ticket 10. Consumer → Facade: ensureSandbox or execUnderGrant 11. Facade → Sandbox DO: ensure(recipe) 12. Sandbox DO → Sandbox DO: ticket gate — verify the stored ticket alt no valid ticket: 13. Sandbox DO --> Facade: ticket-rejected, nothing boots else valid ticket: 14. Sandbox DO --> Facade: container restored or rebuilt 15. Consumer → Facade: admissionRelease, checkpoint or abort 16. Facade → Admission D1: release the slot loopdurable steps, until admitted altno valid ticketvalid ticket ConsumerConsumer FacadeFacade Admission D1Admission D1 Sandbox DOSandbox DO admissionEnqueue(key,recipe) enqueue — FIFO row in theclass's pool admissionAttempt(key,recipe) claim if the pool is under itscap refused — position, poolBusy admitted false, position,poolBusy, cap mint ticket — HMAC overconsumer, key, pool, 10 minTTL admit(consumer, key, ticket) verify, then store the ticket ensureSandbox orexecUnderGrant ensure(recipe) ticket gate — verify the storedticket ticket-rejected, nothing boots container restored or rebuilt admissionRelease,checkpoint or abort release the slot
Admission by ticket
Diagram source
sequenceDiagram
accTitle: Admission by ticket
participant C as Consumer
participant F as Facade
participant D as Admission D1
participant S as Sandbox DO
C->>F: admissionEnqueue(key, recipe)
F->>D: enqueue — FIFO row in the class's pool
loop durable steps, until admitted
C->>F: admissionAttempt(key, recipe)
F->>D: claim if the pool is under its cap
D-->>F: refused — position, poolBusy
F-->>C: admitted false, position, poolBusy, cap
end
F->>F: mint ticket — HMAC over consumer, key, pool, 10 min TTL
F->>S: admit(consumer, key, ticket)
S->>S: verify, then store the ticket
C->>F: ensureSandbox or execUnderGrant
F->>S: ensure(recipe)
S->>S: ticket gate — verify the stored ticket
alt no valid ticket
S-->>F: ticket-rejected, nothing boots
else valid ticket
S-->>F: container restored or rebuilt
end
C->>F: admissionRelease, checkpoint or abort
F->>D: release the slot
  • “Zero out-of-gate container creates” becomes testable: call ensure() without a ticket and watch it fail closed; structurally, no containers stanza exists outside the substrate’s wrangler config.
  • fractalbot gains real admission for the first time; its fail-open behavior is deliberately not ported into the shared gate.