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:
co-authored by
Claude Sonnet 5
parent
e437711c2a
commit
846b9e8dca
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user