/speckit-plan output for 003-ticketing: technical context and constitution gate check (all PASS), Phase 0 research (9 decisions: ticket code format, explicit 12-state lifecycle transition table, optimistic concurrency via version column, idempotency-key upsert reusing 002's CustomerReference pattern, explicit-reference-only recurring-problem linking, config-driven message visibility mapping, presigned-PUT attachment pipeline, a fail-closed placeholder malware scanner since none exists in this stack, and adding MinIO to Docker Compose for local/test S3-compatible storage), Phase 1 data model (Ticket/Problem/TicketMessage/TicketAttachment plus the inbound request -> ticket creation behavior), the lifecycle/messages/ attachments contract, and a 6-scenario quickstart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
10 KiB
Phase 0 Research: Ticket Creation, Messages & Attachments
Decision: Ticket code format
- Decision:
<PRODUCT_CODE>-<YEAR>-<SEQUENCE>, e.g.DQB-2026-00567(matches doc 01/04's own example exactly).PRODUCT_CODEis derived from theProduct.externalProductId(first alphabetic segment, uppercased, max 4 chars, falling back to a generic prefix if the external id doesn't yield one cleanly) andSEQUENCEis a per-product-per-year monotonic counter. - Rationale: Doc 01/04 use this exact shape as the running example throughout the guide; matching it keeps generated codes recognizable against the spec's own illustrations. A per-product-per-year counter (not a global one) keeps codes short and stable even as ticket volume grows across many products.
- Alternatives considered: A UUID-derived short code — rejected, not human-referenceable the way doc 01's own example implies support agents need ("DQB-2026-00567" is meant to be readable and speakable, not just unique).
Decision: Ticket lifecycle state machine
- Decision: States, exactly as doc 04 §1 and doc 06's
Ticket.statuscomment list them:NEW, AI_ANALYZING, AI_TROUBLESHOOTING, AI_VERIFYING, AI_RESOLVED, HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED. Valid transitions are defined as an explicit adjacency table (not "anything can go anywhere"):NEW→AI_ANALYZING,HUMAN_ESCALATIONAI_ANALYZING→AI_TROUBLESHOOTING,HUMAN_ESCALATIONAI_TROUBLESHOOTING→AI_VERIFYING,HUMAN_ESCALATIONAI_VERIFYING→AI_RESOLVED,HUMAN_ESCALATIONAI_RESOLVED→RESOLUTION_PENDING_CUSTOMER,RESOLVED,HUMAN_ESCALATION(verification failure re-escalating, per doc 04 §7)HUMAN_ESCALATION→IN_PROGRESSIN_PROGRESS→WAITING_FOR_CUSTOMER,RESOLUTION_PENDING_CUSTOMER,HUMAN_ESCALATION(re-escalation)WAITING_FOR_CUSTOMER→IN_PROGRESSRESOLUTION_PENDING_CUSTOMER→RESOLVED,IN_PROGRESS(customer disputes, reopens investigation per doc 04 §7)RESOLVED→CLOSED,REOPENEDCLOSED→REOPENEDREOPENED→IN_PROGRESS,AI_ANALYZING
- Rationale: An explicit table is what makes FR-006 ("only valid transitions") actually enforceable and testable, rather than a status field anyone can set to anything. The specific edges are read directly off doc 04's described flows (§1 happy path, §3 human flow, §7 verification-failure branches, §9 resolve/close/reopen).
- Alternatives considered: No enforced table (any transition allowed) — rejected, directly contradicts FR-006; a generic workflow-engine library — rejected as disproportionate for a fixed, spec-defined state set with no per-tenant customization need at this phase.
Decision: Ticket status optimistic concurrency
- Decision:
Ticketgains anIntversioncolumn (default1, matching doc 06's general guidance to add this to mutable shared-state tables). A status update runsUPDATE tickets SET status = ?, version = version + 1 WHERE id = ? AND version = ?; zero rows affected means the caller's view was stale, and the update is rejected (409 CONFLICT, caller must re-read and retry). - Rationale: This is the standard optimistic-locking pattern and satisfies FR-007/SC-007
without needing row-level locks or a distributed lock service — Postgres's own atomic
UPDATE ... WHEREalready guarantees exactly one concurrent writer wins. - Alternatives considered:
SELECT ... FOR UPDATEpessimistic locking — rejected, holds a transaction open across what could be a slow caller round-trip; optimistic locking only holds the lock for the single atomic statement.
Decision: Idempotency-key enforcement
- Decision:
Ticketgains a nullable, unique-per-productidempotencyKeycolumn (@@unique([productId, idempotencyKey]), nullable values excluded from the constraint per Postgres's standard NULL-handling). Ticket creation is an atomicINSERT ... ON CONFLICT (productId, idempotencyKey) DO NOTHING RETURNING *-style upsert; a conflict means the key was already used, and the existing ticket is looked up and returned instead. - Rationale: Same atomic-upsert pattern already used for
CustomerReference.findOrCreatein 002-saas-integration — proven, race-safe under concurrent retries, no separate idempotency-key cache/table needed. Scoped per-product (not globally unique) because two different products' clients could coincidentally generate the same key value. - Alternatives considered: A separate
IdempotencyKeytracking table — rejected as unnecessary indirection when the key can live directly on the row it's deduplicating.
Decision: Recurring-problem linking
- Decision: Per spec.md's Assumptions, this feature links to an existing
Problemonly when the inbound request'sreferenceIds(docs/02 §3) or the ticket-creation call explicitly supplies a known prior ticket/problem id; otherwise a newProblemis always created. No fuzzy/semantic matching is attempted here. - Rationale: Real recurring-problem detection needs product-aware understanding of symptoms — that's the AI-support feature's job (Phase 4), not this one's. Building a heuristic here would either be too naive to be useful or scope-creep into Phase 4's actual responsibility.
- Alternatives considered: Simple text-similarity matching on
Problem.statement— rejected, would produce false-positive links (different problems, similar wording) with no way to correct them until Phase 4 exists to do it properly.
Decision: Message type → visibility mapping
- Decision: A single source-of-truth constant map (not per-message logic):
CUSTOMER_MESSAGE: true, AI_MESSAGE: true, AGENT_MESSAGE: true, SYSTEM_EVENT: true, INTERNAL_NOTE: false, INVESTIGATION_NOTE: false, SOLUTION_NOTE: falseTicketMessage.visibleToCustomer(doc 06's own field) is set from this map at write time, never accepted as caller input — and customer-scoped reads filterWHERE visibleToCustomer = trueat the repository/query layer, not by trimming the response after fetching everything. - Rationale: Directly satisfies FR-008 (visibility derived from type, not independently settable) and FR-009 (enforced at the query layer, so a serialization bug can't leak a note that was never fetched in the first place).
- Alternatives considered: Accepting
visibleToCustomeras an API input — rejected outright, this is exactly the "trust the client" mistake FR-008 exists to prevent.
Decision: Attachment pipeline shape
- Decision: Two-phase upload — (1) caller requests a presigned PUT URL for a given
filename/content-type (
storageServicealready has the S3 client wired, needs a presigned-PUT method added alongside its existing presigned-GETgetPresignedUrl); (2) caller PUTs the file directly to object storage, then confirms the upload, which creates theTicketAttachmentrow (scanStatus: pending) and enqueues a scan job on the existingattachments-queue(src/jobs/attachments/index.ts, currently a log-only stub). Downloads use the existingstorageService.getPresignedUrl(GET), but only after the repository confirmsscanStatus: clean— the presigned URL is never generated for apending/infected/rejectedattachment. - Rationale: A presigned-PUT upload means file bytes never transit the Fastify process at all
(satisfies FR-011 more strongly than a server-side proxy-upload would, and avoids adding
multipart-body handling to this feature). The existing
attachments-queuescaffold is exactly where the scan step belongs per doc 07's own module layout. - Alternatives considered: Server-side proxy upload (client → Fastify → S3) — rejected as unnecessary complexity/latency when presigned PUT achieves the same security properties with less code.
Decision: Malware scanning — no scanner exists in this stack yet
- Decision: Define a small
MalwareScannerinterface (scan(objectKey): Promise<'clean' | 'infected'>) and ship one implementation now: a clearly-namedUnimplementedPlaceholderScannerthat always returns'infected'(fails closed, never'clean') and logs a loud warning — so attachments are correctly gated as never-downloadable until a real scanner (e.g. ClamAV via a sidecar, or a cloud provider's scanning API) is wired in as a follow-up. The job worker calls whichever implementation is bound at startup. - Rationale: No antivirus/scanning service exists anywhere in this codebase or its
dependencies, and standing one up (e.g. deploying ClamAV) is real infrastructure work outside
this feature's scope. The alternative — a scanner that always returns
'clean'— would satisfy the code path but silently defeat FR-013's actual security purpose; failing closed means the pipeline is honest about "attachments aren't actually safe to download yet" rather than pretending they are. This mirrors the same honesty principle as the credential-storage placeholder decision in 002-saas-integration's research.md. - Alternatives considered: Always-
'clean'stub — rejected, defeats the feature's own purpose and would be easy to forget to replace since nothing would ever surface the gap. Skipping the scan step's implementation entirely (leave the job as its current log-only stub) — rejected, FR-013 requires attachments be gated on scan status, and a permanently-pendingattachment (nothing ever calls the job) makes attachments unusable rather than correctly gated.
Decision: Local/test object storage — add MinIO to Docker Compose
- Decision: Add a
minioservice todocker-compose.test.ymlanddocker-compose.development.yml, and setAWS_S3_ENDPOINTin.env.test/.env.developmentto point at it.storageClient.tsalready branches onstorageConfig.endpointbeing set (forcePathStyle: truefor MinIO compatibility) — no client code changes needed, only compose wiring and env values. - Rationale: Doc 04 explicitly calls for "MinIO for local dev," and the client already
anticipated this (the
endpoint/forcePathStylebranch exists in code that predates this feature) — this decision just finishes wiring what was already half-built. - Alternatives considered: Mocking S3 calls in tests instead of running real MinIO — rejected, this feature's whole point includes verifying presigned URLs actually work and expire, which a mock can't meaningfully verify.