← Back to Blog

Day 146: The One Thing the Model Never Decides

I’m starting to build the Inbox Agent, and the shape of it matters more than any one feature. The job: an email arrives, a workflow decides it’s relevant, an agent drafts a response in the source system, and a human approves and sends. Draft-only, always. No auto-send. It is deliberately not a visual step-chain of the Zapier kind, but a structured trigger and filter (cheap, deterministic) wrapped around an agentic action (the part that needs judgment). PR #710 is the least glamorous slice of it and the one I most wanted to get right: the ledger that remembers what’s already been handled. On the same background-jobs foundation that an org-wide timezone setting quietly started the same day (PR #707), it’s four tables and no behavior yet, because nothing calls it until the dispatcher exists and building the dispatcher first would be guessing at an interface.

Why the model can’t be the one to remember

The tempting shortcut, in an LLM-shaped product, is to let the model handle everything, including “is this email new?” Read the inbox, notice what’s unread, act on those. It even sounds robust. It’s exactly wrong, for two reasons that compound.

First, unread is a lie you don’t control. The read/unread flag and the labels on a mailbox are mutable by every human and every other tool touching that inbox. Someone opens an email in their phone client and it’s read now. A rule re-labels it. If “unread” is your definition of “not yet handled,” your source of truth is a value anyone can change out from under you, and the one thing a ledger must be is authoritative. Second, a language model asked “have I done this before?” is being asked to be a database, and it is a bad database. It doesn’t remember across runs, it approximates, and approximation is precisely the wrong tool for a question whose only acceptable answers are exactly-once.

So the ledger owns that question, in code. Each candidate is claimed by an INSERT ... ON CONFLICT DO NOTHING against a unique index on the workflow, the connection, and the provider’s own message id. The insert returns true only for the caller that won the race. Everyone else, a retry, a concurrent run, a second process, gets a clean false and stops. The dedup is deterministic, atomic, and has nothing to do with the model. It’s the same idempotency pattern the conversation transcript already uses, applied to the one decision the whole feature’s correctness rests on.

The payoff is that resync stops being scary

The reason this is worth four tables before there’s any code to use them: it makes recovery boring. There’s a per-connection cursor, a fast pointer into the mailbox so a sweep doesn’t rescan everything. Cursors expire. When one does, the safe move is a full resync, re-listing the whole mailbox, and normally that’s the terrifying option, because re-listing means re-seeing every old email and potentially re-acting on all of it. With the ledger, it isn’t terrifying at all. A re-discovered email tries to re-claim, the unique index rejects it, and nothing happens. The cursor is an optimization; the ledger is the truth. Losing the fast path can never cost you correctness.

Day 146

The whole design is one line drawn deliberately: judgment on one side, guarantees on the other. Let the agent read the email, weigh whether it matters, draft the reply in your voice, that’s where a model’s judgment earns its place. But “did we already handle this?” is not a judgment call, it’s a fact, and facts you need to be exactly right get a unique index, not a prompt. Most of the failures I’ve written about this month come from putting a model where a guarantee belonged, or a guarantee where a model belonged. The work is knowing which question you’re actually asking. “Trust the model” was never an architecture. Knowing what to never trust it with is.

← Day 145: The String That Wasn't the URL Day 147: It Looked Like the Agent Couldn't Read →

Pinchy is open source and ready to deploy. Clone the repo, run docker compose up, and your first agent is live in minutes.