ADR-0039: Attempt History on Todos — Every Claim Opens a Record, Every Ending Closes It
Status · accepted · Date · 2026-09-22 · Deciders · Joe Stump
Context and Problem Statement
A todo is a dispatch lease (ADR-0007): it is claimed, heartbeated, and then completed, failed and retried, or dead-lettered. Switchboard keeps the lease's current state and nothing about the ones before it. On main:
- One
result, overwritten.todos.resultis a singlejsonbcolumn.CompleteTodoandFailTodoeach replace it, andRetryTodosets it toNULL(internal/store/todos.go). After three failed attempts, the row holds the third attempt's result and nothing about the first two. - A counter, not a history.
todos.attemptis incremented by every claim and reset to0by a manual retry. It says how many, never who, when or how it ended. - Deaths leave no trace.
ReapExpiredreturns an expired lease topending(or dead-letters it at the cap) and clearsownerandlease_expires_at. A claim that takes over a lapsed lease does the same in itscand.prior_state = 'claimed'arm. Neither records that an attempt died, so the next claimer cannot tell a worker that crashed from one that tried and failed. SPEC-0023 REQ-3 counts these expiries (switchboard_lease_expired_total), but a counter cannot say which todo, which worker or when. - Every worker on an endpoint looks the same.
ownerisagent:<agent_id>, shared by every session on an endpoint (per-session owner identity is deferred). A stale worker whose lease lapsed can stillcompletea todo a newer worker on the same endpoint has claimed. - Nothing reads it back.
todoOutcarries neitherresultnornext_retry_at, and there is no single-todo read verb, onlylist_todos.
There is no attempt or result history anywhere in the schema: no table in migrations 0001–0021 records a claim.
Three consumers need it now:
- Relay attempts. Harness ADR-0025 (written in parallel with this ADR) runs a fresh one-shot per attempt. Harness claims, heartbeats and reports, and a new process takes over with the ticket if the build is still failing. The new process needs what the earlier ones tried, and whether they died or failed.
- A human reading a dead letter needs to see what each attempt tried, not just the last result.
- The Board. A self-hosting customer reported the Board's reaper ticker as a wall of "re-surfaced to queue" entries. The reaper was doing its job; nothing on the page could say whose attempt had died or what it had done.
How should Switchboard record each attempt on a todo, so that the next claimer and the owning human know what was tried, by whom, how it ended, and whether it died rather than failed, without unbounded growth or leaks across tenants?
Decision Drivers
- The queue stays the ledger (ADR-0013). Attempt history belongs with the todo, in the same database, written in the same transaction as the transition it records.
- Tenant isolation is a hard rule (ADR-0022; the teams model in ADR-0038 / SPEC-0033). An attempt is visible exactly where its todo is, and nowhere else. An unknown id and a foreign id must be indistinguishable.
- "Died" must be distinguishable from "failed". An attempt that ended because its lease lapsed carries no report. The next claimer must be told that, not shown an empty failure.
- Bounded. Per-attempt text is capped, attempts per todo are capped, and history ages out with its todo under the existing retention policy (ADR-0002).
- Additive. Every new argument is optional and every new response field is additive. Nothing is renamed or superseded, so existing clients keep working unchanged, and
resultkeeps its meaning. - Text is data. Attempt summaries are written by agents that read attacker-reachable input. Switchboard stores and returns them as data, and never interprets them.
- Minimal verb surface. Endpoints are frozen at their vend-time verb set, so history should reach existing endpoints without re-vending.
Considered Options
- (A) A
todo_attemptstable. One row per committed claim, closed by whichever transition ends the attempt. (chosen) - (B) A
jsonbarray ontodos. Append an element on each claim and close it in place. - (C) A generic transition log. Record every todo transition in a
todo_transitionstable, and derive attempts by folding the log. - (D) No server-side history. Tell consumers to carry notes in
resultor in Cairn, and to pass handles forward themselves.
Decision Outcome
Chosen option: "(A) A todo_attempts table." An attempt is a first-class record with a clear start (a committed claim) and a clear end (the transition that took the lease away), and a row per attempt is what makes both atomic with the todo's own update.
The todo says where the work is. The attempts say how it got there.
The record
Each row holds:
- the todo it belongs to, and a sequence number
seqthat increases per todo and is never reset; attempt, the todo's counter at claim time, which a manual retry resets;- the claimer:
claimer_kind(endpointorowner, the latter for a human claiming from the Board), the claiming endpoint, the MCP session id when the claim arrived over MCP,owneras recorded on the todo, and an optional caller-suppliedclaimantlabel of at most 128 bytes (for example,harness/buildbox/ci-fixer/run-42); claimed_at,last_heartbeat_at,lease_expires_at(the last value the lease held) andended_at;outcome:completed,failed,released,lease_expired,reaped,canceledorrevoked;disposition, what the todo did next:done,retry_scheduled,requeued,dead_letteredorcanceled;summary, bounded text of at most 2048 bytes stored, andartifact, an optional handle of at most 512 bytes (anmcp://cairn/<id>handle or anhttpsURL, never fetched);- a hash of the attempt's lease token when the claim asked for one.
Tenancy is not a column on the row. An attempt is reachable only through its todo, and it inherits the todo's owner scope: today the owning endpoint (ADR-0022), and after ADR-0038 whichever owner scope that record gives a queue. The claimer fields are provenance, never authority. For team-owned work, the scope that governs reads is the todo's, not the claimer's.
Opening and closing
-
Every committed claim opens exactly one attempt, in the same transaction as the
todosupdate. That coversclaim,claim_next, a lease takeover, and a human claiming from the Board. It is the same event queue admission control (ADR-0035 / SPEC-0030) charges one unit for, so the two count identically. -
The transition that ends a lease closes the open attempt, in the same transaction:
Transition Outcome Disposition completecompleteddonefailbelow the capfailedretry_scheduledfailat the capfaileddead_letteredreleasereleasedrequeuedA claim takes over a lapsed lease lease_expiredrequeued(the new claim follows at once)The reaper expires the lease below the cap reapedrequeuedThe reaper expires the lease at the cap reapeddead_letteredA2A cancel of a claimed todo canceledcanceledThe owning endpoint is revoked revokeddead_lettered -
"Died" is derived.
lease_expiredandreapedmean the holder never reported. Every read carriesdied: truefor them.last_heartbeat_atbounds when the holder was last known alive. The next claimer therefore sees "attempt 2 died after its last heartbeat at 14:21", not an empty failure.
Summaries, artifacts and the fence
complete,failandreleaseaccept an optionalsummaryandartifact. A missingsummaryis stored as null. An operator can setSWITCHBOARD_ATTEMPT_SUMMARY_FROM_RESULT=trueto derive it fromresultinstead (compact JSON, truncated). That is off by default, because it would replay what existing clients wrote toresult, which no read returns today, to later claimers and to notification sinks (Joe, 2026-09-22: risky options are fine when configurable and off by default).resultitself keeps its meaning on the todo.- The lease-token fence.
claimandclaim_nextacceptrequire_fence. With it, the response carries an opaquelease_token, returned once and stored only as a hash.heartbeat,complete,failandreleaseacceptlease_token. A token that does not match the todo's open attempt isconflict. On a fenced attempt, a call with no token isconflicttoo. This lets Harness hold an attempt that an agent holding the same endpoint credential cannot close. It also fixes the stale-worker case for any client that opts in, without changingowner.
Reading it back
- On claim.
claimandclaim_nextresponses carryattempt_seq, thelease_tokenwhen fenced, andprior_attempts: the five most recent closed attempts, newest first. That is everything a relay consumer needs, in the response it already receives. get_todoreturns one todo with itsresult,next_retry_at, a deriveddead_letter, and up to 50 attempts (default 20), plusattempts_totalandattempts_pruned. Any endpoint that holdslist_todosorget_todomay call it, because it reads only the endpoint's own todos, whichlist_todosalready enumerates, and the attempt text on them was written through the endpoint's own credential. It is the frozen-verb-set "no new power" argument applied to one read verb.releasejoins the drain verbs. The store has had an endpoint-scopedReleaseTodosince the Board shipped, and SPEC-0006 anticipated it ("MAY exposerelease"). A supervisor needs it to end an attempt without passing a verdict on the work, for example on shutdown or a usage limit.- The Board's todo drawer and the A2UI todo detail list attempts.
Retention and bounds
- Attempts are deleted with their todo (
ON DELETE CASCADE), so the existing age and row-cap retention of terminal todos bounds them too. Live todos are never pruned, and neither are their attempts. - Each todo keeps at most
attempt_history_max_per_todorows (a setting, default 50). When a claim would exceed it, the oldest closed rows are deleted in the same transaction, and a counter on the todo records how many. A todo retried by hand for weeks cannot grow without bound. - Stored text is capped at 2048 bytes for
summary, 512 bytes forartifactand 128 bytes forclaimant. A larger input is truncated on a UTF-8 boundary and marked, never rejected, so a verbose reporter still gets its verdict recorded.
Security and tenancy
- Reads filter by the todo's owner scope. The agent path uses
endpoint_id = $1exactly asListTodosdoes, and the Board path uses theoperatorOwnspredicate. ADR-0038 widens both to team membership, and attempts follow automatically because they have no scope of their own. An unknown id and a foreign id both answernot_found. That is theclassifyMissrule, andget_todomust not reintroduce the existence oracle ADR-0022 closed. - The instance operator sees aggregates, such as SPEC-0023 counters, and never another user's attempt text. Attempt summaries are user content.
- Background sweeps are exempt from caller scoping, as
ReapExpiredandRequeueDueRetriesalready are. The reaper closes attempts for every tenant because it has no caller, it moves no row between tenants, and it reads nothing out. - Summaries are untrusted data. Switchboard does not scan them for secrets. A producer such as Harness redacts before sending, and the docs say plainly that a summary is returned to every later claimer of that todo. Every read labels the field as data written by an earlier attempt, never an instruction, as the doorbell text already is.
- Artifacts are never dereferenced. A handle is a string Switchboard stores and returns. It is not fetched, so it is not an SSRF primitive.
- Lease tokens are 128 random bits, returned once, stored as SHA-256, compared in constant time, and never logged.
How it composes with Harness and Cairn
- Harness ADR-0025 / SPEC-0019 (in flight) is the first consumer. It claims with
require_fenceand aclaimantlabel, heartbeats and reports with the token, and writesprior_attemptsinto the next attempt's context file. Harness's relay requires a Switchboard that has attempt history and refuses to run against one that does not (stump.wtf/harness#435). There is no detection or fallback path, per Joe's pre-1.0 "no compat shims" rule; the release that ships this spec is Harness relay's minimum. - Cairn holds the long form. An attempt's
artifactis typically a Cairn receipt (Cairn ADR-0027 / SPEC-0021, in flight) or a trace. Switchboard stores the handle, and Cairn's own tenancy (Cairn ADR-0029, in flight) governs who can open it. - Notifications. When a todo dead-letters, the notification sinks (ADR-0034 / SPEC-0029) receive the final attempt's outcome, summary and artifact, and the attempt count, so the human is told what was tried in the notification itself.
- Notify hooks (ADR-0029 / SPEC-0024). A retry re-queued after a failed attempt must wake a hook consumer, or a relay on the webhook path stalls until its safety-net schedule. SPEC-0034 names this as an interface requirement on SPEC-0024.
Consequences
- Good, because a relay's next attempt starts with what the last ones tried, and knows which of them died.
- Good, because a dead letter explains itself: every attempt, with its claimant, outcome and summary.
- Good, because the fence gives any client a per-attempt identity, so a stale worker cannot complete a newer worker's attempt, and the shared owner string does not have to change.
- Good, because
get_todoalso delivers the missingnext_retry_atanddead_letter. - Good, because it is one table and a few CTE arms on statements that already exist. No new process, no new trust.
- Bad, because every claim, heartbeat and closing transition writes one more row or column, which adds write amplification on the hottest path. The heartbeat update is one indexed row, and the cost is measured before the heartbeat write is kept (see Confirmation).
- Bad, because attempt summaries are a cross-attempt prompt-injection channel that Switchboard carries. Switchboard labels them and bounds them; it cannot sanitize them.
- Bad, because the drain surface grows by two verbs (
get_todo,release) and five optional arguments, which is more contract to keep stable. - Neutral, because clients that ignore attempts see no change except new optional response fields.
Confirmation
- A claim, heartbeat,
fail, backoff, re-queue and second claim on one todo produce two attempts,failedthen open, and the second claim's response lists the first inprior_attempts. - A claim whose worker stops heartbeating, reaped by the reaper, produces an attempt with outcome
reapedanddied: true, and the next claim lists it. The same through a lease takeover produceslease_expired. - Endpoint B calling
get_todoon endpoint A's todo getsnot_found, byte-identical to an id that was never minted. The same holds on the Board path for a second human. - On a fenced attempt,
completewith no token, or with another attempt's token, isconflict, and the todo stays claimed. - A todo retried 60 times keeps 50 attempt rows, and
attempts_prunedis 10. - Retention that deletes a terminal todo deletes its attempts, and a live todo's attempts are never pruned.
- Claim throughput under
claim_nextcontention regresses by no more than 10% (the existing concurrency tests, timed), or the heartbeat write moves to a sampled update.
Pros and Cons of the Options
(A) A todo_attempts table
- Good, because each attempt has a row whose open and close are single statements inside the transitions that already exist.
- Good, because an index on
(todo_id, seq)makes "the last N attempts" one cheap read, and a deferred exclusion constraint enforces "at most one open attempt per todo" at commit. - Good, because cascade delete ties retention to the todo with no new sweep.
- Bad, because it is a new table, a migration, and a join on every read that wants attempts.
(B) A jsonb array on todos
- Good, because there is no join, and the history travels with the row.
- Bad, because every heartbeat and close rewrites the whole array on the hottest row in the system, and TOAST makes large rows expensive to update.
- Bad, because "exactly one open attempt" and the per-todo cap become application logic over a blob, with no index to enforce them.
(C) A generic transition log
- Good, because it records everything, including transitions this ADR does not model.
- Bad, because every reader has to fold a log to answer "how did attempt 2 end?", and the fold rules become a second, implicit state machine.
- Bad, because it grows by several rows per attempt and needs its own retention.
- Neutral, because an audit log may still be wanted later for other reasons. It would complement attempts, not replace them.
(D) No server-side history
- Good, because it costs Switchboard nothing.
- Bad, because a worker that dies writes nothing. The one case the next claimer most needs to know about is exactly the case a client-side scheme cannot record.
- Bad, because
RetryTodoclearsresult, so even the last note is lost on a manual retry.
Architecture Diagram
More Information
- Extends ADR-0007. The lifecycle is unchanged. Each claim-to-end span now leaves a record.
- Extends ADR-0022. Attempts inherit the todo's scope, and reads keep ADR-0022's no-oracle rule.
- Extends ADR-0002. History is bounded by cascade and a per-todo cap under the existing hybrid retention.
- Related ADR-0028. An attempt-closed counter labelled by outcome complements
switchboard_lease_expired_total. - Related ADR-0029 and ADR-0013. A re-queued retry must ring through both doorbell transports for relay consumers to take the next attempt.
- Related ADR-0027. A clocked-out endpoint still gets attempts on its claims. Presence gates doorbells, not history.
- Companion records, accepted together on 2026-09-22 and linked as front-matter edges: ADR-0034 / SPEC-0029 (notification sinks), ADR-0035 / SPEC-0030 (admission control), ADR-0038 / SPEC-0033 (teams and tenancy), SPEC-0024 (notify hooks). Cross-product, cited in prose: Harness ADR-0025 / SPEC-0019 (relay attempts), and Cairn ADR-0027 / SPEC-0021 (receipts).
- Known gaps this touches: per-session owner identity (the fence covers the stale-worker case for clients that opt in),
next_retry_at/dead_letteron reads, and frozen verb sets (get_todois gated onlist_todosfor that reason).