Skip to content

Escalations

An escalation is the row a workflow waits on. This page covers every /api/escalations endpoint: reading the queue, claiming and resolving rows, bulk and set-based operations, batches and accumulators, faceted search and interval analytics.

All endpoints require authentication: send Authorization: Bearer <token> with a JWT or a bot API key. See HTTP API for login and the error shape.

pending ──► claimed ──► resolved
│ │
│ └──► (claim expires) ──► pending (available again)
│
└──► cancelled (workflow terminated or explicit cancel)

A claim sets assigned_to and sets assigned_until to a future timestamp. When the claim expires, the escalation is available again and its status stays pending.

Admins can override a live claim without changing status. Bulk assign with reassign: true hands the claim to another user (publishes claimed), and bulk unassign returns it to the pool (publishes released). The reassigned event names a role move (bulk escalate) only.

cancelled is terminal. A cancelled escalation cannot be claimed, resolved or cancelled again. When a workflow is terminated (POST /api/workflows/:workflowId/terminate), HotMesh cancels any pending escalations tied to that workflow. POST /api/escalations/:id/cancel cancels one directly.

A role is a task queue with four verbs: search (list, get), claim, ack (resolve) and delete (cancel). A member grant carries read_scope (self or all) and write_scope (none, self or all), which set the breadth of those verbs on that role’s escalations. admin and superadmin ignore scope and act on the whole queue.

Scope Governs self means Other values
read_scope Which escalations a member sees: list, /available, get by id, find by metadata, stats Escalations assigned to the member (assigned_to = user) all: the whole role queue
write_scope Which escalations a member may act on: claim, resolve, cancel Only items already assigned to the member all: the whole queue. none: read-only

Releasing, escalating and assigning by query are queue-management verbs and require write_scope=all. Creating a standalone escalation (POST /api/escalations) requires write_scope=all or global escalation access.

Both scopes default to all (the full-queue worker), so a plain member works the whole queue. See Work-surface scope in the Roles API for the five member profiles and the write ⊆ read constraint.

GET /api/escalations

Lists escalations with optional filters. Superadmins see every role; other callers see the roles they hold, within their read scope.

Scope: Enforces read scope.

Query parameters

Parameter Type Description
status string pending, resolved, cancelled or expired
role string Filter by target role
type string Filter by escalation type
subtype string Filter by subtype
assigned_to string Filter by claimer’s user ID
priority integer Filter by priority (1 to 4)
search string Exact match on a correlation id: escalation id, workflow id or origin id (server-side, index-served). To match a value inside metadata, use facets
sort_by / order string Single-column sort and direction (asc or desc)
limit integer Max results (default 50)
offset integer Pagination offset (default 0)

Faceted query parameters (JSON-encoded). A facet is a key/value inside the row’s metadata JSONB. The filter and count run in SQL, role-scoped.

Parameter JSON shape Description
facets object Required facets: metadata @> facets. {"flags":"too_short"} matches metadata.flags == "too_short" for a top-level scalar (containment for nested values and arrays)
block array of objects Exclude rows containing ANY set: NOT (metadata @> ANY(block))
range array of {facet, op, value} Numeric range, op one of <, <=, >, >=, =. Example: [{"facet":"confidence","op":"<=","value":0.7}]
exists array of strings Keys that must be present: metadata ? key
roles array of strings Restrict to these roles (narrows within scope, never widens)
available true / false true: unclaimed or expired only. false: held now
jeopardy 1 / true Only rows past their role’s priority threshold. This is the predicate behind the Pace Board’s priority count, so a jeopardy list’s total equals the badge. Age is measured from the role’s priority_facet metadata timestamp (created_at when unset) against priority_threshold_minutes (sla_minutes when unset). Roles with neither set contribute no rows
orderBy array of {field, direction?, numeric?} Sort by column or metadata.<key>
groupBy string A metadata key. The page holds whole groups of rows sharing it (a row without it is a group of one), total counts groups, and the response adds groups: [{ key, ids }] in page order. A role’s grouped facet-table sends it

When any faceted parameter is present, the request runs through the scoped faceted query. See Faceted routing.

Example request

Terminal window
curl -G https://lt.acme.dev/api/escalations \
-H "Authorization: Bearer $LT_TOKEN" \
--data-urlencode 'status=pending' \
--data-urlencode 'facets={"flags":"too_short"}' \
--data-urlencode 'range=[{"facet":"confidence","op":"<=","value":0.7}]'

Response 200

{
"escalations": [
{
"id": "esc-a1b2c3d4-...",
"type": "review",
"subtype": "content",
"modality": "default",
"description": "Review needed (confidence: 0.72)",
"status": "pending",
"priority": 2,
"task_id": "d4e5f6a7-...",
"origin_id": "review-orch-post-456-a1b2c3d4",
"parent_id": null,
"workflow_id": "review-post-456-x9y8z7",
"task_queue": "long-tail",
"workflow_type": "reviewContent",
"role": "reviewer",
"assigned_to": null,
"assigned_until": null,
"resolved_at": null,
"claimed_at": null,
"envelope": "{\"data\":{\"contentId\":\"post-456\"},\"metadata\":{}}",
"metadata": null,
"escalation_payload": "{\"content\":\"...\",\"analysis\":{\"confidence\":0.72}}",
"resolver_payload": null,
"created_at": "2025-01-15T10:00:05.000Z",
"updated_at": "2025-01-15T10:00:05.000Z"
}
],
"total": 1
}
GET /api/escalations/available

Returns only escalations that are pending and either unassigned or holding an expired claim. A reviewer calls this to read their queue.

Scope: Enforces read scope.

Query parameters

Parameter Type Description
role string Filter by target role
type string Filter by escalation type
subtype string Filter by subtype
limit integer Max results (default 50)
offset integer Pagination offset (default 0)

Example request

Terminal window
curl "https://lt.acme.dev/api/escalations/available?role=reviewer" \
-H "Authorization: Bearer $LT_TOKEN"

Response 200: same shape as List escalations, holding only available escalations.

GET /api/escalations/:id

Returns one escalation. The response embeds a form_schema field: the target role’s versioned form_schema, resolved to the escalation’s pinned metadata.schema_version (or the role’s latest when unpinned). It is joined from the roles tables in the same query and never stored on the escalation row. The dashboard renders the resolve form from this field; an MCP agent sees the same schema through check_resolution.

Scope: Enforces read scope. A member with read_scope=self sees only escalations assigned to them; an item outside their read surface returns 404.

Path parameters

Parameter Description
id Escalation UUID

Example request

Terminal window
curl https://lt.acme.dev/api/escalations/$ESCALATION_ID \
-H "Authorization: Bearer $LT_TOKEN"

Response 200: a single escalation object (see Escalation fields) with form_schema.

Response 404

{ "error": "Escalation not found" }
GET /api/escalations/:id/lookups

