TicketReportingEventConsumer folds Tickets lifecycle events into the current Rpt_TicketSummary snapshot. Topic publication, business payload mapping, failure propagation, and all six lifecycle handlers now exist. The remaining blockers are the opened event’s placeholder entity ID, the absence of an aggregate version, and concurrent Store semantics.
1. Publication and consumption contract
All six Ticket domain events inherit DomainEvent, implement IIntegrationEvent, and declare [IntegrationTopic]. The source generator maps each event type to its topic and scalar business fields. DomainEventDispatchPipelineBehavior adds the framework envelope fields:
eventId = DomainEvent.EventIdeventType = domain event type namebusiness fields = every public event property emitted by the generated mapperThe command transaction writes this message to the CAP Outbox through INotificationPublisher. Publication or commit failure propagates, rolling back business data and Outbox together.
Reporting binds the same body to separate consumer DTOs:
| Topic | Tickets publisher type | Reporting parameter type | Business fields |
|---|---|---|---|
ticket.opened | Events.TicketOpened | TicketOpenedIntegrationEvent | TicketId, TenantId, RequesterId, Subject, PriorityName, OccurredAt |
ticket.assigned | Events.TicketAssigned | TicketAssignedIntegrationEvent | TicketId, TenantId, AssigneeId, AssignedBy, OccurredAt |
ticket.started | Events.TicketStarted | TicketStartedIntegrationEvent | TicketId, TenantId, StartedBy, OccurredAt |
ticket.resolved | Events.TicketResolved | TicketResolvedIntegrationEvent | TicketId, TenantId, ResolvedBy, OccurredAt |
ticket.closed | Events.TicketClosed | TicketClosedIntegrationEvent | TicketId, TenantId, ClosedBy, OccurredAt |
ticket.reopened | Events.TicketReopened | TicketReopenedIntegrationEvent | TicketId, TenantId, ReopenedBy, OccurredAt |
Current tests call the consumer with constructed DTOs. They do not capture bytes from the real transactional Outbox and pass them through CAP model binding. Source-level field alignment is evidenced; host serialization is not yet pinned by an independent contract test.
2. Six lifecycle changes
| Event | Requires opened row | Projection write |
|---|---|---|
| opened | No | Create New with RequesterId, Subject, PriorityName, and OpenedAt |
| assigned | Yes | Set AssigneeId and Assigned |
| started | Yes | Set InProgress |
| resolved | Yes | Set Resolved |
| closed | Yes | Set Closed and ClosedAt=OccurredAt |
| reopened | Yes | Set Reopened and ClosedAt=null |
AssigneeId now has a real writer, and Reopened can clear ClosedAt because Store update directly assigns existing.ClosedAt = summary.ClosedAt. Earlier documentation claiming a null-preserving merge or opened/closed-only coverage is obsolete.
3. Opened still carries the placeholder ID
Ticket.Open currently executes in this order:
var ticket = new Ticket("0", tenantId, requesterId, subject, priority, nowUtc);
ticket.Raise(new TicketOpened( ticket.Id, // still "0" ticket.TenantId, ticket.RequesterId, ticket.Subject, ticket.Priority.Name, nowUtc));
// OpenTicket then calls TicketRepository.SaveAsync(ticket).// IEntitySet<T>.AddAsync assigns the final persistence ID later.The Entity base already provides RaiseWhenPersistenceIdAssigned(Func<TId, IDomainEvent>), but Ticket.Open does not use it. Consequently:
- opened writes a Mart row with
TicketId="0"; - the saved Ticket response and assigned/started/… events use the final Snowflake ID;
- later consumers cannot find the opened row and throw “cannot be projected before its opened event”;
- CAP retry cannot repair an identity mismatch.
Opened should be registered through RaiseWhenPersistenceIdAssigned(id => new TicketOpened(id, ...)), with an end-to-end OpenTicketCommand → persistence → Outbox → Reporting test. A consumer must never guess which final ticket a 0 event belongs to.
4. Duplicate, stale, and predecessor handling
Non-opened handlers share this decision pattern:
var existing = await GetExistingAsync(tenantId, ticketId, eventName, ct);
// LastEventId recognizes only a direct duplicate of the current last write.if (existing.LastEventId == eventId) return; // immediate duplicate
if (occurredAt < existing.LastUpdatedAt) return; // older event time
// An equal timestamp is not stale and may overwrite.// Production ordering therefore still needs Version/sequence for determinism.Apply(existing);existing.LastEventId = eventId;existing.LastUpdatedAt = occurredAt;await UpsertAsync(existing, eventName, ct);Opened checks an immediate duplicate but does not compare OccurredAt; it creates a complete New snapshot. A non-opened event without its predecessor throws so CAP retries rather than acknowledging and losing the event. That corrects the old “closed-before-opened is silently lost” failure mode, but there is no gap ledger. A predecessor that can never succeed eventually exhausts retry policy.
5. LastEventId and OccurredAt boundaries
LastEventId recognizes only a direct redelivery of the most recent event: A,A. In A,B,A, the second A is no longer the last ID; the decision relies entirely on OccurredAt. An older replay is skipped, while an equal or incorrectly advanced timestamp can still overwrite.
OccurredAt is not a strict business sequence:
- two transitions can share timestamp precision, and the comparison is
<, not<=; - clock, import, or repair data can be equal or incorrect;
- it cannot identify a missing version;
- opened does not use the time guard.
A production projection should carry a monotonic per-Ticket AggregateVersion and store LastAppliedVersion in the Mart:
version <= current: duplicate/stale; acknowledge and count;version == current + 1: apply atomically;version > current + 1: persist a gap and request repair instead of skipping ahead.
An EventId inbox and version gate solve different problems. The inbox prevents arbitrary redelivery; version decides business order. Inbox write and Mart mutation should commit in the same transaction.
6. Failure and CAP retry
The consumer logs the exception type and rethrows. It does not disguise these conditions as successful consumption:
- Mart read Result failure;
- Mart Upsert Result failure;
- non-opened event with no opened row;
- another non-cancellation, non-out-of-memory exception.
OperationCanceledException and OutOfMemoryException are outside the catch filter and also propagate naturally. Logs do not include Subject or the complete payload.
The runtime still needs explicit failure classes:
| Class | Recommended handling |
|---|---|
| Transient database/network failure | bounded retry, then DLQ and alert |
| Missing opened predecessor | record a gap, short retry, controlled source repair |
| Invalid schema/required field | quarantine rather than unbounded retry |
| Duplicate/stale | acknowledge and increment a metric |
| Concurrent version conflict | reload and retry under the version rule |
7. Store concurrency window
ReportingMartStore.UpsertTicketSummaryAsync still queries (TenantId,TicketId) before Add or Update. It has no atomic Upsert, ExpectedVersion, or affected-row compare-and-set.
Possible races include:
- two first events both observe no row and compete on the unique key;
- two consumers load the same old row, then the later commit overwrites the earlier update;
- LastEventId, LastUpdatedAt, and business columns have no database condition;
- equal-timestamp events resolve by commit order.
The unique index prevents duplicate physical rows, not incorrect business order. A dual-ORM suite must exercise unique-conflict recovery and conditional updates against real databases.
8. Current test evidence
TicketReportingEventConsumerTests verifies:
- Opened → Assigned → Started → Resolved → Closed → Reopened;
- AssigneeId and all six status names;
- ClosedAt assignment and Reopened clearing;
- immediate Reopened redelivery does not Upsert again;
- an older Closed does not regress the Reopened snapshot.
It does not verify:
- final identity in the
Ticket.Openevent; - actual CAP bytes emitted by the generated payload mapping;
- A,B,A, equal OccurredAt, or version gaps;
- retry/DLQ policy for a missing predecessor;
- concurrent consumers and real unique-key conflicts;
- SqlSugar/EF Core behavior parity;
- replay, reconciliation, or shadow rebuild.
9. Replay and rebuild
The normal consumer should not call Tickets internals. A recovery surface can use one of these controlled sources:
- versioned event archive;
- a snapshot-export stream owned by Tickets;
- controlled replica/ETL;
- a shadow Mart followed by count, status-distribution, and sample reconciliation before atomic cutover.
The repository currently has no Reporting inbox, checkpoint, event-archive adapter, rebuild command, or reconciliation job. Rebuild remains a target capability.
10. Required projection matrix
- Opened carries the final TicketId after OpenTicket persistence, never
0. - All six real transactional Outbox payloads bind to consumer DTOs.
- A,A; A,B,A; equal timestamp; older timestamp; version gap.
- Non-opened first, delayed opened, and permanently missing opened.
- Transient Mart read/write failure, retry exhaustion, and DLQ.
- Two consumers on one Ticket and first-insert unique conflict.
- Reopened explicitly clears ClosedAt.
- TenantId/TicketId isolation and malformed payloads.
- Crash recovery between inbox and Mart update.
- Live projection and shadow rebuild produce the same snapshot.
11. Review commands
# Publisher types, generated payload, and six subscriptions.rg -n "IntegrationTopic|CapSubscribe|BuildParameters|Ticket(Open|Assign|Start|Resolv|Clos|Reopen)" \ src/Platform/{Tickets,Reporting} src/Framework/BitzOrcas.Application/Pipelines -g '*.cs'
# The final-ID fix must replace direct Raise with RaiseWhenPersistenceIdAssigned.rg -n 'new Ticket\(|Raise\(new TicketOpened|RaiseWhenPersistenceIdAssigned' \ src/Platform/Tickets -g '*.cs'
# Idempotency, time gate, concurrent Upsert, and test evidence.rg -n "LastEventId|LastUpdatedAt|FirstOrDefaultAsync|TicketReportingEventConsumerTests" \ src/Platform/Reporting tests -g '*.cs'Back to Reporting · Ticket events and notifications · Testing and GA