DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: An Outbox Worker That Refuses Sent Without an Explicit Ack

Case Study: An Outbox Worker That Refuses Sent Without an Explicit Ack

You should lock the outbox delivery contract in executable tests before you ask any coding agent to draft the worker. A worker that marks a row sent before the downstream acknowledgment will drop customer messages on the floor. A worker that retries permanent failures will burn the queue and hide a bug under a retry metric. The tests below encode those two refusals, so a generated implementation either passes them or stays out of the branch.

Background

Your notification path already inserts a pending outbox row in the same database transaction as the business change. The missing piece is a worker that claims those rows, calls a downstream transport, and records a terminal state. An agent can draft that loop quickly, but the dangerous lines are the ones that update status after a partial failure. Generated workers often treat any HTTP response as success, and they often retry a client error as if it were a timeout.

The project in this case is deliberately small, with one table, one worker function, and one in-memory transport fake. You are not designing a multi-region broker, and you are not claiming exactly-once delivery across unreliable networks. You are freezing the local state machine so a later agent edit cannot silently widen the meaning of sent. The same pattern can fit other agent-written edges, but this write-up stays on the outbox so the rules stay checkable.

Goal

You want four outcomes that a later reviewer can check without reading a model transcript or a chat log. The worker may mark a row sent only after the transport returns an explicit acceptance token from the contract. A transient failure must return the row to pending until the configured attempt budget is fully spent. A permanent failure, or any unknown status, must land in dead and must not be retried by the next claim.

The claim step must not treat an already sent row as fresh available work for another worker pass. Those four outcomes are the contract, and the implementation may change only when the tests change with them. The model you choose can change as well, but a weakened assertion should fail review before it fails a customer. You should treat a green run as a gate for human review, not as proof that production traffic is already safe.

The contract you freeze first

Closed vocabulary

Write the closed vocabulary in a small module before you open an agent session or paste a prompt. Keep the result set tiny so an unknown string cannot fall through a default branch into success. The function below is a teaching artifact, and you should run it locally before you treat it as a gate. You can widen the accepted set later, but only with a reviewed change to this function and its tests.

# outbox_contract.py
# Teaching artifact. Run it yourself; this draft does not claim an execution log.

PENDING = "pending"
INFLIGHT = "inflight"
SENT = "sent"
DEAD = "dead"

TRANSIENT = "transient"
PERMANENT = "permanent"
ACCEPTED = "accepted"
MAX_ATTEMPTS = 5

def decide(state, attempts, transport_result):
    """Return the next state. Unknown results fail closed."""
    if state != INFLIGHT:
        raise ValueError("decide() only accepts an inflight claim")
    if transport_result == ACCEPTED:
        return SENT
    if transport_result == PERMANENT or attempts >= MAX_ATTEMPTS:
        return DEAD
    if transport_result == TRANSIENT:
        return PENDING
    return DEAD
Enter fullscreen mode Exit fullscreen mode

The fail-closed return is the point of the function, not a decorative else branch you can delete. A new transport code, a timeout wrapper that returns None, or a model that invents ok must not become sent. You should not let that widening happen inside a generated worker that nobody has diffed line by line. If the business later accepts another code, add the string in the contract and add a test that names it.

Decision table

Read the table as the same contract in review form, not as optional color beside the code. Treat a blank cell as a bug in the spec rather than as freedom for the worker. Every row either names a next state or raises, so a generated worker has no silent default. You should update this table in the same commit as the function when a real transport adds a code.

Current state Transport result Attempts Next state
inflight accepted below max sent
inflight transient below max pending
inflight transient at max dead
inflight permanent any dead
inflight any other value any dead
not inflight any any raise

Tests that define the case

The tests are the artifact you keep, and they do not need a live queue or a reachable network. Label them as the specification for this example, because this write-up does not claim a production execution. Run them yourself before you trust a generated file, and stop if the run depends on a chat window. A prompt that authorizes weaker assertions will erase the protection this case study is trying to hold.

# test_outbox_contract.py
import pytest
from outbox_contract import (
    ACCEPTED, DEAD, INFLIGHT, PENDING, PERMANENT, SENT, TRANSIENT, decide,
)
from worker import MemoryStore, deliver_one

def test_ack_required_before_sent():
    assert decide(INFLIGHT, 1, ACCEPTED) == SENT

