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.
Escalation lifecycle
Section titled “Escalation lifecycle”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.
Work-surface scope
Section titled “Work-surface scope”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.
List escalations
Section titled “List escalations”GET /api/escalationsLists 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
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}List available escalations
Section titled “List available escalations”GET /api/escalations/availableReturns 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
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 escalation details
Section titled “Get escalation details”GET /api/escalations/:idReturns 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
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 escalation lookups
Section titled “Get escalation lookups”GET /api/escalations/:id/lookupsResolves 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
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.
Claim an escalation
Section titled “Claim an escalation”POST /api/escalations/:id/claimLocks 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
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.
Resolve an escalation
Section titled “Resolve an escalation”POST /api/escalations/:id/resolveOn 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
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.
Signal-key resolution (signal_key)
Section titled “Signal-key resolution (signal_key)”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.
What happens during resolution
Section titled “What happens during resolution”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.
- The route reads the escalation record and verifies it is still
pending. - It reconstructs the original workflow envelope from the escalation’s
envelopefield (or from the parent task if the escalation envelope is missing). - It injects
resolver(the reviewer’s payload) andlt.escalationIdinto the envelope. - It starts a new workflow with the modified envelope on the original task queue.
- The interceptor detects
envelope.lt.escalationId, marks the escalation resolved and signals the parent orchestrator (if any) that the child workflow has completed.
Resolve by signal key
Section titled “Resolve by signal key”POST /api/escalations/resolve-by-signal-keyResolves 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
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.
Resolver form schemas
Section titled “Resolver form schemas”When a reviewer claims an escalation, the dashboard renders a typed form when a schema is available. There are two ways to attach one.
Option 1: Role form_schema (versioned)
Section titled “Option 1: Role form_schema (versioned)”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.
Option 2: Escalation metadata (dynamic)
Section titled “Option 2: Escalation metadata (dynamic)”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)" } } } }}Supported field features
Section titled “Supported field features”| 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 |
Field type rendering
Section titled “Field type rendering”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 |
Hidden fields
Section titled “Hidden fields”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.
Schema priority
Section titled “Schema priority”The form that renders is chosen by precedence:
metadata.form_schema(a legacy inline schema on the escalation row).- The role’s
form_schema, resolved to the escalation’s pinnedmetadata.schema_version(or the role’s latest when unpinned) and embedded in theGET /api/escalations/:idresponse. - The deprecated workflow-config
resolver_schemalegacy fallback.
Release expired claims
Section titled “Release expired claims”POST /api/escalations/release-expiredClears 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
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 escalation types
Section titled “Get escalation types”GET /api/escalations/typesReturns the distinct escalation type values across all escalations.
Example request
curl https://lt.acme.dev/api/escalations/types \ -H "Authorization: Bearer $LT_TOKEN"Response 200
{ "types": ["review", "approval", "verification"]}Station metrics
Section titled “Station metrics”GET /api/escalations/station-metricsPer-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
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:
pendingandclaimedreflect the live queue, not the selected period.throughput_pctabove 100 means the station resolved more than its target (ahead of schedule); below 100 means behind.priority_countmeasures age from the role’spriority_facetmetadata timestamp (created_atwhen unset) againstpriority_threshold_minutes(sla_minuteswhen 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 escalation stats
Section titled “Get escalation stats”GET /api/escalations/statsAggregated 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
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": []}Bulk update priority
Section titled “Bulk update priority”PATCH /api/escalations/priorityUpdates 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
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\"" }Bulk claim escalations
Section titled “Bulk claim escalations”POST /api/escalations/bulk-claimClaims 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
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" }Bulk assign escalations
Section titled “Bulk assign escalations”POST /api/escalations/bulk-assignAssigns 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)
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)
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" }Bulk unassign
Section titled “Bulk unassign”POST /api/escalations/bulk-unassignReturns 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
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.
Bulk escalate to role
Section titled “Bulk escalate to role”PATCH /api/escalations/bulk-escalateMoves 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
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" }Bulk triage escalations
Section titled “Bulk triage escalations”POST /api/escalations/bulk-triageResolves 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
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"]}Escalate to role (single)
Section titled “Escalate to role (single)”PATCH /api/escalations/:id/escalateMoves 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
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 escalations by workflow
Section titled “Get escalations by workflow”GET /api/escalations/by-workflow/:workflowIdReturns 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
curl https://lt.acme.dev/api/escalations/by-workflow/review-post-456-x9y8z7 \ -H "Authorization: Bearer $LT_TOKEN"Response 200
{ "escalations": [ { "id": "esc-a1b2c3d4-...", "...": "..." } ]}Cancel an escalation
Section titled “Cancel an escalation”POST /api/escalations/:id/cancelPermanently 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
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" }Bulk cancel escalations
Section titled “Bulk cancel escalations”POST /api/escalations/bulk-cancelCancels 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
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" }Release a claim
Section titled “Release a claim”POST /api/escalations/:id/releaseReleases 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
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" }Escalation fields
Section titled “Escalation fields”| 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.
Metadata candidate key operations
Section titled “Metadata candidate key operations”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.
Find by metadata
Section titled “Find by metadata”GET /api/escalations/by-metadataFinds 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
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}Claim by metadata
Section titled “Claim by metadata”POST /api/escalations/claim-by-metadataFinds 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
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)
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).
Resolve by metadata
Section titled “Resolve by metadata”POST /api/escalations/resolve-by-metadataOne 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
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": "..."}Batch items
Section titled “Batch items”POST /api/escalations/:id/resolve-batch-itemPOST /api/escalations/resolve-batch-item-by-signal-keyPOST /api/escalations/resolve-batch-item-by-metadataSubmits 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
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).
Batch item flags
Section titled “Batch item flags”POST /api/escalations/:id/flag-batch-itemPOST /api/escalations/:id/unflag-batch-itemFlags 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
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.
Roster actions
Section titled “Roster actions”POST /api/escalations/:id/roster-actionRuns 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
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 |
Accumulate items
Section titled “Accumulate items”POST /api/escalations/:id/accumulatePOST /api/escalations/accumulate-by-signal-keyPOST /api/escalations/accumulate-by-metadataAdds 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
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 |
Remove an accumulated item
Section titled “Remove an accumulated item”POST /api/escalations/:id/remove-itemPOST /api/escalations/remove-item-by-signal-keyPOST /api/escalations/remove-item-by-metadataRemoves 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
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.
List held items
Section titled “List held items”GET /api/escalations/:id/itemsGET /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
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.
Resolve a set of escalations
Section titled “Resolve a set of escalations”POST /api/escalations/resolve-by-idsResolves 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
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.
Resolve a set atomically (all-or-none)
Section titled “Resolve a set atomically (all-or-none)”POST /api/escalations/resolve-all-or-noneAtomic 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
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": "..." }] }Faceted search
Section titled “Faceted search”POST /api/escalations/search-by-facetsItem-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
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 }Claim groups
Section titled “Claim groups”POST /api/escalations/claim-groupsBatch-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
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": [] }Claim by facets
Section titled “Claim by facets”POST /api/escalations/claim-by-facetsBatch-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
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": [] }Aggregate by facets
Section titled “Aggregate by facets”POST /api/escalations/aggregate-by-facetsGrouped 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 pastasOfreconstructs 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).
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.
Timeline by facet
Section titled “Timeline by facet”POST /api/escalations/timeline-by-facetOne 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.
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.
Related sets
Section titled “Related sets”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.
Read related sets
Section titled “Read related sets”POST /api/escalations/relatedReturns 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
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.
Read related set rows
Section titled “Read related set rows”POST /api/escalations/related/rowsReturns 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
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.
