Skip to content

Event Processing

The EventProcessor is the central component of the Tech Strategy Tool's domain logic. It maintains the in-memory strategy document and processes all events that modify it. This page explains the processor's design, handler dispatch, and key patterns.

Architecture

The EventProcessor is a sealed partial class split across 7 files for organization:

File Responsibility
EventProcessor.cs Core loop, dispatch table, shared helpers
EventProcessor.TeamHandlers.cs Team CRUD + hex color validation
EventProcessor.GroupHandlers.cs Group CRUD
EventProcessor.PrincipleHandlers.cs Principle CRUD + HasConflict helper
EventProcessor.ObjectiveHandlers.cs Objective CRUD + group slice helpers
EventProcessor.InitiativeHandlers.cs Initiative CRUD
EventProcessor.SystemHandlers.cs restore_history event

All files are in src/TechStrat.Core/Processing/.

Public API

// Process a new event
ProcessingResult ProcessEvent(EventEnvelope envelope)

// Restore from a checkpoint during cold start
void LoadFromCheckpoint(Strategy document, long sequenceNumber)

// Replay a single stored event (skips rejected events)
void ReplayEvent(StoredEvent storedEvent)

// Replace the document with a historical state
void RestoreToSequence(Strategy document)

// Reset the checkpoint counter after a checkpoint is saved
void ResetCheckpointCounter()

// Build a document at any historical sequence number (static)
static Strategy BuildDocumentAtSequence(
    Strategy checkpoint, long checkpointSeq,
    IEnumerable<StoredEvent> events, long targetSeq)

Processing Flow

graph TD
    A[EventEnvelope] --> B{Dispatch by eventType}
    B --> C[Handler validates]
    C --> D{Valid?}
    D -->|No| E[DispatchResult: Rejected]
    D -->|Yes| F{No-op?}
    F -->|Yes| G[DispatchResult: NoChange]
    F -->|No| H[Apply to document]
    H --> I[DispatchResult: Applied]
    I --> J{DerivedCommands?}
    J -->|Yes| K[Process each derived command]
    J -->|No| L[Build ProcessingResult]
    K --> L
    L --> M[Return to API layer]

Step by step

  1. Dispatch: The core loop maps the eventType string to a handler method
  2. Validate: The handler checks entity existence, field validity, required data
  3. No-op check: For update events, compares new value against current value (trimmed). Identical values return NoChange
  4. Apply: The handler mutates the in-memory document
  5. Decompose: If the handler returns DerivedCommands, each derived command is processed recursively
  6. Result: The processor builds a ProcessingResult with sequence number, status, notifications, and emitted events

DispatchResult

All event handlers return a DispatchResult, an internal type that carries:

Field Description
Status Applied, Rejected, or NoChange
RejectionReason Why the event was rejected (if applicable)
PreviousValue The field's value before the change — only set on applied events (for history)
ConflictingServerValue The current server value — only set on conflict rejections (the value the client is stale against)
Notifications List of SSE notifications to broadcast
Associations List of HistoryAssociation records mapping this event to affected entities
DerivedCommands Additional events to process (for decomposition)

Helper methods

Handlers use convenience methods to create DispatchResult:

  • Applied(notifications, previousValue) — successful mutation (sets PreviousValue)
  • Applied(notifications, associations, previousValue) — successful mutation with history associations (sets PreviousValue)
  • Direct(entityId, eventSequence, previousSequence) — creates a direct HistoryAssociation
  • Transitive(entityId, eventSequence) — creates a transitive HistoryAssociation
  • Rejected(reason) — validation failure
  • RejectedConflict(reason, conflictingServerValue) — conflict detected (sets ConflictingServerValue)
  • NoChange() — update with same value

HistoryAssociation

Each handler emits HistoryAssociation records to map events to affected entities:

Field Type Description
EntityId Guid The entity whose history this entry belongs to
EventSequence long The event's sequence number
PreviousSequence long? For field edits: the sequence of the previous event that modified the same field
IsTransitive bool false for direct actions, true for cascaded effects

Event Handlers

29 event types

Category Events
Team (4) create_team, update_team_name, update_team_color, delete_team
Group (5) create_group, update_group_name, update_group_description, delete_group, reorder_group
Principle (5) create_principle, update_principle_title, update_principle_description, delete_principle, reorder_principle
Objective (8) create_objective, update_objective_title, assign_objective_to_group, remove_objective_from_group, assign_principle_to_objective, remove_principle_from_objective, reorder_objective, delete_objective
Initiative (7) create_initiative, update_initiative_name, update_initiative_progress, set_initiative_jira_key, remove_initiative_jira_key, reorder_initiative, delete_initiative
System (1) restore_history

Defensive parsing

All handlers use TryGetValue and TryParse for data dictionary access. Create handlers validate required fields via ValidateRequired (rejects empty/whitespace values and enforces max length) and optional fields via ValidateOptional (enforces max length when present). The processor never throws exceptions on invalid input — malformed data results in rejection, not a crash. This is critical for safe cold start replay of potentially old or malformed persisted events.

Notifications

Each handler emits appropriate notifications:

  • card-changed — data updates (title, description, progress changes)
  • view-reload — structural changes (create, delete, reorder, group assignment)

Cascade notifications are emitted when a change affects multiple entities. For example, deleting a principle emits card-changed for the principle and for every objective that referenced it.

Event Decomposition

Some events trigger multiple state changes. The assign_principle_to_objective event is the primary example:

sequenceDiagram
    participant API
    participant EP as EventProcessor

    API->>EP: assign_principle_to_objective (source: team A, target: team B)
    EP->>EP: Create derived commands
    EP->>EP: Process: create_principle (in team B, with title + description + sourceTeamId)
    EP->>EP: Process: assign_principle_to_objective (record link)
    EP-->>API: ProcessingResult with 2 emitted events

Each derived command is processed as a full event with its own sequence number, status, and notifications. All emitted events are collected in the ProcessingResult.EmittedEvents list.

Checkpointing

The processor tracks applied events since the last checkpoint reset. When the count exceeds a configurable threshold (default: 100), ShouldCheckpoint returns true.

The API layer is responsible for:

  1. Serializing the current strategy document
  2. Persisting the checkpoint to the database
  3. Calling ResetCheckpointCounter() to reset the counter

Startup checkpoint

After cold start replay, ProcessorInitializer saves a checkpoint unconditionally. This ensures the latest document state (potentially with new decomposition rules) is captured before the system starts accepting requests.

Replay vs. Process

Aspect ProcessEvent ReplayEvent
Purpose Process new events Reconstruct state from stored events
Validation Full validation Skips rejected events
Sequence numbers Assigns new sequence number Uses stored sequence number
Notifications Generates SSE notifications No notifications
Used by API event submission Cold start, BuildDocumentAtSequence

Key Design Decisions

Single-writer model

The processor is not thread-safe. Concurrent access is prevented by the SemaphoreSlim(1,1) lock in the API layer. This is a deliberate choice — the simplicity of a single-writer model outweighs the complexity of concurrent processing for this use case.

In-memory state

The entire strategy document is held in memory. This makes reads extremely fast (no database query) and processing simple (direct mutation). The trade-off is that state must be reconstructed on startup via checkpoint + replay.

Pure domain logic

The EventProcessor lives in TechStrat.Core and has zero external dependencies. It knows nothing about HTTP, databases, or SSE. This makes it straightforward to test — all 164+ unit tests run without any infrastructure.