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>
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-controlled — fastify.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/statusrequiresexpectedVersion(optimistic concurrency — a stale version is rejected with409, never silently overwritten) and only accepts transitions defined in the state machine (400 INVALID_TRANSITIONotherwise). - Messages:
POST/GET /tickets/:ticketId/messages(customer-scoped — internal note types are never returned) andGET /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-url→POST .../attachments/confirm) against MinIO/S3 — file bytes never transit this API. Nothing is downloadable yet (GET .../attachments/:attachmentId/download-urlalways returns409): 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
minioservice indocker-compose.development.yml/docker-compose.test.ymland theAWS_S3_ENDPOINTvalue 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
KnowledgeEntryorRunbooknever overwrites it in place — it creates a new row (versionincremented,isCurrentVersion: true), and the prior version stays queryable (GET /admin/knowledge/:code/versions). RequiresexpectedVersion; a stale value is rejected with409, same concurrency pattern as ticket status updates. - Retrieval (
GET /knowledge/retrieve?productId=&feature=&category=) only ever returnspublished, currently-effective, current-version entries scoped to the given product —validatedentries are ranked ahead ofunvalidatedones. An unregisteredproductIdreturns an empty array, not an error. - Full semantic/embedding-based retrieval is intentionally not implemented here — see
specs/004-product-knowledge/spec.mdAssumptions.
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_EFFORTand 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 ontoTicket.statusthrough 003-ticketing's existing state machine (AI_ANALYZING→AI_TROUBLESHOOTING→AI_VERIFYING→AI_RESOLVED, orHUMAN_ESCALATIONfrom any point) — seespecs/005-ai-support/research.md"AISupportSession.status drives Ticket.status". A customer reply isPOST /tickets/:ticketId/ai-session/messages; the current session (with its diagnosis and conversation) isGET /tickets/:ticketId/ai-session. - Confidence policy:
PUT/GET /admin/products/:externalProductId/ai-policysets 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;overrideTicketPriorityis high-risk and always stayspending_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 viaGET /tickets/:ticketId/ai-session/actions. verifyProductResolutionis a documented, fail-closed placeholder (same pattern asticketing/attachments's malware scanner) — it always returnsconfirmed: false, since there's no real per-product operational signal to check yet (doc 11 §A2). A ticket is only ever markedAI_RESOLVEDon 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
problemTypematches a runbook'skeyfor 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.mdAssumptions.
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/:skillTagupserts a proficiency level (never duplicates the same tag).PUT /admin/agents/:agentId/availabilityupserts the agent's single current record — status/working hours/current load — using last-write-wins, not this system's usualexpectedVersionoptimistic-concurrency pattern: availability is frequently-changing operational telemetry, not a durable business record (seespecs/006-support-organization/research.md). - The dynamic hierarchy:
POST/PUT /admin/hierarchy-nodesbuild an arbitrary, nestable tree scoped to product/category/priority, each node carrying anassignmentStrategyandslaPolicyId/escalationPolicyIdreference (no such policy tables exist yet — Phase 7/8) and opaqueentryConditions/exitConditions. A reparenting edit that would make a node its own ancestor is rejected with400 CYCLE_DETECTED; every create/edit/activate/deactivate writes anAuditLogrow. - 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/agentsscaffold stub, which queried a genericUser/UserRolemodel unrelated to this system's real architecture (nothing else uses it —CustomerReference, built in 002, is the real customer-identity mechanism).identity/customersandidentity/authare 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 whennewStatus === 'HUMAN_ESCALATION'. Neitherticketingnorai-supporthas any import oforchestration— the event bus is what keeps that direction one-way. The event bus itself was fixed while building this feature:EventBus.publishnow actually awaits its subscribers (it previously fired them viaEventEmitter.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 frombuildApp()itself now (idempotently — see its own code comment), not only fromserver.ts's production startup path, so this is true in tests too, not just production. - Strategies:
ROUND_ROBIN(concurrency-safe via an atomic RedisINCR, verified under real concurrent load intests/concurrency/round-robin.test.ts),LEAST_LOADED,SKILL_BASEDare auto-selected from the matched hierarchy node's ownassignmentStrategy(or a configurable system default,ORCHESTRATION_DEFAULT_STRATEGY, when no node matched).MANUAL/DIRECTare never auto-selected — they only ever come fromPOST /admin/tickets/:ticketId/assignment's explicitagentId.LEAST_LOADED/SKILL_BASEDties fall through to the same concurrency-safe cursorROUND_ROBINuses, 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 ownskills(006) — when neither exists, there's no skill constraint at all (every active agent is eligible), never zero eligible agents; seespecs/007-orchestration-assignment/research.md. - History:
Assignmentis a version-row-per-period model (reassigning supersedes the current row, never overwrites it);AssignmentHistoryis a separate, purely-additive event log —GET /tickets/:ticketId/assignment(current) andGET /tickets/:ticketId/assignment-history(everything, including "no eligible agent" outcomes withagentId: null). currentLoad(006) is read byLEAST_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.mdAssumptions.