Resolves the versioned knowledge lookups pinned on the row (envelope.lookups). The refs are the grant: any user who may read the escalation may fetch exactly the pinned editions it names, while the general knowledge API stays a builder surface. See Lookups.

Scope: Enforces the same read scope as GET /api/escalations/:id.

Path parameters

Parameter Description
id Escalation UUID

Example request

Terminal window
curl https://lt.acme.dev/api/escalations/$ESCALATION_ID/lookups \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"lookups": [
{ "domain": "catalog", "key": "materials", "version": 2, "data": { "items": ["aluminum", "steel"] } },
{ "domain": "catalog", "key": "geo", "version": 9, "data": null, "missing": true }
]
}

A ref whose snapshot does not exist answers with missing: true. The batch always returns.

POST /api/escalations/:id/claim

Locks the escalation so no other reviewer can pick it up. The lock is time-boxed: if the reviewer does not resolve it within the duration, the escalation returns to the available queue. The user id comes from the auth token (req.auth.userId), not the request body.

Scope: Governed by write scope. A member with write_scope=self may claim only items already assigned to them; write_scope=none cannot claim.

Path parameters

Parameter Description
id Escalation UUID

Request body

Field Type Default Description
durationMinutes integer 30 How long the claim lasts

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/claim \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "durationMinutes": 60 }'

Response 200: the updated escalation object with assigned_to, assigned_until and claimed_at populated.

Response 409

{ "error": "Escalation not available for claim" }

Returned when the escalation is already claimed by someone else with an unexpired lock, or is already resolved.

POST /api/escalations/:id/resolve

On the re-run path, resolving starts a new workflow execution with the resolver’s payload injected into envelope.resolver. The workflow re-runs, takes the if (envelope.resolver) branch and completes with the reviewer’s decision. Signal-backed escalations resume the waiting workflow in place instead (see below).

Scope: Governed by write scope (resolve is the ack verb). A member with write_scope=self may resolve only items assigned to them; write_scope=none cannot resolve.

Path parameters

Parameter Description
id Escalation UUID

Request body

Field Type Required Description
resolverPayload object yes The reviewer’s decision, injected into envelope.resolver
metadata object no Outcome facets merged into the escalation’s GIN-indexed metadata. Records what happened (disposition, timing) next to what was asked, and is @>-queryable. resolverPayload resumes the workflow and is not indexed

The resolverPayload is stored exactly as submitted. The client forms the final payload: the dashboard maps the flat form to the nested payload through each field’s x-lt-bind before submitting.

Schema enforcement. For roles with enforce_schema, every resolve surface validates resolverPayload against the escalation’s resolved form schema (metadata.form_schema override, else the pinned lt_role_schemas snapshot, else the role’s latest) and rejects violations with 422 before any state changes. The body is the canonical validation shape, identical across HTTP, SDK, MCP and CLI. See Schema enforcement.

{
"error": "resolverPayload failed schema validation (1 violation)",
"code": "schema_validation",
"violations": [{ "field": "contact_email", "message": "Enter a valid email address" }],
"role": "intake-reviewer",
"schemaVersion": 3
}

Bulk surfaces (resolve-by-ids, resolve-all-or-none) tag each violation with its escalationId, and one failing item blocks the batch before anything resolves.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/resolve \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"resolverPayload": { "approved": true, "notes": "Content is fine, AI was overly cautious" },
"metadata": { "outcome": "approved", "reviewedBy": "alice", "durationMs": 1240 }
}'

Response 200

{
"started": true,
"escalationId": "esc-a1b2c3d4-...",
"workflowId": "rerun-esc-a1b2c3d4-...-1705312800000"
}

The new workflow ID follows the pattern rerun-{escalationId}-{timestamp}.

Response 400

{ "error": "resolverPayload is required" }

Response 404

{ "error": "Escalation not found" }

Response 409: the escalation was cancelled (workflow terminated or explicit cancel) and cannot be resolved.

{ "error": "Escalation is cancelled" }

Response 409: the escalation is already resolved or otherwise not pending.

{ "error": "Escalation not available for resolution" }

Signal-based resolution (metadata.signal_id)

Section titled “Signal-based resolution (metadata.signal_id)”

When an escalation has metadata.signal_id, the resolve endpoint signals the running workflow instead of starting a new one. The workflow is still alive: it called conditional(signalId) and is paused.

The resolver payload is augmented with $escalation_id before signaling:

{ "approved": true, "notes": "Looks good", "$escalation_id": "esc-a1b2c3d4-..." }

The workflow resolves the escalation. The conditional() helper does this: it strips $escalation_id, calls ltResolveEscalation as a durable activity and returns the clean payload. If you use raw Durable.workflow.condition() instead, resolve the escalation yourself with the $escalation_id from the signal data.

When conditional(signalId, config) (or Durable.workflow.condition(signalId, config)) wrote the escalation atomically, the row carries a signal_key and no signal_id or signal_routing metadata. The resolve endpoint detects signal_key and resolves through the SDK: one transaction marks the row resolved and delivers the signal to the waiting condition(), so the original job resumes in place with no re-run and no separate resolve activity. system.escalation.{role}.{id}.resolved fires. Callers that know the signal id can use Resolve by signal key.

This applies to the re-run path (an escalation with no signal_id, signal_routing or signal_key). Signal-based and signal-key escalations resume the live workflow in place, as described above.

  1. The route reads the escalation record and verifies it is still pending.
  2. It reconstructs the original workflow envelope from the escalation’s envelope field (or from the parent task if the escalation envelope is missing).
  3. It injects resolver (the reviewer’s payload) and lt.escalationId into the envelope.
  4. It starts a new workflow with the modified envelope on the original task queue.
  5. The interceptor detects envelope.lt.escalationId, marks the escalation resolved and signals the parent orchestrator (if any) that the child workflow has completed.
POST /api/escalations/resolve-by-signal-key

Resolves a signal-key escalation by its deterministic signal id instead of its row id. Webhooks use it (for example with signal-scan-${orderId}) to skip the id lookup. The row is marked resolved and the signal reaches the waiting condition() in one transaction.

Scope: RBAC-scoped to the caller’s visible roles; enforces write scope.

Request body

Field Type Required Description
signalKey string yes The signal id passed to conditional(signalId, config)
resolverPayload object yes The decision payload delivered to the waiting workflow
metadata object no Outcome facets merged into the row’s GIN-indexed metadata (see Resolve an escalation)

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/resolve-by-signal-key \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "signalKey": "signal-scan-ORD-1042", "resolverPayload": { "approved": true } }'

Response 200

{ "signaled": true }

Response 404: the signal key is unknown.

Response 409: the escalation is already terminal.

When a reviewer claims an escalation, the dashboard renders a typed form when a schema is available. There are two ways to attach one.

