Files
saqib mirandClaude Sonnet 5 9357f03e1d feat: implement SLA and escalation (008)
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:02:05 +05:30

18 KiB

Phase 0 Research: SLA and Escalation

Decision: Module placement — three existing stubs, mapped directly

  • Decision: platform/business-calendars (currently a one-file stub, isWorkingHour hardcoded true), orchestration/sla (stub SlaEngine.evaluateSlaTargets always returns NORMAL; stub SlaDueDateCalculator does naive createdAt + hours), and orchestration/escalation (stub EscalationEngine.triggerEscalation always returns { escalated: false }) are populated directly, matching doc 07's placement exactly — no new module locations invented.
  • Rationale: Documented layout, not an open choice; every stub's current behavior is exactly what doc 05 §5 explicitly warns against (SlaDueDateCalculator's naive addition is the literal anti-pattern FR-004 forbids) — replacing it is the point of this feature.
  • Alternatives considered: None.

Decision: A real timezone-aware date library — luxon — is a genuinely new dependency

  • Decision: Add luxon (a single package, no companion timezone package needed, IANA timezone support built in) for every calendar-aware date computation in this feature.
  • Rationale: No date/timezone library exists anywhere in this codebase yet — every prior feature's DateTime/Json-typed "schedule" fields (e.g. 006's AgentAvailability. workingHours) were stored but never actually walked by any code. This feature is the first to need to compute against calendar time correctly (FR-004's explicit "MUST NOT... ignore the calendar"), and hand-rolling DST-correct, IANA-timezone-aware business-hour arithmetic without a library is exactly the kind of mistake this system's own constitution warns against elsewhere ("don't reinvent what a library already solves correctly" is this codebase's working norm, even if not literally in the constitution's text) — matching the same "one new dependency for the one new genuinely-needed capability" precedent 005 set for @anthropic-ai/sdk.
  • Alternatives considered: date-fns + date-fns-tz (two packages for the same capability) — rejected in favor of the single-package option. Hand-rolled arithmetic — rejected; timezone/DST correctness is precisely the kind of subtly-wrong-most-of-the-time code a library exists to prevent.

Decision: BusinessCalendar.workingHours shape — one window per weekday

  • Decision: { mon?: { start: "09:00", end: "17:00" }, tue?: ..., ..., sun?: ... } — three- letter weekday keys, HH:mm 24-hour strings interpreted in the calendar's own timezone, a missing key meaning "not a working day" (FR's "unconfigured day contributes zero time").
  • Rationale: Doc 05 §5's stated need ("business hours, weekends... per-team schedules") is satisfied by one contiguous window per day — doc 06 doesn't specify a richer shape (split shifts), and nothing in spec.md asks for one; a single window per day is the simplest structure that satisfies every acceptance scenario without speculative complexity.
  • Alternatives considered: An array of windows per day (split-shift support) — rejected as unrequested scope; the shape can be extended later (an array is a strict superset) without a breaking change to a single-window calendar's own data.

