Neither Ticket nor Problem carries a skill/problem-type field — the only real signal is AIDiagnosis.problemType (005), and only for tickets that went through AI support first. Resolves this before implementation: use that diagnosis when available, fall back to no skill constraint (not zero eligible agents) otherwise, matching FR-004's explicit "rather than failing" and 006's own empty-scope-matches- everything convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
13 KiB
Phase 0 Research: Orchestration and Assignment
Decision: Where a ticket's "required skill" actually comes from
- Decision: Neither
TicketnorProblemcarries a skill/problem-type field today — the only place a real one exists isAIDiagnosis.problemType(005), when a ticket went through an AI session before escalating.RoutingService.resolveEligibleAgentsbuilds the caller-suppliedrequiredSkillsarray 006'scapabilityLookupService.findEligibleAgentstakes as: the most recentAIDiagnosis.problemTypefor the ticket's most recentAISupportSession, if one exists (treated as a skill tag, the same "problemType doubles as a matching key" convention 005 already established for runbooks) — otherwise an empty array. 006's lookup then unions in whatever matching hierarchy node's ownskillsapply on top of that, exactly as it already does for a direct caller. When neither an AI diagnosis nor a matching node exists,requiredSkillsstays empty — meaning every active agent (on an active team) is eligible, not zero — the same "an unconfigured requirement matches everything, never nothing" convention 006's ownscopeMatchesalready established for hierarchy scope, extended one level further here. - Rationale: FR-002/FR-004 require resolving some required-skill context and degrading
gracefully, not failing, when nothing configured applies — but neither
spec.mdnor doc 05/06 actually models a problem-type-to-skill mapping on the ticket itself. Rather than inventing a new mapping table not asked for by any FR (out of scope per spec.md's Assumptions, which already excludes SLA/escalation-policy modeling as a similar class of not-yet-built reference data), this uses the one real, already-existing signal (AIDiagnosis.problemType) and falls back to "no constraint" — never to "no eligible agents," which would make a ticket that never touched AI support and matches no hierarchy node permanently unassignable, contradicting FR-004's explicit "rather than failing." - Alternatives considered: A new
RequiredSkill/problem-type-to-skill mapping model — rejected as unrequested scope; nothing in spec.md asks for admin-configurable skill mapping independent of the hierarchy node, and 006 already gives hierarchy nodes their ownskillsfield for exactly this purpose when a node's scope does match. Treating "no signal" as "zero eligible agents" — rejected; directly contradicts FR-004.
Decision: Module placement — three existing stubs, mapped to distinct responsibilities
- Decision: Doc 07 already scaffolds
orchestration/routing,orchestration/orchestration, andorchestration/assignments(the latter already has theengine//strategies//rules//calculators/extended structure doc 05 §8 calls for, populated with placeholder stubs — e.g.RoundRobinAssignmentStrategy.selectNextAgentjust returnscandidateIds[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'scapabilityLookupService/hierarchyRepositorydirectly, never a second, divergent matching algorithm (FR-002). No HTTP surface of its own.assignments: ownsAssignment/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, callsroutingthenassignments, and moves the ticket toIN_PROGRESSon success. No HTTP surface of its own; purely event-driven (plus an internal functionassignments' 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
assignmentsstub's pre-existingengine/strategies/rules/calculatorssplit 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(ticketIdnot unique,unassignedAtnullable) is refined the same way 004 refinedKnowledgeEntry: one row per assignment period, with an explicitisCurrent Boolean(not just "unassignedAtis null" implied) for a clean, indexed "the current assignment for this ticket" query. Reassigning creates a newAssignmentrow (isCurrent: true) and, in the same transaction, sets the previous row'sisCurrent: false/unassignedAt: now().AssignmentHistoryis a separate, purely-additive event log (action: assigned | reassigned | unassigned) — distinct fromAssignmentstate the same way 005-ai-support'sAIInteraction/AIActionevent trail is distinct fromAISupportSessionstate. - 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
Assignmentis the same "refine during Phase 1 modeling" precedent 004 and 005 already established for their own conceptual models. - Alternatives considered: A single
Assignmenttable withunassignedAtalone (noisCurrentflag, no separateAssignmentHistory) — rejected; querying "the current one" viaunassignedAt IS NULLworks but doesn't distinguish why a row changed (assigned vs. reassigned vs. explicitly unassigned with no replacement), whichAssignmentHistory.actionexists 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 isINCR ticketing:round_robin:<hierarchyNodeId ?? 'unscoped'>against the existing shared Redis client (src/infrastructure/cache) — an atomic, single round-trip operation — then(count - 1) % eligibleAgents.lengthselects 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'sjtitracking) — 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 UPDATEtransaction incrementing a cursor column onHierarchyNode— rejected; would require a schema change to a model 006-support-organization already shipped and finalized, and RedisINCRis 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 sameINCR-based selectionROUND_ROBINuses, 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:
currentLoadis 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
currentLoadpermanently 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
currentLoadon 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-closedverifyProductResolutionplaceholder over a half-real one).
Decision: Trigger — subscribe to the existing TICKET_UPDATED domain event
- Decision:
orchestration's engine registers a handler (wired insrc/events/handlers/index.ts, alongside 005's existing subscriber) forDomainEventName.TICKET_UPDATED, checkingpayload.newStatus === 'HUMAN_ESCALATION'. No modification totickets.service.tsis 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
ticketsever 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),
orchestrationcalls the existingticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system')(003-ticketing) —HUMAN_ESCALATION → IN_PROGRESSis 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 reachesHUMAN_ESCALATIONany 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
ASSIGNEDstatus — rejected; 003's state machine already hasIN_PROGRESSfor 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? },strategydefaulting toMANUALand acceptingDIRECTas the only other caller-supplied value (both mean the same thing operationally — an explicitly suppliedagentId, 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'sAgentsService.createvalidatesteamId— 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
DIRECTwith 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/assignmentand any read endpoints in this feature are gated byfastify.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.