The target role owns the escalation form as a versioned form_schema, declared on the role (for example with PATCH /api/roles/:role). Every escalation targeting that role resolves against it. A workflow pins a version through conditional’s schemaVersion, which stamps metadata.schema_version on the escalation; unpinned escalations resolve against the role’s latest form_schema. Fields may carry x-lt-bind to map a form value to a path in the resolver payload. See Forms.

The deprecated workflow-config resolver_schema remains only as a legacy fallback when no role form_schema is available.

Pass form_schema inside metadata when creating an escalation. It overrides any workflow-level schema and suits one-off or generated forms.

{
"type": "approval",
"role": "reviewer",
"description": "Review deployment to production",
"metadata": {
"form_schema": {
"properties": {
"approved": {
"type": "boolean",
"default": false,
"description": "Approve this deployment?"
},
"environment": {
"type": "string",
"enum": ["staging", "production"],
"description": "Target environment"
},
"api_key": {
"type": "string",
"format": "password",
"description": "Deployment API key (stored as ephemeral token)"
},
"notes": {
"type": "string",
"default": "",
"description": "Optional reviewer notes"
},
"confidence": {
"type": "number",
"default": 0,
"description": "Confidence score (0-1)"
}
}
}
}
}
Schema property Effect
type Inferred from value at runtime; hints only
default Pre-fills the form field
description Helper text displayed below the field label
enum Renders a dropdown select instead of free text
format: "password" Masks input. On resolution the value is replaced with a 15-minute ephemeral token and is never stored as plaintext

The dashboard infers field types from the default value:

Value type Renders as
boolean Checkbox
number Number input
string (short) Text input
string (more than 80 chars) Textarea
string + enum Dropdown select
string + format: "password" Password input
null Disabled placeholder
array Read-only tag list
object Nested section with recursive field rendering

Keys prefixed with _ (for example _internal_id) are stored in the payload and hidden from the form UI. The _form_schema key is reserved: the dashboard stores the schema there for round-trip access during resolution.

The form that renders is chosen by precedence:

  1. metadata.form_schema (a legacy inline schema on the escalation row).
  2. The role’s form_schema, resolved to the escalation’s pinned metadata.schema_version (or the role’s latest when unpinned) and embedded in the GET /api/escalations/:id response.
  3. The deprecated workflow-config resolver_schema legacy fallback.
POST /api/escalations/release-expired

Clears assigned_to and assigned_until on escalations whose claim has expired. This is housekeeping: /available already filters out expired claims at query time.

Request body: none.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/release-expired \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{ "released": 3 }

The number is how many escalation records were updated.

GET /api/escalations/types

Returns the distinct escalation type values across all escalations.

Example request

Terminal window
curl https://lt.acme.dev/api/escalations/types \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"types": ["review", "approval", "verification"]
}
GET /api/escalations/station-metrics

Per-role operational metrics for every visible station: queue depth, throughput, wait time (creation to claim) and work time (claim to resolution) at several percentiles. It drives the Operations pace chart and the station detail panel.

Auth: superadmin sees all stations; other callers see the stations for their assigned roles.

Query parameters

Parameter Type Default Description
period string 24h Lookback window for resolved, wait and work metrics. One of 15m, 1h, 24h, 7d, 30d

Example request

Terminal window
curl "https://lt.acme.dev/api/escalations/station-metrics?period=1h" \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"stations": [
{
"role": "reviewer",
"pending": 12,
"claimed": 3,
"resolved": 45,
"priority_count": 2,
"throughput_pct": 112.5,
"wait": { "p99": 18.3, "p50": 4.1, "avg": 5.2, "max": 22.0 },
"work": { "p99": 28.7, "p50": 14.0, "avg": 15.3, "max": 35.1 }
}
]
}

Response fields

Field Type Description
role string Role key
pending number Escalations currently pending (real-time)
claimed number Escalations actively claimed (real-time)
resolved number Escalations resolved within the selected period
priority_count number Pending, unclaimed escalations past the role’s age threshold: the Pace Board rebalance signal
throughput_pct number | null resolved / (target_per_hour × period_hours) × 100. null when the role has no target_per_hour
wait.p99 number | null 99th-percentile wait time in minutes (creation to first claim) within the period. null when nothing resolved
wait.p50 number | null Median wait time in minutes
wait.avg number | null Mean wait time in minutes
wait.max number | null Max wait time in minutes
work.p99 number | null 99th-percentile work time in minutes (claim to resolution)
work.p50 number | null Median work time
work.avg number | null Mean work time
work.max number | null Max work time

Notes:

  • pending and claimed reflect the live queue, not the selected period.
  • throughput_pct above 100 means the station resolved more than its target (ahead of schedule); below 100 means behind.
  • priority_count measures age from the role’s priority_facet metadata timestamp (created_at when unset) against priority_threshold_minutes (sla_minutes when unset). It is 0 when neither threshold is configured. Claimed items are excluded. With a facet configured, items missing the key or holding an unparseable value are excluded.
  • Time values are decimal minutes.
GET /api/escalations/stats

Aggregated escalation counts. Superadmins see all roles; other callers see their roles.

Scope: The aggregate reflects read_all memberships only. A member’s read_scope=self items are left out, since self-scope members get the single-item surface rather than a queue dashboard.

Query parameters

Parameter Type Description
period string Counting window for created and resolved. One of 1h, 24h, 7d, 30d (default 24h; other values fall back to 24h). Station metrics also support 15m

Example request

Terminal window
curl "https://lt.acme.dev/api/escalations/stats?period=7d" \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"pending": 12,
"claimed": 3,
"created": 25,
"resolved": 10,
"by_role": [],
"by_type": []
}
PATCH /api/escalations/priority

Updates the priority of several escalations at once.

Auth: Requires admin or superadmin permission for the escalation roles.

Request body

Field Type Required Description
ids string[] yes Escalation UUIDs to update
priority integer yes New priority value (1, 2, 3 or 4)

Example request

Terminal window
curl -X PATCH https://lt.acme.dev/api/escalations/priority \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."], "priority": 1 }'

Response 200

{ "updated": 2 }

Response 400

{ "error": "ids must be a non-empty array" }
{ "error": "priority must be 1, 2, 3, or 4" }

Response 403

{ "error": "Insufficient permissions for role \"reviewer\"" }
POST /api/escalations/bulk-claim

Claims several escalations for the authenticated user.

Auth: Requires admin or superadmin permission for the escalation roles.

Request body

Field Type Default Description
ids string[] Escalation UUIDs to claim
durationMinutes integer 30 How long each claim lasts

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-claim \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."], "durationMinutes": 60 }'

Response 200: a result object with the claim outcomes.

Response 400

{ "error": "ids must be a non-empty array" }
POST /api/escalations/bulk-assign

Assigns several escalations to a user, by id set or by query (exactly one). Superadmins can assign anyone. Admins can assign only to users who hold the escalation’s role.