Decision: Calendar-aware due-date arithmetic — a day-by-day walk

  • Decision: addBusinessMinutes(start, minutes, calendar, holidays) walks forward from start one calendar day at a time (in the calendar's timezone): a holiday date or a weekday with no configured window contributes zero available minutes; otherwise the day's working window (clipped by start's own time on the first day) contributes up to its own duration, consumed from the running minutes total; the walk ends the moment minutes reaches zero, returning that exact timestamp.
  • Rationale: This directly implements FR-004 — every acceptance scenario (weekend/holiday exclusion) is a direct consequence of this algorithm, not a special case bolted on. A day-granularity loop is bounded (even a multi-week SLA window is, at most, a few dozen iterations) and easy to unit-test exhaustively.
  • Alternatives considered: Minute-by-minute simulation — rejected as needlessly slow and harder to reason about for the same result; day-granularity with within-day clipping is exactly as correct and far simpler.

Decision: SLA policy resolution — most-specific match, same shape as 005's confidence policy

  • Decision: Given a ticket's productId/categoryId/problemTypeId/priority, an active SLAPolicy matches when each of its own scope fields is either null (wildcard) or equal to the ticket's corresponding value. Among matches, the one with the fewest null scope fields (most specific) wins; a tie is broken by most-recently-updatedAt.
  • Rationale: FR-002 requires most-specific-match, not first-found — this is the same resolution shape 005's AIConfidencePolicy and 006's hierarchy scope matching already established in this codebase, reused rather than reinvented a third time.
  • Alternatives considered: A single global default policy with per-scope overrides (005's (productId, categoryId) two-level shape) — rejected; SLA policy has four independent scope dimensions doc 06 itself defines, so a strict specificity count (not a fixed lookup order) is the correct generalization.

Decision: Pause/resume — shift the absolute due date by the paused wall-clock duration

  • Decision: Pausing records pausedAt = now() (status → paused); resuming shifts resolutionDueAt (and firstResponseDueAt, if still pending) forward by now() - pausedAt and clears pausedAt (status → running). No separate "remaining minutes" bookkeeping field is needed — the absolute due-date field itself, shifted, is the remaining-time record.
  • Rationale: FR-007/FR-008/SC-002 require the paused duration to be excluded, durably, across a restart — shifting an absolute timestamp already stored in Postgres satisfies both with the simplest possible mechanism; no in-memory state exists at any point.
  • Alternatives considered: Storing remaining minutes and recomputing the due date via the calendar walk on every resume — rejected as unnecessary; the pause window itself doesn't need calendar-awareness (a paused SLA isn't "elapsing" business time by definition, so shifting by real wall-clock pause duration is exactly correct, not an approximation).

Decision: Breach detection — one repeatable BullMQ job, not one delayed job per run

  • Decision: A single repeatable job (e.g. every 60 seconds) queries every SLARun with status: 'running' whose resolutionDueAt <= now(), marking each breached — and separately, every running run with firstResponseDueAt <= now() and no firstResponseBreachedAt yet (data-model.md refinement) and no AGENT_MESSAGE recorded for the ticket, marking firstResponseBreachedAt. Each newly-detected breach triggers escalation-rule evaluation (research.md below).
  • Rationale: Constitution Principle VII requires durability, not sub-second precision — a short-interval polling job is trivially durable (BullMQ's repeatable jobs are themselves persisted, and a missed tick is caught by the next one) and avoids the bookkeeping a per-run delayed-job approach would need on every pause/resume (canceling and rescheduling a delayed job each time, versus just updating a timestamp a polling query already reads).
  • Alternatives considered: One delayed BullMQ job scheduled per SLARun, rescheduled on every pause/resume — rejected; every pause/resume would need to cancel and re-add a job, doubling the operations pause/resume already does, for a precision (sub-minute breach detection) nothing in spec.md actually requires.

Decision: Escalation firing reuses 007's AssignmentEngine, scoped to a specific node

  • Decision: AssignmentEngine (007) gains a new method, assignToSpecificNode(ticketId, hierarchyNodeId, strategyOverride?, actor, reason?) — resolves the eligible-agent set the same way RoutingService already does, but scoped to exactly the given node (its own skills unioned with the ticket's derived required skills, per 007's existing composition rule) rather than 007's general "find whichever node matches the ticket's context" resolution. Runs the node's own configured strategy (or strategyOverride) and persists through the same Assignment/AssignmentHistory mechanism 007 already built and tested for concurrent writes.
  • Rationale: FR-014 requires escalation to land the ticket specifically at the rule's targetNodeId — 007's existing evaluateAndAssign always re-derives the applicable node from ticket context, which could resolve to a different node than the one the rule targeted (the ticket's context hasn't changed, only its status has). A new, explicit "assign to this node" entry point is the correct extension, not a workaround.
  • Alternatives considered: Having 008 duplicate 007's eligible-agent-resolution and Assignment-persistence logic — rejected; directly against this codebase's repeated "extend an existing module's public surface for a later feature" precedent (004's productsRepository, 005's problemsRepository, 007's own reuse of 006's capabilityLookupService).

Decision: EscalationRule.triggerType is stored broadly; only two types are ever evaluated

  • Decision: The Zod schema for creating a rule accepts any of doc 06's ten triggerType values — an admin can configure a rule for inactivity or critical_incident today, and it will simply never fire (no code path evaluates those triggers yet), rather than being rejected at creation time.
  • Rationale: spec.md's Assumptions state this explicitly — storing configuration ahead of the event source that will eventually feed it is this codebase's established pattern (006's slaPolicyId stored before this feature existed to validate it); rejecting valid doc-06-shaped configuration at the schema layer would be a regression from that pattern, not a safety improvement (nothing unsafe happens from an inert rule sitting unfired).
  • Alternatives considered: Restricting the schema to only the two implemented trigger types — rejected; would force a breaking schema change on every future phase that wires up one more trigger type, for no correctness benefit today.

Decision: Escalation policy resolution — product match or global, most-specific first

  • Decision: EscalationPolicy.productId is the only scope dimension doc 06 gives it (unlike SLAPolicy's four). Resolution: prefer an active policy whose productId equals the ticket's product; fall back to an active policy with productId: null (a global policy) if no product-specific one exists. A breach with neither is recorded breached with no rule evaluated (spec.md Edge Cases: "a breach with no matching rule is still recorded as breached").
  • Rationale: Same most-specific-first shape as SLAPolicy, degenerately simple because doc 06 only gives EscalationPolicy one scope field — no new resolution mechanism invented.
  • Alternatives considered: None; doc 06's shape leaves no other reasonable reading.

Decision: EscalationRule.condition is stored, not evaluated, by this feature

  • Decision: Every active EscalationRule under the resolved policy whose triggerType matches the firing breach type (resolution_breach or first_response_breach) fires — the condition Json field is persisted as given at creation but not parsed or evaluated as a filter.
  • Rationale: spec.md's FR-013 says "every matching active EscalationRule fires" scoped by trigger type alone; nothing in spec.md defines a condition grammar to evaluate, and inventing one now would be exactly the kind of unrequested scope this codebase's established discipline (005's inert trigger types, 006's unvalidated slaPolicyId) consistently avoids. condition is accepted and returned by the CRUD schema so a future feature can give it real meaning without a breaking change.
  • Alternatives considered: A minimal condition-matching evaluator (e.g. { minPriority }) — rejected as speculative; spec.md never asked for conditional rule filtering beyond trigger type.

Decision: SLA-run lifecycle is wired entirely through the existing domain-event bus

  • Decision: DomainEventName.TICKET_ASSIGNED — defined in src/events/domain-events.ts since 007 but never actually published by any code — is published for the first time by AssignmentEngine.persistAndTransition (007's single shared success path for automatic, manual, and this feature's new scoped-escalation assignment) with { ticketId, agentId, strategy, actor }. A new subscriber in src/events/handlers/index.ts reacts by resolving the applicable SLAPolicy and creating the SLARun — but only if ticketId doesn't already have one (SLARun.ticketId @unique makes this a natural existence check), so a re-escalation's second TICKET_ASSIGNED publish (spec.md Assumptions: 1:1 with the first assignment only) is correctly a no-op. Two further TICKET_UPDATED subscribers (same file, same pattern as 005's and 007's own) watch for newStatus === 'WAITING_FOR_CUSTOMER' (pause) / previousStatus === 'WAITING_FOR_CUSTOMER' (resume), and for newStatus === 'RESOLVED' (complete, per 003's state machine — RESOLVED is the terminal status every path reaches before CLOSED/REOPENED).
  • Rationale: Same "a module never needs to import another module it affects" decoupling this codebase has used consistently since 005 — orchestration/assignments doesn't need to know orchestration/sla exists, and ticketing/tickets already doesn't know about any of its status-change consumers. Publishing TICKET_ASSIGNED for real is the natural use of an event this codebase already named and reserved for exactly this purpose.
  • Alternatives considered: A direct call from AssignmentEngine.persistAndTransition into an orchestration/sla service method — rejected; would create the exact cross-module coupling 007→008 the event bus exists to avoid, and would need every future consumer of "a ticket got assigned" to be added as another direct call in 007's own code.

Decision: The breach-detection job reuses src/jobs/sla/'s existing stub; escalation firing reuses src/jobs/escalation/'s

  • Decision: registerSlaWorker() (src/jobs/sla/index.ts, currently just a log line) is extended to, on startup, schedule one BullMQ repeatable job (queueManager.getQueue(QueueName .SLA).add('detect-breaches', {}, { repeat: { every: 60_000 } })) whose processor calls a single, directly-callable, side-effect-only method — slaService.runBreachDetectionSweep() — containing 100% of the actual logic: the two polling queries from research.md's breach- detection decision, marking runs breached/first-response-breached, and, for each new breach, calling escalationService.handleBreach(ticketId, triggerType) directly (a plain in-process call, not a second queued job) since escalation firing has no meaningful reason to be async-relative-to-detection. src/jobs/escalation/'s existing registerEscalationWorker() stub, and its ESCALATION queue, are left untouched — reserved, per their own existing scaffold, for a possible future async notification-dispatch step (spec.md Assumptions: no notification delivery is built by this feature).
  • Rationale: runBreachDetectionSweep() being a plain importable async function (not reachable only through a running BullMQ worker) is what makes it possible to write an integration test for "one job tick" without a real running worker process or a real 60-second wait — the exact "no worker process in this test, call the job's own logic inline" convention already established by tests/integration/ticket-attachments.test.ts for the malware-scan job.
  • Alternatives considered: Splitting detection and escalation firing into two separately queued BullMQ jobs (using the ESCALATION queue for the firing step) — rejected as an unnecessary indirection; nothing in spec.md requires escalation firing to be decoupled in time from the breach that caused it, and a single sweep function is simpler to test and reason about.

Decision: SLA_BREACHED/ESCALATION_TRIGGERED are also published, for audit, not for logic

  • Decision: DomainEventName.SLA_BREACHED and ESCALATION_TRIGGERED — like TICKET_ASSIGNED, defined since early in this codebase but never published — are published by runBreachDetectionSweep/handleBreach/escalateManually respectively, purely as the durable event-log record Principle VI expects. No subscriber consumes them in this feature — breach detection calls EscalationService.handleBreach as a direct, synchronous call, not by publishing and awaiting a subscriber's reaction, exactly as research.md's job-design decision already settled.
  • Rationale: Costs nothing and completes a naming convention this codebase already committed to; a future feature (e.g. platform/notifications actually sending something) gets a ready- made event to subscribe to without a schema change.
  • Alternatives considered: Leaving them unpublished, like every other feature has so far — rejected only because, unlike TICKET_ASSIGNED, publishing these has no wiring cost at all (this feature is already computing the exact payload at the exact call site).

Decision: SLA/Escalation admin endpoints reuse the existing auth stub

  • Decision: Every admin CRUD endpoint (policies, calendars, escalation rules) and the manual- escalation endpoint are gated by fastify.authenticate, same known-limitation stub as every prior feature.
  • Rationale: Consistency with established precedent.
  • Alternatives considered: None.