Specify occurrences and receipts in process
Use Cratis.Chronicle.Testing to specify a trigger handler’s effects, retries and recovery without a running kernel. Each scenario owns isolated in-memory storage. Recording and delivery are separate actions: advancing time never invokes a handler.
The examples below use Cratis.Chronicle.Testing, .Events and .Occurrences, together with Cratis.Chronicle.Occurrences. ImportRequested denotes your attributed trigger and ImportHandler its IOccurrenceHandler<ImportRequested> implementation. Dispose each store and scenario after its specification.
Choose the advertised kernel capabilities
Section titled “Choose the advertised kernel capabilities”An EventStoreForTesting advertises Current() by default. This includes precise event schemas, occurrences and effect receipts. Scenarios now discover precise schemas rather than the legacy fallback shape.
For example, create using var store = new EventStoreForTesting();, then call store.Capabilities.Old(); before exercising a receipt enrollment. unit.UseEffectReceipt(key) throws EffectReceiptsNotSupported. store.Capabilities.Unknown(); models a skipped or failed compatibility check and also fails closed. store.Capabilities.Current(); restores the current advertisement.
Schema discovery happens during store construction. To choose the advertisement before discovery, create a KernelCapabilitiesForTesting, call Old() or Unknown(), and pass it to new EventStoreForTesting(null, artifacts, null, new ScenarioTime(), capabilities). Changing the switch afterward changes operation admission, not schemas already discovered.
Commit an event and its receipt together
Section titled “Commit an event and its receipt together”Use the real unit of work with the scenario’s store. For example:
- Create
using var unit = new UnitOfWork(CorrelationId.New(), _ => { }, store);fromCratis.Chronicle.Transactions. - Enroll
unit.UseEffectReceipt(context.EffectKey("import"));. - Stage an event with
unit.AddEvent(EventSequenceId.Log, "import", new ImportCompleted(), Causation.Unknown());. - Run
await unit.Commit();and inspectunit.IsSuccessandunit.EffectReceipt. - Read
await store.Occurrences.GetEffectReceipt(context.EffectKey("import"));to recover the winning result.
The append and receipt use the real kernel append path and the production in-memory storage’s atomic write. Repeating the effect key in a fresh unit returns AlreadyRecorded and appends no second batch, even when the repeated handler computes different content. Receipt-only operations accept NoEvents or Refused, for example await store.Occurrences.RecordEffectReceipt(key, new NoEvents());.
For a validate-only example, enroll the key, then call unit.AddEvents(EventSequenceId.Log, [], [new("source", new ConcurrencyScope(EventSequenceNumber.BeforeFirst, "source"))]); before committing. The empty batch validates that source’s scope and records NoEvents only if it still matches. A conflicting append writes no receipt. Explicit scopes use Cratis.Chronicle.EventSequences.Concurrency; protected decisions use their normal owning completion capability.
Record and deliver a typed trigger
Section titled “Record and deliver a typed trigger”Create using var scenario = new OccurrenceScenario<ImportHandler>();. Register constructor dependencies in scenario.Services before the first operation. The scenario supplies its own IEventStore, IEventLog and IUnitOfWorkManager.
Record with var recorded = await scenario.Record(new ImportRequested(), "import-42");, then invoke var outcome = await scenario.DeliverNext();. Successful handling returns Completed. await scenario.StatusOf(recorded.Id) returns the durable snapshot, including attempts, epoch, next attempt and receipts. With nothing claimable, delivery returns NothingDue.
The scenario activates a fresh handler in a fresh dependency scope for each attempt. It runs the real occurrence service and delivery policy over in-memory storage. It deliberately replaces the Orleans queue’s reminder/timer shell with serialized calls: there are no background workers or automatic lease renewals. This is not a test of silo recovery, network transport, production persistence or lifecycle fan-out.
Build an occurrence context
Section titled “Build an occurrence context”For a handler or command specification that needs a context without delivery, use new OccurrenceContextBuilder().For<ImportRequested>().WithIdempotencyId("import-42").BuildDetached().
ForTrigger(id) selects an explicit trigger. Call it before WithIdempotencyId so identity derivation uses the intended trigger. WithId(id), Kind(kind), RecordedAt(instant), DueAt(instant), Attempt(number) and Epoch(number) select individual metadata values. Attempts and epochs default to one.
A detached context owns no durable lease: receipts still deduplicate, but completing its token returns OccurrenceLeaseLost. For a real lease, use await new OccurrenceContextBuilder(store.Time).For<ImportRequested>().WithIdempotencyId("import-42").RecordAndClaimIn(store) instead. This seeds a synthetic occurrence with an empty payload and claims it; it does not validate a payload or invoke a handler. The trigger queue must be empty. Recorded contexts default to the store’s clock; detached contexts use the builder’s clock.
Advance time and retry explicitly
Section titled “Advance time and retry explicitly”ScenarioTime starts at 2026-01-01T00:00:00Z, or at the instant supplied to its constructor. Now and GetUtcNow() report UTC.
For example, call await scenario.DeliverNextFailing(new ImportFailed()); with your exception type. This substitutes a handler failure without invoking the handler, and records normal backoff. Read the snapshot, then call scenario.Time.AdvanceTo(snapshot.NextAttempt); and await scenario.DeliverNext();. Delivery before the backoff expires returns NothingDue. Ordinary handler exceptions follow the same failure path; exhausted attempts become quarantined.
Use scenario.Time.Advance(TimeSpan.FromSeconds(60)); to expire a lease. Moving backward through either advance method throws ScenarioTimeCannotMoveBackwards and preserves the current instant.
To share an explicit clock with event scenarios, construct new Defaults(artifacts, null, null, time) and pass those defaults to EventScenario or OccurrenceScenario<ImportHandler>. EventScenario.Time exposes that clock. Event append timestamps and receipt timestamps still use the kernel’s system clock; this driver controls occurrence recording, lease expiry and backoff only.
Lose a reply after commit
Section titled “Lose a reply after commit”Queue a fault with scenario.Replies.DropNext(DroppedReply.Append); before delivery. The next successful receipted or keyed append (any append shape) commits, then throws Grpc.Core.RpcException with StatusCode.Unavailable. For a receipted handler append, the handler attempt fails, but its receipt survives. On retry with the same effect key, the original outcome wins and no duplicate events are appended.
For keyed appends through store.EventLog, queue the same fault with store.Replies.DropNext(DroppedReply.Append);. Retry AppendOnce or AppendManyOnce with the same IdempotencyKey after reply loss; the retry reports AlreadyAppended without duplicating events. The named-tag variants behave the same way.
Other fault points are RecordEffectReceipt, RecordOccurrence, ClaimOccurrences, CompleteOccurrence and FailOccurrence. For example, store.Replies.DropNext(DroppedReply.RecordEffectReceipt, 2); discards two successful receipt-only replies. Dropped counts replies actually discarded, not faults queued. Refused mutations and ordinary appends without a receipt or idempotency key do not consume these faults.
An unknown outcome is not permission to execute an unrelated replacement. Inspect durable state or retry with the same identity. A lost claim remains leased until expiry. A repeated completion at the same epoch returns AlreadyCompleted.
To model a worker lost after its effects but before completion, call await scenario.DeliverWithoutCompleting();. It returns Delivered, leaving the successful lease intact. Advance past its expiry and deliver again to check receipt-based recovery.
Assert on collected state
Section titled “Assert on collected state”After delivery, assertions are synchronous:
scenario.ShouldHaveReceipt(context.EffectKey("import"));checks the collected receipt identity.scenario.ShouldHaveAppended<ImportCompleted>();checks events collected from the scenario’s event log; a predicate overload checks their content.scenario.ShouldBeQuarantined(recorded.Id);checks a collected occurrence snapshot.
Delivery collects its occurrence and the event log automatically. After a direct service mutation, await scenario.StatusOf(id) to refresh that occurrence before asserting on it. Assertions perform no hidden asynchronous work and throw OccurrenceScenarioAssertionFailed when an expectation is unmet.
Use integration specifications against a running kernel for restart, clustering and provider durability. See effect receipts for the production commit and recovery contract.