Skip to content
C.W.K.
Lesson 02 of 05 · published

A Request Remembers Its Answer

~15 min · idempotency, request-id, retries, distributed-systems

Level 0Loose Note
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Answer That Never Came Back

Compare-and-swap protects a write from other writers. It does not protect a writer from itself. Picture a write sent from a laptop to the Mac that holds the store. The store commits it, and the reply is lost on the way back: the network blinks, the laptop's process is killed, the lid closes. The laptop now has no idea whether its write happened.

Both obvious reactions are wrong. Sending it again risks doing it twice; and with compare-and-swap, the second attempt would fail anyway with revision_conflict, because the note's revision is now the one the first attempt produced. The writer would be told "someone else changed this" when the someone else was itself. Giving up risks losing a write that may never have landed.

Every Write Has a Name

Soul Vault's root refuses any write without a request_id, the caller's own name for this one request. On a store that declares durable_idempotency (Delta does), the first thing write_batch does under the vault's lock, before the identity-note rule and before any comparison, is ask the child whether this request id already completed. Two kinds of check come earlier: the input checks (an actor, a request id, a non-empty batch in one vault), and the refusals that taking the lock itself can raise (the write gate, the seal, a moved pointer). A replay never slips past those.

  • Not seen before: the write proceeds normally.
  • Seen, same content: the store returns the first result, flagged replayed=True. Nothing is applied a second time, and there is no conflict against the caller's own earlier success.
  • Seen, different content: policy_denied, "a request id names one request". Reusing an id for a different write is a caller bug, and the store says so instead of guessing which one was meant.

"Same content" is decided by a digest the root computes over the whole batch: for each change, the operation, the note id, the SHA-256 of the new text, the expected revision and whether the note must exist. The Delta store keeps one row per completed request (id, vault, digest, result, batch), written in the same SQLite transaction that makes the batch real, so a request is recorded as completed exactly when it is.

Naming the Unknown

Contract v5 also gave the lost answer a name. When a write left the caller and no answer came back, the caller does not report success or failure. It raises outcome_unknown with the request id. The caller then has two honest ways to find out:

  • Ask request_outcome(request_id) (the CLI's soul-vault outcome): it returns what that request published, or nothing if it never completed.
  • Send the very same request again, same id and same content. If the first one landed, the replay answers with its result; if not, this one applies.

That pairing was measured on the live store on 2026-11-10 from three Macs, using throwaway test souls: a write from another Mac took 72 to 83 ms and made one commit; the same request id sent again was answered as replayed in 5 to 7 ms, and the history still held one commit.

A Detail That Bit

Request ids travel through Delta's write call, which accepts only letters, digits and . _ : -. The daily canary once built its cleanup request ids with a vault name inside, guest-souls/<slug>, slash included. Those removals never left the Mac that sent them, and two test fixtures were left on the live store until they were removed by hand the same minute. The ids are now built without the slash, and the tests' fake Delta refuses any id the real one refuses, so the fake can no longer be more forgiving than production.

Code

Replay before the identity rule and compare-and-swap (trimmed from write_batch in soulvault/root.py)·python
with self._exclusive(vault):
    if self.capabilities().durable_idempotency:
        # The same request again (its answer was lost on the way back): the first
        # result, never a second application and never a conflict against the
        # caller's own earlier success.
        prior = self._completed_request(str(request_id), self._batch_digest(checked))
        if prior is not None:
            return CommitResult(request_id=request_id, revisions=dict(prior),
                                served=self.served(), replayed=True)
    # ... identity-note rule, compare-and-swap, then self._apply(...)
A caller that handles a lost answer honestly (pattern)·python
import uuid

rid = f"note-edit:{uuid.uuid4().hex}"     # letters, digits and . _ : - only
try:
    result = vault.write_note(note, text, expected_revision=rev, actor=me,
                              request_id=rid, reason="fix a date")
except VaultError as e:
    if e.code != "outcome_unknown":
        raise
    done = vault.request_outcome(rid)    # None: it never completed
    if done is None:
        result = vault.write_note(note, text, expected_revision=rev, actor=me,
                                  request_id=rid, reason="fix a date")   # same id, same content
    else:
        result = done                      # it landed; nothing to do

External links

Exercise

Add request ids to a write API of yours: the server stores (id, digest of the request, result) in the same transaction as the write, answers a repeated id with the same digest by returning the stored result, and refuses a repeated id with a different digest. Then simulate a lost reply by killing the client after the server commits, and recover using the same id.
Hint
Record the request in the same transaction as the effect. If they are separate, a crash between them recreates the exact uncertainty the id was meant to remove.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.