# Feature Specification: Ticket Creation, Messages & Attachments **Feature Branch**: `003-ticketing` **Created**: 2026-09-02 **Status**: Draft **Input**: User description: "Phase 5 of docs/10-implementation-roadmap.md: Ticket + Problem models (kept separate), message types, attachment pipeline (object storage, scanning, expiring URLs), ticket lifecycle state machine. Per docs/04-ticketing-and-problem-management.md and docs/06-database-schema.md." ## User Scenarios & Testing *(mandatory)* ### User Story 1 - A trusted request creates a durable ticket immediately (Priority: P1) The moment a validated inbound request (from the SaaS integration trust boundary) describes a customer's problem, SupportHub creates a durable ticket right away — before any diagnosis, before any human is involved. The ticket starts in a `NEW` status. If the same underlying problem recurs for the same product/tenant, the new ticket is linked to the existing `Problem` record rather than creating a duplicate; if it's a genuinely new problem, a new `Problem` record is created alongside the ticket. A retried request (same idempotency key) returns the already-created ticket instead of creating a second one. **Why this priority**: This is the foundational principle of the whole product ("ticket created at the start of the journey, not after AI gives up") and everything else in this feature — and every future feature (AI, orchestration, resolution) — depends on the ticket/problem records existing first. **Independent Test**: Send a valid inbound request through the trust boundary and confirm a `Ticket` (status `NEW`) and a `Problem` exist immediately, correctly linked to the validated product/tenant/user context; send the same request again with the same idempotency key and confirm no second ticket is created. **Acceptance Scenarios**: 1. **Given** a validated inbound request describing a problem, **When** it's processed, **Then** a `Ticket` is created in `NEW` status and a `Problem` is created (or an existing one reused — see Scenario 3), both linked to the validated product/tenant/user context, before any further processing occurs. 2. **Given** a ticket was just created, **When** its record is inspected, **Then** it has a human-referenceable code (e.g. `DQB-2026-00567`-style), the originating product, and the trusted customer reference — never a raw, unvalidated value from the request. 3. **Given** a customer reports what is recognizably the same underlying problem again (same product, same recognizable symptoms/context) as an existing open `Problem`, **When** a new ticket is created for it, **Then** the new ticket links to the *existing* `Problem` record rather than creating a duplicate one. 4. **Given** an inbound request carries an idempotency key that was already used for a successfully created ticket, **When** the request is retried, **Then** the existing ticket is returned and no second ticket or problem is created. --- ### User Story 2 - Ticket messages are typed, and internal notes are never visible to customers (Priority: P2) A ticket accumulates a timeline of messages — from the customer, from AI (in future), from agents, from the system, and internal-only notes (investigation/solution notes, general internal notes). Every message has a type, and the API enforces — not just the UI — that customer-invisible message types can never reach a customer-scoped read. **Why this priority**: Without a message timeline there's no record of the interaction to show anyone; without enforced internal-note privacy, an agent's private note becomes a customer-facing leak the moment someone builds a UI that forgets to filter client-side. **Independent Test**: Post one of each message type on a ticket, then read the ticket's messages as a customer-scoped caller and confirm only customer-visible types appear; read the same messages as an agent-scoped caller and confirm all types appear. **Acceptance Scenarios**: 1. **Given** a ticket, **When** a message of any defined type is posted to it, **Then** it's stored with its type, author reference, body, and a customer-visibility flag derived from its type (never independently settable per-message in a way that contradicts the type). 2. **Given** a ticket has both customer-visible and internal-only messages, **When** its messages are read through a customer-scoped endpoint, **Then** only customer-visible messages are returned — internal notes are absent from the response entirely, not merely hidden by a flag. 3. **Given** the same ticket, **When** its messages are read through an agent-scoped endpoint, **Then** every message, including internal notes, is returned. 4. **Given** a caller not authorized for a given ticket's tenant, **When** they attempt to read or post a message on it, **Then** the request is rejected regardless of message type. --- ### User Story 3 - Attachments are safely stored and only ever downloaded through expiring, authorized URLs (Priority: P3) A customer or agent can attach a file (screenshot, PDF, log, video, document) to a ticket. The file goes to object storage, never to PostgreSQL — only its metadata and a storage reference are stored in the database. It is not available for download until it has cleared a malware scan. Every download happens through a short-lived, authorization-checked URL scoped to that ticket's tenant/user context — never a permanent or unauthenticated link. **Why this priority**: Attachments are common (screenshots, logs) but not required for the minimum ticket flow to work, and getting the security properties right (never in Postgres, never downloadable pre-scan, never a permanent link) matters more than shipping it first. **Independent Test**: Upload a file to a ticket and confirm it's rejected for download until scan status clears; confirm a generated download URL stops working after it expires; confirm a caller outside the ticket's tenant cannot generate or use a download URL for it. **Acceptance Scenarios**: 1. **Given** a file upload to a ticket, **When** it's outside the configured type/size limits, **Then** it's rejected before being sent to object storage. 2. **Given** an accepted upload, **When** it has not yet cleared malware scanning, **Then** it is not downloadable — its status is visibly `pending`, not silently unavailable. 3. **Given** a file that clears scanning, **When** an authorized caller requests to download it, **Then** they receive a time-limited URL that stops working after it expires. 4. **Given** a file that fails malware scanning, **When** anyone attempts to download it, **Then** the download is refused and the failure is visible on the attachment's record. 5. **Given** a caller outside the ticket's tenant/user context, **When** they attempt to generate or use a download URL for one of its attachments, **Then** the request is rejected. --- ### Edge Cases - What happens when two requests for the same new problem arrive concurrently (not a literal retried idempotency key, but a genuine race — e.g. a flaky client double-submits without reusing the idempotency key)? Out of scope to fully solve here beyond the idempotency-key mechanism in User Story 1 — true duplicate-problem detection beyond exact idempotency-key reuse is a knowledge/classification concern for a future AI feature, not this one. - What happens when a ticket's status is updated by two actors at nearly the same time (e.g. a customer reopens while an agent is closing)? The update that observes a stale status MUST be rejected and retried against the current state — not silently overwrite the other actor's change (Constitution Principle VII). - What happens when an attachment upload is interrupted mid-transfer? The attachment record MUST NOT be considered available; a resumed/retried upload is a new attempt, not a partial record left in a downloadable-looking state. - What happens when a malware scan itself fails to run (infrastructure error, not "found malware")? The attachment MUST remain `pending`, never silently promoted to available. - What happens when a message is posted with a type that doesn't exist in the defined set? The request MUST be rejected — message type is not free text. ## Requirements *(mandatory)* ### Functional Requirements - **FR-001**: The system MUST create a `Ticket` record immediately upon processing a validated inbound request describing a problem — before any diagnosis, classification, or human involvement occurs. - **FR-002**: The system MUST create or reuse a `Problem` record for every ticket: a new `Problem` when none matches, or the existing `Problem` when the ticket represents a recurrence of one already open for the same product/tenant context. - **FR-003**: `Ticket` and `Problem` MUST remain separate, related entities — a ticket references exactly one problem; a problem may have many tickets (Constitution Principle VIII). - **FR-004**: The system MUST honor the inbound request's idempotency key: a retried request carrying a previously-used key returns the already-created ticket rather than creating a new one. - **FR-005**: Every ticket MUST have a human-referenceable code, unique, generated by the system — never supplied by the caller. - **FR-006**: A ticket's status MUST only ever be one of the defined lifecycle states, and MUST only transition through valid state changes (an invalid transition is rejected, not silently coerced). - **FR-007**: A ticket's status update MUST use optimistic concurrency control: an update based on a stale prior status is rejected, not applied on top of a change it didn't observe. - **FR-008**: Every message posted to a ticket MUST have one of the defined message types, and its customer-visibility MUST be determined by its type, not independently settable in a way that contradicts the type. - **FR-009**: The system MUST NOT return customer-invisible message types (internal notes, investigation notes, solution notes) through any customer-scoped read of a ticket's messages — enforced at the API/serialization layer, not left to client-side filtering. - **FR-010**: The system MUST reject reading or posting on a ticket by a caller not authorized for that ticket's tenant/user context, regardless of message type or attachment involved. - **FR-011**: Attachment files MUST be stored in object storage, never in PostgreSQL — the database stores only metadata and a storage reference. - **FR-012**: The system MUST validate an attachment's type and size against configured limits before accepting the upload. - **FR-013**: An uploaded attachment MUST NOT be downloadable until it has cleared malware scanning; its scan status MUST be visible on its record (`pending` / `clean` / `infected` / `rejected`). - **FR-014**: Attachment downloads MUST only be possible through a time-limited, authorization-checked URL scoped to the ticket's tenant/user context — never a permanent or unauthenticated link. - **FR-015**: The system MUST scope every ticket, message, and attachment query by the caller's validated tenant/user context — a caller-supplied identifier alone is never sufficient authorization (Constitution Principle I). ### Key Entities - **Ticket**: The durable, operational record of one support interaction — status, priority, severity, the product/tenant/customer it belongs to, and links to its problem, messages, attachments, and (in later features) AI sessions, assignments, and escalation events. - **Problem**: The underlying issue being solved, which can outlive and span multiple tickets — statement, symptoms, impact, severity, environment. Deliberately separate from `Ticket`. - **Ticket Message**: One entry in a ticket's timeline — typed (`CUSTOMER_MESSAGE`, `AI_MESSAGE`, `AGENT_MESSAGE`, `INTERNAL_NOTE`, `SYSTEM_EVENT`, `INVESTIGATION_NOTE`, `SOLUTION_NOTE`), with an author reference, body, and a visibility derived from its type. - **Ticket Attachment**: Metadata for one uploaded file — storage reference (never the file itself), original filename, MIME type, size, scan status, and who uploaded it. ## Success Criteria *(mandatory)* ### Measurable Outcomes - **SC-001**: 100% of validated inbound requests result in a ticket existing before any further processing — zero requests that pass the trust boundary without a corresponding ticket. - **SC-002**: A retried request using the same idempotency key never produces more than one ticket, regardless of how many times it's retried. - **SC-003**: Zero internal-only messages ever appear in a customer-scoped read of a ticket's timeline, verified across every defined message type. - **SC-004**: Zero attachment files are ever persisted directly in the database — 100% go to object storage with only a reference stored. - **SC-005**: An attachment that hasn't cleared malware scanning is rejected for download 100% of the time it's attempted. - **SC-006**: A generated attachment download URL becomes unusable after its configured expiry — verified by attempting to use it past that point. - **SC-007**: Two concurrent status updates to the same ticket never both apply silently — exactly one succeeds against the state it observed, and the other is rejected and must retry. ## Assumptions - This feature covers ticket/problem creation, the message timeline, and the attachment pipeline only. It does NOT include: AI diagnosis/classification (Phase 4), investigation/root cause/solution/resolution workflows (Phase 9), orchestration/assignment/SLA (Phases 6-8), or the customer-confirmation/auto-close/reopen *workflow* automation (Phase 9) — though the `REOPENED` status itself is part of the lifecycle state machine this feature defines, since doc 04 lists it as a core ticket status. - "Recognizably the same underlying problem" (FR-002/User Story 1 Scenario 3) is intentionally left without a precise matching algorithm here — real recurring-problem detection is a knowledge/classification capability that belongs to the AI-support feature (Phase 4). For this feature, an explicit, caller-supplied reference (e.g. a prior ticket/problem id in the inbound request's `referenceIds`, per docs/02 §3) is sufficient grounds to link to an existing `Problem` — this feature does not attempt fuzzy/semantic matching on its own. - AI-driven status transitions (`AI_ANALYZING`, `AI_TROUBLESHOOTING`, `AI_VERIFYING`, `AI_RESOLVED`) are part of the lifecycle state machine's defined states (FR-006), but nothing in this feature *automatically drives* a ticket into them — that requires the AI-support feature (Phase 4), which doesn't exist yet. This feature only guarantees the state machine itself is correct and that transitions can be triggered (e.g. by an authorized caller or a future feature) without corrupting ticket state under concurrency. - Row-level security (Postgres RLS) as a defense-in-depth layer under the application-level tenant scoping in FR-015 (per `docs/11-architect-additions-gaps-and-recommendations.md` §A3) is a valuable hardening step but is deliberately deferred — `REQUIRES BUSINESS/PLATFORM CONFIRMATION` on whether/when to adopt it, not invented here. Application-level scoping (FR-015) is the enforced control for this feature. - Malware scanning integration specifics (which scanner/service) are a technical decision left to planning — this spec only requires that the scan gate and its visible states exist.