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:
saqib mir
2026-09-03 14:37:42 +05:30
co-authored by Claude Sonnet 5
parent 14c6793460
commit 9ce34d8ca4
5 changed files with 546 additions and 0 deletions
@@ -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).
+134
View File
@@ -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.
+174
View File
@@ -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.