Files
support_backend/specs/009-problem-resolution/research.md
T
saqib mirandClaude Sonnet 5 16daf8d32d feat: implement problem resolution (009)
Populates the five real problem-management stubs (investigation,
root-causes, solutions, verification, resolutions -- problems is
confirmed dead/unwired scaffold and stays untouched) with doc04's
sequential workflow engine:

- investigation: version-row-per-attempt (never overwritten), with a
  customer-safe read path that always strips internalNotes.
- root-causes/solutions/verification: a strict existence chain
  (investigation -> root cause -> solution -> approval -> implementation
  -> verification), each step resolve-or-409 on its own precondition,
  matching doc06's schema field-for-field with no invented columns.
- resolutions: gated on a successfully verified solution (no stored
  solutionId FK, per doc06 -- resolved via a join at write time), moving
  the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer
  confirmation and a durable auto-close sweep (the previously-unregistered
  CLEANUP queue stub, mirroring 008's breach-detection job) both resolve
  it from there.
- reopen (ticketing/tickets): two real, separately-audited transitions
  (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior
  problem-resolution record and no SLARun -- closes the loop 008's own
  spec.md left open.

Verification-failure escalation reuses 003/007's existing
HUMAN_ESCALATION transition directly rather than adding an eleventh
trigger type to 008's already-shipped escalation rules.

Customer-facing confirm-resolution/reopen needed a body-shape variant of
002's inbound trust boundary that didn't previously exist:
fastify.authenticateProductIntegration hard-required a full
ticket-creation-shaped body. Extracted the shared token/scope/replay
verification into verifyIntegrationIdentity and added a narrower
authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema
on top of it -- purely additive, ticket creation's own behavior is
unchanged.

Also fixes a real test-data-hygiene bug surfaced by running this
feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an
intentionally-global SLAPolicy in 008's own test fixtures were silently
affecting other test files' tickets sharing the same live Postgres.

Verified against throwaway Docker Postgres/Redis: typecheck, lint,
architecture-check all clean; full regression (tests/unit +
tests/integration together, 172 tests) passes except the 2 pre-existing
MinIO-dependent attachment failures, unrelated to this feature.

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

15 KiB

Phase 0 Research: Problem Resolution

Decision: Module placement — five real stubs; problem-management/problems is dead scaffold, left untouched

  • Decision: problem-management/{investigation,root-causes,solutions,resolutions,verification} (each a one-file, hardcoded-placeholder stub) are populated directly. problem-management/ problems — a second, never-wired ProblemsRepository.findAll() returning [] — is left exactly as-is; it is not this feature's Problem (that one has lived in, and been used since, ticketing/tickets/repository/problems.repository.ts, created by 003-ticketing).
  • Rationale: Every real caller of Problem (003's ticket creation, 005's AI diagnosis, 007's routing context, this feature's own investigation/root-cause/solution FKs) already resolves it through ticketing/tickets's repository. problem-management/problems was never imported by anything (confirmed by search) — a leftover from the original pre-spec-driven scaffold, the same class of dead placeholder this codebase's discipline is to leave alone unless a documented phase's roadmap item actually names it. Phase 9's own roadmap line names Investigation/ RootCause/Solution/.../Resolution, not a second Problem implementation.
  • Alternatives considered: Migrating Problem into problem-management/problems and re-pointing every existing caller — rejected as an unrequested, high-blast-radius refactor of working code three prior features already depend on, for a rename with no functional benefit.

Decision: Investigation is version-row-per-attempt, matching 004/007's established pattern

  • Decision: Every investigation (the first one, and any created after a failed verification, FR-013) is its own Investigation row for the same problemId — never an update to a prior row. "Which investigation is current" for a problem is simply the most recent by createdAt.
  • Rationale: Doc 06's Investigation model has no version/current-row field at all (unlike KnowledgeEntry.isCurrentVersion or Assignment.isCurrent) — the simplest reading consistent with "each investigation attempt is real, preserved history" (spec.md US1) is an unbounded, append-only set of rows per problem, ordered by createdAt, with no additional schema needed.
  • Alternatives considered: Adding an isCurrent boolean to Investigation (mirroring 007's refinement of Assignment) — rejected as unrequested schema embellishment; nothing in spec.md requires querying "the current investigation" faster than an orderBy: createdAt desc, take: 1 already provides, and doc 06 doesn't define the field.

Decision: A strict existence chain — investigation → root cause → solution → implementation → verification

  • Decision: Each write validates its own prerequisite exists for the same problemId (root cause requires an investigation; solution requires a root cause) or the same solutionId (implementation requires an approved solution; verification requires an implementation) — resolve-or-reject, the same "don't invent a default, don't skip a step" discipline this codebase has used for every other FK-shaped precondition since 002.
  • Rationale: Doc 04 §4-8 describes a strictly sequential workflow ("Investigation → Root Cause → Solution → Verification → Resolution") — the acceptance scenarios (spec.md US2-US4) explicitly test that skipping a step is rejected, not silently tolerated.
  • Alternatives considered: Allowing any order and only validating at Resolution time — rejected; doc 04's own workflow diagram is sequential by design, and rejecting out-of-order writes early gives a caller a much clearer error than a late rejection at the final step.

Decision: Resolution has no stored FK back to Solution — matches doc 06's shape exactly

  • Decision: Resolution is validated at write time (a successfully verified solution must exist for the ticket's problemId) but the Resolution row itself stores no solutionId — doc 06's own Resolution model has no such field (id, ticketId @unique, outcome, resolvedBy, resolvedAt only).
  • Rationale: Not a gap to fill — the existence check is enforced by the service layer at write time (the same "validate at the boundary, don't over-model the schema" approach 002/003 already use for non-FK cross-references like TicketMessage.authorRef), and doc 06 is explicit about what Resolution stores. Inventing a FK doc 06 doesn't define would be scope creep, not correctness.
  • Alternatives considered: Adding solutionId to Resolution as an additive refinement (this codebase's own established pattern for filling real gaps, e.g. 008's firstResponseBreachedAt) — considered and rejected specifically here, since unlike 008's gap (a genuinely missing idempotency guard with no other way to express it), the existence check this feature needs is fully satisfiable without a stored reference — a real refinement changes behavior; this one would only change provenance-tracing convenience nothing in spec.md asks for.

Decision: Verification-failure escalation reuses 003/007's HUMAN_ESCALATION transition directly

  • Decision: When an agent chooses escalation on a failed verification (FR-013), this feature calls ticketsService.updateStatus(ticketId, 'HUMAN_ESCALATION', ...) — the same transition 001-caliber tickets already support — and does nothing else. 007's existing TICKET_UPDATED subscriber (src/events/handlers/index.ts) picks this up and runs orchestration automatically, exactly as it does for every other route into HUMAN_ESCALATION.
  • Rationale: "Solution verification failed" is not one of doc 05 §6's ten escalation-rule trigger types 008 already modeled (first_response_breach | resolution_breach | inactivity | priority_increase | customer_escalation | repeated_reopen | manual | product_defect | dependency_timeout | critical_incident) — inventing an eleventh type, a new EscalationEvent, and a new call into 008's EscalationService for one internal flow this feature owns would be real, unrequested coupling across a module boundary 008 was deliberately built not to need. Reusing the plain status transition is exactly the mechanism 007 already exists to react to.
  • Alternatives considered: Adding solution_verification_failed as an eleventh EscalationRule.triggerType and calling 008's EscalationService.handleBreach-equivalent — rejected; 008 is already shipped and committed with a closed, deliberately-bounded set of two real trigger types (spec.md 008 Assumptions) — retroactively expanding it from within a later feature, for a flow that doesn't need the rule-matching machinery at all (there's exactly one outcome: HUMAN_ESCALATION, not "evaluate every matching rule"), is unjustified complexity.

Decision: Customer-facing confirm-resolution and reopen reuse 002's trust boundary via a new, narrower authenticateProductIntegrationIdentity decorator; agent reopen uses fastify.authenticate

  • Decision: Two new customer-reachable routes, POST /v1/support/tickets/:ticketId/confirm- resolution and POST /v1/support/tickets/:ticketId/reopen, are gated by a new fastify.authenticateProductIntegrationIdentity + the existing fastify. checkIntegrationRateLimit preHandler pair, then additionally verify the caller's externalTenantId/externalUserId (from request.reqContext) matches the ticket's own recorded values before allowing the action. A third route, POST /admin/tickets/:ticketId/reopen, is gated by fastify.authenticate for the agent-initiated reopen path FR-017 also requires. Confirm-resolution has no agent-initiated equivalent (spec.md US5 only ever has the customer confirming explicitly; an agent's own path to close things out is the existing auto-close job, not a manual override this feature adds).
  • Implementation note (found during /speckit-implement, not anticipated at planning time): fastify.authenticateProductIntegration (002) unconditionally validates request.body against the full inboundRequestSchema — which requires source/problem, ticket-creation-specific fields neither new route has any reason to send. Reusing it as originally planned made every call to these two routes fail Zod validation before token verification ever ran. Fixed by extracting steps 2-10 of authenticateProductIntegration's logic (everything after the body's own shape is known — token verification, replay/revocation/scope checks, reqContext population) into a shared verifyIntegrationIdentity function in product-integration-auth.plugin.ts, and adding a new identityOnlyRequestSchema ({productId, tenantId, userId} only) plus a new authenticateProductIntegrationIdentity decorator that parses that narrower shape and calls the same shared function. The original authenticateProductIntegration (and POST /v1/support/requests) is unchanged in behavior — purely additive.
  • Rationale: inbound-request.routes.ts's own comment ("Acting further on the ticket... belongs to later features that don't exist yet") names exactly this need — 002's trust boundary was already built to be extended, just not with a body shape that happened to fit an action on an existing ticket. Requiring the caller's own token to match the ticket's tenant/user prevents one customer from confirming or reopening another tenant's ticket.
  • Alternatives considered: A single unauthenticated or fastify.authenticate-gated endpoint for both actor types — rejected; a customer is never an authenticated SupportHub principal (Constitution Principle I — SaaS is the sole identity authority for its own end users), so reusing the internal-agent auth mechanism for a customer-initiated action would be a security regression, not a simplification. Sending a dummy source/problem value to satisfy the existing schema — rejected as a hack that would misrepresent the request and pollute validatedInboundBody for a handler that was never meant to receive it.

Decision: Auto-close is a repeatable BullMQ job on the existing, unclaimed CLEANUP queue

  • Decision: src/jobs/cleanup/index.ts (currently a log-only stub registered on QueueName.CLEANUP, never wired into queue.bootstrap.ts) is extended the same way 008 extended src/jobs/sla/index.ts — a repeatable job (every 5 minutes; less time-sensitive than 008's breach detection, since this only ever fires after a multi-hour/day waiting period) whose processor calls a single, directly-callable ResolutionsService.runAutoCloseSweep() — querying every ticket with status: 'RESOLUTION_PENDING_CUSTOMER' whose most recent status-change (Ticket.updatedAt) is older than the configured waiting period, transitioning each to RESOLVED.
  • Rationale: CLEANUP is exactly this kind of periodic housekeeping sweep, and — like SLA/ESCALATION before this feature — was defined and left completely unregistered since the original scaffold. Reusing it needs no new QueueName value. A directly-callable sweep method (not only reachable through a running worker) is what let 008's breach-detection tests avoid a real wait; the same shape applies here.
  • Alternatives considered: A per-ticket delayed job scheduled at the moment Resolution is recorded — rejected for the same reason 008 rejected the equivalent per-run design: a reopened-then-re-resolved ticket, or a resolution recorded twice in error, would each need their own cancel/reschedule bookkeeping a polling sweep avoids entirely.

Decision: The auto-close waiting period is one system-wide config value, not a per-scope policy

  • Decision: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS (default 72, i.e. 3 days), exposed via a new src/config/problem-resolution.tsproblemResolutionConfig.autoCloseWaitingHours — mirroring orchestrationConfig.defaultStrategy's exact shape.
  • Rationale: Doc 04 §9 describes "a configured waiting period" in the singular, system-wide sense — not a per-product/category policy table the way 008's SLAPolicy is; doc 06 defines no entity for a scoped auto-close policy. A single env-configured default (Constitution Principle II — never hardcoded, but not over-modeled into a policy table nothing asks for) is the proportionate reading.
  • Alternatives considered: A ResolutionPolicy table scoped like SLAPolicy — rejected as speculative; nothing in doc 04/06 describes per-context auto-close variation, unlike SLA's explicit product/category/priority scoping in doc 06's own SLAPolicy shape.

Decision: The reopen transition is two real, separately-audited status updates

  • Decision: Reopening calls ticketsService.updateStatus(ticketId, 'REOPENED', ...) followed immediately by ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ...) — two real transitions through 003's existing state machine (both already valid edges: RESOLVED|CLOSED → REOPENED and REOPENED → IN_PROGRESS), each producing its own SYSTEM_EVENT ticket message and TICKET_UPDATED publish, rather than a single hop straight to IN_PROGRESS that would skip recording the reopen milestone itself.
  • Rationale: Doc 04 §9's own phrasing — "reopen... should re-enter the appropriate lifecycle stage" — matches the state machine's own two-hop shape exactly; both hops are independently meaningful audit events (Constitution Principle VI), not one compound action worth collapsing.
  • Alternatives considered: A single, direct RESOLVED|CLOSED → IN_PROGRESS transition (bypassing REOPENED as a status value entirely) — rejected; 003's state machine doesn't even define that edge (only REOPENED → IN_PROGRESS), and skipping the REOPENED status would erase a real lifecycle milestone doc 04 explicitly names.

Decision: 008's SLA run is explicitly left untouched by reopen — no new decision needed here

  • Decision: Reopening a ticket does not create, restart, or modify its existing SLARun (008) in any way — the run (if one exists) simply remains in whatever terminal state it was already in (completed or breached).
  • Rationale: 008's own spec.md already closed this decision from its side ("SLA runs are 1:1 with a ticket's first successful assignment only... out of scope for this feature to define a new run automatically") — this feature's job is only to confirm that boundary still holds, not to re-litigate it. FR-018/SC-005 make this an explicit, tested guarantee rather than an accidental side effect of simply not writing any SLARun-touching code.
  • Alternatives considered: Restarting the SLA run on reopen — explicitly out of scope per 008's own spec; would require this feature to modify 008's already-shipped module, which nothing in Phase 9's roadmap line asks for.