Files
support_backend/specs/003-ticketing/research.md
T
saqib mirandClaude Sonnet 5 4751cf3164 docs: plan and design artifacts for ticketing feature
/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>
2026-09-02 14:53:53 +05:30

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_CODE is derived from the Product.externalProductId (first alphabetic segment, uppercased, max 4 chars, falling back to a generic prefix if the external id doesn't yield one cleanly) and SEQUENCE is 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.status comment 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"):
    • NEWAI_ANALYZING, HUMAN_ESCALATION
    • AI_ANALYZINGAI_TROUBLESHOOTING, HUMAN_ESCALATION
    • AI_TROUBLESHOOTINGAI_VERIFYING, HUMAN_ESCALATION
    • AI_VERIFYINGAI_RESOLVED, HUMAN_ESCALATION
    • AI_RESOLVEDRESOLUTION_PENDING_CUSTOMER, RESOLVED, HUMAN_ESCALATION (verification failure re-escalating, per doc 04 §7)
    • HUMAN_ESCALATIONIN_PROGRESS
    • IN_PROGRESSWAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, HUMAN_ESCALATION (re-escalation)
    • WAITING_FOR_CUSTOMERIN_PROGRESS
    • RESOLUTION_PENDING_CUSTOMERRESOLVED, IN_PROGRESS (customer disputes, reopens investigation per doc 04 §7)
    • RESOLVEDCLOSED, REOPENED
    • CLOSEDREOPENED
    • REOPENEDIN_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: Ticket gains an Int version column (default 1, matching doc 06's general guidance to add this to mutable shared-state tables). A status update runs UPDATE 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 ... WHERE already guarantees exactly one concurrent writer wins.
  • Alternatives considered: SELECT ... FOR UPDATE pessimistic 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: Ticket gains a nullable, unique-per-product idempotencyKey column (@@unique([productId, idempotencyKey]), nullable values excluded from the constraint per Postgres's standard NULL-handling). Ticket creation is an atomic INSERT ... 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.findOrCreate in 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 IdempotencyKey tracking 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 Problem only when the inbound request's referenceIds (docs/02 §3) or the ticket-creation call explicitly supplies a known prior ticket/problem id; otherwise a new Problem is 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: false
    
    TicketMessage.visibleToCustomer (doc 06's own field) is set from this map at write time, never accepted as caller input — and customer-scoped reads filter WHERE visibleToCustomer = true at 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 visibleToCustomer as 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 (storageService already has the S3 client wired, needs a presigned-PUT method added alongside its existing presigned-GET getPresignedUrl); (2) caller PUTs the file directly to object storage, then confirms the upload, which creates the TicketAttachment row (scanStatus: pending) and enqueues a scan job on the existing attachments-queue (src/jobs/attachments/index.ts, currently a log-only stub). Downloads use the existing storageService.getPresignedUrl (GET), but only after the repository confirms scanStatus: clean — the presigned URL is never generated for a pending/infected/rejected attachment.
  • 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-queue scaffold 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 MalwareScanner interface (scan(objectKey): Promise<'clean' | 'infected'>) and ship one implementation now: a clearly-named UnimplementedPlaceholderScanner that 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-pending attachment (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 minio service to docker-compose.test.yml and docker-compose.development.yml, and set AWS_S3_ENDPOINT in .env.test/.env.development to point at it. storageClient.ts already branches on storageConfig.endpoint being set (forcePathStyle: true for 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/forcePathStyle branch 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.