def test_transient_returns_to_pending():
    assert decide(INFLIGHT, 1, TRANSIENT) == PENDING

def test_budget_exhaustion_is_dead():
    assert decide(INFLIGHT, 5, TRANSIENT) == DEAD

def test_permanent_is_dead_immediately():
    assert decide(INFLIGHT, 1, PERMANENT) == DEAD

def test_unknown_result_fails_closed():
    assert decide(INFLIGHT, 1, "ok") == DEAD
    assert decide(INFLIGHT, 1, None) == DEAD

def test_sent_is_not_a_claim_input():
    with pytest.raises(ValueError):
        decide(SENT, 1, ACCEPTED)

def test_timeout_never_marks_sent():
    store = MemoryStore({
        "id": 1,
        "state": PENDING,
        "attempts": 0,
        "payload": {"kind": "welcome"},
    })

    class Boom:
        def send(self, payload):
            raise TimeoutError("slow")

    nxt = deliver_one(store, Boom(), now=0)
    assert nxt == PENDING
    assert SENT not in store.writes
Enter fullscreen mode Exit fullscreen mode
python -m pytest test_outbox_contract.py -q
Enter fullscreen mode Exit fullscreen mode

You should see seven passed tests before you ask any agent for a replacement implementation of the worker. If a test fails, fix the contract in review rather than prompting the model to edit the assertion. The command above uses a normal Python interpreter so the gate stays outside the drafting session entirely.

What a red run means

A red unknown-result test means some path still treats a friendly string as acceptance, and that path is not ready to merge. A red timeout test means the worker wrote sent, or skipped the contract, when the transport raised instead of acknowledging. A red permanent test means a client error will be claimed again, which is how a poison row eats the worker. You fix the code or you revise the named rule, and you do not delete the assertion to calm a generator.

Implementation boundary

After the contract tests are green, you can ask an agent to write the worker against that function only. Give it the signature, and forbid it from classifying transport results with strings it invented alone. The worker may claim a pending row, call the transport, and then call decide with the mapped result. It may not assign a sent status from a boolean, a status code, or a comment the model added.

# worker.py
# Proposal: keep status changes inside decide(). Not a measured production worker.

from outbox_contract import INFLIGHT, PENDING, decide

TRANSIENT = "transient"
PERMANENT = "permanent"

def deliver_one(store, transport, now):
    row = store.claim_pending(now)
    if row is None:
        return None
    try:
        result = transport.send(row["payload"])
    except TimeoutError:
        result = TRANSIENT
    except Exception:
        result = PERMANENT
    nxt = decide(row["state"], row["attempts"], result)
    store.transition(row["id"], nxt, now)
    return nxt

class MemoryStore:
    def __init__(self, row):
        self.row = dict(row)
        self.writes = []

    def claim_pending(self, now):
        if self.row["state"] != PENDING:
            return None
        self.row["state"] = INFLIGHT
        self.row["attempts"] += 1
        return self.row

    def transition(self, row_id, nxt, now):
        self.writes.append(nxt)
        self.row["state"] = nxt
Enter fullscreen mode Exit fullscreen mode

That sketch is a proposal for the boundary, not a measured worker from a production on-call review. The broad exception handler is a limitation you should narrow once you know the real client library. It is still safer than marking the row sent inside the except block, which is the bug this case catches. You should keep the store fake in the same review, because it records transitions the pure function never sees.

Pair that fake with a transport that raises, and assert the write list matches the contract for that attempt count. A generated worker that writes sent on the exception path will fail this assertion even if the unit tests were skipped. Keep both layers, because the pure function stops bad classification and the fake stops a bypass. A review that only reads the model summary will miss a direct status write hiding under a helper name.

Where a free drafting setup fits

You can draft the worker with a coding agent you already know how to review, then run the same pytest file. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access fits this step as a way to draft the worker against tests you already froze. The free server option fits as a clean place to rerun that pytest gate away from your laptop state.

Confirm the current terms before you rely on either option, because availability language goes stale quickly. This article does not state quotas, hardware, model names, duration, or any promise that the offer stays fixed. Use the session for a narrow diff rather than a broad request to build an outbox platform from scratch. Paste the contract, paste the tests, and ask for a worker that calls decide rather than inventing status strings.

