IdempotencyGuard.record() appends to the ledger after the side effect returns. A crash in the window between the external call succeeding and the ledger append leaves no evidence of the call, so the retry re-executes it — the exact duplicate the contract exists to prevent.
The window is small but it is the highest-consequence one: it is open precisely while the payment/email/webhook is in flight.
Fix is a pending/terminal split:
- Before the external call, append a
pending record keyed on the same key_for(ctx).
- After it returns, append a
terminal record with the result, linked by the same key.
check_pre blocks on either. A pending hit means "a prior attempt reached the external system and we do not know the outcome" — which must block re-execution and surface for reconciliation, not silently replay a result we never recorded.
Point 3 is the part that matters: a pending hit is not the same as a terminal hit, and collapsing them either duplicates the effect or fabricates a result. Violation.recovery should say which case it is.
Prior art for the same split: idempotency-ref-v1 in giskard09/argentum-core (docs/spec/idempotency-ref.md), which separates the pre-call reservation from the post-call anchor for this reason.
Surfaced while answering the durability question in crewAIInc/crewAI#5802. Relates to FM-037.
IdempotencyGuard.record()appends to the ledger after the side effect returns. A crash in the window between the external call succeeding and the ledger append leaves no evidence of the call, so the retry re-executes it — the exact duplicate the contract exists to prevent.The window is small but it is the highest-consequence one: it is open precisely while the payment/email/webhook is in flight.
Fix is a pending/terminal split:
pendingrecord keyed on the samekey_for(ctx).terminalrecord with the result, linked by the same key.check_preblocks on either. Apendinghit means "a prior attempt reached the external system and we do not know the outcome" — which must block re-execution and surface for reconciliation, not silently replay a result we never recorded.Point 3 is the part that matters: a pending hit is not the same as a terminal hit, and collapsing them either duplicates the effect or fabricates a result.
Violation.recoveryshould say which case it is.Prior art for the same split:
idempotency-ref-v1in giskard09/argentum-core (docs/spec/idempotency-ref.md), which separates the pre-call reservation from the post-call anchor for this reason.Surfaced while answering the durability question in crewAIInc/crewAI#5802. Relates to FM-037.