# Cases (/docs/management/cases)



A **case** is a unit of investigative or operational <Term name="Work" /> that a
human (or a workflow acting on their behalf) owns from creation to resolution.
Where a [work assignment](/docs/strategies/work) is the routing *decision* — which
analyst, queue, or agent (the <Term name="Worker" />) should pick something up — a
case is the durable *record* that decision attaches to: it carries status,
priority, severity, a comment thread, tasks, attachments, an audit timeline, and
links to the tenant data rows it concerns.

This page is the entity-and-lifecycle surface. The **assignment** substrate that
decides *who* a case goes to (least-loaded selection, escalation workers, the
`human_loop` workflow step) is documented in [Work strategies](/docs/strategies/work)
— this page does not re-explain it. Everything here belongs to the `Cases` tag
under `/api/cases`.

<Callout title="Where it lives">
  Cases are served by `CaseService` under `/api/cases`. The domain aggregates
  are in `domain/casemgmt/` (`case.go`, `comment.go`, `event.go`, `task.go`,
  `attachment.go`, `table_row_link.go`); the application logic is in
  `application/casemgmt/`. Every operation is tenant-scoped and gated by the
  `case:read`, `case:write`, or `case:delete` scopes.
</Callout>

## The case object [#the-case-object]

<TypeTable
  type="{
  id: { description: &#x22;Output-only unique identifier.&#x22;, type: &#x22;string&#x22; },
  summary: { description: &#x22;Required short title.&#x22;, type: &#x22;string&#x22; },
  description: { description: &#x22;Detailed context.&#x22;, type: &#x22;string&#x22; },
  status: { description: &#x22;Lifecycle state — see below.&#x22;, type: &#x22;CaseStatus&#x22; },
  priority: { description: &#x22;Urgency: low, medium, high, critical.&#x22;, type: &#x22;CasePriority&#x22; },
  severity: { description: &#x22;Impact: informational, low, medium, high, critical.&#x22;, type: &#x22;CaseSeverity&#x22; },
  queue_id: { description: &#x22;Required. The assignment queue the case belongs to.&#x22;, type: &#x22;string&#x22; },
  assignee_id: { description: &#x22;The analyst the case is assigned to; empty when unassigned.&#x22;, type: &#x22;string&#x22; },
  tags: { description: &#x22;Free-form labels; supports tag-intersection filtering on list.&#x22;, type: &#x22;repeated string&#x22; },
  custom_fields: { description: &#x22;Tenant-defined field values as JSON.&#x22;, type: &#x22;object&#x22; },
  due_at: { description: &#x22;SLA deadline, if set.&#x22;, type: &#x22;timestamp&#x22; },
  resolved_at: { description: &#x22;Output-only; set when resolved.&#x22;, type: &#x22;timestamp&#x22; },
  closed_at: { description: &#x22;Output-only; set when closed.&#x22;, type: &#x22;timestamp&#x22; },
  created_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
  updated_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
}"
/>

## The lifecycle [#the-lifecycle]

A case moves through a fixed set of statuses. Transitions are validated against a
per-tenant transition graph — an illegal jump (say, `new` straight to `resolved`)
is rejected with a conflict rather than silently applied. `closed` is the only
**terminal** status; a `resolved` case can still be reopened to `in_progress`.

```mermaid
stateDiagram-v2
    [*] --> new
    new --> triaged
    new --> in_progress
    triaged --> in_progress
    triaged --> pending_info
    in_progress --> pending_info
    in_progress --> resolved
    in_progress --> closed
    pending_info --> in_progress
    pending_info --> resolved
    pending_info --> closed
    resolved --> in_progress
    resolved --> closed
    closed --> [*]
```

<Callout type="info">
  The graph above is the default transition set Ductor seeds for tenants that
  haven't customized their rules (`DefaultTransitionRules` in
  `domain/casemgmt/transition.go`). A tenant may define its own `from → to`
  rules; `ValidateTransition` enforces whichever set applies. There is no
  hard-delete endpoint for a case — retiring one means closing it, which
  preserves the record and its timeline for audit.
</Callout>

### 1. Create [#1-create]

`CreateCase` — `POST /api/cases` (`case:write`) — opens a case in a queue.
`summary` and `queue_id` are required; priority and severity default when
omitted. A new case starts at status `new` and emits a `created` event.

```bash
curl -s -X POST https://api.ductor.io/api/cases \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "summary": "Suspicious login from new geo",
    "description": "Multiple failed logins followed by a success.",
    "priority": "CASE_PRIORITY_HIGH",
    "severity": "CASE_SEVERITY_MEDIUM",
    "queue_id": "tier1-triage",
    "tags": ["auth", "geo-anomaly"]
  }'
```

### 2. Assign or claim [#2-assign-or-claim]

Two paths move a case from a queue to an owner:

* **`AssignCase`** — `POST /api/cases/{case_id}/assign` (`case:write`) — a lead
  assigns the case to a specific `assignee_id`.
* **`ClaimCase`** — `POST /api/cases/{case_id}/claim` (`case:write`) — an analyst
  pulls the case from a `queue_id`, assigning it to themselves.

```bash
# Lead assigns explicitly:
curl -s -X POST https://api.ductor.io/api/cases/$CASE_ID/assign \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "case_id": "'"$CASE_ID"'", "assignee_id": "analyst-42" }'

# ...or an analyst claims from the queue:
curl -s -X POST https://api.ductor.io/api/cases/$CASE_ID/claim \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "case_id": "'"$CASE_ID"'", "queue_id": "tier1-triage" }'
```

Both record an `assigned` event. For *automatic* assignment — least-loaded
selection across a candidate set, or routing to an AI agent — the decision is
made by the work-assignment substrate; see [Work strategies](/docs/strategies/work).

### 3. Escalate [#3-escalate]

`EscalateCase` — `POST /api/cases/{case_id}/escalate` (`case:write`) — pushes a
case into an active work state and records an `escalated` event with an optional
`reason`. Escalation is idempotent per the case's `escalated_at` marker, so the
escalation worker re-running does not double-fire.

```bash
curl -s -X POST https://api.ductor.io/api/cases/$CASE_ID/escalate \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "case_id": "'"$CASE_ID"'", "reason": "SLA at risk" }'
```

### 4. Resolve or close [#4-resolve-or-close]

`ResolveCase` — `POST /api/cases/{case_id}/resolve` (`case:write`) — transitions
a case to `RESOLVED` or `CLOSED` (any other target status is rejected), with an
optional `response_fields` payload capturing the resolution.

```bash
curl -s -X POST https://api.ductor.io/api/cases/$CASE_ID/resolve \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "case_id": "'"$CASE_ID"'",
    "status": "CASE_STATUS_RESOLVED",
    "response_fields": { "disposition": "false_positive" }
  }'
```

Freeform field edits (summary, priority, severity, status, queue, tags) go
through `UpdateCase` — `PUT /api/cases/{case_id}` (`case:write`).

## Sub-collections [#sub-collections]

Around the case aggregate sit five child collections. Each is addressed under the
case and carries its own read/write scopes.

### Comments [#comments]

A threaded discussion log. `AddComment` — `POST /api/cases/{case_id}/comments`
(`case:write`) — appends a markdown comment; supply `parent_comment_id` for a
single-level reply. `ListComments` — `GET .../comments` (`case:read`) — paginates
the thread. Each comment records its `author_type` — `human`, `workflow`, or
`system` — so automated notes are distinguishable from analyst notes.

### Tasks [#tasks]

Discrete, assignable work items inside a case. `CreateCaseTask`
(`POST .../tasks`), `UpdateCaseTask` (`PATCH .../tasks/{task_id}`), and
`DeleteCaseTask` (`DELETE .../tasks/{task_id}`) manage them; `ListCaseTasks`
(`GET .../tasks`) returns them in display order. A task has its own status
machine — `todo → in_progress → {blocked, completed}` with `blocked → in_progress`
— and status changes are validated against it. `completed` is terminal.

```mermaid
stateDiagram-v2
    [*] --> todo
    todo --> in_progress
    todo --> blocked
    in_progress --> blocked
    in_progress --> completed
    blocked --> in_progress
    completed --> [*]
```

### Attachments [#attachments]

Binary files stored against a case. `CreateCaseAttachment`
(`POST .../attachments`, `case:write`) uploads bytes inline (base64-encoded in
JSON); the server writes them to the configured object store and keeps only
metadata — `filename`, `mime_type`, `size_bytes`, `storage_key`, `uploaded_by` —
on the case. `GetCaseAttachment` downloads one, `ListCaseAttachments` lists them,
and `DeleteCaseAttachment` removes both the row and the underlying object.
Uploads and deletions emit `attachment_added` / `attachment_removed` events.

### Events (the timeline) [#events-the-timeline]

`ListEvents` — `GET /api/cases/{case_id}/events` (`case:read`) — returns the
case's audit timeline: an append-only sequence of `CaseEvent` records, each with
an `event_type`, an `actor_id`, a `source_type` (`api`, `workflow`,
`escalation_worker`, or `system`), and field-level `changes`. The timeline is how
you reconstruct exactly what happened to a case and who (or what) did it.

<Callout title="Event coverage">
  Core lifecycle events — `created`, `status_changed`, `assigned`, `escalated`,
  `resolved`, `updated`, and task, attachment, and table-row events — are always
  emitted. Additional events such as `viewed`, `reopened`, `priority_changed`,
  and tag or dropdown changes require the dynamic-config flag
  `ductor.cases.full_event_coverage`.
</Callout>

### Table-row links [#table-row-links]

A case can point at the tenant data rows it concerns — hosts, assets, indicators,
records. `LinkTableRowToCase` — `POST .../table-row-links` (`case:write`) — links
a `(table_ref, row_id)` tuple; re-linking the same tuple is a no-op that returns
the existing link. `UnlinkTableRowFromCase` (`DELETE .../table-row-links/{link_id}`)
removes it. `ListCaseTableRowLinks` (`GET .../table-row-links`, `case:read`)
returns them in link order.

<Callout type="info">
  Each link captures a **snapshot** of the row payload at link time, and the
  `table_row_unlinked` event carries that snapshot. This is deliberate: the
  timeline survives the source row being mutated or deleted, which a live binding
  could not guarantee. `table_ref` is a client-owned identifier validated for
  shape (`^[a-z0-9_]+(\.[a-z0-9_]+)?$`). Ductor stores the reference and
  snapshot but does not resolve the source row when the case is read.
</Callout>

## Operations at a glance [#operations-at-a-glance]

| Operation                | Method & path                                                     | Scope                      |
| ------------------------ | ----------------------------------------------------------------- | -------------------------- |
| List cases               | `GET /api/cases`                                                  | `case:read`                |
| Create case              | `POST /api/cases`                                                 | `case:write`               |
| Get case                 | `GET /api/cases/{case_id}`                                        | `case:read`                |
| Update case              | `PUT /api/cases/{case_id}`                                        | `case:write`               |
| Assign case              | `POST /api/cases/{case_id}/assign`                                | `case:write`               |
| Claim case               | `POST /api/cases/{case_id}/claim`                                 | `case:write`               |
| Escalate case            | `POST /api/cases/{case_id}/escalate`                              | `case:write`               |
| Resolve case             | `POST /api/cases/{case_id}/resolve`                               | `case:write`               |
| List / add comment       | `GET` / `POST /api/cases/{case_id}/comments`                      | `case:read` / `case:write` |
| List events              | `GET /api/cases/{case_id}/events`                                 | `case:read`                |
| List / create task       | `GET` / `POST /api/cases/{case_id}/tasks`                         | `case:read` / `case:write` |
| Update / delete task     | `PATCH` / `DELETE /api/cases/{case_id}/tasks/{task_id}`           | `case:write`               |
| List / upload attachment | `GET` / `POST /api/cases/{case_id}/attachments`                   | `case:read` / `case:write` |
| Get / delete attachment  | `GET` / `DELETE /api/cases/{case_id}/attachments/{attachment_id}` | `case:read` / `case:write` |
| List / link table row    | `GET` / `POST /api/cases/{case_id}/table-row-links`               | `case:read` / `case:write` |
| Unlink table row         | `DELETE /api/cases/{case_id}/table-row-links/{link_id}`           | `case:write`               |

List endpoints use cursor pagination (`pagination.page_size` up to 1000, default
50; `pagination.page_token`). `ListCases` additionally filters by `status`,
`priority`, `severity`, `queue_id`, `assignee_id`, and `tags`.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Work strategies" href="/docs/strategies/work">
    How cases get routed to analysts, queues, and AI agents — the assignment
    substrate this page builds on.
  </Card>

  <Card title="Pools & recipients" href="/docs/management/pools-and-recipients">
    The routing targets an assignment decision chooses from.
  </Card>

  <Card title="Managing resources" href="/docs/management">
    Shared conventions — headers, pagination, errors — across every call.
  </Card>
</Cards>
