Conversation State
A Sundry ticket is not a chat session. It lasts three days and four messages, it crosses two deploys, a person may take it over halfway through, and the buyer replies from a phone on Thursday to a thread that started on Tuesday. Whatever holds that conversation has to survive all of it, which makes conversation state ordinary application data with a schema, an owner and a retention policy.
The loop from Chapter 1 offers no help. Its messages list is a local variable, and when the process exits the ticket goes with it. Replacing that variable with a JSON blob in a cache takes twenty minutes and is the version most teams ship; it fails the first time somebody asks why resolution dropped two points last month. The design worth an hour is what to write down, keyed by what, and for how long.
What to Persist
Six things, and the message array is only the first. The messages exactly as sent, including the system prompt, because the system prompt changes. The exact model identifier behind that turn. The prompt version that produced it. Every tool request with its arguments, and every result as returned rather than as summarized. The task-state object from Topic 30. And the run's outcome: turn count, stop reason, token counts, cost, and whether it escalated.
{"run_id": "R-9f31",
"ticket": "T-40219", # the key: the ticket, not the buyer
"started_at": "2026-03-10T14:31:52Z",
"model": "provider/model-id", # exact identifier, not "the usual one"
"prompt_version": "sys-2026-03-04",
"messages": ["…as sent, in order, including tool results…"],
"task_state": {"order": "SU-88421", "decision": "refund", "actions_taken": ["…"]},
"outcome": {"turns": 6, "stop": "answered", "in_tokens": 28400,
"out_tokens": 1180, "cost_usd": 0.11, "escalated": False}}
Everything in that record answers a question somebody asks later. The model identifier and the prompt version answer "what changed" when resolution moves. The messages as sent answer "why did it do that" without anybody guessing at what the context held. The token counts and cost turn a vague worry about spend into a per-ticket number Chapter 13 can chart. The test to apply is not "can I read this back" but "can I replay this run offline". Not word for word — sampling is stochastic, and Chapter 2 explains why the same input legitimately produces two runs — but with the same inputs at every step.
Persisting only the text fails that test in a specific, irritating way. Six weeks later resolution is down two points, and nobody can say whether the prompt was edited, the provider moved the model underneath a floating alias, or March simply brought harder tickets. Three suspects, no evidence, and the argument is settled by whoever is most confident.
Resumption
A thread picked up on Thursday is not an in-memory object continuing. It is context rebuilt from storage: load the run record, reconstruct the message array, reattach task state, append the new customer message, and enter the loop at a turn boundary. That single requirement — the loop must be able to start from persisted state at any turn boundary — shapes the code more than it sounds like it will, because it forbids any state that lives only between iterations.
Sundry got two things wrong on the first attempt, and both are worth stealing. The rebuild loaded the raw transcript at full length even though the previous run had compacted it, so a resumed ticket paid roughly three times the input tokens of the run that produced it. And the rebuild included only the agent's messages, so when a human had replied in the middle of the ticket, the agent's next message cheerfully contradicted a colleague in front of the buyer. Resumption reconstructs what the model needs to know now, which includes everything that happened while the agent was not running.
Threading and Identity
The key is the ticket. Not the buyer, not the order, not the mail thread. One ticket about a delayed kitchen delivery can touch three orders and two sellers; order SU-88421 can appear in two unrelated tickets a month apart. Key on the order and two conversations merge into one confused thread. Key on the email address and a household becomes a single customer with a very strange purchase history.
Customer identity belongs in state as a recorded fact, not as an assumption inherited from the channel. An address in a From header is a claim: the writer may be a colleague chasing a gift somebody else ordered, or a partner using the household account. Sundry stores who the buyer was verified to be and how — matched against the order's account, or asked and confirmed in the thread — and every action with a consequence reads that field rather than the header. It is a two-column addition that saves a genuinely bad afternoon the first time somebody asks the agent to redirect a parcel.
Retention and Deletion
Transcripts hold what the customer wrote and what the model wrote about the customer: addresses, order history, the occasional complaint about a neighbour, and now and then a medical reason for a return that nobody asked for. The retention window, the deletion path, and what deletion means for the eval set are decided at design time, not the week the first request arrives.
Sundry keeps full transcripts for 90 days, then reduces a run to its structured fields and outcome; action rows that moved money are kept with the finance record they support. A deletion request removes the transcript and any long-term facts derived from it and leaves the ledger rows finance is required to hold. The awkward part is the eval set: 120 real tickets, held indefinitely because they are the only honest measure the team has. That is a governed copy with a named owner and its own deletion path, or it is a permanent archive of customer data that nobody ever decided to keep — and Chapter 9 assumes the first.
Concurrency
Two replies arrive four seconds apart. A human agent opens a ticket the loop is already working. A retry of a queue message starts a second run of the same ticket. All three are routine, and all three produce duplicate actions unless something stops them. Idempotency keys do not close this on their own: two runs reasoning independently can decide on $118.00 and $127.95 for the same ticket, which are two different intents, two different keys, and two real payments.
Sundry's rule is one lock per ticket, held for the duration of a run, with a lease so a crashed process releases it. A second arrival does not wait for the lock — it appends the message to the ticket and exits, and the holding run checks for unprocessed messages before it finishes and starts another turn if it finds any. A human taking over acquires the same lock and keeps it, so the agent simply cannot act on a ticket a person is holding. The one week the lease was set to 30 seconds against a p95 run time of 34, two runs overlapped on eight tickets in a day and each buyer received two different replies — which is what a lock lease shorter than the work it protects buys you.
- Holding conversation state in process memory — a deploy at four in the afternoon loses every in-flight ticket, and the ones halfway through a refund are exactly the ones you did not want to lose.
- Persisting the text but not the model identifier or prompt version — a two-point regression six weeks later cannot be attributed to anything, and the debate is won by whoever sounds surest (Chapter 13).
- Letting two runs work one ticket at once — the buyer gets two replies, and two independently chosen refund amounts are two different idempotency keys and two real payments.
- Keeping transcripts forever because they are useful for evals — "useful" is not a retention basis, the decision carries legal weight, and it needs a named owner rather than a default.
- Persist the full run record — messages as sent, model and prompt versions, tool calls and results, task state, and outcome — and treat it as the replay artefact from day one.
- Make the loop restartable from storage at any turn boundary, and prove it by killing the process mid-run in staging rather than by reading the code.
- Lock per ticket with a lease comfortably longer than the p95 run time, and define what a second arrival does instead of leaving it to race.
- Set retention and deletion rules with the same seriousness as any other customer data store, including what a deletion request means for the eval set.
Knowledge Check
Resolution drops two points over six weeks. What in the run record makes that attributable?
- The timestamp on every run, which shows exactly when the drop in resolution began
- The model identifier and prompt version stored per run, which let you slice the drop by each
- The cost and token counts recorded per run, which reveal whether the agent started working harder
- The full message array stored on every ticket, which lets a reviewer read what actually happened
A customer replies on Thursday to a ticket last worked on Tuesday. What makes resumption correct rather than merely possible?
- Keeping the original worker process alive so its in-memory conversation object survives
- Replaying every earlier turn through the model again so the state is rebuilt from scratch
- Rebuilding the context as it should stand now, including the compacted form and any human replies
- Starting an entirely new run from the customer's latest message so the agent is not misled by old turns
Why is the ticket, rather than the customer's email address, the right key for conversation state?
- A ticket is one unit of work, while an address can cover a household and an order can recur across tickets
- Ticket identifiers index far more efficiently than email addresses do in any relational store
- Keying by ticket is the only arrangement that lets a deletion request reach every affected row
- The email address is unverified, so no part of the agent's state may be keyed on it at all
Two runs work the same ticket at once and each decides on a refund. Why do idempotency keys not save you?
- Keys are generated per process, so two runs produce different keys for identical intents
- Two runs can choose different amounts, and different amounts are different intents with different keys
- The key expires at the payment provider before the second run reaches the refund call
- The ledger check runs only on retries, so a first attempt from a second run bypasses it
You got correct