A plain assign skips rows under a live claim and counts them in the response’s skipped: assignment is claim-on-behalf, and an active claim wins. To take over live claims, pass reassign: true (ids form, admin or superadmin only). The takeover is one guarded statement, the displaced holder’s in-flight resolve fails its claim assertion, and each taken row’s claimed event carries reassigned_from.

The query form is one atomic statement: selection and claim happen in the same UPDATE, so a row that re-parks between a search and an ids-assign is still captured. Use it whenever role and facets describe the population.

Scope: A member may use the query form only with write_scope=all on the queried role, and the target user must hold that role.

Request body

Field Type Default Description
ids string[] Escalation UUIDs to assign (ids form)
query object { role, facets? } selector (query form). role is required; facets filter by metadata containment
targetUserId string User ID to assign the escalations to
durationMinutes integer 30 How long each assignment lasts
reassign boolean false Also take over rows under a live claim (ids form only; admin or superadmin)

Example request (ids form)

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-assign \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-..."], "targetUserId": "user-x1y2z3", "durationMinutes": 60 }'

Example request (query form)

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-assign \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "query": { "role": "order-items", "facets": { "orderId": "ORD-1042" } }, "targetUserId": "user-x1y2z3", "durationMinutes": 60 }'

Response 200: a result object with the assignment outcomes.

Response 400

{ "error": "targetUserId is required" }
{ "error": "Target user does not hold the \"reviewer\" role" }
POST /api/escalations/bulk-unassign

Returns claimed escalations to the available pool. This is the admin override of someone else’s live claim; a holder returning their own item uses Release a claim. One guarded statement skips unclaimed and terminal rows, and each returned row publishes a released event carrying released_by (the acting admin) and unassigned_from (the displaced holder).

Auth: Admin or superadmin (global escalation access).

Request body

Field Type Description
ids string[] Escalation UUIDs to return to the pool

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-unassign \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-...", "esc-c9d0e1f2-..."] }'

Response 200

{ "unassigned": 2, "skipped": 1 }

skipped counts rows that were unclaimed or terminal at call time.

PATCH /api/escalations/bulk-escalate

Moves several escalations to a different role.

Auth: Requires admin or superadmin permission for the current escalation roles.

Request body

Field Type Required Description
ids string[] yes Escalation UUIDs to move
targetRole string yes Role to move the escalations to

Example request

Terminal window
curl -X PATCH https://lt.acme.dev/api/escalations/bulk-escalate \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."], "targetRole": "senior-reviewer" }'

Response 200

{ "updated": 2 }

Response 400

{ "error": "targetRole is required" }
POST /api/escalations/bulk-triage

Resolves several escalations and starts an AI triage workflow (mcpTriage) for each. Rows backing a live condition() waiter (signal_key set) stay pending and are excluded from triaged; settle those individually with POST /api/escalations/:id/resolve, which carries the workflow’s wake.

Auth: Requires admin or superadmin permission for the escalation roles.

Request body

Field Type Required Description
ids string[] yes Escalation UUIDs to triage
hint string no Optional hint to guide the AI triage

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-triage \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-..."], "hint": "Check the document orientation" }'

Response 200

{
"triaged": 1,
"workflows": ["triage-esc-a1b2c3d4-...-1705312800000"]
}
PATCH /api/escalations/:id/escalate

Moves one escalation to a different role. The caller must be authorized to escalate from the current role to the target role, checked through escalation chains.

Scope: Escalating is a queue-management verb and requires write_scope=all for a member.

Path parameters

Parameter Description
id Escalation UUID

Request body

Field Type Required Description
targetRole string yes Role to move the escalation to

Example request

Terminal window
curl -X PATCH https://lt.acme.dev/api/escalations/$ESCALATION_ID/escalate \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "targetRole": "senior-reviewer" }'

Response 200: the updated escalation object with the new role.

Response 400

{ "error": "targetRole is required" }

Response 403

{ "error": "Not authorized to escalate to this role" }

Response 404

{ "error": "Escalation not found" }

Response 409

{ "error": "Escalation is not pending" }
GET /api/escalations/by-workflow/:workflowId

Returns the escalations linked to a workflow ID that the caller may read.

Scope: Callers with global escalation access see every escalation. Otherwise the result holds escalations in roles the caller reads at all scope and, for roles held at self read scope, only those assigned to the caller.

Path parameters

Parameter Description
workflowId Workflow ID to search for

Example request

Terminal window
curl https://lt.acme.dev/api/escalations/by-workflow/review-post-456-x9y8z7 \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"escalations": [
{ "id": "esc-a1b2c3d4-...", "...": "..." }
]
}
POST /api/escalations/:id/cancel

Permanently cancels a pending or claimed escalation. The workflow waiting on it through conditional receives null as the condition result and can handle the cancellation. Terminal escalations (resolved or already cancelled) return 409.

Auth: Requires admin or superadmin for the escalation’s role.

Path parameters

Parameter Description
id Escalation UUID

Request body: none.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/cancel \
-H "Authorization: Bearer $LT_TOKEN"

Response 200: the cancelled escalation object.

Response 404

{ "error": "Escalation not found" }

Response 409

{ "error": "Escalation already resolved or cancelled" }
POST /api/escalations/bulk-cancel

Cancels several escalations at once and skips any that are already terminal.

Auth: Requires admin or superadmin for the escalation roles.

Request body

Field Type Required Description
ids string[] yes Escalation UUIDs to cancel

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/bulk-cancel \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."] }'

Response 200

{ "cancelled": 2, "skipped": 0 }

skipped counts escalations that were already terminal (resolved or cancelled) at call time.

Response 400

{ "error": "ids must be a non-empty array" }
POST /api/escalations/:id/release

Releases a claimed escalation back to the available pool. Only the user who holds the current claim can release it.

Scope: Releasing is a queue-management verb and requires write_scope=all for a member.

Path parameters

Parameter Description
id Escalation UUID

Request body (optional)

Field Type Description
quiet boolean Perform the same release without publishing the released event. Use it for bookkeeping releases that are not lifecycle transitions, such as a dispatcher’s held-skip loop freeing rows every ranking window. Faceted machine claims are already silent; this is the release-side counterpart. Default false (publishes)

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/release \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "quiet": false }'

Response 200

{
"escalation": { "id": "esc-a1b2c3d4-...", "assigned_to": null, "assigned_until": null, "...": "..." }
}

Response 409

