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>
13 KiB
13 KiB
Phase 0 Research: Problem Resolution
Decision: Module placement — five real stubs; problem-management/problems is dead scaffold, left untouched
- Decision:
problem-management/{investigation,root-causes,solutions,resolutions,verification}(each a one-file, hardcoded-placeholder stub) are populated directly.problem-management/ problems— a second, never-wiredProblemsRepository.findAll()returning[]— is left exactly as-is; it is not this feature'sProblem(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 throughticketing/tickets's repository.problem-management/problemswas 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
Problemintoproblem-management/problemsand 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
Investigationrow for the sameproblemId— never an update to a prior row. "Which investigation is current" for a problem is simply the most recent bycreatedAt. - Rationale: Doc 06's
Investigationmodel has no version/current-row field at all (unlikeKnowledgeEntry.isCurrentVersionorAssignment.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 bycreatedAt, with no additional schema needed. - Alternatives considered: Adding an
isCurrentboolean toInvestigation(mirroring 007's refinement ofAssignment) — rejected as unrequested schema embellishment; nothing in spec.md requires querying "the current investigation" faster than anorderBy: createdAt desc, take: 1already 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 samesolutionId(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:
Resolutionis validated at write time (a successfully verified solution must exist for the ticket'sproblemId) but theResolutionrow itself stores nosolutionId— doc 06's ownResolutionmodel has no such field (id, ticketId @unique, outcome, resolvedBy, resolvedAtonly). - 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 whatResolutionstores. Inventing a FK doc 06 doesn't define would be scope creep, not correctness. - Alternatives considered: Adding
solutionIdtoResolutionas an additive refinement (this codebase's own established pattern for filling real gaps, e.g. 008'sfirstResponseBreachedAt) — 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 existingTICKET_UPDATEDsubscriber (src/events/handlers/index.ts) picks this up and runs orchestration automatically, exactly as it does for every other route intoHUMAN_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 newEscalationEvent, and a new call into 008'sEscalationServicefor 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_failedas an eleventhEscalationRule.triggerTypeand calling 008'sEscalationService.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- resolutionandPOST /v1/support/tickets/:ticketId/reopen, are gated by the samefastify.authenticateProductIntegration+fastify.checkIntegrationRateLimitpreHandler pairPOST /v1/support/requestsalready uses (002-saas-integration) — verifying the caller'sexternalTenantId/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 byfastify.authenticatefor 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 onQueueName.CLEANUP, never wired intoqueue.bootstrap.ts) is extended the same way 008 extendedsrc/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-callableResolutionsService.runAutoCloseSweep()— querying every ticket withstatus: 'RESOLUTION_PENDING_CUSTOMER'whose most recent status-change (Ticket.updatedAt) is older than the configured waiting period, transitioning each toRESOLVED. - Rationale:
CLEANUPis exactly this kind of periodic housekeeping sweep, and — likeSLA/ESCALATIONbefore this feature — was defined and left completely unregistered since the original scaffold. Reusing it needs no newQueueNamevalue. 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
Resolutionis 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(default72, i.e. 3 days), exposed via a newsrc/config/problem-resolution.ts—problemResolutionConfig.autoCloseWaitingHours— mirroringorchestrationConfig.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
SLAPolicyis; 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
ResolutionPolicytable scoped likeSLAPolicy— 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 ownSLAPolicyshape.
Decision: The reopen transition is two real, separately-audited status updates
- Decision: Reopening calls
ticketsService.updateStatus(ticketId, 'REOPENED', ...)followed immediately byticketsService.updateStatus(ticketId, 'IN_PROGRESS', ...)— two real transitions through 003's existing state machine (both already valid edges:RESOLVED|CLOSED → REOPENEDandREOPENED → IN_PROGRESS), each producing its ownSYSTEM_EVENTticket message andTICKET_UPDATEDpublish, rather than a single hop straight toIN_PROGRESSthat 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_PROGRESStransition (bypassingREOPENEDas a status value entirely) — rejected; 003's state machine doesn't even define that edge (onlyREOPENED → IN_PROGRESS), and skipping theREOPENEDstatus 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 (completedorbreached). - 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.