Concepts
Core concepts
Section titled “Core concepts”Emails and Messages. One Email has shared content and an envelope: from,
to, cc, bcc, subject, text, html or both, and optional headers.
Each recipient becomes a separate Message with its own identity and roles. The
ordinary interface is send() and get().
Acceptance. send() resolves after the Email, its recipient Messages, the
queued work and the wake commit together. A queued receipt means durable local
acceptance. It does not mean the provider accepted the email or delivered it.
Replay. Use a stable source ID across handoff retries. A repeated ID with
identical content returns the existing receipt. Changing its content, envelope,
headers or expiry produces CarrierError with code: "conflict". get()
returns undefined when the ID is absent.
Dispatch evidence. Carrier records dispatch intent before it invokes the
provider. A successful response records provider_accepted and its
providerMessageId. That is an aggregate acknowledgment. It does not prove
every recipient was accepted or delivered, so recipient Messages stay
unconfirmed. A lost response or interrupted dispatch becomes
outcome_unknown. Carrier never sends that email again automatically, and an
unchanged-ID replay does not resend it. Sending under a new ID can duplicate
delivery. Work interrupted before dispatch intent recovers safely.
Statuses. A receipt’s status is one of queued, dispatching,
provider_accepted, outcome_unknown, rejected, refused, expired or
superseded. Terminal receipts keep identities and dispatch evidence. Carrier
removes their stored content.
Provider rejections and retries. The Cloudflare transport decodes the
binding’s documented error code. A whole-request refusal before acceptance is
provider_rejected evidence with a class:
temporarycodes (E_RATE_LIMIT_EXCEEDED,E_DAILY_LIMIT_EXCEEDED,E_INTERNAL_SERVER_ERROR) return the Email toqueued. The same owner and wake dispatch it again after exponential backoff, until the retry budget is spent.permanentcodes (sender, recipient, validation, size and header errors) end asrejected.
The receipt’s providerRejection carries the last code and class.
E_DELIVERY_FAILED, undocumented codes and exceptions without a code stay
outcome_unknown, because some recipients may already have been accepted.
Per-recipient cap. Each Carrier store accepts a bounded number of Emails per
normalized recipient address in a rolling window. Emails that never reached the
provider (refused, rejected, expired or superseded) do not count. Over the cap,
send() durably records the submission as refused and never dispatches it. A
replay of that ID returns the same refused receipt.
Expiring or superseded email. Ordinary email needs no expiry or validity
callback. A source that owns expiring messages can supply sendBefore, an
absolute Unix timestamp in milliseconds. Carrier checks it before dispatch and
records expired without calling the provider once the deadline has passed. For
invitations or other replaceable notifications, construction can also take
canSend: async (emailId) => boolean. It must consult the source of truth:
return false for a definitely obsolete message, and throw when that truth is
unavailable. false records superseded. A throw leaves the email unsent for
later recovery. This is a pre-dispatch check, not an atomic fence against a
concurrent source change. Carrier does not create or validate authentication
tokens.
Transports. Provider and storage choices are independent. A transport takes
Carrier’s Email and makes one provider attempt. It returns
{ kind: "provider_accepted", providerMessageId } only on an actual aggregate
acknowledgment, { kind: "provider_rejected", code, class } only for a
documented refusal of the whole request, or { kind: "outcome_unknown" } when
acceptance is uncertain. Credentials and provider configuration belong in the
transport, outside send(). Carrier’s local submission ID does not give you
provider idempotency. Each transport handles its own SDK retry behaviour and
event correlation.
Hosts. createCarrier({ storage, host, transport, now }) returns send,
get, tick and hasPendingWork. Its storage is Watchdog’s structural
SQLite owner. host.commit(mutation) must commit the mutation and a durable
wake atomically. host.requestWake() must durably schedule recovery. Call
tick() from that wake, and keep the wake armed while hasPendingWork() is
true. Construct exactly one owner per dedicated store and runtime lifetime. A
replacement can start after its predecessor has stopped.
createCloudflareCarrier does all of this for a Durable Object.
Operator inspection
Section titled “Operator inspection”@fungi.computer/carrier/inspection supplies createDeliveryInspection(sql)
for hosts that have already admitted an operator. It reads existing storage
without opening a delivery owner, configuring a provider, creating tables,
repairing jobs or changing alarms. Carrier does not authenticate the host’s
caller. Do not grant this capability to models or guests.
Each call scans at most 64 rows (32 by default). Continue with
after: page.nextCursor until the cursor is null. Cursors and Post receipt
keys use the producer’s key, without the internal post: prefix. A filtered
dead-letter page can be empty and still have a continuation. These are ordered
scans, not snapshots across concurrent writes.
An initialized store returns { status: "available", records, nextCursor }. A
store without that delivery table returns { status: "uninitialized" }. Results
include dispatch state, attempts, retry time and available provider evidence or
dead-letter reason. They omit Post addresses and events, email bodies, headers
and recipient addresses. Delivered Post rows keep only their key and state.
Email attempts counts definite provider rejections, so an accepted or
ambiguous first dispatch has zero. Post attempts counts receiver invocations.
@fungi.computer/carrier/post delivers typed, point-to-point envelopes between
named Durable Objects. definePostRoute pairs each address schema and event
schema with its Durable Object namespace, name function and fixed addressKind
label. Every event route to the same namespace shares that label, so different
event kinds for one recipient keep producer order. The receiver implements
deliver(envelope) and calls admitPost inside its own SQLite transaction, so
the event mutation and admission receipt commit together. Keys must identify the
producing event across all senders to that receiver.
Construct createPost with the producer’s structural SQLite owner, typed
routes, Watchdog’s transactional projection and readJob operation, a
clock, and a durable scheduleWake. Post owns its tables. Call send inside
the same transaction as the producer’s state change and durable initial wake. It
commits the envelope and Watchdog delivery work together. Each producer’s events
reach a named address in order. A pending predecessor holds its successor until
it is delivered or dead. Replaying the same stable key keeps the existing work
or its completion receipt. Keep those receipts while the source can replay.
Use execute as the Watchdog executor for Post jobs. It records an attempt and
arms recovery before calling the addressed Durable Object. After the owner ticks
Watchdog, call enqueueDue at a wake to inspect settled jobs and enqueue due
retries, then tick Watchdog for the newly queued work. Continue its bounded
pages with the returned cursor. Queued or running jobs stay with Watchdog’s
lifecycle. A missing job or row is an error. After a lost response or failed
receipt write, a retry carries the same envelope key and the receiver reports
duplicate.
latest(kind, address) reads the newest staged event or its receipt for that
recipient without scanning other recipients. A member or operator command can
call retry(key) inside the producer’s transaction with its awaited wake. It
expedites a settled failed attempt or puts a dead letter after current queued
work, keeping its prepared fact. Queued and running jobs stay with Watchdog, and
a delivered receipt is never reopened.
A route can prepare a wire fact from its staged intent with
prepare: { event: wireSchema, run(address, intent, signal) }. Preparation and
receiver I/O share the ten-second attempt deadline. Post persists the parsed
prepared envelope before sending it. Restarts and lost responses reuse that
exact fact while the original intent still identifies a repeated send. A late
preparation result cannot send after its attempt expires. Use onDelivered to
acknowledge a domain ledger in the same producer transaction as the Post
receipt. An acknowledgment failure leaves both pending for retry. Prepared facts
should carry sealed grants, never raw credentials.
A definite refusal or an exhausted retry budget keeps a dead-letter envelope,
its reason and its attempt count. deadLetters returns letters and
nextCursor, including a continuation through pages that contain only receipts.
The same retry and receipt machinery is exported as makePostOutbox for a
string-backed outbox. BirdDog uses it for agent reply
delivery.
Post transactions on D1 and PostgreSQL
Section titled “Post transactions on D1 and PostgreSQL”@fungi.computer/carrier/post-d1 and /post-postgres bind the ordinary Post
address and event registry to Watchdog’s database adapters. Apply postD1Schema
or postPostgresSchema alongside the matching Watchdog schema through your
migration tool.
On D1, post.send(envelope) returns the envelope and job statements. Include
both in the same database.batch as the producer fact. sendFromSelect accepts
a trusted native SQLite ?NNN-bound SELECT yielding post_key,
envelope_json, queue_name, next_attempt_at and job_id, for conditional
source facts created inside that batch. PostgreSQL post.send(client, envelope)
writes both rows on the producer’s existing transaction client. Neither send API
commits for the producer. A conflicting replay rolls the whole transaction back.
Wire Watchdog’s executor as (job, signal) => post.execute(job, signal).
enqueueDue recovers a bounded page of settled delivery attempts, and the host
keeps its durable cron or wake. Preparation, bounded receiver calls, response
parsing and settlement decisions are shared with SQLite Post. Receipts suppress
source replay, failed attempts retry with the same Post policy, permanent
refusal or exhaustion becomes a dead letter, and earlier pending mail blocks
later mail to the same address. A receiver can return { status, receipt } with
JSON receipt metadata. Post persists it with terminal settlement and exposes it
on the delivered receipt or dead letter. That is how an email producer can
record provider acceptance while minting credentials only during delivery. These
async adapters reject synchronous SQLite receipt hooks rather than run them
outside a transaction.
purgeD1Post and purgePostgresPost return parameterized statements selected
by trusted producer SQL yielding queue_name. Compose all of them in the same
native batch or transaction as your source erasure. They remove envelopes and
every Watchdog attempt, settled attempts included. PostgreSQL locks the selected
envelopes first, so an overlapping retry cannot leave an orphaned job.