TicketTransitionRules and aggregate behaviors jointly own ticket state. Start/Resolve/Close/Reopen share TicketStatusTransitionFlow; Assign uses TicketAssignmentFlow. Those templates standardize load, authorization, save, notification, and audit ordering, but they do not invent missing business rules.
1. The six states are not freely connected
| Current | Allowed targets | Public command | Rejected example |
|---|---|---|---|
| New | Assigned | Assign | New→Start/Resolve/Close |
| Assigned | InProgress | Start | Assigned→Close |
| InProgress | Resolved | Resolve | InProgress→Assigned |
| Resolved | Closed, Reopened | Close, Reopen | Resolved→Assign |
| Closed | Reopened | Reopen | Closed→Start/Assign |
| Reopened | InProgress, Assigned | Start, Assign | Reopened→Resolved |
TransitionTo returns success when target already equals current state. The state Handler compares before/after and skips persistence, aggregate tracking, notification, and audit on a no-op replay. The aggregate method itself still constructs an event after a successful TransitionTo, so this guarantee applies to the public Handler boundary, not to isolated aggregate calls as a complete idempotent messaging boundary.
2. Exact assignment semantics
The first Assign performs both New→Assigned and writes TicketAssignment(AssigneeId, AssignedBy, AssignedAt). Assigned, InProgress, and Reopened can be reassigned. Resolved/Closed return Ticket.AssignmentClosed.
Assigning the same assignee outside New succeeds without refreshing AssignedBy/AssignedAt or raising another event. A different assignee replaces the assignment snapshot and raises TicketAssigned.
var ticket = Ticket.Open( "tenant-a", "requester-1", "Cannot sign in", "MFA fails", TicketPriority.High, dueAt: now.AddHours(4), now).Value!;
// First assignment also performs New -> Assigned and records actor/time.ticket.Assign("agent-1", "lead-1", now).IsSuccess.ShouldBeTrue();ticket.Status.ShouldBe(TicketStatus.Assigned);
// Same assignee is idempotent and does not refresh AssignedAt.var assignedAt = ticket.Assignment!.AssignedAt;ticket.Assign("agent-1", "lead-2", now.AddMinutes(5)).IsSuccess.ShouldBeTrue();ticket.Assignment.AssignedAt.ShouldBe(assignedAt);
// A resolved ticket must reopen before another Assign or Start.ticket.Start(now.AddMinutes(10));ticket.Resolve(now.AddMinutes(20));ticket.Assign("agent-2", "lead-1", now.AddMinutes(30)) .Error.Code.ShouldBe(TicketErrorCodes.AssignmentClosed);3. Assignment-subject validation
AssigneeId represents either a direct user key or a namespaced group-subject key. Before mutating the aggregate, TicketAssignmentFlow calls TicketAssignmentSubjectResolver.EnsureActiveAsync; the Authorization owner’s IAuthorizationSubjectReader verifies that:
- the subject key is nonblank and valid;
- the user or group exists in the current tenant;
- the subject is neither disabled nor deleted;
- a cross-tenant subject is not accepted.
Failure returns Ticket.AssigneeUnavailable. Notifications expand a group into active current-tenant users, and personal todo queries include both the current user key and active group keys.
var active = await assignmentSubjects.EnsureActiveAsync( command.AssigneeId, cancellationToken);// One stable error hides missing, disabled, deleted, and cross-tenant directory details.if (active.IsFailure) return Result.Failure<TicketSummary>(active.Error);
// The aggregate still owns transition, assignment snapshot, and event creation.var assigned = ticket.Assign( command.AssigneeId, callerUserId, // IAppClock supplies business time; the aggregate normalizes it to UTC. clock.UtcNow);The check does not cover support role, skill, capacity, schedule, or leave, and there is no automatic unassign/reassign when a subject later becomes inactive. Add an assignability policy above the current subject-reader boundary if the product needs those rules; “active in this tenant” is not “suitable for this work.”
4. Resolve, Close, and timestamps
Resolve sets ResolvedAt and optional Resolution and raises TicketResolved; Close sets ClosedAt and raises TicketClosed; Reopen clears ResolvedAt, ClosedAt, and Resolution and raises TicketReopened; Start raises TicketStarted. With Open and Assign, all six visible lifecycle behaviors have topics.
Reporting subscribes to all six typed integration events and can reconstruct New, Assigned, InProgress, Resolved, Closed, and Reopened. Events still have no Ticket Version/sequence; distinct events with equal OccurredAt overwrite by arrival order, so ordering protection remains incomplete.
Close only follows Resolved, so normal ClosedAt is later than ResolvedAt. Restore does not validate status/timestamp consistency; corrupted data can restore New with ResolvedAt populated.
5. DueAt is not an SLA engine
Current SLA-related behavior is one nullable UTC DueAt column:
- Open accepts past or future values;
UpdateTicketcan change Priority/DueAt, and batch priority change has an HTTP entry point;- no first-response/resolution/pause/customer-wait clocks;
- no business calendar, timezone, holiday, or priority policy;
- no deadline scan, escalation, reminder, routing, or breach state;
- summaries include DueAt, but list filters have no DueAt range;
- no SLA metric or alert.
The UI may call it a target date, not an auditable SLA. A real SLA needs a policy snapshot so later policy edits do not reinterpret historical tickets.
6. Repository bypasses optimistic concurrency
Ticket inherits AggregateRoot.Version, and ORM metadata marks Version as a concurrency field. The update branch of TicketRepository.SaveAsync, however, uses UpdateWhereAsync with TenantId+Id only. It neither compares the old Version nor increments it.
Two requests can load the same snapshot and both succeed; the last save can overwrite the other’s state or JSON. Commands have no ExpectedVersion, and TicketSummary omits Version, so clients cannot send If-Match.
public static class TicketErrors{ public static readonly Error VersionConflict = Error.Conflict("Ticket.VersionConflict", "Ticket changed concurrently.");}
// Compare the version read by the client and increment it in the same statement.var affected = await tickets.UpdateWhereAsync( row => row.TenantId == ticket.TenantId && row.Id == ticket.Id && row.Version == expectedVersion, update => update .Set(row => row.StatusName, _ => ticket.StatusName) .Set(row => row.CommentsJson, _ => ticket.CommentsJson) .Set(row => row.Version, row => row.Version + 1), cancellationToken);
// Zero affected rows is a version conflict, not an ordinary NotFound.if (affected == 0) return TicketErrors.VersionConflict;The current IEntitySet.UpdateWhereAsync returns no affected count, so the target requires a narrower repository contract, not only another Handler field.
7. Business idempotency boundary
Same target state and same assignee are aggregate-level no-ops. CommentId and AttachmentId/FileId deduplicate collection operations. OpenTicket has no business idempotency key; a retry creates another Ticket. There is no HTTP Idempotency-Key contract.
For mobile or partner clients, Open should accept a stable OperationId protected by (TenantId, OperationId) uniqueness. Subject+Description is not a valid key because the same incident may legitimately recur.
8. Recommended lifecycle contract
Every mutation response should include TicketId, Status, AssigneeId, DueAt, Version, and ModifyTime. State commands should accept ExpectedVersion; conflict returns 409 plus the current resource/ETag.
All six lifecycle events now exist. The next contract step is Ticket Version/sequence plus TraceId; consumers should reject stale writes by TicketId+Version rather than relying only on EventId and OccurredAt.
9. Required lifecycle tests
Add coverage for:
- all 36 from/to pairs, not representative samples;
- every repeated command and its absence of side effects;
- Reopen timestamp/Resolution clearing plus an emitted event;
- cross-tenant/missing/disabled/deleted assignees, group expansion, and the still-uncovered skill rule;
- comment/comment, state/comment, and assign/close races;
- past DueAt, timezone normalization, policy snapshots, escalation idempotency;
- concurrent Open replays with one OperationId;
- Restore status/ResolvedAt/ClosedAt consistency.
10. Review commands
# One transition source: new states must update rules, events, serialization, and tests.rg -n "AllowedTransitions|TransitionTo|TicketStatus" src/Platform/Tickets -g '*.cs'
# Check whether SLA is still only a field with no policy, job, or metric.rg -n "DueAt|Sla|SLA|Escalat|Breach" src/Platform/Tickets src/Jobs -g '*.cs'
# The repaired save must compare Version and expose a typed conflict.rg -n "UpdateWhereAsync|Version|VersionConflict" src/Platform/Tickets -g '*.cs'Back to Tickets · Authorization and queries · Testing and GA