Ledgenter

Blog · July 24, 2026

Two ways to bind a task to something outside Ledgenter. Both of them break under real reruns.

An agent loop that turns external events into tasks — a Gmail thread, a support ticket, a webhook delivery — has one job that sounds trivial and isn't: run the same event through the loop twice and land on the same task, not two. We had exactly this problem in our own inbox-autonomy loop, which mints a task per Gmail thread and re-touches it on every poll. We had two existing tools for it. Neither one actually holds up.

labels[] — dedup by lookup, not by constraint

The obvious move is to tag the task with the external id and query for it before creating: find a task labeled gmail:<threadId>, and if none exists, create one. That's a read followed by a conditional write, with nothing enforcing atomicity between them. Two overlapping runs of the same loop — which is exactly the failure mode a 24/7 unattended agent loop needs to survive, not just tolerate — can both miss on the read and both proceed to create. You get two tasks for one thread, and nothing in the schema stops it.

It also doesn't recover: task_create sets labels[] once, at creation. There's no relink operation. If a task's label ever drifts from the external id — a rename, a migration, a bug in an earlier version of the loop — there's no path back to one-task-per-thread except a manual fix.

idempotency_keys — dedup by payload, with a clock running

Ledgenter already has a real idempotency mechanism: task_create accepts an idempotency key, hashes project_id | title | parent, and returns the original task on a repeat. That's atomic and it's the right tool for "don't double-submit this exact request." It is the wrong tool for "always converge on one task for this external thread," for two reasons that only show up once you run it for weeks instead of once:

Neither of these is a defect in idempotency_keys — it's doing exactly what a request-dedup mechanism should do. The problem is using a payload-sensitive, time-boxed tool for a job that needs to be payload-independent and permanent.

What we shipped instead

external_ref is a new, separate binding — deliberately not built on top of either of the above. A citext column on tasks, a partial unique index enforcing one live task per (tenant_id, external_ref), and a new RPC, task_upsert_by_external_ref, that's a true find-or-create keyed on the ref alone:

create unique index tasks_tenant_external_ref_uq
  on public.tasks (tenant_id, external_ref)
  where external_ref is not null and deleted_at is null;

The partial index is doing two jobs at once. It's the thing that makes the concurrent-create race resolvable — the RPC does a fast-path select, and if that misses, an insert ... on conflict (tenant_id, external_ref) where (...) do nothing lets Postgres arbitrate two racing creates instead of the application trying to. And the deleted_at is null clause means a soft-deleted task releases its ref, so a thread whose task got cleaned up can legitimately get a new one — the one case where you actually want a second task to be possible.

The RPC's find path is a pure read: no new sequence number, no activity emission, no notification. Only a genuine create does any of that. That matters for a loop that calls this on every poll — most calls are a hit, and a hit should cost nothing but a lookup.

Payload independence is the whole point, so the title, body, and every other field are free to drift across reruns without ever affecting whether it's a find or a create. The binding is the external id. Nothing else.

The pattern, if you're building the same thing

If an agent (yours or a customer's) needs to bind a unit of work to something outside the system it's writing to, ask whether the dedup key is supposed to be a snapshot of the payload or an identity. idempotency_keys is right for the former — don't double-submit this exact request. A durable external reference is right for the latter — always land on this one thing, no matter how its payload changes or how long the gap between touches. Building the second on top of the first, or faking it with a mutable label and a query, both work until concurrency or time makes them not.

Start at ledgenter.com.

Give your agents an office, not a to-do list.