From 9ce34d8ca49405fba3f62bfd02790b244bca44c8 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 14:37:42 +0530 Subject: [PATCH] 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 --- .../contracts/problem-resolution-contract.md | 67 +++++++ specs/009-problem-resolution/data-model.md | 99 ++++++++++ specs/009-problem-resolution/plan.md | 134 ++++++++++++++ specs/009-problem-resolution/quickstart.md | 72 ++++++++ specs/009-problem-resolution/research.md | 174 ++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 specs/009-problem-resolution/contracts/problem-resolution-contract.md create mode 100644 specs/009-problem-resolution/data-model.md create mode 100644 specs/009-problem-resolution/plan.md create mode 100644 specs/009-problem-resolution/quickstart.md create mode 100644 specs/009-problem-resolution/research.md diff --git a/specs/009-problem-resolution/contracts/problem-resolution-contract.md b/specs/009-problem-resolution/contracts/problem-resolution-contract.md new file mode 100644 index 0000000..f2c8848 --- /dev/null +++ b/specs/009-problem-resolution/contracts/problem-resolution-contract.md @@ -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. diff --git a/specs/009-problem-resolution/data-model.md b/specs/009-problem-resolution/data-model.md new file mode 100644 index 0000000..20c5d04 --- /dev/null +++ b/specs/009-problem-resolution/data-model.md @@ -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). diff --git a/specs/009-problem-resolution/plan.md b/specs/009-problem-resolution/plan.md new file mode 100644 index 0000000..ad0f6d6 --- /dev/null +++ b/specs/009-problem-resolution/plan.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.* diff --git a/specs/009-problem-resolution/quickstart.md b/specs/009-problem-resolution/quickstart.md new file mode 100644 index 0000000..49b992b --- /dev/null +++ b/specs/009-problem-resolution/quickstart.md @@ -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. diff --git a/specs/009-problem-resolution/research.md b/specs/009-problem-resolution/research.md new file mode 100644 index 0000000..c2224a1 --- /dev/null +++ b/specs/009-problem-resolution/research.md @@ -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.