Files
support_backend/specs/007-orchestration-assignment/research.md
T
saqib mirandClaude Sonnet 5 d3d57b9954 docs: correct 007-orchestration-assignment design on required-skill sourcing
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>
2026-09-03 11:32:49 +05:30

13 KiB

Phase 0 Research: Orchestration and Assignment

Decision: Where a ticket's "required skill" actually comes from

  • Decision: Neither Ticket nor Problem carries a skill/problem-type field today — the only place a real one exists is AIDiagnosis.problemType (005), when a ticket went through an AI session before escalating. RoutingService.resolveEligibleAgents builds the caller-supplied requiredSkills array 006's capabilityLookupService.findEligibleAgents takes as: the most recent AIDiagnosis.problemType for the ticket's most recent AISupportSession, 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 own skills apply on top of that, exactly as it already does for a direct caller. When neither an AI diagnosis nor a matching node exists, requiredSkills stays 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 own scopeMatches already 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.md nor 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 own skills field 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, 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.