Blog · July 31, 2026
Two unrelated requests came in on the same email thread. Our own inbox loop turned them into one task.
task_upsert_by_external_ref exists to solve one problem: run the same external event through an agent loop twice, land on the same task, not two. Bind a task to a durable id — gmail:<threadId>, stripe:<eventId>, whatever the outside world hands you — and every re-run converges on it instead of minting a duplicate. It's payload-independent (the title can drift and the binding doesn't care) and it never expires (no idempotency-key clock running out on a quiet thread). We wrote about the mechanism itself, and the two weaker tools it replaced, in Two ways to bind a task to something outside.
Last week it did exactly what it was built to do, on our own inbox-autonomy loop, and the result was wrong anyway.
What happened
Jarvis's inbox loop mints one task per Gmail thread: external_ref: "gmail:<threadId>". A client thread came in with an ask. The loop upserted a task for it, worked it, marked it done. Later the same day, a reply landed on the same thread with a second, unrelated ask — a different question, a different piece of work, just conversationally attached to the same email chain the way real correspondence is. The loop upserted again with the same external_ref, because as far as the loop's model went, that was still the right key: same thread, same binding.
task_upsert_by_external_ref found a live task already carrying that ref — the first ask's, already done — and returned it untouched. No new task. The second ask had nowhere to land. It didn't error, didn't warn, didn't drop a breadcrumb. It just quietly resolved to the wrong thing, because from the RPC's side, everything about that call was correct.
The RPC wasn't wrong. The key was scoped to the wrong thing.
external_ref's whole design point is that dedup should be payload-independent and durable — bind once, converge forever, regardless of what the title says or how long the gap between touches. That's the right contract for the actual problem it solves: don't double-submit this one thing. It says nothing about what "this one thing" is, and it can't — that's the caller's modeling decision, not the primitive's.
Our caller modeled "this one thing" as the email thread. A Gmail thread is a container, not a unit of work. It can legitimately hold one ask or five, spread over a day or a month, and the RPC has no way to know the difference between "this is a re-run of the same request" and "this is a new request that happens to share a container with an old one" — both look identical from where it sits: same ref, live task, return it. The bug wasn't in the arbiter. It was upstream, in choosing a key that named the wrong scope.
It's the same mistake a payload-sensitive idempotency key would make in the opposite direction — except here the failure mode is silent instead of a hard error, because payload-independence is precisely the feature that was working as intended.
The fix is a modeling rule, not a schema change
task_upsert_by_external_ref didn't move. The fix is documentation load-bearing enough to sit next to the RPC's own contract, in the validation module every caller's input passes through:
Caller footgun: external_ref must name the UNIT of work, not just its container.
A shared container id (e.g. one email thread) that legitimately carries several
distinct asks over time will silently collide if every ask is keyed on the
container id alone — the 2nd ask's upsert returns the 1st ask's already-resolved
task instead of creating its own. Callers with this shape should compose the ref
from something unique to the unit (row id, sub-thread id, ...), not just the
container.
For a container that can hold more than one unit of work, the ref needs to name the unit: gmail:<threadId>#<messageId> if every message is its own ask, or a row id from wherever the loop already tracks "which ask is this" — not the thread alone. The container id is still useful; it's just the wrong granularity to dedup on. Keep it as a label or a search field if the grouping is worth preserving, and let the ref itself point at the thing that shouldn't be duplicated.
Same tools table entry, same MCP-server description, same generated skill doc — all regenerated from one source so a caller reading any of them sees the same warning before making the same choice we did.
Found by hitting it, not by auditing for it
This wasn't a defect audit. It was external_ref used exactly as documented, by the loop that dogfoods it hardest, against a real inbox with real correspondence patterns — and correspondence doesn't respect the one-container-one-ask assumption that made the mechanism look complete on paper. That's the case for running your own product on your own operations before a customer does: an audit finds what a reader thinks to check for; a live loop finds what the container actually looks like once real threads run through it.
Start at ledgenter.com.