# Data Model: Load and Concurrency Testing All changes below are additive to existing models — no existing column is removed or retyped, and no existing consumer (012-admin-list-views, 015-reporting-dashboards) needs any change, since none of them write to `Assignment`/`SLARun`/`EscalationEvent` directly (all writes already go through the repositories being changed here). ## `SLARun` (existing model, one new field) | Field | Type | Notes | |---|---|---| | `version` | `Int @default(0)` | NEW. Optimistic-concurrency counter, identical convention to `Ticket.version` (003-ticketing). Incremented on every successful `updateWithVersion` call. | Migration: additive `ALTER TABLE sla_runs ADD COLUMN version INTEGER NOT NULL DEFAULT 0;` — every existing row defaults to `0`, which is exactly the version any in-flight or future `updateWithVersion` call expects for a run nobody has updated since this migration ran. ## `Assignment` (existing model, no column change — one new index) New raw partial unique index (Prisma schema DSL cannot express a partial predicate directly, so this is added via a raw-SQL migration step, same approach already used elsewhere in this project for Postgres-specific constraints): ```sql CREATE UNIQUE INDEX assignments_one_current_per_ticket ON assignments (ticket_id) WHERE is_current = true; ``` Enforces at the database level: a ticket may have at most one `Assignment` row with `isCurrent = true` at any moment, closing the race research.md §1 describes. The existing non-unique `@@index([ticketId, isCurrent])` is unaffected and stays for the repository's own `findCurrent` lookup. ## `EscalationEvent` (existing model, no column change — one new index) ```sql CREATE UNIQUE INDEX escalation_events_ticket_rule_unique ON escalation_events (ticket_id, rule_id) WHERE rule_id IS NOT NULL; ``` Enforces at the database level: a given rule may fire at most once per ticket over that ticket's lifetime (manual escalations, where `rule_id IS NULL`, are explicitly excluded and remain repeatable). Closes the race research.md §3 describes. ## Repository contract changes ### `SlaRunRepository` - `update(id, data)` → **replaced** by `updateWithVersion(id, expectedVersion, data): Promise`, mirroring `TicketsRepository.updateStatus`'s exact shape: an atomic `updateMany({where:{id, version: expectedVersion}, data:{...data, version:{increment:1}}})`, returning the fresh row on success (count === 1) or `null` on a stale-version mismatch. Every existing call site (`pause`, `resume`, `complete`, `runBreachDetectionSweep`) is updated to pass its own last-read `version` and to retry (re-read + recompute + re-call) up to 3 times on a `null` result before giving up silently (matching the sweep's own existing no-throw, best-effort style — these are internal transitions with no HTTP caller waiting on a 409). ### `AssignmentRepository` - `createAssignment(data)` — same signature and return type; internally catches a Prisma `P2002` on the new `assignments_one_current_per_ticket` index and retries the entire transaction (bounded to 3 attempts) before rethrowing. ### `EscalationEventRepository` - `create(data)` — same signature; internally catches a Prisma `P2002` on the new `escalation_events_ticket_rule_unique` index and returns the pre-existing row for that `(ticketId, ruleId)` pair (a `findFirst({where:{ticketId, ruleId}})` fallback) instead of throwing, so `EscalationService.fire`'s caller sees a normal `EscalationEvent` either way — a duplicate trigger is invisible to the caller, not an error. ## Test-only entities (not persisted — in-memory test scaffolding) - **Load test report** (`tests/load/`): `{ endpoint: string; connections: number; durationSec: number; requestsPerSec: number; latencyP50Ms: number; latencyP90Ms: number; latencyP99Ms: number; non2xxCount: number; rateLimitedCount: number }` — printed to console and written as JSON under `tests/load/reports/-.json` (gitignored) for each run, satisfying FR-008's separation of rate-limited responses from genuine failures.