docs: plan and design artifacts for orchestration and assignment feature

Maps the feature onto the three existing orchestration/{routing,
assignments,orchestration} scaffold stubs. Key decisions: Assignment
refined as a version-row-per-period model (paired with a separate
append-only AssignmentHistory event log), round-robin concurrency
safety via atomic Redis INCR (reusing existing infra, not a new one),
LEAST_LOADED/SKILL_BASED tie-breaks falling back to that same cursor,
currentLoad read but never mutated by this feature, and the escalation
trigger reusing 005's existing domain-event bus rather than a new
notification path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-03 11:30:23 +05:30
co-authored by Claude Sonnet 5
parent e437711c2a
commit 846b9e8dca
5 changed files with 439 additions and 0 deletions
@@ -0,0 +1,36 @@
# Contract: Orchestration and Assignment
Admin/read routes gated by `fastify.authenticate` (research.md — known limitation inherited from
002/003/004/005/006). Orchestration itself has no public trigger endpoint — it runs automatically
on the `HUMAN_ESCALATION` domain event (research.md "Trigger").
## Manual assignment
- `POST /admin/tickets/:ticketId/assignment` — body `{ agentId, reason?, strategy? }`
(`strategy` defaults to `MANUAL`, or `DIRECT`). `404` if `ticketId` or `agentId` doesn't exist.
Supersedes any current assignment for the ticket (FR-011).
## Reads
- `GET /tickets/:ticketId/assignment` — the current assignment, or `404` if the ticket has never
been assigned.
- `GET /tickets/:ticketId/assignment-history` — every assignment decision ever made for this
ticket, oldest first, including `no eligible agent` outcomes (`agentId: null`).
## Guarantees (callable contract)
1. **A ticket reaching `HUMAN_ESCALATION` gets orchestration run against it automatically** — no
caller has to invoke anything (SC-001).
2. **Two concurrent assignment attempts against the same eligible set under `ROUND_ROBIN` never
select the same agent** (unless the eligible set has exactly one agent) **and never corrupt
the cycle for later assignments** (SC-002).
3. **Every assignment decision — automatic or manual, successful or "no eligible agent" —
produces exactly one `AssignmentHistory` row** (SC-003).
4. **`GET .../assignment-history` always returns every prior assignment**, even after multiple
reassignments — never a gap (SC-004).
5. **A manual assignment targeting a nonexistent agent always returns `404`**, never creating a
dangling `Assignment` row (SC-005).
6. **On a successful assignment, the ticket's status moves from `HUMAN_ESCALATION` to
`IN_PROGRESS`** through the existing 003-ticketing state machine — never a new status.
7. **When the eligible-agent set is empty, the ticket remains in `HUMAN_ESCALATION`** and an
`AssignmentHistory` row with `agentId: null` records that outcome — never a silent no-op.
@@ -0,0 +1,54 @@
# Phase 1 Data Model: Orchestration and Assignment
All new models use `cuid()` ids. Refines `docs/06-database-schema.md`'s conceptual `Assignment`/
`AssignmentHistory` shapes (research.md).
## Assignment
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → `Ticket`. Not unique — one row per assignment period (research.md) |
| agentId | String | FK → `Agent` |
| strategy | String | `ROUND_ROBIN` \| `LEAST_LOADED` \| `SKILL_BASED` \| `MANUAL` \| `DIRECT` |
| reason | String? | Free text — required-in-practice for `MANUAL`/`DIRECT`, optional for automatic strategies |
| isCurrent | Boolean @default(true) | Exactly one `true` row per `ticketId` at a time — set `false` when superseded |
| assignedAt | DateTime @default(now()) | |
| unassignedAt | DateTime? | Set when superseded by a later assignment |
**Constraints**: Index on `(ticketId, isCurrent)` — the exact shape "the current assignment for
this ticket" queries on. No DB-level unique on `(ticketId, isCurrent: true)` (Postgres partial
unique indexes aren't expressed directly in this Prisma version's schema syntax used elsewhere in
this codebase) — enforced instead by the repository's single transaction that supersedes the
prior row and inserts the new one together (same class of guarantee as 004's
conditional-update-then-insert).
## AssignmentHistory
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → `Ticket` |
| agentId | String? | Null for a `no eligible agent` outcome (FR-008) |
| action | String | `assigned` \| `reassigned` \| `unassigned` |
| strategy | String | Same value set as `Assignment.strategy` |
| reason | String? | |
| actor | String | `system` (automatic orchestration) or an admin/agent identifier (manual) |
| createdAt | DateTime @default(now()) | |
**Never updated or deleted** — this is the append-only audit trail FR-009/SC-003/SC-004 require.
Written in the same transaction as the `Assignment` row it corresponds to (or on its own, for a
"no eligible agent" outcome that produces no `Assignment` row at all).
## Ticket / Agent (relations added by this feature)
`Ticket.assignments Assignment[]`, `Ticket.assignmentHistory AssignmentHistory[]`,
`Agent.assignments Assignment[]` — forward relations doc 06 already implied but that couldn't be
added until these models existed (same pattern every prior feature has used for its own new
back-relations).
## No new fields on `HierarchyNode` or `AgentAvailability`
Round-robin's cursor lives in Redis, not Postgres (research.md) — no schema change to either
model 006-support-organization already shipped. `AgentAvailability.currentLoad` is read, never
written, by this feature (research.md) — no new mutation path added to it here.
+138
View File
@@ -0,0 +1,138 @@
# Implementation Plan: Orchestration and Assignment
**Branch**: `007-orchestration-assignment` | **Date**: 2026-09-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/007-orchestration-assignment/spec.md`
## Summary
Populate the existing `orchestration/routing`, `orchestration/assignments`, and
`orchestration/orchestration` stub directories (all placeholders today — e.g. the round-robin
strategy stub just returns the first candidate) with the real engine: on a ticket's transition to
`HUMAN_ESCALATION` (via the existing domain-event bus 005-ai-support first put to use),
`routing` resolves the applicable hierarchy node and eligible-agent set by calling
006-support-organization's capability-eligibility lookup directly; `assignments` runs the
resolved (or manually-overridden) pluggable strategy — `ROUND_ROBIN` (concurrency-safe via
atomic Redis `INCR`), `LEAST_LOADED`, `SKILL_BASED`, `MANUAL`, `DIRECT` — and persists both the
current `Assignment` and its append-only `AssignmentHistory`; `orchestration` ties the two
together and moves the ticket to `IN_PROGRESS` through 003-ticketing's existing state machine.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod, `ioredis` (already a dependency — atomic
`INCR` for round-robin, reusing the existing shared client). No new runtime dependency.
**Storage**: PostgreSQL via Prisma (new `Assignment`, `AssignmentHistory` models). Redis for the
round-robin cursor (research.md) — no new infrastructure, reusing `src/infrastructure/cache`.
**Testing**: Vitest — unit tests for each pluggable strategy's pure selection logic (given an
eligible set + context, which agent) and for the tie-break composition; integration/concurrency
tests for the full escalation→assignment flow and, specifically, `ROUND_ROBIN` under genuinely
concurrent requests (Constitution's Testing gate explicitly requires an assignment concurrency
test category — the first time this codebase has a feature that actually needs one, since
003-ticketing's own concurrency guarantee was single-writer-race on ticket status, not a
multi-way selection race).
**Target Platform**: Same Fastify modular monolith. Populates existing module directories:
`src/modules/orchestration/{routing,assignments,orchestration}/`.
**Project Type**: Backend service — single project.
**Performance Goals**: Round-robin's Redis `INCR` must stay a single round trip per assignment —
no read-modify-write race window. Not otherwise performance-sensitive.
**Constraints**: MUST run automatically on human escalation (FR-001); MUST evaluate capability
before availability (FR-003, inherited from 006); MUST be concurrency-safe for `ROUND_ROBIN`
(FR-006); MUST never lose assignment history (FR-009); MUST reuse 003's ticket state machine, not
invent a new status (FR-014).
**Scale/Scope**: Three populated modules, five assignment strategies, one manual-assignment
admin endpoint, two read endpoints. Explicitly excludes: SLA policy execution, rule-driven
escalation, agent-deactivation reaction, workload lifecycle mutation (see spec.md Assumptions).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | Assignment references `Agent`/`Ticket` — both SupportHub's own domain (Principle I's own list names "routing/assignment" as SupportHub's authority). No SaaS identity touched. | PASS |
| II. Configuration Over Hardcoding | The strategy actually used per ticket is read from the hierarchy node's own `assignmentStrategy` field (006) — never hardcoded to one strategy; five pluggable implementations selected by that config value. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Three modules follow the standard shape (`assignments` also keeps its pre-existing `engine/strategies/rules/calculators` extension per doc 05 §8); `routing``orchestration/hierarchy` (006) and `assignments``identity/agents` are one-directional, no cycle — `orchestration` depends on both `routing` and `assignments`, neither of which depends back on it. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable in the AI sense — but structurally the same shape: the *strategy* is deterministic policy, never an LLM call; this feature has no AI involvement at all. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | `AssignmentHistory` is the durable, append-only record doc 07 explicitly requires for "assignment/reassignment" (its own audit list). | PASS |
| VII. Concurrency-Safe, Durable Job Handling | This is the principle's own named example ("two tickets assigned simultaneously must never double-assign or corrupt round-robin state") — directly implemented via atomic Redis `INCR`, verified under real concurrent load (research.md, quickstart Scenario 2). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Assignment references `Ticket`, not `Problem` — doesn't touch the distinction. | PASS — N/A |
| Technology & Platform Constraints | Prisma + Zod + existing `ioredis` client only, no new dependency. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Worth calling out against Principle VII
explicitly: this is the first feature in this codebase since 003-ticketing's optimistic-
concurrency ticket-status guarantee to have a genuine multi-writer race condition as a first-class
requirement (not just a theoretical one) — `ROUND_ROBIN`'s atomic-`INCR` design and its dedicated
concurrency test (tasks.md) are what make this principle a verified guarantee here, not an
aspiration.
## Project Structure
### Documentation (this feature)
```text
specs/007-orchestration-assignment/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — add Assignment, AssignmentHistory
├── src/
│ ├── infrastructure/
│ │ └── cache/ # reused as-is — round-robin's INCR goes through
│ │ the existing redisClient, no new file needed
│ │ beyond the strategy implementation itself
│ └── modules/
│ └── orchestration/
│ ├── hierarchy/ # existing (006) — untouched
│ ├── routing/ # REPLACED stub — resolves node + eligible agents
│ │ └── service/ types/ index.ts (no controller/routes — internal only)
│ ├── assignments/ # REPLACED stub — keeps engine/strategies/rules/
│ │ ├── controller/ routes/ schema/ calculators, adds the standard shape around it
│ │ │ repository/ service/ types/
│ │ │ mapper/ constants/ index.ts
│ │ ├── strategies/ # round-robin, least-loaded, skill-based,
│ │ │ manual/direct (thin — just records the given
│ │ │ agentId)
│ │ ├── engine/ # ties a resolved eligible set + chosen strategy
│ │ │ together into a persisted Assignment
│ │ └── calculators/ # workload/skill-level comparison helpers
│ └── orchestration/ # REPLACED stub — event subscriber + the
│ └── service/ types/ index.ts escalation→assignment→IN_PROGRESS workflow
└── tests/
├── unit/orchestration/ # each strategy's pure selection logic
├── integration/ # full escalation→assignment flow, manual
│ (re)assignment, history
└── concurrency/ # ROUND_ROBIN under real concurrent requests
```
**Structure Decision**: Single project. `routing` and `orchestration` are internal-only
submodules (no HTTP surface), matching 005's `troubleshooting`/`escalation` precedent for
modules whose job is pure orchestration logic invoked by another module's service, not a
caller-facing endpoint of their own.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,60 @@
# Quickstart: Validating Orchestration and Assignment
Prerequisites: migrations applied; at least one team/agent/hierarchy node set up per
006-support-organization's own quickstart, since this feature is a real consumer of that data.
## Scenario 1 — automatic resolution on human escalation (User Story 1)
1. Configure a hierarchy node scoped to a product with a required skill; give one agent that
skill.
2. Create a ticket for that product and force it to `HUMAN_ESCALATION` (e.g., via 003's status
update endpoint, or 005's confidence-threshold escalation path).
3. **Expected**: without any further caller action, an `Assignment` is created for the skilled
agent, and the ticket's status becomes `IN_PROGRESS`.
4. Repeat for a product with no matching hierarchy node. **Expected**: orchestration still runs,
falling back to a skill-only match across all active agents.
## Scenario 2 — pluggable strategies, including concurrency-safe round robin (User Story 2)
1. Configure a node with `ROUND_ROBIN` and three eligible agents. Escalate three tickets in
sequence. **Expected**: each is assigned to a different agent; a fourth escalation cycles back
to the first agent.
2. Fire two escalations against the same eligible set at the same time (e.g., two concurrent
requests). **Expected**: they resolve to two different agents (for a set of two or more), and
a subsequent, sequential escalation continues the cycle correctly — no skipped or repeated
position.
3. Configure `LEAST_LOADED`; set two agents' `currentLoad` to different values. Escalate a
ticket. **Expected**: the lower-load agent is chosen.
4. Configure `SKILL_BASED`; give two agents the required skill at different proficiency levels.
Escalate a ticket. **Expected**: the higher-level agent is chosen.
5. Escalate a ticket whose context resolves to zero eligible agents. **Expected**: no
`Assignment` is created, the ticket stays in `HUMAN_ESCALATION`, and an `AssignmentHistory` row
with `agentId: null` exists.
## Scenario 3 — durable history across reassignment (User Story 3)
1. Assign a ticket (Scenario 1). Manually reassign it (Scenario 4) to a different agent.
2. `GET /tickets/:ticketId/assignment-history`. **Expected**: both the original assignment and
the reassignment are present, in order, each with its own `strategy`/`actor`.
3. `GET /tickets/:ticketId/assignment`. **Expected**: reflects only the most recent assignment.
## Scenario 4 — manual assignment and reassignment (User Story 4)
1. `POST /admin/tickets/:ticketId/assignment` with a specific `agentId`. **Expected**: that agent
becomes the current assignment regardless of what the configured strategy would have picked.
2. Repeat with a nonexistent `agentId`. **Expected**: `404`, no `Assignment` row created.
3. Repeat step 1 with a different agent. **Expected**: the ticket's current assignment changes;
the prior one remains in history (Scenario 3).
## Scenario 5 — re-escalation reassigns fresh (User Story 5)
1. From an already-assigned ticket, trigger `HUMAN_ESCALATION` again.
2. **Expected**: a fresh eligible-agent set is computed (not reused from the first assignment)
and a new `Assignment` is created; the previous one is no longer current but remains in
history.
## What "done" looks like
All five scenarios pass, and together they demonstrate every functional requirement and success
criterion in `spec.md` — including SC-002, which specifically requires verifying concurrency
safety under genuinely concurrent load, not just sequential calls.
@@ -0,0 +1,151 @@
# Phase 0 Research: Orchestration and Assignment
## Decision: Module placement — three existing stubs, mapped to distinct responsibilities
- **Decision**: Doc 07 already scaffolds `orchestration/routing`, `orchestration/orchestration`,
and `orchestration/assignments` (the latter already has the `engine/`/`strategies/`/`rules/`/
`calculators/` extended structure doc 05 §8 calls for, populated with placeholder stubs — e.g.
`RoundRobinAssignmentStrategy.selectNextAgent` just returns `candidateIds[0]`). This feature
maps its responsibilities onto exactly those three, each superseding its stub:
- **`routing`**: resolves the hierarchy node(s) and eligible-agent set for a ticket's context —
a thin wrapper reusing 006-support-organization's `capabilityLookupService`/
`hierarchyRepository` directly, never a second, divergent matching algorithm (FR-002). No
HTTP surface of its own.
- **`assignments`**: owns `Assignment`/`AssignmentHistory`, the pluggable strategy
implementations, and the manual-assignment admin endpoint (User Stories 2-4).
- **`orchestration`**: the top-level engine — subscribes to the ticket-status domain event,
calls `routing` then `assignments`, and moves the ticket to `IN_PROGRESS` on success. No
HTTP surface of its own; purely event-driven (plus an internal function `assignments`'
manual-assignment path can call directly for a re-escalation re-run — see below).
- **Rationale**: This is the documented module layout, not an open design choice, and the
`assignments` stub's pre-existing `engine/strategies/rules/calculators` split already matches
the shape this feature needs — extended, not restructured.
- **Alternatives considered**: Collapsing all three into one module — rejected; three genuinely
distinct responsibilities (resolution, strategy execution + persistence, event-driven workflow)
benefit from the same separation every other module in this codebase uses, and doc 07 already
names them separately.
## Decision: `Assignment` refined as a version-row-per-period model; `AssignmentHistory` stays a separate append-only event log
- **Decision**: Doc 06's conceptual `Assignment` (`ticketId` not unique, `unassignedAt` nullable)
is refined the same way 004 refined `KnowledgeEntry`: one row per assignment period, with an
explicit `isCurrent Boolean` (not just "`unassignedAt` is null" implied) for a clean, indexed
"the current assignment for this ticket" query. Reassigning creates a **new** `Assignment` row
(`isCurrent: true`) and, in the same transaction, sets the previous row's `isCurrent: false`/
`unassignedAt: now()`. `AssignmentHistory` is a separate, purely-additive event log (`action:
assigned | reassigned | unassigned`) — distinct from `Assignment` state the same way
005-ai-support's `AIInteraction`/`AIAction` event trail is distinct from `AISupportSession`
state.
- **Rationale**: FR-009/FR-010/SC-003/SC-004 require both "the current assignment, unambiguously"
and "the full history, never lost" — doc 06's two-model split already gives each concern its
own home; the version-row-per-period refinement on `Assignment` is the same "refine during
Phase 1 modeling" precedent 004 and 005 already established for their own conceptual models.
- **Alternatives considered**: A single `Assignment` table with `unassignedAt` alone (no
`isCurrent` flag, no separate `AssignmentHistory`) — rejected; querying "the current one" via
`unassignedAt IS NULL` works but doesn't distinguish *why* a row changed (assigned vs.
reassigned vs. explicitly unassigned with no replacement), which `AssignmentHistory.action`
exists specifically to capture per doc 06's own conceptual shape.
## Decision: Round-robin concurrency safety — atomic Redis `INCR`, scoped per hierarchy node
- **Decision**: `ROUND_ROBIN`'s cursor is `INCR ticketing:round_robin:<hierarchyNodeId ??
'unscoped'>` against the existing shared Redis client (`src/infrastructure/cache`) — an atomic,
single round-trip operation — then `(count - 1) % eligibleAgents.length` selects the index into
the eligible-agent array (sorted by a stable key, `agent.id`, so the same count always maps to
the same relative position for a given eligible set).
- **Rationale**: Doc 05 §4 explicitly names "an atomic Redis operation" as an acceptable
alternative to a DB-level lock/transaction, and this codebase already has Redis wired for
exactly this class of atomic-counter need (`checkRateLimit`, replay-guard's `jti` tracking) —
reusing the same infrastructure, not introducing a new one. Scoping the counter key per
hierarchy node (rather than one global counter) means two unrelated nodes' round-robin cycles
never interfere with each other.
- **Alternatives considered**: A DB-level `SELECT ... FOR UPDATE` transaction incrementing a
cursor column on `HierarchyNode` — rejected; would require a schema change to a model
006-support-organization already shipped and finalized, and Redis `INCR` is strictly simpler
for a value that doesn't need to survive Redis being cleared (a lost cursor just restarts the
cycle from a different point — never a correctness problem, only a fairness one, and doc 05's
own guidance treats the Redis path as equally acceptable).
## Decision: `LEAST_LOADED`/`SKILL_BASED` tie-breaking — fall back to the same round-robin cursor
- **Decision**: When multiple eligible agents tie on workload (`LEAST_LOADED`) or on
best-matching skill level (`SKILL_BASED`), the tied subset is passed through the same
`INCR`-based selection `ROUND_ROBIN` uses, scoped under a strategy-specific Redis key.
- **Rationale**: Edge Cases calls for a stable, non-arbitrary tiebreak — reusing the
already-concurrency-safe mechanism is simpler than inventing a second tiebreak algorithm, and
keeps every strategy's final selection step concurrency-safe by construction, not just
`ROUND_ROBIN`'s.
- **Alternatives considered**: First-match-in-query-order — rejected; Postgres doesn't guarantee
stable ordering without an explicit `ORDER BY`, and an arbitrary tiebreak would make otherwise-
identical runs nondeterministic in a way a fairness-sensitive routing system shouldn't be.
## Decision: `LEAST_LOADED` reads `AgentAvailability.currentLoad` as-is — this feature never mutates it
- **Decision**: `currentLoad` is read, never written, by this feature. Nothing in this feature
increments it on assignment or decrements it on resolution/closure.
- **Rationale**: No FR in spec.md requires load-lifecycle mutation, and no later phase's
resolution/closure flow exists yet to decrement it correctly — inventing an increment-only
half of that lifecycle here would leave `currentLoad` permanently climbing with no matching
decrement, actively misleading rather than merely incomplete. 006 already gave admins a way to
set it directly (`PUT /admin/agents/:agentId/availability`); this feature is a consumer of that
value, not its lifecycle owner.
- **Alternatives considered**: Incrementing `currentLoad` on every assignment — rejected for the
reason above; a half-built lifecycle is worse than an explicitly-deferred one (this codebase's
established preference, e.g. 005's fail-closed `verifyProductResolution` placeholder over a
half-real one).
## Decision: Trigger — subscribe to the existing `TICKET_UPDATED` domain event
- **Decision**: `orchestration`'s engine registers a handler (wired in
`src/events/handlers/index.ts`, alongside 005's existing subscriber) for
`DomainEventName.TICKET_UPDATED`, checking `payload.newStatus === 'HUMAN_ESCALATION'`. No
modification to `tickets.service.ts` is needed — it already publishes this event
unconditionally for every status change (005-ai-support's own FR-023 hook already required
that).
- **Rationale**: This is exactly the event-bus infrastructure 005 put to its first real use,
built specifically to let a foreign module react to a ticket status change without `tickets`
ever needing to know that module exists — reusing it here is the direct payoff of that design,
not a new pattern.
- **Alternatives considered**: A new, orchestration-specific event or a direct service call from
`tickets` — rejected; would either duplicate the event bus's job or reintroduce the
cross-module-dependency problem the event bus exists to avoid (research.md precedent from 005).
## Decision: Ticket status transition on successful assignment reuses 003's existing state machine
- **Decision**: On a successful assignment (automatic or manual), `orchestration` calls the
existing `ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system')`
(003-ticketing) — `HUMAN_ESCALATION → IN_PROGRESS` is already a valid transition in that state
machine. `actor: 'system'` (not `'ai'`) — this never triggers 005's FR-023 "human actor ends
the AI session" hook incorrectly, though by the time a ticket reaches `HUMAN_ESCALATION` any
active AI session has already ended itself (FR-020 in 005), so that hook is a no-op here either
way.
- **Rationale**: FR-014 explicitly requires reusing the existing lifecycle state machine, not
defining a new ticket status — matching every prior feature's convention of extending, never
duplicating, `Ticket.status`.
- **Alternatives considered**: A new `ASSIGNED` status — rejected; 003's state machine already
has `IN_PROGRESS` for exactly this "a human now owns this ticket" state, and FR-014 forbids
introducing a parallel one.
## Decision: Manual assignment — one endpoint, `strategy` field distinguishes MANUAL vs. DIRECT
- **Decision**: `POST /admin/tickets/:ticketId/assignment` — body `{ agentId, reason?, strategy?
}`, `strategy` defaulting to `MANUAL` and accepting `DIRECT` as the only other caller-supplied
value (both mean the same thing operationally — an explicitly supplied `agentId`, not a
computed one — doc 05 §4's own table doesn't describe a behavioral difference between them).
Validates the target agent exists (FR-011) the same way 006's `AgentsService.create` validates
`teamId` — resolve-or-404, never a raw FK error.
- **Rationale**: FR-005 requires both strategies to exist; without a real behavioral distinction
documented anywhere, a single code path with a caller-chosen label is simpler than two
near-identical handlers, and keeps the door open for a future caller (e.g., a "reassign to the
agent who handled a linked prior ticket" feature) to use `DIRECT` with its own real semantics
later without a breaking change here.
- **Alternatives considered**: Two separate endpoints — rejected as unwarranted surface area for
a distinction doc 05 itself doesn't specify.
## Decision: Admin endpoint authentication — reuse the existing stub
- **Decision**: `POST /admin/tickets/:ticketId/assignment` and any read endpoints in this feature
are gated by `fastify.authenticate`, the same known-limitation stub every prior feature's admin
surface uses.
- **Rationale**: Consistency with established precedent (spec.md Assumptions).
- **Alternatives considered**: None — direct reuse.