Dead-Letter Replay Does Not Belong on Free Inference A developer argues that dead-letter queue replay should be governed by deterministic code rather than free inference, since replaying a failed message can re-trigger payments, webhooks, inventory mutations, or email sends. The proposed Python replay gate reads durable fields such as topic, error class, attempt count, and idempotency key, returning one of three verbs — never, replay_once, or hold — and defaults unknown cases to hold so new error codes fail closed. The model, if used at all, only writes a proposal file and never calls the broker. Live replay of a dead-letter message is a control-plane act. Free inference should not own it. A dead-letter queue is not a suggestion box. It is a holding pen for work that already failed, often after a side effect has been attempted. Replay means the consumer will call a payment API, emit a webhook, mutate inventory, or send mail again. The payload may be poison. It may be a duplicate of a request that already committed. It may be a timeout whose remote party succeeded while the local waiter expired. Those cases do not share a prompt. They share a contract, and the contract has to survive a model outage. Teams reach for a chat completion because the dump looks linguistic. Error strings wander. Partner codes collide. A model can cluster the mess into a tidy story. That clustering is useful on a desk. It is a poor runtime for a switch that re-enters the world. Think of the DLQ as a loaded elevator. Classification is the inspection tag on the door. Replay is the button that sends the car. Handing the button to a volunteer intern with a sticky note is the free-inference pattern: cheap, fluent, and unbound to the interlock. The failure mode is not only a wrong label. Free inference is a best-effort path. It can stall, rate-limit, or answer with a different JSON shape than yesterday. A consumer that blocks on that path turns a message bus into a chat session. A consumer that proceeds on timeout turns a maybe into a charge. Neither is an SLO. A replay gate belongs in deterministic code next to the consumer. The gate reads durable fields: topic, error class, attempt count, idempotency key, and a human-committed policy table. It returns one of three verbs. never parks the message for a ticket. replay once requeues with a fresh attempt counter and the same key. hold waits for an operator. The model, if it appears at all, writes a proposal file. It does not call the broker. The artifact below is that gate. It is ordinary Python. It is meant to be copied into a worker repo and tested without a network. python replay gate.py from dataclasses import dataclass from enum import Enum from typing import Mapping, Optional, Tuple class Verb str, Enum : NEVER = "never" REPLAY ONCE = "replay once" HOLD = "hold" @dataclass frozen=True class DeadLetter: topic: str error class: str attempts: int idempotency key: str body hash: str already committed: bool PolicyKey = Tuple str, str POLICY: Mapping PolicyKey, Verb = { "payment.capture", "timeout" : Verb.REPLAY ONCE, "payment.capture", "insufficient funds" : Verb.NEVER, "payment.capture", "duplicate" : Verb.NEVER, "inventory.reserve", "lock timeout" : Verb.REPLAY ONCE, "inventory.reserve", "overbook" : Verb.NEVER, "webhook.partner", "http 503" : Verb.REPLAY ONCE, "webhook.partner", "http 400" : Verb.NEVER, "mail.receipt", "smtp 421" : Verb.REPLAY ONCE, "mail.receipt", "unknown recipient" : Verb.NEVER, } MAX ATTEMPTS = 3 def decide letter: DeadLetter, policy: Mapping PolicyKey, Verb = POLICY - Verb: if not letter.idempotency key: return Verb.HOLD if letter.already committed: return Verb.NEVER if letter.attempts = MAX ATTEMPTS: return Verb.HOLD verb = policy.get letter.topic, letter.error class if verb is None: return Verb.HOLD return verb def apply letter: DeadLetter, broker, audit - Verb: verb = decide letter audit.record letter.body hash, verb.value if verb is Verb.REPLAY ONCE: broker.requeue letter, extra headers={"x-replay": "1"} return verb The unknown row is the whole point. A missing topic, error class pair does not become a prompt. It becomes hold . That is how a new partner code fails closed instead of becoming a creative retry. The already committed bit is equally blunt. If the outbox, the payment ledger, or the partner receipt says the work landed, replay is vandalism dressed as healing. Tests pin the verbs. They do not pin a temperature. python test replay gate.py from replay gate import DeadLetter, Verb, decide def letter kwargs : base = dict topic="payment.capture", error class="timeout", attempts=1, idempotency key="cap 9f3a", body hash="sha256:abc", already committed=False, base.update kwargs return DeadLetter base def test timeout replays once : assert decide letter is Verb.REPLAY ONCE def test committed work never replays : assert decide letter already committed=True is Verb.NEVER def test unknown error holds : assert decide letter error class="weird partner code" is Verb.HOLD def test missing idempotency key holds : assert decide letter idempotency key="" is Verb.HOLD def test insufficient funds never replays : assert decide letter error class="insufficient funds" is Verb.NEVER Run them with python -m pytest test replay gate.py -q . The suite is the oracle. A model that restates the same rules in prose is commentary, not control. Free inference still has a desk job. Unstructured DLQ dumps are noisy, and a first pass that clusters raw error strings into candidate topic, error class rows can save an afternoon. Disclosure: This article was prepared as part of MonkeyCode's product outreach. A workspace with free model access and a free server option can sit beside those dumps, emit a proposed policy patch, and stop there. The patch lands in review like any other config change. The consumer never calls the model on the hot path. That rehearsal looks like a file, not a tool call inside apply . propose policy.py — offline sketch, not a worker dependency Label as unexecuted until a human copies rows into POLICY. PROPOSAL SCHEMA = { "type": "object", "required": "topic", "error class", "verb", "rationale" , "properties": { "topic": {"type": "string"}, "error class": {"type": "string"}, "verb": {"enum": "never", "replay once", "hold" }, "rationale": {"type": "string"}, }, } def write proposal path, rows : Persist proposals for review. Do not import this module from the consumer. import json from pathlib import Path Path path .write text json.dumps rows, indent=2 , encoding="utf-8" Red flags show up before the first wrong charge. The consumer imports an SDK in the same process that acknowledges Kafka or SQS. Replay waits on a completion token. Two identical payloads, five minutes apart, receive two verbs. The policy lives only in a system prompt. The audit log stores the model's paragraph and not the verb. Any one of those is enough to pull the model out of the loop. Better alternatives are boring on purpose. Map partner error codes at the edge. Persist an idempotency key before the first attempt, not after the failure. Record already committed from the ledger, not from a summary of logs. Cap attempts in the broker. Put unknown classes on hold and page a human. If the dump is truly unstructured, run the clusterer on a sample in a scratch environment, then type the surviving rows into POLICY . Exit criteria belong next to the policy, not in a runbook nobody opens. Stop using even the offline proposer when the same payload yields two different verbs across sessions. Stop when proposal latency exceeds the time a reviewer would spend reading twenty raw lines. Stop when the topic moves money, identity, or medical data through a third party that is not under the team's data agreement. Stop when the DLQ volume is a load test in disguise: a replay storm is a traffic generator, and a chat API is not a traffic shaper. Who should not use this approach is as important as the gate. A team without idempotency keys should not replay at all, with or without a model. A team whose ledger cannot answer already committed should park every payment topic on hold . A team that needs sub-second classification under partition loss should not add a network hop that is allowed to vanish. A regulated workload that cannot put payloads on a shared free endpoint should keep the dumps inside the boundary and type the table by hand. Limitations of the gate itself are real. The table lags a new partner. error class is only as good as the parser that produces it. MAX ATTEMPTS does not know about downstream quotas. HOLD can hide a growing pile if nobody pages. The design accepts those limits because they are visible. A fluent paragraph is not visible in the same way. It fails like weather. The current wave of agent demos makes the anti-pattern tempting. Tool calling looks like a universal adapter: feed the DLQ body in, get a verb out, let the agent requeue. That adapter is a completion. Completions are not leases, not locks, and not ledgers. They are a way to draft the table that the lease, the lock, and the ledger already require. A free model and a free server are enough to rehearse that table against fixtures. They are not a substitute for the switch. Keep the intern at the desk. Keep the elevator button in code.