Skip to content

Settle an occurrence effect with a receipt

Use an effect receipt when a delivered occurrence may append events again after a crash or a lost reply. The receipt and its events become durable together. A repeated attempt returns the first recorded outcome and response instead of appending another batch.

Use the same namespace-bound event store for receipt lookup and the effect’s unit of work. The kernel must advertise effect-receipts. Enrollment, lookup and receipt-only recording throw EffectReceiptsNotSupported when support is absent or unknown; there is no unreceipted fallback.

An effect key identifies one effect within an occurrence. Choose a stable name, such as reserve-stock, and obtain the key through OccurrenceContext.EffectKey(name). The key’s identity excludes the delivery attempt and lease epoch. Its Value is the idempotency key to pass to external endpoints.

  1. Look up the effect with eventStore.Occurrences.GetEffectReceipt(key). If it exists, use its recorded outcome and response without repeating the effect.
  2. Begin a unit of work and call UseEffectReceipt(key, response) before committing. Stage events through the transactional event sequence, not through an immediate append.
  3. Call UseEffectReceipt again with the same key if the response becomes available later. The call snapshots its JSON; subsequent mutation of the original object does not change the commit. A different key throws UnitOfWorkEffectReceiptAlreadySet.
  4. Commit the unit, using its owning completion capability when protected reads require one. Inspect IsSuccess and EffectReceipt.

A successful receipted commit exposes EffectReceiptResult. Recorded means this attempt recorded it. AlreadyRecorded means another attempt won; use Receipt.Response, not the response this attempt computed. The initial lookup is an optimization: the atomic commit checks again before validating or appending.

A receipt’s outcome is Appended with the sequence and first and last positions, NoEvents, or Refused with reasons. Only the unit of work can record an Appended outcome.

Keep immediate appends inside the effect boundary

Section titled “Keep immediate appends inside the effect boundary”

An immediate append commits outside the unit of work and its receipt. When a host runs nested commands or application callbacks for one occurrence effect, open IUnitOfWork.RefuseImmediateAppends() before executing them. Native event sequences then throw ImmediateAppendRefused before resolving event types or providers, serializing, enumerating a batch, publishing append notifications, or contacting the kernel. This covers prepared and routed appends, batches and named-tag variants, including injected event logs, captured sequence references, and sequences from another store.

For example, given an effect key, an eventSourceId, and an event to stage:

  1. Begin the effect’s unit with var unit = eventStore.UnitOfWorkManager.Begin(correlationId);.
  2. Open the owning lifetime with using var refusal = unit.RefuseImmediateAppends(); before invoking application code.
  3. Enroll the receipt with unit.UseEffectReceipt(key); and stage the event with await eventStore.EventLog.Transactional.Append(eventSourceId, @event);. Nested commands can stage more events in the same unit; they must not complete it.
  4. Commit once through await refusal.Commit();, then inspect unit.IsSuccess and unit.EffectReceipt. A call to eventStore.EventLog.Append(eventSourceId, @event) anywhere in this flow would instead throw ImmediateAppendRefused.
  5. Dispose the owning frame in the same scope, including on failure. The using declaration handles this; disposal rolls back an unfinished unit and restores the previous append-admission state.

The first frame owns completion only when the unit has no existing completion owner and is not already enforcing read protection. If a host has claimed a DecisionReadCommitOwner (as the unit-of-work middleware does), or has already called EnforceReadProtection(), the frame is refusal-only: its Commit() and Rollback() throw ImmediateAppendRefusalRequiresOwner, and disposing it does not complete or roll back the unit. Continue committing or rolling back through the existing owner; its protected commit path and read dependencies remain intact. For enforced read protection without a claimed capability, the host must claim completion through ClaimDecisionReadCommitOwnership() and use CommitAsOwner() or RollbackAsOwner() on the native unit.

Later calls to RefuseImmediateAppends() return borrowed frames: disposing one does not lift refusal, and its Commit() and Rollback() also throw ImmediateAppendRefusalRequiresOwner. When the first refusal frame does own completion, direct unit completion and a second completion-ownership claim throw that exception; use the frame’s Rollback() to abandon the effect explicitly. Begin or SetCurrent replacement is refused throughout either kind of refusal lifetime. Refusal remains active after completion, even when the ambient unit has been cleared, until the first frame is disposed. Await an in-flight commit before disposing a completion-owning frame; disposal during commit throws UnitOfWorkIsCompleting and does not lift refusal. Disposal restores admission even if a completion callback throws after rollback.

Admission follows the asynchronous execution context across await and inherited Task.Run work; it does not affect unrelated sibling flows. The trusted owner commit does not temporarily reopen admission, so serializers and append callbacks cannot use it to make another immediate append. Keep the owning frame private to the host rather than passing it to nested application code.

This capability does not turn on read protection, enroll read dependencies, or change revision, redaction, stream-completion, or receipt-recording policies. Ordinary reads remain ordinary reads. If the decision needs protected reads, also use EnforceReadProtection() and its read allowances; those rules remain in force, and no read allowance permits an immediate append. Establish the host’s completion ownership and read protection before opening a refusal-only frame; refusal never takes that completion ownership away.

Use this boundary when the host must keep all appends in its final atomic commit, especially when that commit carries an effect receipt. Do not use it as an external-system fence or as a substitute for read protection, and leave it off for work that intentionally appends immediately. Third-party unit implementations that do not support the capability throw UnitOfWorkImmediateAppendRefusalNotSupported; a unit bound to a sequence without the trusted commit path throws EventSequenceImmediateAppendRefusalNotSupported rather than falling back to an immediate append.

Keep the receipt enrolled even when the decision produces no events. With protected reads, the commit sends the scopes and receipt together through a validate-only append. A scope conflict writes no receipt. This applies to both decision reads and contributed read protection.

Without protected reads, the unit records NoEvents through the receipt-only operation. To seal a terminal refusal explicitly, call eventStore.Occurrences.RecordEffectReceipt(key, new Refused(reasons), response). The public receipt-only operation is refused inside enforced read protection; it cannot replace a guarded commit.

A failed append pipeline writes no receipt. Classify the failure before deciding whether to record a permanent refusal. Concurrency conflicts, transport failures and handler exceptions must remain retryable rather than being sealed as successful no-event effects.

  • A response is optional opaque JSON, limited to 64 KiB of serialized UTF-8. Oversized responses throw EffectReceiptResponseTooLarge before transport.
  • Responses are plaintext and are not crypto-shredded. Store a minimal result without personal data or secrets, not a raw command response.
  • One unit of work carries one receipt into one sequence commit. Nested commands sharing that unit belong to the outer effect; they do not receive independent receipts.
  • An immediate append bypasses the enrolled receipt. It can duplicate events on redelivery; open an immediate-append refusal frame when executing nested code that must stay inside the effect’s commit.
  • After a lost acknowledgement, look up the receipt before executing again. The stored outcome wins even if the repeated attempt computed different content.
  • A receipt deduplicates Chronicle effects; it does not fence an external system. Exactly-once external effects require that system to honor the effect key.