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>
This commit is contained in:
saqib mir
2026-09-02 14:53:53 +05:30
co-authored by Claude Sonnet 5
parent be2eb9907a
commit 4751cf3164
5 changed files with 509 additions and 0 deletions
@@ -0,0 +1,58 @@
# 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: <new status>, expectedVersion: <int> }`.
| 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. 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/:attachmentId/confirm` — creates the `TicketAttachment`
row (`scanStatus: pending`) and enqueues the scan job on `attachments-queue`.
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).