{ "error": "Escalation not found or not claimed by you" }
Field Type Description
id UUID Primary key
type string Escalation category
subtype string Subcategory for finer routing
modality string Modality from workflow config
description string Human-readable reason
status string pending, resolved, cancelled or expired
priority integer Numeric priority
task_id UUID FK to the task that triggered this escalation
origin_id string Correlation ID from the parent orchestrator
parent_id string Direct parent workflow ID
workflow_id string HotMesh workflow ID
task_queue string Task queue (used for the resolution re-run)
workflow_type string Workflow name (used for the resolution re-run)
role string Target role
assigned_to string Claimer’s user ID
assigned_until ISO 8601 Claim expiry
resolved_at ISO 8601 When the escalation was resolved
claimed_at ISO 8601 When the escalation was claimed
envelope string JSON-serialized original workflow envelope
metadata object Arbitrary metadata
escalation_payload string JSON data the workflow attached to the escalation
resolver_payload string JSON decision from the reviewer

See Data model for the full SQL schema and index strategy.

These endpoints find, claim and resolve escalations by a business key stored in the metadata JSONB column (for example orderId). Lookups go through the GIN index on metadata, so they stay fast at any scale.

The claim and resolve endpoints accept an optional assignee field: an external_id from your auth system. Long Tail resolves it to an internal user id. When omitted, the authenticated caller is used.

GET /api/escalations/by-metadata

Finds escalations whose metadata holds key = value.

Scope: Scoped to the caller’s read access. A member with read_scope=self matches only escalations assigned to them.

Query parameters

Parameter Type Description
key string Required. Metadata field name
value string Required. Metadata field value
status string Filter by status (pending, resolved, cancelled, expired)
limit integer Max results (default 50)
offset integer Pagination offset (default 0)

Example request

Terminal window
curl "https://lt.acme.dev/api/escalations/by-metadata?key=orderId&value=order-123" \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"escalations": [
{ "id": "...", "type": "order", "role": "operator", "metadata": { "orderId": "order-123" }, "...": "..." }
],
"total": 1
}
POST /api/escalations/claim-by-metadata

Finds one available escalation (pending, and unassigned or expired) matching the metadata and claims it atomically.

Scope: For non-global callers this is scoped to write_scope=all roles. Self-scope members are excluded: their items are pre-claimed and resolved by id, not discovered through the queue.

Request body

Field Type Description
key string Required. Metadata field name
value string Required. Metadata field value
durationMinutes number Claim duration (default 30)
assignee string Claim as a Long Tail user (resolved through getUserByExternalId)
metadata object Additional metadata to merge (new keys added, existing keys overwritten)
provisionIfAbsent object Provision the assignee just in time if they do not exist (global escalation access only)

provisionIfAbsent. When the assignee is missing from lt_users or lacks the escalation’s role, it is provisioned inline. Each role entry accepts optional read_scope and write_scope, so a global caller can provision a one-time user with read_scope=self and write_scope=self and pre-claim their item in the same call. Only callers with global escalation access (superadmin, or admin of the admin role) can use it. A missing user is created with the declared roles; an existing user missing a required role gets the role added. When the user exists and holds the role, no extra queries run. A user provisioned at read_self + write_self sees and acts on exactly the one item the workflow routed to them.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/claim-by-metadata \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "orderId",
"value": "order-123",
"durationMinutes": 30,
"metadata": { "claimedBy": "alice", "station": "scanning" }
}'

Example request (provision a one-time user)

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/claim-by-metadata \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "orderId",
"value": "order-123",
"assignee": "new-user",
"provisionIfAbsent": {
"displayName": "New User",
"email": "new-user@acme.dev",
"roles": [{ "role": "operator", "type": "member", "read_scope": "self", "write_scope": "self" }]
}
}'

Response 200

{
"escalation": { "id": "...", "assigned_to": "user-uuid", "assigned_until": "2025-01-15T10:30:00Z", "...": "..." },
"isExtension": false
}

Response 404: no pending escalation found.

Response 409: escalation not available (already claimed).

POST /api/escalations/resolve-by-metadata

One atomic query finds the pending escalation by metadata, claims it if unclaimed, and resolves it. RBAC is enforced in the SQL WHERE clause.

Scope: Write scope is honored. A member with write_scope=self may resolve their own assigned item atomically, which is how a one-time user completes the form routed to them.

Signal guard. If the escalation has metadata.signal_id (created by conditional), the SQL leaves the row alone and the endpoint signals the running workflow. conditional receives the signal and resolves the escalation durably inside the workflow, with the same transactional integrity as the resolve-by-id path.

Request body

Field Type Description
key string Required. Metadata field name
value string Required. Metadata field value
resolverPayload object Required. Resolution data passed to the workflow
assignee string Resolve as a Long Tail user (resolved through getUserByExternalId)
metadata object Additional metadata to merge (new keys added, existing keys overwritten)

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/resolve-by-metadata \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"key": "orderId",
"value": "order-123",
"resolverPayload": { "approved": true, "targetStatus": "completed" },
"metadata": { "completedBy": "alice" }
}'

Response 200 (non-signal): the escalation resolved atomically.

{
"escalation": { "id": "...", "status": "resolved", "...": "..." }
}

Response 200 (signal-backed): the workflow was signaled; conditional resolves the escalation durably.

{
"signaled": true,
"escalationId": "...",
"workflowId": "..."
}
POST /api/escalations/:id/resolve-batch-item
POST /api/escalations/resolve-batch-item-by-signal-key
POST /api/escalations/resolve-batch-item-by-metadata

Submits ONE declared item of a batch escalation (a conditional wait created with batch: [...]). Interim items answer { outcome: 'accepted', remaining }. The last item answers { outcome: 'completed', remaining: 0, signaled, escalationId, workflowId }, and the waiting workflow resumes with the full collection in the same statement. Each item validates against the row’s versioned role form (canonical 422). See Batches and accumulators.

Scope: RBAC mirrors the single-resolve forms. The by-id form answers 404 for rows outside the caller’s read scope and 403 inside it but outside write scope; the ingress forms collapse a write denial to 404.

Request body

Form Body
By id { itemKey, resolverPayload, metadata?, assertClaim? }
By signal key { signalKey, itemKey, resolverPayload, metadata? }
By metadata { key, value, itemKey, resolverPayload, metadata?, restrictRoles? }

Claim semantics: claim-agnostic by default. assertClaim: true (by id) requires the caller’s own live claim, asserted atomically.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/resolve-batch-item \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "itemKey": "item-2", "resolverPayload": { "approved": true } }'

Response 200 (interim)

{ "outcome": "accepted", "remaining": 2 }

Response 200 (last item)

{ "outcome": "completed", "remaining": 0, "signaled": true, "escalationId": "...", "workflowId": "..." }

Response 400: an undeclared item key, or a non-batch row.

Response 409

{ "error": "Batch item already submitted" }

Response 422: the item payload fails the role form (canonical schema_validation body).

POST /api/escalations/:id/flag-batch-item
POST /api/escalations/:id/unflag-batch-item

Flags ONE pending key of a batch escalation, or clears its flag. A flag writes envelope.batch_flags[itemKey] (label, payload, the caller as actor, the database at) in one statement, only while the row and the key are pending. The key stays pending and still fills.

