Commands for Chronicle occurrences
A Chronicle occurrence is work the event store hands to your application when no one made a request: a schedule came due, the application started or stopped, a clock rule fired. An occurrence can be delivered more than once — a crash, a lost lease or a restart redelivers it. IOccurrenceCommandExecutor commits a transactional command’s events and receipt atomically. A later delivery with the same effect key returns the recorded outcome without running the handler. Concurrent attempts can still enter the handler before one wins the commit; external side effects need their own idempotency keys.
It is part of Cratis.Arc.Chronicle and needs a Chronicle kernel that advertises the effect-receipts capability (client 19.81 and later).
How it works
Section titled “How it works”The executor identifies each command by an effect key: the occurrence id, an effect name you choose, and the lease that is writing. When the command succeeds, Chronicle records an effect receipt in the same atomic commit as the command’s events. The receipt and the events either both exist or neither does, so a crash after the commit can never be followed by a second run.
For each call the executor:
- Opens the scope of the occurrence’s namespace and checks that the tenant mapping resolves to that same namespace.
- Looks up an existing receipt for the key. If one exists, it returns the stored outcome without running anything.
- Runs the command as the system with only the roles you pass, on a fresh correlation id, with an
Occurrenceentry on the causation chain. - Commits the command’s unit of work with the receipt enrolled, and returns the winning receipt. If another attempt recorded one first, you get that stored outcome and nothing of this attempt is kept.
Execute a command for an occurrence
Section titled “Execute a command for an occurrence”public class SendReminders(IOccurrenceCommandExecutor executor) : IOccurrenceHandler<ReminderDue>{ public async Task Handle(ReminderDue due, OccurrenceContext occurrence, CancellationToken cancellationToken) { var result = await executor.Execute( new SendInvoiceReminder(due.InvoiceId), occurrence, "send-reminder", ["Scheduler"], cancellationToken);
if (!result.IsSettled) { // Nothing was recorded. Throw so the occurrence is redelivered and the effect is attempted again. throw new ReminderAttemptFailed(due.InvoiceId); } }}ReminderDue, SendInvoiceReminder and ReminderAttemptFailed are the application’s own types. The overload without roles runs as the system with no roles; name the roles the command’s authorization needs. Nothing is inherited from a caller.
What the result tells you
Section titled “What the result tells you”| Disposition | Meaning | What to do |
|---|---|---|
Recorded | This attempt wrote the receipt. The effect is settled. | Acknowledge the occurrence. |
AlreadyRecorded | A receipt already existed. Outcome and Response are the stored ones. | Acknowledge the occurrence. |
Failed | A later attempt can succeed. No receipt, nothing committed. | Let the occurrence be redelivered. |
Outcome is Appended, NoEvents or Refused. CommandResult carries the command’s own result when this attempt ran it.
What is terminal and what is retried
Section titled “What is terminal and what is retried”| Command outcome | Receipt | Disposition |
|---|---|---|
| Events committed | Written atomically with the events | Recorded (Appended) |
| Succeeded without events | Written (NoEvents) | Recorded |
| Validation failure, business refusal, constraint violation | Written (Refused) | Recorded — an operator reruns with a new effect key |
| Concurrency conflict or a protected read that changed | None | Failed |
| Handler exception or infrastructure failure | None | Failed |
| Authorization failure | None | Failed |
A refusal is terminal because retrying the same input would be refused again. To run the effect again after an operator has fixed the cause, use a new effect name.
The stored response
Section titled “The stored response”The receipt stores a minimal plaintext response: the command type name, the outcome, the number of events, and for a refusal the category and member or constraint names. It never serializes command property values or the handler’s response values. Validation messages are also excluded because they can echo personal data or secrets. Keep member and constraint names free of personal data.
Command names and individual reasons are capped at 256 characters, with at most 16 reasons plus an omission marker. Truncated strings end in [truncated]. These limits keep the serialized response below 64 KiB even when JSON escapes every character. The same bounded refusal reasons are used in the receipt’s outcome.
Because the receipt does not store the command’s response values, a personal-data annotation or an oversized handler response does not require up-front refusal of keyed execution. If you need the command’s own response on redelivery, query the state the events produced; it is not replayed from the receipt.
What a command cannot do inside an occurrence
Section titled “What a command cannot do inside an occurrence”One receipt covers one unit of work, and the receipt is written only by its final commit. The executor refuses these:
-
Running inside another command.
OccurrenceCommandCannotRunInsideCommandis thrown. Call the executor from the occurrence handler, not from a command or reactor that already owns a unit of work. Commands the command returns or nests share the unit and are covered by the same key. -
Committing early. An aggregate root
Commit()throwsOccurrenceCommandCannotCommitEarly. Even if the pipeline catches it in aCommandResult, the executor rethrows it to the occurrence handler; it is a programming error, not a retryableFailedresult. -
Completing a stream. Stream completion is applied outside the atomic commit. The executor surfaces
OccurrenceCommandCannotCompleteStreamsto the occurrence handler, also rethrowing it when the pipeline captured it. No receipt is written. -
Appending immediately. Chronicle’s built-in event sequences refuse direct (non-transactional) appends before sending anything in the command’s async flow. The executor rethrows
ImmediateAppendRefused, rolls back pending transactional events and writes no receipt. This is a programming error, not a retryableFailedresult. Return events from the handler or append through the command’s unit of work using.Transactional; those events still commit atomically with the receipt. The refusal ends when the invocation exits, so later commands outside the occurrence can append immediately. CustomIEventSequenceimplementations must honor Chronicle’s append-admission contract; the executor retains post-commit detection on the command’s event log as a backstop, not prevention or coverage of other sequence instances. See transactional commands.
Fail closed without the kernel capability
Section titled “Fail closed without the kernel capability”When the kernel does not advertise effect-receipts, or the capability is unknown, the executor throws Chronicle’s EffectReceiptsNotSupported before running the command. It never runs a command it cannot make retry-safe.
Occurrence causation and time
Section titled “Occurrence causation and time”The causation chain of every event the command appends includes an Occurrence entry before the command’s entry. A root or process entry can come first. The occurrence entry carries occurrenceId, triggerId, kind, recordedAt, dueAt for scheduled occurrences, attempt, epoch and effectKey, followed by the usual command causation. Every attempt gets a fresh correlation id. The event’s Occurred time is the time it was appended, not the due time of the occurrence; read dueAt from the causation when you need it.
Namespaces and tenants
Section titled “Namespaces and tenants”The occurrence’s namespace selects the tenant: the default namespace is the default tenant, any other namespace is a tenant of that name. The executor then verifies that the event store resolves to the same namespace and throws OccurrenceCommandNamespaceMismatch when your tenant mapping disagrees. An occurrence without a namespace runs in the default namespace.
Testing
Section titled “Testing”Test an occurrence handler against the in-process kernel from Cratis.Chronicle.Testing: its capability switch makes the kernel advertise or hide effect-receipts, and it stores receipts so a second execution of the same effect key returns AlreadyRecorded. See Testing with Chronicle.
Things to know
Section titled “Things to know”- The effect name must be stable across attempts and unique per effect inside one occurrence. Pass
occurrence.EffectKey(name).Valueto external systems that accept an idempotency key. - An effect needing more than one unit of work needs more than one effect name; each name has its own receipt.
- Receipt replay carries only the minimal outcome, never a command’s response values. The receipt remains plaintext.
- Event reactors that return commands are a different mechanism; see command side effects.