-- v1.1.1: Universal trigger outbox — design notes §2. -- -- One table for every async dispatch in the system. KV/cron/pubsub/ -- queue/email/dead-letter all write rows in this shape; the dispatcher -- claims due rows with `FOR UPDATE SKIP LOCKED` and routes them to -- the executor. -- -- Sync HTTP also writes here (NATS-style inbox, design notes §3) — -- `reply_to` carries an `inbox_id` that the orchestrator awaits on a -- oneshot channel. `reply_to.is_some()` is the "don't retry" signal: -- one attempt, surface the result via the inbox. -- -- `trigger_id` is a polymorphic reference discriminated by -- `source_kind`: for `source_kind='http'` it references `routes.id`; -- otherwise it references `triggers.id`. Polymorphism handled in -- Rust (the dispatcher); no DB-level FK because Postgres doesn't -- support polymorphic FKs cleanly. NULL is allowed because direct -- admin-replay paths may not have a triggering row at all. -- -- `script_id` denormalized so the dispatcher resolves the target -- script without an extra round-trip per row. CREATE TABLE outbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, source_kind TEXT NOT NULL CHECK (source_kind IN ('http', 'kv', 'dead_letter')), -- Polymorphic — see comment above. No FK constraint. trigger_id UUID, -- Pre-resolved at write time so the dispatcher doesn't re-look it up. script_id UUID, -- NULL = async (retry per policy). Some(inbox_id) = sync HTTP -- (never retry; resolve the inbox with the result). reply_to UUID, -- ServiceEvent + ExecRequest scaffold serialized as JSONB. payload JSONB NOT NULL, -- Forensic field — the principal that triggered the originating -- event. NOT the execution principal for trigger fan-out (that -- comes from `triggers.registered_by_principal`). origin_principal UUID, -- Trigger-depth as the dispatcher will hand it to the executor. -- Read out into ExecRequest.trigger_depth at dispatch time. trigger_depth INT NOT NULL DEFAULT 0, -- Originating execution id (for audit log grouping). Equals the -- root for direct invocations; preserved across fan-out chains. root_execution_id UUID, attempt_count INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Set inside the SELECT FOR UPDATE SKIP LOCKED transaction so -- the dispatcher can't double-pick a row across concurrent loop -- iterations. claimed_at TIMESTAMPTZ, claimed_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Hot index: the dispatcher's `WHERE next_attempt_at <= NOW() AND -- claimed_at IS NULL` claim query. Partial index keeps the hot set -- small even if the table grows large. CREATE INDEX idx_outbox_due ON outbox (next_attempt_at) WHERE claimed_at IS NULL; CREATE INDEX idx_outbox_app ON outbox (app_id);