On a wait declared with wakeOnFlag, the flag resolves the row (recording the caller in metadata.resolved_by) and the workflow wakes with the items so far, $flags and $trigger: "flag". The answer is then { outcome: 'completed', workflowId }; otherwise it is { outcome: 'flagged' }.

Scope: RBAC matches the by-id batch fill: 404 outside read scope, 403 outside write scope. Both endpoints accept X-LT-Acting-Token as the other work verbs do.

Request body

Endpoint Body
flag-batch-item { itemKey, label, payload? }
unflag-batch-item { itemKey }

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/flag-batch-item \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "itemKey": "item-3", "label": "damaged", "payload": { "note": "Cracked casing" } }'

Response 200

{ "outcome": "flagged" }

Response 400: an undeclared key, or a non-batch row.

Response 409: the key is already filled, the key has no flag (unflag), or the row is no longer pending.

POST /api/escalations/:id/roster-action

Runs one action that a roster entry offers on this row’s form. See roster actions.

The request is a pointer. The server re-reads the action from the row’s pinned form schema, requires the entry to be on the roster it computes, builds the payload from the declaration, and runs the act (resolve, batch fill, flag, unflag) through its own endpoint as the person acting, under their RBAC and enforce_schema. With X-LT-Acting-Token, the person acting is the badged person: the badge is read first and spent only when the act runs, and a use is returned when the act does not land.

Request body

Field Description
field The roster field
action The action’s index among the field’s x-lt-actions
entry The entry key
code Optional. A key-in’s entered code

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/roster-action \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "field": "items", "action": 0, "entry": "item-3" }'

Response 200: the act’s own answer.

Status outcome Meaning
403 needs_badge The action is limited to roles the person acting lacks; a badge of that role answers it
403 role_refused The badged person lacks the roles; the badge is not spent
401 not_primed The badge expired or was spent
422 code_mismatch The entered code does not name the entry’s code
409 The action or the entry is no longer offered
POST /api/escalations/:id/accumulate
POST /api/escalations/accumulate-by-signal-key
POST /api/escalations/accumulate-by-metadata

Adds ONE item to an open accumulator escalation (a conditionalAccumulator wait declared with accumulate: {...}). Interim adds are atomic row appends. The add that reaches max completes the escalation, and the waiting workflow resumes with the ordered collection in the same statement. See Batches and accumulators.

Request body (by id)

Field Type Description
itemKey string Required. The key the item is held under (1 to 128 characters)
payload object Stored on the entry and delivered inside $accumulated. Validates against the row’s versioned role form when present (canonical 422)
metadata object Merge patch for the container’s GIN-indexed metadata, same statement. Reserved accumulate_* keys are rejected
assertClaim boolean By id only. Require the caller’s own live claim, asserted atomically
reciprocal object A second accumulator row written in the same statement, both or neither. Exactly one of id, signalKey, or key + value; optional payload stored on its entry

Other forms

Form Body
By signal key { signalKey, itemKey, payload?, metadata?, reciprocal? }
By metadata { key, value, itemKey, payload?, metadata?, restrictRoles?, reciprocal? }

The by-metadata form selects the highest-priority pending accumulator whose metadata contains the key/value, within the caller’s write scope intersected with restrictRoles. Only rows carrying the accumulator declaration are candidates, so a release or remediation row that shares the facet is never picked. When no open container shares it, the answer is 404 No pending accumulator found for this metadata. The same rule selects a reciprocal named by key + value.

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/accumulate \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"itemKey": "ORD-9",
"payload": { "weight": 2 },
"metadata": { "lane": "A" },
"assertClaim": false,
"reciprocal": { "id": "..." }
}'

Response 200 (interim)

{
"outcome": "accepted", "count": 3, "remaining": 9, "escalationId": "...",
"reciprocal": { "outcome": "completed", "count": 1, "remaining": 0, "escalationId": "...", "signaled": true }
}

remaining is null on an unbounded accumulator. reciprocal is present only when one was named.

Response 200 (completed)

{ "outcome": "completed", "count": 12, "remaining": 0, "signaled": true, "escalationId": "...", "workflowId": "..." }

Errors

Status Error
400 itemKey is required, Escalation is not an accumulator, payload must be an object, reciprocal requires exactly one of id, signalKey, or key/value
404 Escalation not found (unknown, or outside the caller’s scope on the ingress forms), Reciprocal escalation not found
409 Item already held ({ itemKey }), Accumulator is full, Escalation is claimed by another user, Your claim has expired, Escalation not available for accumulation (resolved, cancelled or expired). Reciprocal blocks, each with outcome: Reciprocal escalation already holds this container, Reciprocal accumulator is full, Reciprocal escalation is no longer pending
422 Payload violations
POST /api/escalations/:id/remove-item
POST /api/escalations/remove-item-by-signal-key
POST /api/escalations/remove-item-by-metadata

Removes ONE held item from a pending accumulator in one guarded statement. The row stays pending and the waiter stays asleep.

Request body: { itemKey, reciprocal? }, plus the selector fields of the ingress forms (signalKey, or key and value).

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/$ESCALATION_ID/remove-item \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "itemKey": "ORD-9" }'

Response 200

{ "outcome": "removed", "count": 2, "escalationId": "..." }

When a reciprocal was named, the response adds reciprocal: { count, escalationId }.

Response 404: Item not held by this escalation ({ itemKey }), or Reciprocal escalation does not hold this container. 400 and 409 match Accumulate items.

GET /api/escalations/:id/items
GET /api/escalations/items-by-signal-key?signalKey=...

Returns the held items of an accumulator or batch row in arrival order, by row id or by the signal_key a workflow already knows.

Scope: Read access. Rows outside it answer 404.

Example request

Terminal window
curl https://lt.acme.dev/api/escalations/$ESCALATION_ID/items \
-H "Authorization: Bearer $LT_TOKEN"

Response 200

{
"escalationId": "...", "kind": "accumulate", "status": "pending", "count": 2, "max": 12,
"items": [
{ "itemKey": "ORD-9", "payload": { "weight": 2 }, "at": "2026-09-18T14:02:11.402Z", "actor": "...", "reciprocalId": "..." },
{ "itemKey": "ORD-12", "at": "2026-09-18T14:03:40.118Z", "actor": "..." }
]
}

A batch row answers kind: "batch", max set to the declared size, and pending listing the keys still awaiting submission.

Response 400: Escalation holds no items.

POST /api/escalations/resolve-by-ids

Resolves many escalations in one guarded statement: the set-based sibling of POST /api/escalations/:id/resolve. It serves bookkeeping rows that are woken collectively and delivers no per-row signal. Rows backing a live condition() waiter (signal_key set) stay pending and are left out of the response; settle those with POST /api/escalations/:id/resolve, which carries the wake.

