# Contract: Ticket Lifecycle, Messages & Attachments ## Ticket creation (via the inbound trust boundary) `POST /v1/support/requests` (002-saas-integration) now, after successful auth: 1. Resolve/create `Problem` (research.md's explicit-reference-only rule). 2. Atomic create-or-fetch `Ticket` on `(productId, idempotencyKey)` — a retried request returns the same ticket, never a second one (FR-004/SC-002). 3. Write a `SYSTEM_EVENT` message. 4. Respond `202` with `{ ticketId, code, status, problemId }`. **Guarantee**: no request that passes the trust boundary ever completes without a ticket existing (FR-001/SC-001) — ticket creation is synchronous within the same request, not queued. ## Ticket status transitions `PATCH /tickets/:ticketId/status` — body `{ status: , expectedVersion: }`. | Step | Failure | |---|---| | Ticket exists and caller is tenant-authorized | `404` / `403` | | `expectedVersion` matches the ticket's current `version` | `409 CONFLICT` (FR-007/SC-007) — caller must re-read and retry | | Requested transition is a valid edge from the current status (research.md's table) | `400 INVALID_TRANSITION` | On success: `status` and `version` (+1) update atomically; a `SYSTEM_EVENT` message records the transition. ## Messages - `POST /tickets/:ticketId/messages` — body `{ type, body }` (`authorRef`/`visibleToCustomer` derived server-side, never accepted as input — FR-008). - `GET /tickets/:ticketId/messages` — the caller's scope (customer vs. agent/admin) determines which types are queried; a customer-scoped caller's query never includes `visibleToCustomer: false` rows (FR-009) — enforced in the repository's `WHERE` clause, not by filtering an already-fetched list. ## Attachments 1. `POST /tickets/:ticketId/attachments/upload-url` — body `{ fileName, mimeType, sizeBytes }`, validated against configured limits (FR-012) before a presigned PUT URL is returned, along with the `storageKey` the caller must echo back in step 3. No `TicketAttachment` row exists yet at this point. 2. Caller PUTs the file directly to the returned URL (file bytes never transit this API). 3. `POST /tickets/:ticketId/attachments/confirm` — body `{ storageKey, fileName, mimeType, sizeBytes }` (echoing step 1's values) — creates the `TicketAttachment` row (`scanStatus: pending`) and enqueues the scan job on `attachments-queue`. No `attachmentId` exists before this call, so it isn't a path param here. 4. `GET /tickets/:ticketId/attachments/:attachmentId/download-url` — returns a presigned GET URL only if `scanStatus == 'clean'`; otherwise `409` with the current scan status (FR-013/FR-014). ## Guarantees (callable contract) 1. **Ticket existence is synchronous with trust-boundary success** — never eventually-consistent. 2. **Idempotency key reuse never creates a second ticket**, regardless of retry count (SC-002). 3. **No internal-only message type is ever returned to a customer-scoped read**, verified per type (SC-003). 4. **No attachment file byte ever reaches PostgreSQL** — only `storageKey` metadata (SC-004). 5. **No attachment is downloadable before `scanStatus: clean`**, every time it's attempted (SC-005). 6. **A concurrent, stale-version status update is rejected, never silently overwritten** (SC-007).