plan: design for problem resolution feature (009)
Phase 0 research resolves module placement (problem-management/problems confirmed dead/unwired, left untouched), Investigation's version-row-per- attempt shape, the strict investigation->root-cause->solution-> implementation->verification existence chain, why Resolution has no solutionId FK (matches doc06 exactly), why verification-failure escalation reuses 003/007's plain HUMAN_ESCALATION transition instead of adding an eleventh trigger type to 008's already-shipped escalation rules, the customer-facing route design (reusing 002's inbound trust boundary rather than fastify.authenticate), and the auto-close sweep design (the already-defined-but-unused CLEANUP queue, mirroring 008's breach-detection job). Phase 1 adds data-model.md, the admin/customer-facing contract, and six quickstart scenarios covering the full sequential workflow through customer confirmation, auto-close, and reopen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
14c6793460
commit
9ce34d8ca4
@@ -0,0 +1,67 @@
|
|||||||
|
# Contract: Problem Resolution
|
||||||
|
|
||||||
|
Agent-facing write routes are gated by `fastify.authenticate` (known limitation inherited from
|
||||||
|
002-008). Customer-facing routes are gated by `fastify.authenticateProductIntegration` +
|
||||||
|
`fastify.checkIntegrationRateLimit` (002's inbound trust boundary, research.md) and additionally
|
||||||
|
verify the caller's token identifies the same tenant/user as the ticket's own recorded
|
||||||
|
`externalTenantId`/`externalUserId` — a `403` if they don't match.
|
||||||
|
|
||||||
|
## Investigation
|
||||||
|
|
||||||
|
- `POST /admin/problems/:problemId/investigations` — body `{ investigator, findings, evidence?,
|
||||||
|
internalNotes?, status? }` (`status` defaults to `open`). `404` if `problemId` doesn't exist.
|
||||||
|
- `GET /admin/problems/:problemId/investigations` — every investigation for the problem, newest
|
||||||
|
first, including `internalNotes` (agent-facing).
|
||||||
|
- `GET /problems/:problemId/investigations` — customer/public-safe variant: same list, with
|
||||||
|
`internalNotes` always omitted (FR-003).
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
- `POST /admin/problems/:problemId/root-causes` — body `{ type, description }`. `400` if `type`
|
||||||
|
isn't one of the five validated values. `409` if no investigation exists yet for the problem.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
- `POST /admin/problems/:problemId/solutions` — body `{ proposed }`. `409` if no root cause
|
||||||
|
exists yet for the problem.
|
||||||
|
- `PATCH /admin/solutions/:solutionId/approve` — sets `approved: true`.
|
||||||
|
- `POST /admin/solutions/:solutionId/implementation` — body `{ notes?, implementedBy }`. `409` if
|
||||||
|
the solution isn't approved, or already has an implementation.
|
||||||
|
- `POST /admin/solutions/:solutionId/verification` — body `{ method, result, evidence? }`. `400`
|
||||||
|
if `method` isn't one of the four validated values. `409` if the solution has no implementation
|
||||||
|
yet, or already has a verification.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
- `POST /admin/tickets/:ticketId/resolution` — body `{ outcome, resolvedBy }`. `409` if the
|
||||||
|
ticket's problem has no solution with a successful verification. Transitions the ticket to
|
||||||
|
`RESOLUTION_PENDING_CUSTOMER` on success.
|
||||||
|
- `POST /v1/support/tickets/:ticketId/confirm-resolution` — customer-facing (trust boundary
|
||||||
|
above). `409` if the ticket isn't in `RESOLUTION_PENDING_CUSTOMER`. Transitions to `RESOLVED`.
|
||||||
|
|
||||||
|
## Reopen
|
||||||
|
|
||||||
|
- `POST /v1/support/tickets/:ticketId/reopen` — customer-facing. `409` if the ticket isn't
|
||||||
|
`RESOLVED` or `CLOSED`.
|
||||||
|
- `POST /admin/tickets/:ticketId/reopen` — agent-facing, same precondition.
|
||||||
|
|
||||||
|
Both reopen routes transition `RESOLVED|CLOSED → REOPENED → IN_PROGRESS` (research.md's two-hop
|
||||||
|
decision) and touch nothing else — no new `SLARun`, no mutation of any prior investigation/root-
|
||||||
|
cause/solution/verification/resolution record (FR-018, SC-005).
|
||||||
|
|
||||||
|
## Guarantees (callable contract)
|
||||||
|
|
||||||
|
1. **Every investigation/root-cause/solution/implementation/verification/resolution record,
|
||||||
|
once created, is retrievable exactly as given and is never silently overwritten by a later
|
||||||
|
action in the same problem's lifecycle** (SC-001).
|
||||||
|
2. **`internalNotes` never appears in a customer-facing investigation read**, verified by a
|
||||||
|
direct comparison against the agent-facing read of the same record (SC-002).
|
||||||
|
3. **A `Resolution` can never be recorded without a successfully verified solution already on
|
||||||
|
file for the ticket's problem** (SC-003).
|
||||||
|
4. **A ticket in `RESOLUTION_PENDING_CUSTOMER` with no explicit confirmation reaches `RESOLVED`
|
||||||
|
within one auto-close job cycle of its configured waiting period elapsing** (SC-004).
|
||||||
|
5. **Reopening a ticket leaves every prior problem-resolution record and its `SLARun` (008)
|
||||||
|
untouched** (SC-005).
|
||||||
|
6. **A verification failure choosing escalation moves the ticket to `HUMAN_ESCALATION` through
|
||||||
|
003's existing state machine, and 007's orchestration re-runs automatically from that
|
||||||
|
transition alone** — no new escalation mechanism is introduced by this feature.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Data Model: Problem Resolution
|
||||||
|
|
||||||
|
Every model below matches `docs/06-database-schema.md` "Domain: Problem Resolution" field-for-
|
||||||
|
field — no new columns invented (research.md explains the two places this was deliberately
|
||||||
|
considered and rejected: `Resolution.solutionId`, `Investigation.isCurrent`).
|
||||||
|
|
||||||
|
## Investigation
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `problemId` | `String` | FK to `Problem.id` (the existing `ticketing/tickets` one) |
|
||||||
|
| `investigator` | `String` | agentId — same non-FK free-text convention as `TicketMessage.authorRef` |
|
||||||
|
| `findings` | `Json` | structured, not free text (doc 04 §4) |
|
||||||
|
| `evidence` | `Json?` | |
|
||||||
|
| `internalNotes` | `String?` | never exposed on any customer-facing read (FR-003) |
|
||||||
|
| `status` | `String` | `open \| complete` |
|
||||||
|
| `createdAt` | `DateTime @default(now())` | ordering field for "most recent investigation" (research.md — no `isCurrent` flag) |
|
||||||
|
|
||||||
|
## RootCause
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `problemId` | `String` | FK to `Problem.id` |
|
||||||
|
| `type` | `String` | `technical \| configuration \| external_dependency \| business \| contributing_factor` — validated, not free text (FR-005) |
|
||||||
|
| `description` | `String` | |
|
||||||
|
| `createdAt` | `DateTime @default(now())` | |
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `problemId` | `String` | FK to `Problem.id` |
|
||||||
|
| `proposed` | `String` | |
|
||||||
|
| `approved` | `Boolean @default(false)` | explicit approval action (FR-007) |
|
||||||
|
| `createdAt` | `DateTime @default(now())` | |
|
||||||
|
| `implementation` | `SolutionImplementation?` | inverse of the 1:1 below |
|
||||||
|
| `verification` | `SolutionVerification?` | inverse of the 1:1 below |
|
||||||
|
|
||||||
|
## SolutionImplementation
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `solutionId` | `String @unique` | 1:1 with `Solution` — a second implementation attempt is rejected (FR-007 Edge Cases), not overwritten |
|
||||||
|
| `notes` | `String?` | |
|
||||||
|
| `implementedBy` | `String` | agentId |
|
||||||
|
| `implementedAt` | `DateTime @default(now())` | |
|
||||||
|
|
||||||
|
## SolutionVerification
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `solutionId` | `String @unique` | 1:1 with `Solution` — at most one verification per solution (data-model note in Edge Cases) |
|
||||||
|
| `method` | `String` | `automated \| technical_test \| customer_confirmation \| agent_confirmation` — validated (FR-011) |
|
||||||
|
| `result` | `String` | `success \| failed` |
|
||||||
|
| `evidence` | `Json?` | |
|
||||||
|
| `verifiedAt` | `DateTime @default(now())` | |
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `String @id @default(cuid())` | |
|
||||||
|
| `ticketId` | `String @unique` | one resolution per ticket |
|
||||||
|
| `outcome` | `String` | |
|
||||||
|
| `resolvedBy` | `String` | `"ai"` or agentId |
|
||||||
|
| `resolvedAt` | `DateTime @default(now())` | |
|
||||||
|
|
||||||
|
No `solutionId` FK here (research.md) — the "a successfully verified solution exists for this
|
||||||
|
ticket's problem" precondition (FR-014) is enforced by the service layer at write time via a
|
||||||
|
join through `Ticket.problemId → Solution.problemId → Solution.verification.result`, not stored.
|
||||||
|
|
||||||
|
## Relations added to existing models
|
||||||
|
|
||||||
|
- `Problem.investigations Investigation[]`, `Problem.rootCauses RootCause[]`,
|
||||||
|
`Problem.solutions Solution[]` (all on the existing `ticketing/tickets`-owned `Problem` model)
|
||||||
|
- `Ticket.resolution Resolution?` (inverse of `Resolution.ticketId @unique`)
|
||||||
|
|
||||||
|
## Validation chain (service layer, not DB constraints — matches 003's own state-machine convention)
|
||||||
|
|
||||||
|
1. `RootCause` create → `Problem` must have at least one `Investigation` (FR-006).
|
||||||
|
2. `Solution` create → `Problem` must have at least one `RootCause` (FR-009).
|
||||||
|
3. `SolutionImplementation` create → the `Solution` must have `approved: true` (FR-008), and must
|
||||||
|
not already have an implementation (unique constraint surfaces this as a conflict).
|
||||||
|
4. `SolutionVerification` create → the `Solution` must already have a `SolutionImplementation`
|
||||||
|
(verification is of something implemented, doc 04 §7).
|
||||||
|
5. `Resolution` create → the `Ticket`'s `Problem` must have at least one `Solution` whose
|
||||||
|
`verification.result === 'success'` (FR-014).
|
||||||
|
|
||||||
|
## Out of scope for this data model (per spec.md Assumptions)
|
||||||
|
|
||||||
|
- No new `EscalationRule.triggerType` value for verification failure (research.md — reuses the
|
||||||
|
plain `HUMAN_ESCALATION` status transition instead).
|
||||||
|
- No `ResolutionPolicy`/scoped auto-close configuration entity — one system-wide config value
|
||||||
|
(research.md).
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Implementation Plan: Problem Resolution
|
||||||
|
|
||||||
|
**Branch**: `009-problem-resolution` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
|
||||||
|
**Input**: Feature specification from `specs/009-problem-resolution/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Populate the five real `problem-management/{investigation,root-causes,solutions,resolutions,
|
||||||
|
verification}` stubs (each currently a one-file placeholder — `getInvestigationStatus` always
|
||||||
|
`PENDING`, `getResolutions` always `[]`, etc.) with the real doc-04-workflow engine: a strict
|
||||||
|
existence chain from investigation through root cause, solution, implementation, and
|
||||||
|
verification; a `Resolution` record gated on a successfully verified solution, moving the ticket
|
||||||
|
to `RESOLUTION_PENDING_CUSTOMER`; explicit customer confirmation (reusing 002's inbound trust
|
||||||
|
boundary) or a durable auto-close sweep (reusing the unregistered `CLEANUP` queue stub) into
|
||||||
|
`RESOLVED`; and a reopen path (customer or agent) that re-enters `IN_PROGRESS` through 003's
|
||||||
|
existing `REOPENED` state without touching any prior record or 008's `SLARun`.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.4 / Node.js 20+.
|
||||||
|
|
||||||
|
**Primary Dependencies**: Prisma (new models), Zod, BullMQ (reused `CLEANUP` queue). No new
|
||||||
|
runtime dependency.
|
||||||
|
|
||||||
|
**Storage**: PostgreSQL via Prisma (new `Investigation`, `RootCause`, `Solution`,
|
||||||
|
`SolutionImplementation`, `SolutionVerification`, `Resolution` models). Reuses
|
||||||
|
`src/infrastructure/queue` for the auto-close sweep, same as 008's breach-detection job.
|
||||||
|
|
||||||
|
**Testing**: Vitest — unit tests for the existence-chain validation logic and the auto-close
|
||||||
|
due-window predicate; integration tests for the full sequential workflow (investigation through
|
||||||
|
resolution), the customer-confirmation and auto-close paths, and reopen leaving prior records and
|
||||||
|
an `SLARun` untouched.
|
||||||
|
|
||||||
|
**Target Platform**: Same Fastify modular monolith. Populates
|
||||||
|
`src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}/`.
|
||||||
|
Adds two new customer-facing routes under `/v1/support/tickets/:ticketId/...` alongside the
|
||||||
|
existing `POST /v1/support/requests` (002).
|
||||||
|
|
||||||
|
**Project Type**: Backend service — single project.
|
||||||
|
|
||||||
|
**Constraints**: MUST reject out-of-order writes (root cause before investigation, etc. — FR-006/
|
||||||
|
FR-008/FR-009); MUST NOT expose `internalNotes` on any customer-facing read (FR-003); MUST gate
|
||||||
|
`Resolution` on a real successful verification (FR-014); MUST auto-close durably, not via an
|
||||||
|
in-memory timer (FR-016, Constitution Principle VII); MUST NOT create a new `SLARun` on reopen
|
||||||
|
(FR-018).
|
||||||
|
|
||||||
|
**Scale/Scope**: Five populated modules, one new BullMQ repeatable job (reusing an existing
|
||||||
|
queue), two new customer-facing routes reusing 002's trust boundary, one new agent-facing reopen
|
||||||
|
route. Explicitly excludes: a rendered customer confirmation UI (010's territory), a new
|
||||||
|
escalation-rule trigger type for verification failure (reuses 003/007's existing transition
|
||||||
|
instead), per-scope auto-close policy (one system-wide config value).
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle / Section | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| I. SaaS Is the Sole Identity & Access Authority | Customer-facing routes authenticate via 002's product-integration token, never a SupportHub-native customer login — and additionally verify the token's tenant/user matches the ticket's own recorded values. | PASS |
|
||||||
|
| II. Configuration Over Hardcoding | The auto-close waiting period is env-configured (research.md), never a hardcoded number; validated-value sets (root-cause type, verification method) are Zod-enforced closed lists matching doc 04's own documented values, not ad hoc. | PASS |
|
||||||
|
| III. Layered Architecture With Enforced Module Boundaries | Five modules follow the standard shape; each references `ticketing/tickets`'s `Problem` (one-directional, already established), and the verification-failure-escalation path calls `ticketsService.updateStatus` directly rather than reaching into 008's `EscalationService` — no new module dependency edge into 008 at all. | PASS |
|
||||||
|
| IV. AI Recommends, Deterministic Policy Decides | `Resolution.resolvedBy` accepts `"ai"` per doc 06's own shape, but this feature adds no AI-driven decision logic of its own — every gate (approval, verification result, escalate-vs-reinvestigate) is an explicit human/deterministic action. | PASS |
|
||||||
|
| V. Evidence-Based Verification | This principle's own domain — `SolutionVerification.evidence`/`Investigation.evidence` are exactly the durable evidence records Principle V requires before a resolution is trusted. | PASS |
|
||||||
|
| VI. Durable Audit & History | Every investigation attempt is its own preserved row (never overwritten); reopen produces two real, separately-audited status transitions rather than one collapsed hop. | PASS |
|
||||||
|
| VII. Concurrency-Safe, Durable Job Handling | Auto-close is a repeatable BullMQ job querying durable DB state (`Ticket.status`/`updatedAt`), never an in-memory timer — same discipline 008's breach-detection sweep already established. | PASS |
|
||||||
|
| VIII. Problem and Ticket Are Separate, Related Entities | This principle's own domain — every investigation/root-cause/solution record is scoped to `Problem`, never `Ticket`, while `Resolution` (necessarily ticket-scoped, since a shared `Problem` could span multiple tickets) is the one exception doc 06 itself defines. | PASS |
|
||||||
|
| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure only, no new dependency. | PASS |
|
||||||
|
|
||||||
|
No violations requiring Complexity Tracking justification.
|
||||||
|
|
||||||
|
## Post-Design Constitution Re-check
|
||||||
|
|
||||||
|
All gates above remain PASS after Phase 1 design. Worth calling out against Principle VIII
|
||||||
|
explicitly: `Resolution.ticketId` (not `problemId`) is the one place in this whole feature where
|
||||||
|
a record is ticket-scoped rather than problem-scoped — a deliberate, doc-06-defined exception
|
||||||
|
(a shared `Problem` can have multiple tickets, each needing its own outcome), not an
|
||||||
|
inconsistency with the rest of this feature's problem-scoped chain.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/009-problem-resolution/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
supporthub-api/
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # MODIFIED — add Investigation, RootCause,
|
||||||
|
│ Solution, SolutionImplementation,
|
||||||
|
│ SolutionVerification, Resolution
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── problem-resolution.ts # NEW — autoCloseWaitingHours
|
||||||
|
│ ├── jobs/
|
||||||
|
│ │ └── cleanup/index.ts # REPLACED stub — schedules the repeatable
|
||||||
|
│ │ auto-close sweep (research.md)
|
||||||
|
│ └── modules/
|
||||||
|
│ ├── ticketing/tickets/ # MODIFIED — reopen calls updateStatus twice
|
||||||
|
│ └── problem-management/
|
||||||
|
│ ├── problems/ # UNTOUCHED — dead duplicate scaffold
|
||||||
|
│ │ (research.md) — not this feature's Problem
|
||||||
|
│ ├── investigation/ # REPLACED stub — full standard shape
|
||||||
|
│ ├── root-causes/ # REPLACED stub — full standard shape
|
||||||
|
│ ├── solutions/ # REPLACED stub — full standard shape
|
||||||
|
│ ├── verification/ # REPLACED stub — full standard shape
|
||||||
|
│ └── resolutions/ # REPLACED stub — full standard shape,
|
||||||
|
│ including the auto-close sweep + the two
|
||||||
|
│ new customer-facing routes
|
||||||
|
└── tests/
|
||||||
|
├── unit/problem-management/ # existence-chain validation, auto-close
|
||||||
|
│ due-window predicate
|
||||||
|
└── integration/ # full sequential workflow, customer
|
||||||
|
confirmation, auto-close, reopen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single project. Every module gets the full standard shape (each has its
|
||||||
|
own real CRUD/read surface, unlike 007's internal-only `routing`) — matching 008's precedent for
|
||||||
|
a multi-module feature where every module has genuine callers beyond another module in the same
|
||||||
|
feature.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
*No constitution violations — table intentionally omitted.*
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Quickstart: Validating Problem Resolution
|
||||||
|
|
||||||
|
Prerequisites: migrations applied; a ticket created per 003-ticketing's own quickstart (this
|
||||||
|
feature works against its `problemId`).
|
||||||
|
|
||||||
|
## Scenario 1 — structured investigation, preserved across attempts (User Story 1)
|
||||||
|
|
||||||
|
1. `POST /admin/problems/:problemId/investigations` with findings/evidence/internalNotes.
|
||||||
|
**Expected**: `201`, retrievable via `GET /admin/problems/:problemId/investigations` with
|
||||||
|
every field intact.
|
||||||
|
2. `GET /problems/:problemId/investigations` (customer-safe variant). **Expected**: same rows,
|
||||||
|
`internalNotes` absent from every one.
|
||||||
|
3. Record a second investigation for the same problem. **Expected**: both rows remain, in order —
|
||||||
|
the first is never overwritten.
|
||||||
|
|
||||||
|
## Scenario 2 — root cause requires an investigation on file (User Story 2)
|
||||||
|
|
||||||
|
1. `POST /admin/problems/:problemId/root-causes` for a problem with no investigation.
|
||||||
|
**Expected**: `409`.
|
||||||
|
2. Repeat after Scenario 1's investigation exists. **Expected**: `201`, `type` one of the five
|
||||||
|
validated values.
|
||||||
|
3. Repeat with an invalid `type`. **Expected**: `400`.
|
||||||
|
|
||||||
|
## Scenario 3 — solution proposed, approved, implemented as distinct states (User Story 3)
|
||||||
|
|
||||||
|
1. `POST /admin/problems/:problemId/solutions` before any root cause exists. **Expected**: `409`.
|
||||||
|
2. Repeat after Scenario 2's root cause exists. **Expected**: `201`, `approved: false`.
|
||||||
|
3. `POST /admin/solutions/:solutionId/implementation` before approval. **Expected**: `409`.
|
||||||
|
4. `PATCH /admin/solutions/:solutionId/approve`, then repeat step 3. **Expected**: `201`.
|
||||||
|
5. Repeat step 3 again (a second implementation). **Expected**: `409`.
|
||||||
|
|
||||||
|
## Scenario 4 — verification, and what happens on failure (User Story 4)
|
||||||
|
|
||||||
|
1. `POST /admin/solutions/:solutionId/verification` with `result: success`. **Expected**: `201`.
|
||||||
|
2. On a different solution (Scenario 3 repeated for a fresh problem), verify with
|
||||||
|
`result: failed`. **Expected**: `201`, but no `Resolution` can be recorded referencing it
|
||||||
|
(Scenario 5, step 1).
|
||||||
|
3. On the failed-verification path, request a fresh investigation. **Expected**: a new
|
||||||
|
`Investigation` row for the same problem, the original untouched.
|
||||||
|
4. On the failed-verification path, request escalation instead. **Expected**: the ticket
|
||||||
|
transitions to `HUMAN_ESCALATION`, and (007) is automatically assigned from that transition
|
||||||
|
alone — no separate escalation call needed.
|
||||||
|
|
||||||
|
## Scenario 5 — resolution, customer confirmation, and auto-close (User Story 5)
|
||||||
|
|
||||||
|
1. `POST /admin/tickets/:ticketId/resolution` for a ticket whose problem has no successfully
|
||||||
|
verified solution. **Expected**: `409`.
|
||||||
|
2. Repeat once Scenario 4 step 1's successful verification exists. **Expected**: `201`, ticket
|
||||||
|
status becomes `RESOLUTION_PENDING_CUSTOMER`.
|
||||||
|
3. `POST /v1/support/tickets/:ticketId/confirm-resolution` with the customer's own token.
|
||||||
|
**Expected**: `200`, ticket status becomes `RESOLVED`.
|
||||||
|
4. Repeat steps 1-2 for a second ticket; instead of confirming, directly age the ticket's
|
||||||
|
`updatedAt` past the configured waiting period and run the auto-close sweep.
|
||||||
|
**Expected**: ticket status becomes `RESOLVED` without any explicit confirmation call.
|
||||||
|
|
||||||
|
## Scenario 6 — reopen (User Story 6)
|
||||||
|
|
||||||
|
1. `POST /v1/support/tickets/:ticketId/reopen` on the `RESOLVED` ticket from Scenario 5.
|
||||||
|
**Expected**: `200`, ticket status becomes `IN_PROGRESS` (via `REOPENED`).
|
||||||
|
2. `GET /tickets/:ticketId/resolution` (or the admin equivalent). **Expected**: the original
|
||||||
|
`Resolution` record is still present, unchanged.
|
||||||
|
3. If the ticket has an `SLARun` (008) from its original assignment, **Expected**: it is
|
||||||
|
unchanged — no new run created, its status exactly what it was before the reopen.
|
||||||
|
4. `POST /admin/tickets/:ticketId/reopen` on a `CLOSED` ticket, as an agent. **Expected**: same
|
||||||
|
`REOPENED → IN_PROGRESS` result, this time attributed to the agent, not `"customer"`.
|
||||||
|
|
||||||
|
## What "done" looks like
|
||||||
|
|
||||||
|
All six scenarios pass, together demonstrating every functional requirement and success
|
||||||
|
criterion in `spec.md` — including SC-004's auto-close job cycle and SC-005's "reopen touches
|
||||||
|
nothing else" guarantee, both of which need direct-DB-state manipulation (not just waiting) to
|
||||||
|
verify without a multi-hour real-time test run.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 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 inbound trust boundary; 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 the same
|
||||||
|
`fastify.authenticateProductIntegration` + `fastify.checkIntegrationRateLimit` preHandler pair
|
||||||
|
`POST /v1/support/requests` already uses (002-saas-integration) — verifying the caller's
|
||||||
|
`externalTenantId`/`externalUserId` (from the signed token) 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).
|
||||||
|
- **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 generically enough to reuse, not something this feature has to reinvent.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.ts` — `problemResolutionConfig.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.
|
||||||
Reference in New Issue
Block a user