Scope: A scoped caller may resolve only rows whose role they hold, within write scope. Global principals are unrestricted.

Request body

Field Type Required Description
ids string[] yes Escalation ids to resolve as one set
resolverPayload object yes Payload applied to every row
metadata object no Outcome patch merged into each row’s GIN-indexed metadata

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/resolve-by-ids \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ids": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."], "resolverPayload": { "done": true } }'

Response 200

{ "resolved": 2, "escalationIds": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."] }

Only rows still pending and without a signal_key are resolved and returned.

POST /api/escalations/resolve-all-or-none

Atomic bulk resolve with per-row payloads: every listed escalation resolves with its own resolverPayload in one SQL statement, or nothing resolves. Rows backing a live condition() waiter are first-class: each waiter’s wake commits with its resolve and delivers that row’s payload as the condition’s return value, the same wake contract as POST /api/escalations/:id/resolve. Use it for gang handoffs where each member needs a distinct mandate and a partial batch is unacceptable: either all mandates are delivered, or the set stays intact for a retry.

Scope: Matches resolve-by-ids: per-item write scope. Any missing or out-of-scope id returns 404 with nothing resolved.

Rows that resolve through legacy signal routing (metadata.signal_id or metadata.signal_routing) require POST /api/escalations/:id/resolve and block the batch with reason unsupported-resolution-path. Password-format fields are redacted per row against that row’s own form_schema.

Request body

Field Type Required Description
items Array<{ id, resolverPayload }> yes The batch; each row resolves with its own payload. Ids must be unique; max 100 items (LT_ESCALATION_BULK_RESOLVE_MAX)
metadata object no Shared outcome patch merged into every row’s GIN-indexed metadata
requireClaimed boolean no Assert every row is assigned to the caller, inside the atomic statement. Closes the re-claim race for claim-then-resolve flows

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/resolve-all-or-none \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "id": "esc-a1b2c3d4-...", "resolverPayload": { "station": "pack-1" } },
{ "id": "esc-e5f6a7b8-...", "resolverPayload": { "station": "pack-2" } }
],
"requireClaimed": true
}'

Response 200: every listed row resolved.

{ "resolved": 2, "escalationIds": ["esc-a1b2c3d4-...", "esc-e5f6a7b8-..."] }

Response 409: nothing resolved. Only the rows that blocked the batch are listed, with a reason of not-found, already-resolved, already-cancelled, already-expired, assignee-mismatch or unsupported-resolution-path. Resolvable members stay pending and untouched.

{ "error": "...", "failedIds": ["..."], "failed": [{ "id": "...", "reason": "..." }] }
POST /api/escalations/search-by-facets

Item-level faceted search over a single pond role, scoped to the caller’s role. The body is a faceted query. See Faceted routing.

Request body

Field Type Required Description
role string yes Pond role to search
status string no For example pending
available boolean no Only rows not currently claimed
facets object no Metadata facet equality filters
orderBy { column, direction }[] no Sort order
limit / offset integer no Paging

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/search-by-facets \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "role": "order-items", "status": "pending", "available": true, "facets": { "orderId": "ORD-1042" }, "limit": 25 }'

Response 200

{ "escalations": [], "total": 0 }
POST /api/escalations/claim-groups

Batch-claims complete origin groups (for example all items of an order) in priority order over a pond, assigned to the caller.

Scope: RBAC-scoped to the pond role.

Request body

Field Type Required Description
query FacetQuery yes The pond and facet selector (see Faceted search)
limit integer no Max groups to claim
durationMinutes integer no Claim TTL
sizeFacet string no Metadata key holding the group size

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/claim-groups \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "query": { "role": "order-items", "status": "pending" }, "limit": 2, "durationMinutes": 30, "sizeFacet": "itemCount" }'

Response 200

{ "groups": [] }
POST /api/escalations/claim-by-facets

Batch-claims individual rows matching a facet query (FOR UPDATE SKIP LOCKED), assigned to the caller. With allOrNone, the claim commits only when the full limit is acquired.

Scope: RBAC-scoped to the pond role.

Request body

Field Type Required Description
query FacetQuery yes The pond and facet selector
limit integer no Max rows to claim
durationMinutes integer no Claim TTL
allOrNone boolean no Commit only if the full set was acquired

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/claim-by-facets \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "query": { "role": "order-items", "facets": { "lane": "A" } }, "limit": 5, "durationMinutes": 15, "allOrNone": true }'

Response 200

{ "claimed": [] }
POST /api/escalations/aggregate-by-facets

Grouped analytics over escalation intervals. Every escalation is one open interval [created_at, ended_at), where ended_at is the instant the row left the live set (resolved, cancelled or expired) and NULL while live. One call replaces N per-filter count round-trips. See Analytics for the full guide.

The aggregate reads the interval time series two ways:

  • Membership: rows (or, with distinctBy, distinct entities) whose interval is open at an instant. A past asOf reconstructs the live set at that moment.
  • Dwell: open-seconds per group within a half-open [from, to) window, clipped to it on both ends.

Request body

Field Type Required Description
query object yes The filter: role/roles (or entity), facets, anyOf (rows carrying ANY of the given facet sets, max 200; targets an explicit entity set such as one table page), block, range, exists, and prefix (case-insensitive prefix match on facet values, for locating, e.g. {"serialNumber":"PRN-00"}). entity names an entity facet key and resolves server-side to every role declaring it as its entity_facet (the entity’s system). It excludes role/roles and implies exists: [entity], so rows without the key stay out
groupBy object yes Group keys: columns (whitelisted: role, subtype, status), facets (metadata keys, projected as text; NULL group key when absent) and state (group by the derived state label: each role contributes per its entity_state_source, its subtypes or itself; excludes states[]). An empty object yields one total row
measure object yes Exactly one kind: { "kind": "membership", "asOf"? } (default anchor: now) or { "kind": "dwell", "window": { "from", "to" } }
distinctBy string no Membership only. Count DISTINCT of this metadata facet per group (entities, not rows). Omit to count rows
states array no Labeling only: tag each group with the FIRST matching { name, match } entry, evaluated top to bottom. match compares grouped columns and facets. Grouping is unchanged
liveStatuses string[] no Statuses considered live (default ["pending"])
orderBy array no Order the result groups: { field, direction? } with count, dwellSeconds, sampleCount, a grouped column or a grouped facet key
limit / offset integer no Result-group paging, capped at LT_ANALYTICS_MAX_GROUPS

The filter takes the WHAT fields only. status, available and jeopardy are rejected: liveness derives from the interval, liveStatuses and the measure anchor. Query-level orderBy, limit and offset are rejected too; order and paginate the result groups with the top-level fields.