Reject any patch that edits the tests so a weaker worker can go green without a reviewed reason. The drafting help does not change the merge rule, and you still need to understand every status write. If you use a second environment, run the identical pytest command there and compare the summary with your laptop. A mismatch means the example is not reproducible yet, so you fix imports and versions before judging any model.

Do not treat a remote green run as a benchmark, and do not publish a latency number you did not measure. A clean rerun only shows that this teaching gate still passes, which is the only result this case claims. You should save the pytest summary beside the diff so a later reader can see the gate without rerunning your prompt. That habit matters more than which editor or model produced the first patch you are reading.

Results you can actually claim

This case does not report production delivery rates, and it does not report a score for any coding model. The result is the gate itself, expressed as named tests a teammate can rerun on a clean checkout. A correct worker passes the contract tests and the exception-path assertion on the memory store. A worker that marks sent before acknowledgment fails the ack test or the store assertion that watches writes.

A worker that retries a permanent failure fails the test that expects dead on the first permanent result. A worker that treats an unknown code as success fails the test that maps that code to dead. You should paste those pass and fail names into the pull request, because they are evidence someone else can rerun. You should not replace them with a sentence that says the agent handled edge cases, since that sentence is not inspectable.

The case is done when a reviewer can check out the branch, run the pytest command, and see the same refusals. Until that rerun exists, you have a proposal and a set of assertions, not a finished operational result. That distinction matters if someone later asks what you measured, because the honest answer is the test names. Keep the write-up boring on purpose, so the next reader trusts the gate more than the narrative around it.

Lessons

  • You freeze the state names and the fail-closed default before you invite an agent to write the delivery loop.
  • You keep every status assignment inside one function so a generated edit has a single obvious place to violate.
  • You treat unknown transport results as dead, then widen that set only through a reviewed test that names the code.
  • You may rerun the gate on a free server, but a remote pass never replaces reading the status writes yourself.

The lesson that transfers is smaller than any claim that coding agents should own your production queues. You use an agent only after the failure you fear is already a red test on the branch. That order is what keeps a fast draft from becoming an unreviewable change in customer-visible delivery. If the red test is missing, the draft can look complete while the dangerous assignment sits in a helper.

Review checklist

Use this list on the pull request so the case study ends in checks rather than in an impression. A reviewer should be able to tick each line without opening the chat that drafted the worker. If a line cannot be ticked, the branch is not done, even when the model summary sounds confident. Keep the list next to the test names so the narrative cannot drift away from the gate.

  1. You confirm the contract module contains no path that returns sent for an unknown or empty result.
  2. You run the tests locally and attach the pytest summary to the pull request before anyone reviews prose.
  3. You inspect the worker diff and reject any literal sent assignment that sits outside decide entirely.
  4. You rerun the same command in a clean environment when you used one, and you reconcile every mismatch before merge.

Who should not use this approach

You should skip this sketch when a reviewed broker client already owns acknowledgment and retry policy for you. You should also skip it when the real requirement is cross-region exactly-once delivery across several writers. A single-table outbox does not prove that property, and pretending otherwise will mislead the next on-call reader. You should not use a free drafting path as a substitute for secret handling, migration review, or a runbook.

The example stores no credentials and sends no real messages, so it is a poor place to practice production access. Teams that cannot block a branch on pytest, or that merge generated files without reading status writes, will not benefit. The tests help only when a failing run can stop the merge and force a human to name the broken rule. If your process treats a green chat reply as approval, fix that process before you add another model session.

Limitations

The contract ignores lease expiry, so two workers can claim one row unless the store uses a conditional update. The clock is only an argument, and this write-up does not simulate skew, pauses, or a restarted process. The broad exception mapping is intentionally crude, and you should replace it with the errors your client actually raises. These tests are a teaching specification, and this article does not claim they ran against a named production database.

Free model access and a free server option can change, so you verify the offer on the day you depend on it. None of those limits remove the core rule that a row becomes sent only after explicit acceptance from the contract. If you adapt the table names, keep the assertions and change the adapter rather than loosening the expected states. If a later agent proposes publishing to a real bus, add a duplicate-delivery test instead of deleting the fail-closed cases.

A practical next step

You can copy the two modules into a scratch repository and run the pytest command before you connect any real transport. Start with the unknown-result test if you want to see the gate reject a friendly status string. If free model access and a free server option match the way you already review drafts, use them only for that narrow loop. Leave the merge decision with a person who can explain why sent is still unreachable from an exception path.

Top comments (0)