Managing Resources

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.

new triaged in_progress pending_info resolved closed

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

CreateCasePOST /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:

  • AssignCasePOST /api/cases/{case_id}/assign (case:write) — a lead assigns the case to a specific assignee_id.
  • ClaimCasePOST /api/cases/{case_id}/claim (case:write) — an analyst pulls the case from a queue_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

EscalateCasePOST /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

ResolveCasePOST /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 UpdateCasePUT /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. AddCommentPOST /api/cases/{case_id}/comments (case:write) — appends a markdown comment; supply parent_comment_id for a single-level reply. ListCommentsGET .../comments (case:read) — paginates the thread. Each comment records its author_typehuman, 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.

todo in_progress blocked completed

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)

ListEventsGET /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.

A case can point at the tenant data rows it concerns — hosts, assets, indicators, records. LinkTableRowToCasePOST .../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

OperationMethod & pathScope
List casesGET /api/casescase:read
Create casePOST /api/casescase:write
Get caseGET /api/cases/{case_id}case:read
Update casePUT /api/cases/{case_id}case:write
Assign casePOST /api/cases/{case_id}/assigncase:write
Claim casePOST /api/cases/{case_id}/claimcase:write
Escalate casePOST /api/cases/{case_id}/escalatecase:write
Resolve casePOST /api/cases/{case_id}/resolvecase:write
List / add commentGET / POST /api/cases/{case_id}/commentscase:read / case:write
List eventsGET /api/cases/{case_id}/eventscase:read
List / create taskGET / POST /api/cases/{case_id}/taskscase:read / case:write
Update / delete taskPATCH / DELETE /api/cases/{case_id}/tasks/{task_id}case:write
List / upload attachmentGET / POST /api/cases/{case_id}/attachmentscase:read / case:write
Get / delete attachmentGET / DELETE /api/cases/{case_id}/attachments/{attachment_id}case:read / case:write
List / link table rowGET / POST /api/cases/{case_id}/table-row-linkscase:read / case:write
Unlink table rowDELETE /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