saqib mirandClaude Sonnet 5 77928c4878 feat: implement orchestration and assignment (007) — routing, strategies, history
Phase 7 of the roadmap. On a ticket's automatic transition to
HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and
capability-eligibility lookup directly (never a second matching
algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via
atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one
eligible agent, persisted as a version-row-per-period Assignment plus an
append-only AssignmentHistory event log. MANUAL/DIRECT are never
auto-selected — only an explicit admin-supplied agentId reaches them.
On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through
003's existing state machine. A ticket's "required skill" comes from
its most recent AI diagnosis's problemType (005) when one exists,
unioned with any matching hierarchy node's skills (006); when neither
exists, there's no skill constraint (every active agent eligible),
never zero.

Found and fixed two real, latent bugs in the shared event-bus
infrastructure while building this feature's own tests: (1)
EventBus.publish was built on EventEmitter.emit(), which never awaits
async listeners, so a caller had no guarantee any subscriber (005's
AI-session-ending hook, now also this feature's orchestration hook) had
actually finished — rewritten to track subscribers directly and await
them via Promise.all, same per-handler error isolation as before. (2)
registerDomainEventHandlers() was only called from server.ts's
production startup path, never from buildApp() — meaning every
integration test in this codebase had zero domain-event subscribers
registered at all. Now called (idempotently) from buildApp() itself,
since domain-event wiring is synchronous application behavior, not a
background-worker concern like the queue.

Adds 8 unit tests (each strategy's pure selection/tie-break logic), a
dedicated round-robin concurrency test verifying no two concurrent
selections collide under real parallel load, and 2 integration test
files covering all five user stories. Full regression (every
pre-existing 002-006 integration test plus every new 007 test) run
together against real Postgres/Redis/MinIO: 124 passed, 9 skipped
(005's AI-key-gated tests, unrelated), 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:58:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-20 11:27:15 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30
2026-08-19 16:29:17 +05:30

Development

docker compose --env-file .env.development -f docker-compose.development.yml up -d --build

Test

docker compose --env-file .env.test -f docker-compose.test.yml up --build

Production

docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d

Stop

docker compose -f docker-compose.prod.yml down

Local environment setup

.env.development, .env.test, and .env.prod are gitignored (they hold real credentials) — copy .env.example to the one you need and fill in real values before running any command above.

CI/CD

Every push/PR triggers the Jenkins pipeline defined in Jenkinsfile. Stage order: checkout → install → environment validation → typecheck → lint → format check → unit test → integration test → E2E test → build → Docker build → publish → deploy. Publish/deploy only run on branches with a configured deploy target (main → prod, develop/test → test); other branches validate and build only. Pipeline run status and per-stage logs are visible in the Jenkins UI for the relevant job — see specs/001-ci-pipeline/quickstart.md for how to validate the pipeline itself, and specs/001-ci-pipeline/contracts/pipeline-stage-contract.md for the guarantees each stage makes.

Required Jenkins credentials (see the header comment in Jenkinsfile for exact IDs): per target environment (test, prod) a Postgres password, Redis password, JWT secret, and AWS access key/secret, plus one shared Docker registry username/password. None of these are ever read from a file in this repository.

SaaS Integration

POST /v1/support/requests is the trust boundary a registered SaaS product calls through — every request must carry a Authorization: Bearer <signed-token> header (HMAC-SHA256, signed with the integration's own secret) and a body matching the inbound contract. See specs/002-saas-integration/contracts/inbound-request-contract.md for the full validation order and error codes, and specs/002-saas-integration/quickstart.md for runnable scenarios.

Admins manage integrations under /admin/products/:externalProductId/integration (register) and /admin/integrations/:integrationId/{rotate,revoke,status,audit-trail}. These admin routes are not yet actually access-controlledfastify.authenticate is a stub pending the identity/auth module; don't expose them outside a trusted network until that's implemented.

Rate limits (rateLimitPerMinute, rateLimitPerUserPerMinute) are set per integration at registration time and enforced via a Redis-backed fixed-window counter, independent of the global @fastify/rate-limit floor already applied to every route.

Ticketing

A validated inbound request (see "SaaS Integration" above) creates a Ticket and Problem immediately — before any diagnosis. See specs/003-ticketing/contracts/ticket-lifecycle-contract.md for the full lifecycle state machine, message-visibility rules, and attachment pipeline, and specs/003-ticketing/quickstart.md for runnable scenarios.

  • Status transitions: PATCH /tickets/:ticketId/status requires expectedVersion (optimistic concurrency — a stale version is rejected with 409, never silently overwritten) and only accepts transitions defined in the state machine (400 INVALID_TRANSITION otherwise).
  • Messages: POST/GET /tickets/:ticketId/messages (customer-scoped — internal note types are never returned) and GET /agent/tickets/:ticketId/messages (agent-scoped — everything). A message's customer-visibility is always derived from its type, never caller-supplied.
  • Attachments: presigned-PUT upload (POST .../attachments/upload-urlPOST .../attachments/confirm) against MinIO/S3 — file bytes never transit this API. Nothing is downloadable yet (GET .../attachments/:attachmentId/download-url always returns 409): the malware scanner is a placeholder that fails closed until a real one (src/modules/ticketing/attachments/mapper/malware-scanner.ts) replaces it.
  • Local/test object storage is MinIO — see the minio service in docker-compose.development.yml / docker-compose.test.yml and the AWS_S3_ENDPOINT value in the corresponding .env.* file.

Product Knowledge

Admin CRUD for KnowledgeEntry/ErrorCode/KnownIssue/Runbook, plus GET /knowledge/retrieve — a filtered (not semantic/vector) query the future AI-support feature will call. See specs/004-product-knowledge/contracts/knowledge-contract.md for the full route list and specs/004-product-knowledge/quickstart.md for runnable scenarios.

  • Versioning: editing a published KnowledgeEntry or Runbook never overwrites it in place — it creates a new row (version incremented, isCurrentVersion: true), and the prior version stays queryable (GET /admin/knowledge/:code/versions). Requires expectedVersion; a stale value is rejected with 409, same concurrency pattern as ticket status updates.
  • Retrieval (GET /knowledge/retrieve?productId=&feature=&category=) only ever returns published, currently-effective, current-version entries scoped to the given product — validated entries are ranked ahead of unvalidated ones. An unregistered productId returns an empty array, not an error.
  • Full semantic/embedding-based retrieval is intentionally not implemented here — see specs/004-product-knowledge/spec.md Assumptions.

AI Support

A ticket's AI session diagnoses the problem via a real Anthropic Claude call, applies a configurable confidence-band policy, and — on "proceed" — reasons and acts through a small, permission/risk-gated tool system, optionally walking a matching runbook step by step. See specs/005-ai-support/contracts/ai-support-contract.md for the full route list and specs/005-ai-support/quickstart.md for runnable scenarios.

  • Requires a real ANTHROPIC_API_KEY (per explicit product decision — not a mock or pluggable-interface phase). The app boots and every non-AI test still passes without one; an AI session simply fails closed (escalates) if a reasoning call is attempted with none configured. AI_SUPPORT_MODEL/AI_SUPPORT_EFFORT and the system-wide confidence-threshold/question-budget defaults are all environment-configurable, never hardcoded (doc 11 §B2).
  • Session lifecycle: a session starts automatically (queued, off the hot path of POST /v1/support/requests) when a ticket is created, and its status mirrors onto Ticket.status through 003-ticketing's existing state machine (AI_ANALYZINGAI_TROUBLESHOOTINGAI_VERIFYINGAI_RESOLVED, or HUMAN_ESCALATION from any point) — see specs/005-ai-support/research.md "AISupportSession.status drives Ticket.status". A customer reply is POST /tickets/:ticketId/ai-session/messages; the current session (with its diagnosis and conversation) is GET /tickets/:ticketId/ai-session.
  • Confidence policy: PUT/GET /admin/products/:externalProductId/ai-policy sets per-product (optionally per-category) highThreshold/lowThreshold/maxClarifyingQuestions, falling back to env-configured system defaults when nothing is configured — applies to the very next diagnosis, no deploy required.
  • Tool system: every tool call the AI proposes is evaluated by a deterministic policy gate (src/modules/ai-support/tools/service/policy-gate.ts) before anything executes — the gate never reads the AI's own proposal/justification text, only the tool's declared risk level and product scope. Low-risk tools (getTicketSnapshot, searchProductKnowledge, verifyProductResolution, escalateToHuman) auto-execute; overrideTicketPriority is high-risk and always stays pending_approval — there's no human-approval UI yet (Phase 10), so it never actually runs, by design, not by oversight. Every proposal, decision, and result is recorded and auditable via GET /tickets/:ticketId/ai-session/actions.
  • verifyProductResolution is a documented, fail-closed placeholder (same pattern as ticketing/attachments's malware scanner) — it always returns confirmed: false, since there's no real per-product operational signal to check yet (doc 11 §A2). A ticket is only ever marked AI_RESOLVED on a passing result from this tool, never from what the customer says alone — so in practice, genuinely automatic AI resolution won't happen until a real verification signal replaces this placeholder.
  • Runbook engine: when a diagnosis's problemType matches a runbook's key for the product (004-product-knowledge), the application — never the model — tracks which step is current (currentStepIndex) and advances it by exactly one at a time; exhausting every step without resolving escalates with the full attempted sequence attached.
  • Semantic/vector retrieval, product-signal webhook verification, model routing/fallback, cost dashboards, and localization are intentionally out of scope here — see specs/005-ai-support/spec.md Assumptions.

Support Organization

Admin CRUD for Team/Agent/AgentSkill/AgentAvailability, plus a fully dynamic HierarchyNode tree — this system's explicit routing differentiator (doc 01: "changing one must never require a code change"). See specs/006-support-organization/contracts/support-org-contract.md for the full route list and specs/006-support-organization/quickstart.md for runnable scenarios.

  • Teams and agents: POST/PATCH/GET /admin/teams, POST/PATCH/GET /admin/agents — active/inactive only, never hard deletion. Deactivating a team never cascades into deactivating its agents.
  • Skills and availability: PUT /admin/agents/:agentId/skills/:skillTag upserts a proficiency level (never duplicates the same tag). PUT /admin/agents/:agentId/availability upserts the agent's single current record — status/working hours/current load — using last-write-wins, not this system's usual expectedVersion optimistic-concurrency pattern: availability is frequently-changing operational telemetry, not a durable business record (see specs/006-support-organization/research.md).
  • The dynamic hierarchy: POST/PUT /admin/hierarchy-nodes build an arbitrary, nestable tree scoped to product/category/priority, each node carrying an assignmentStrategy and slaPolicyId/escalationPolicyId reference (no such policy tables exist yet — Phase 7/8) and opaque entryConditions/exitConditions. A reparenting edit that would make a node its own ancestor is rejected with 400 CYCLE_DETECTED; every create/edit/activate/deactivate writes an AuditLog row.
  • Capability-eligibility lookup (GET /support-org/capability-eligibility?skills=&productId=& categoryId=&priorityId=) is a read-only contract for the future orchestration/assignment phase (Phase 7) to call — it answers "who is capability-eligible" (active agent, active team, holds every required skill, composed with any matching hierarchy node's own skills), and deliberately never filters by availability, working hours, or current load (doc 05 §3: "capability is evaluated before availability"). It does not make an assignment decision.
  • This feature supersedes the pre-existing identity/agents scaffold stub, which queried a generic User/UserRole model unrelated to this system's real architecture (nothing else uses it — CustomerReference, built in 002, is the real customer-identity mechanism). identity/customers and identity/auth are untouched — out of scope here.

Orchestration and Assignment

When a ticket reaches HUMAN_ESCALATION, this system automatically resolves 006's hierarchy/ capability-eligibility lookup for it and assigns exactly one eligible agent through a pluggable strategy — no caller has to trigger this. See specs/007-orchestration-assignment/contracts/orchestration-contract.md for the full route list and specs/007-orchestration-assignment/quickstart.md for runnable scenarios.

  • Trigger: entirely event-driven — ticketsService.updateStatus (003) publishes a domain event on every status change, and this feature's subscriber (registered alongside 005's own AI-session-ending one) reacts when newStatus === 'HUMAN_ESCALATION'. Neither ticketing nor ai-support has any import of orchestration — the event bus is what keeps that direction one-way. The event bus itself was fixed while building this feature: EventBus.publish now actually awaits its subscribers (it previously fired them via EventEmitter.emit, which never waits for an async listener) — without that fix, a status-update HTTP call could return before orchestration (or 005's own hook) had actually finished. registerDomainEventHandlers() is called from buildApp() itself now (idempotently — see its own code comment), not only from server.ts's production startup path, so this is true in tests too, not just production.
  • Strategies: ROUND_ROBIN (concurrency-safe via an atomic Redis INCR, verified under real concurrent load in tests/concurrency/round-robin.test.ts), LEAST_LOADED, SKILL_BASED are auto-selected from the matched hierarchy node's own assignmentStrategy (or a configurable system default, ORCHESTRATION_DEFAULT_STRATEGY, when no node matched). MANUAL/DIRECT are never auto-selected — they only ever come from POST /admin/tickets/:ticketId/assignment's explicit agentId. LEAST_LOADED/SKILL_BASED ties fall through to the same concurrency-safe cursor ROUND_ROBIN uses, never an arbitrary/unstable ordering.
  • A ticket's "required skill" comes from its most recent AI diagnosis's problemType (005), when one exists, unioned with any matching hierarchy node's own skills (006) — when neither exists, there's no skill constraint at all (every active agent is eligible), never zero eligible agents; see specs/007-orchestration-assignment/research.md.
  • History: Assignment is a version-row-per-period model (reassigning supersedes the current row, never overwrites it); AssignmentHistory is a separate, purely-additive event log — GET /tickets/:ticketId/assignment (current) and GET /tickets/:ticketId/assignment-history (everything, including "no eligible agent" outcomes with agentId: null).
  • currentLoad (006) is read by LEAST_LOADED, never written by this feature — no increment-on-assign/decrement-on-resolve lifecycle exists yet (that's a later phase's job once ticket resolution itself is built).
  • SLA policy execution and rule-driven escalation are intentionally out of scope here — doc 05 documents them alongside orchestration, but the roadmap places them in Phase 8. See specs/007-orchestration-assignment/spec.md Assumptions.
S
Description
No description provided
Readme
1.2 MiB
Languages
TypeScript 93.2%
PowerShell 6.5%
JavaScript 0.2%