| topic | agents |
|---|---|
| type | design |
| status | design-not-built |
| last-validated | 2026-07-30 |
| related-docs | 2139, 2142, 2138, 2064 |
| original-query | The transactional-outbox: productionize the idempotent side-effect protocol (doc 2142 next focus) - durable intents, deterministic keys, external reconciliation, at-most-once side effects |
| tier | DEEP |
The next-focus milestone from the Architect's Progress Report (doc 2142). This is a DESIGN, not built - it needs a DB migration (ask-first per CLAUDE.md). It productionizes
guardIrreversible(doc 2139) from in-process at-most-once into crash-safe, cross-process at-most-once for every real outbound action (Telegram/Farcaster/mail/on-chain).
Today an autonomous action that fires an external side effect can, on a crash between "did it" and "recorded it", either double-fire (retry re-sends) or lose evidence (looks un-done). guardIrreversible + MemoryEffectLedger (doc 2139) solve this WITHIN one process lifetime; a restart wipes the ledger. The Heart lease layer (doc 2139) gives us fencing + recovery; the receipt emitter (doc 2138/PR #2713) gives us portable evidence. The outbox joins them: the decision to send and the durable record of intent commit in the SAME transaction, and a separate fenced dispatcher performs the send exactly once.
-- The durable intent ledger: the atomic at-most-once claim.
create table effect_intents (
run_id uuid not null, -- the agent_runs row that owns this
effect_key text not null, -- deterministic business identity (below)
state text not null default 'intent'
check (state in ('intent','dispatched','committed','abandoned')),
channel text not null, -- 'telegram' | 'farcaster' | 'mail' | 'onchain'
payload_digest text not null, -- sha256(canonical(payload)) - dreamnet-sorted-json:v0
fence_owner text, -- instance that claimed the dispatch
fence_expires_at timestamptz,
external_ref jsonb, -- message_id / cast hash / tx hash (evidence)
attempts int not null default 0,
last_error text,
created_at timestamptz not null default now(),
committed_at timestamptz,
primary key (run_id, effect_key) -- the idempotency guarantee lives here
);
-- Optional: a pure outbox row per physical send attempt, for audit/replay.
create table outbox_dispatch_log (
id uuid primary key default gen_random_uuid(),
run_id uuid not null,
effect_key text not null,
attempt int not null,
outcome text not null, -- 'sent' | 'failed' | 'deduped' | 'reconciled'
at timestamptz not null default now(),
detail jsonb
);The effect_intents PRIMARY KEY (run_id, effect_key) IS the idempotency lock: INSERT ... ON CONFLICT DO NOTHING is the atomic "claim once" - exactly the EffectLedger.recordOnce contract the heart-fleet package already defines (packages/heart-fleet/src/execute.ts). So the durable ledger is a drop-in EffectLedger implementation; no interface change.
effect_key = channel + ':' + canonical(business-identity). NEVER a timestamp or UUID - a retry MUST recompute the same key. Examples:
- Telegram:
telegram:<chatId>:<sha256(canonical(text))> - Farcaster cast:
farcaster:cast:<sha256(canonical({text, embeds, channel}))> - Mail:
mail:<toHash>:<sha256(canonical({subject, body}))> - On-chain:
onchain:<chainId>:<contract>:<method>:<sha256(canonical(args))>
deterministicResourceId(kind, key) (heart-fleet canonical.ts, DreamNet-conformant) is the helper. Two attempts of the "same" action collide on the key -> the second is deduped.
1. DECIDE + CLAIM (one transaction):
INSERT effect_intents (run_id, effect_key, channel, payload_digest, state='intent')
ON CONFLICT (run_id, effect_key) DO NOTHING RETURNING *;
- 0 rows returned -> already claimed -> DEDUPE, stop (at-most-once).
- 1 row returned -> we own this send.
2. FENCE (lease-guard, doc 2139):
verifyFence(fence) against agent_runs - if we lost the lease, ABORT (do NOT send).
Claim the dispatch: UPDATE ... SET state='dispatched', fence_owner, fence_expires_at,
attempts=attempts+1 WHERE (run_id,effect_key) AND state='intent'.
3. SEND (the ONE external call):
perform the actual Telegram/FC/mail/chain call.
4. COMMIT EVIDENCE (one transaction):
UPDATE effect_intents SET state='committed', external_ref=<message_id/hash>,
committed_at=now() WHERE (run_id,effect_key) AND state='dispatched';
emit a receipt.v1 (doc 2138) whose subject includes the effect_key + external_ref.
Crash at any step:
- After 1, before 3: row is
intent/dispatchedwith noexternal_ref-> the RECONCILER resolves it. - After 3, before 4: the send happened but state is
dispatched-> reconciler queries the external system, finds the message, markscommitted. NO resend. - A naive retry always hits step 1's conflict and dedupes.
A lease-guarded reconciler on the recovery-cron cadence (doc 2139) scans effect_intents WHERE state='dispatched' AND fence_expires_at < now():
- Query the external system by a channel-specific probe (Telegram: was a message with this content sent to this chat recently? FC: does the cast hash exist? chain: is the tx mined?).
- Found ->
committed+ backfillexternal_ref. - Provably not sent + lease long dead -> back to
intentfor one bounded re-dispatch, orabandonedpast the attempt ceiling (heart-fleet retry-ceiling semantics).
Reconciliation is best-effort but MONOTONIC: an intent only moves intent -> dispatched -> committed | abandoned, never backward once committed. The receipt makes committed states externally auditable.
- Heart fleet (PR #2705):
guardIrreversiblealready takes anEffectLedger; the durable ledger is the production impl of that exact interface. Fencing + retry ceiling + quarantine come for free. - Receipt emitter (PR #2713): the commit step emits a
dreamnet.receipt.v1- so every real outbound action becomes portable, Phase-3-verifiable evidence (doc 2138). The outbox is where "receiptable by design" (the WaveWarZ thesis) actually lands for sends. - Mouth (project_brandon_mouth_organ): the governed comms organ's
CommunicationEnvelopeis the natural PAYLOAD of an outbox row; the outbox is Mouth's durable substrate. Design them together. - Canonical layer (PR #2704): payload_digest + effect_key both use
sha256:dreamnet-sorted-json:v0, so keys reconcile across the fleet and across the federation boundary.
The outbox is a DELIVERY guarantee, not an authorization change. Money/public/on-chain sends still require the existing approval gate BEFORE an intent is ever written. The outbox guarantees an APPROVED action fires exactly once - it never approves anything.
- Migration for the two tables (ask-first) +
DurableEffectLedger implements EffectLedgerinpackages/heart-fleet. - Wire ONE low-stakes channel first (internal Telegram status) through the full protocol behind a flag, watch the intents table.
- The reconciler on the recovery cron.
- Graduate FC/mail/on-chain one channel at a time, each behind its own flag.
- Approve the two-table migration when he's up (nothing built until then).
- Channel probe strategy for reconciliation - some externals (Telegram) have no "did I send X" query; the fallback is a short dedup window + accept rare double-sends on that channel only, or store a client-side idempotency token where the API supports one (FC/Stripe do; Telegram does not). Per-channel decision.
- Build with Mouth, or ship the outbox first and let Mouth adopt it? (Recommend: outbox first, minimal, Mouth governs on top.)
This is the single highest-leverage safety upgrade for the autonomous fleet: it is what makes "an agent sent the cast" a fact with evidence rather than a hope. It is deliberately unbuilt tonight - it touches the DB and real outbound, both gated. The design is complete enough to build from directly.
- Doc 2139 (Heart fleet + guardIrreversible + the protocol sketch this expands), doc 2142 (named this as next focus), doc 2138 (receipts the commit emits), project_brandon_mouth_organ (the comms organ this underpins)
- First-party:
packages/heart-fleet/src/execute.ts(the EffectLedger interface), read this run [FULL]