Cases
Manage cases end to end — the create → assign/claim → resolve/escalate lifecycle plus comments, tasks, attachments, the event timeline, and table-row links.
A case is a unit of investigative or operational Work that a human (or a workflow acting on their behalf) owns from creation to resolution. Where a work assignment is the routing decision — which analyst, queue, or agent (the 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
— this page does not re-explain it. Everything here belongs to the Cases tag
under /api/cases.
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.
The case object
Prop
Type
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.
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.
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.
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
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 specificassignee_id.ClaimCase—POST /api/cases/{case_id}/claim(case:write) — an analyst pulls the case from aqueue_id, assigning it to themselves.
# 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.
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.
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
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.
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
Around the case aggregate sit five child collections. Each is addressed under the case and carries its own read/write scopes.
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
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.
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)
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.
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.
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.
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.
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
Configuration
The runtime-writable config surface — system config snapshots, feature flags, per-tenant overrides, runtime config refs, and the routing pipeline mode — versus static env configuration.
Environments
Manage a tenant's dev/staging/production environments — lifecycle, the default and production flags, protection policy, readiness validation, comparison, and artifact/provider bindings.