Example request: how a pool of printers spent a day, one row per state. Three roles declare entity_facet: "serialNumber". printer-pool uses entity_state_source: "subtype" (its idle and printing subtypes are states), while printer-unload and printer-service use "role" (each role is a state).

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/aggregate-by-facets \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": { "entity": "serialNumber" },
"groupBy": { "state": true },
"measure": {
"kind": "dwell",
"window": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-02T00:00:00Z" }
}
}'

Response 200

{
"groups": [
{ "facets": {}, "state": "printing", "dwellSeconds": 214380, "sampleCount": 41 },
{ "facets": {}, "state": "idle", "dwellSeconds": 132600, "sampleCount": 38 },
{ "facets": {}, "state": "printer-unload", "dwellSeconds": 21540, "sampleCount": 17 },
{ "facets": {}, "state": "printer-service", "dwellSeconds": 9060, "sampleCount": 3 }
],
"overflow": false
}

Adding "facets": ["model"] to groupBy slices the same bands per model value.

Response fields (per group)

Field Type Description
role / subtype / status string Present when requested in groupBy.columns. status is the row’s current status: status history is not stored, so a past asOf still groups by current status
facets object One entry per groupBy.facets key; null when the facet is absent on the underlying rows
state string Present when groupBy.state derived it or a states[] entry matched
count number Membership: entities (with distinctBy) or rows (without)
dwellSeconds number Dwell: summed open-seconds within the window
sampleCount number Underlying escalation rows contributing to the group (before distinct)

overflow: true means the group cap was hit and more groups exist beyond this page.

Response 400: a caller-input problem. Causes: a liveness field (status, available, jeopardy) or orderBy/limit/offset on the filter; entity together with role/roles; groupBy.state together with states[]; an unknown status in liveStatuses; an empty window or one wider than LT_ANALYTICS_MAX_WINDOW_DAYS; a future asOf; a malformed facet key; a states[] match referencing a key the query does not group by.

Response 403: the caller must hold read_all on every role in scope (for entity, every role in the derived system). A filter with no role scope spans every pond and requires a global principal (superadmin or admin). While features.publicPaceBoard is on (the default), counts-only groupings (no groupBy.facets keys) are readable by any login, the same data class the Pace Board exposes. A facet-keyed grouping emits facet values (entity ids) as group keys and always takes the full gate.

POST /api/escalations/timeline-by-facet

One entity’s ordered interval sequence: every escalation the entity facet appeared in, as [startedAt, endedAt) spans with durations, in created_at order. Open intervals report endedAt: null. Gaps between consecutive intervals are untracked time and are kept as gaps. The facet match is GIN-served (metadata @> {key: value}), so the stored value must be a JSON string.

Request body

Field Type Required Description
facet { key, value } yes The entity facet to trace
query object no Extra filter or role scope (or entity, the derived system). Same field rules as the aggregate filter
window { from, to } no Only intervals overlapping the window (overlap-filtered, not clipped)
select object no columns and facets to surface per interval (default: all three columns)
liveStatuses string[] no Statuses considered live (default ["pending"])
order string no Interval ordering by start instant: asc (default) or desc. desc with before pages a long history recent-first
before string no Strict upper bound on startedAt (ISO instant): the “load earlier” cursor
limit integer no Max intervals

Example request: one printer’s movement across the pool’s queues.

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/timeline-by-facet \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"facet": { "key": "serialNumber", "value": "PRN-001" },
"query": { "entity": "serialNumber" }
}'

Response 200

{
"intervals": [
{
"role": "printer-pool",
"subtype": "printing",
"status": "resolved",
"facets": {},
"startedAt": "2026-08-01T08:00:00.000Z",
"endedAt": "2026-08-01T09:30:00.000Z",
"durationSeconds": 5400
},
{
"role": "printer-unload",
"subtype": "unload",
"status": "pending",
"facets": {},
"startedAt": "2026-08-01T09:30:00.000Z",
"endedAt": null,
"durationSeconds": 1800
}
],
"overflow": false
}

durationSeconds runs to endedAt, else to window.to clamped at now. overflow: true means the interval cap was hit.

Response 400: the same validation class as the aggregate: a rejected filter field, an invalid window, a malformed facet key.

Response 403: timelines always take the full gate, because an entity’s movement history is item-level disclosure. The caller must hold read_all on every role in scope (for entity, every role in the derived system); a roleless query requires a global principal.

The related sets a role’s list_schema declares under x-lt-related (see Forms reference), read for list rows. The server reads the declaration from the stored list schema; the caller names the role, the rows and the schema version it rendered. Every count and row is scoped to the caller’s read scope, and a role the caller cannot read answers 404.

POST /api/escalations/related

Returns the declared related-set aggregates for a page of list rows. One statement runs per declared (set, split) pair, whatever the page size.

Scope: Enforces read scope; a role the caller cannot read answers 404.

Request body

Field Description
role The list’s role
ids Row ids, up to 100 (one page)
version Optional. The list schema version the caller rendered

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/related \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "role": "order-stage", "ids": ["6f1c..."], "version": 3 }'

Response 200

{
"version": 3,
"sets": {
"contents": {
"source": "items",
"roles": ["order-stage"],
"aggregates": {
"reciprocal.metadata.serviceLevel": {
"6f1c...": { "count": 3, "splits": { "standard": 2, "express": 1 } }
}
}
},
"coming": {
"source": "query",
"roles": ["order-prep", "order-check", "order-stage"],
"roleLabels": { "order-prep": "Order · Prep", "order-check": "Order · Check", "order-stage": "Order · Staged" },
"restricted": [],
"aggregates": { "role": { "6f1c...": { "count": 2, "splits": { "order-check": 1, "order-prep": 1 } } } }
}
}
}
Field Meaning
aggregates Keyed by splitBy ("" when a column is unsplit), then row id
hidden Counts items whose reciprocal row the caller cannot read
unresolved Marks a row whose facet templates did not resolve
restricted Query roles outside the caller’s full read scope (not counted)
roles The roles whose rows a set reads, so a list can follow their events: a query set’s resolved roles in order, or, for an item set split by reciprocal.metadata.<key>, the roles of the readable reciprocal rows

Response 400: a bad body, or a segment naming no sequence.

Response 422: the stored declaration no longer validates.

POST /api/escalations/related/rows

Returns the rows behind one related set for one list row.

Scope: Enforces read scope.

Request body

Field Description
role The list’s role
id The list row id
set The related set name
version Optional. The list schema version the caller rendered
limit Optional. 1 to 100, default 25

Example request

Terminal window
curl -X POST https://lt.acme.dev/api/escalations/related/rows \
-H "Authorization: Bearer $LT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "role": "order-stage", "id": "6f1c...", "set": "contents", "limit": 25 }'

Response 200: { source, rows, total }. An items row is { item: { itemKey, payload, at, actor?, reciprocalId? }, escalation }, where escalation is the reciprocal row or null when unreadable. A query row is { escalation }, in role order then age, and the response adds restricted and roleLabels.