-- One row per (routine, scheduled occurrence), so a firing is idempotent. -- -- The scheduler advanced `next_run_at` *before* dispatching the work -- (`cm-scheduler/src/lib.rs`, "Reschedule first: a firing failure must not -- stall the clock"). That trade is defensible on its own terms, but it has no -- record of the attempt: a crash between the reschedule and the dispatch drops -- the occurrence with nothing anywhere to say it was owed. For a message -- routine that costs a lost reply. For a scheduled *mission* it costs a -- container, a repo checkout, and real money — which is why this lands before -- mission scheduling does. -- -- `scheduled_at` is the occurrence's own timestamp, not the claim time, so the -- primary key is what makes a retry idempotent: re-claiming the same slot -- finds the existing row instead of firing twice. CREATE TABLE routine_fires ( routine_id UUID NOT NULL REFERENCES routines (id) ON DELETE CASCADE, -- The occurrence this row accounts for (the `next_run_at` that came due). scheduled_at TIMESTAMPTZ NOT NULL, claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, -- `claimed` — taken, dispatch not yet known to have finished. A row stuck -- here is a crash mid-fire and is safe to retry. -- `fired` — dispatch completed; never fire this slot again. -- `failed` — dispatch returned an error. Terminal: the clock has already -- moved on, and silently retrying a failing action every tick -- is how a broken routine becomes a denial-of-service. status TEXT NOT NULL DEFAULT 'claimed' CHECK (status IN ('claimed', 'fired', 'failed')), error TEXT, PRIMARY KEY (routine_id, scheduled_at) ); -- The reaper's query: rows still `claimed` past a grace period are crashes. CREATE INDEX routine_fires_stuck_idx ON routine_fires (status, claimed_at) WHERE status = 'claimed';