conditional
conditional parks a workflow on something it does not control and writes that wait as an escalation row. This page is the full contract: what you can declare, what comes back, and what the platform guarantees about the row.
import { conditional } from '@hotmeshio/long-tail';
const decision = await conditional<{ approved: boolean; notes?: string }>(signalId, { role: 'finance-reviewer', type: 'approval', subtype: 'budget-request', priority: 2, description: `Approve spend of $${amount} for ${vendor}`, metadata: { orderId }, envelope: { amount, vendor }, timeout: '72h', schemaVersion: 3,});function conditional<T>(signalId: string, escalation?: ConditionEscalationConfig): Promise<T | false | null>;Options
Section titled “Options”Routing and display
Section titled “Routing and display”| Option | Type | Effect |
|---|---|---|
role |
string |
The queue and its RBAC. Only members see, claim and resolve the row |
type |
string |
Category, for filtering and routing |
subtype |
string |
Finer category. Analytics read it as the state of an entity at a role |
priority |
number |
Queue order. Lower numbers sort first |
description |
string |
The line people read in the queue |
entity |
string |
An entity tag stored on the row |
workflowType |
string |
The workflow type recorded on the row |
| Option | Type | Effect |
|---|---|---|
metadata |
object |
GIN-indexed facets. Search, filter, claim and resolve by them |
envelope |
object |
Unindexed context for the form (envelope.formDefaults prefills fields) |
schemaVersion |
number |
Pins the role form version the row renders and validates against |
lookups |
{ domain, key, version?, as? }[] |
Knowledge entries the form reads as lookup.*. See Lookups |
Deadline and assignment
Section titled “Deadline and assignment”| Option | Type | Effect |
|---|---|---|
timeout |
string |
Deadline such as '30m' or '24h'. When it fires first, the row becomes expired and the wait returns false. Omit it for an open-ended wait |
assignee |
string |
User id written as assigned_to at creation. Alone it is a routing hint: the row shows in that user’s inbox and others can still claim it |
durationMinutes |
number |
With assignee, arms a claim window at creation, so the row is born locked to that user |
Collections
Section titled “Collections”| Option | Type | Effect |
|---|---|---|
batch |
string[] |
Declared item keys. The row resolves when every key is filled |
filled |
Record<key, { payload, at, actor? }> |
Batch items already satisfied when the row is created |
partialOnTimeout |
boolean |
With batch, a timeout delivers the items filled so far |
wakeOnFlag |
boolean |
With batch, a flagged item ends the wait |
accumulate |
{ max?, resolveAtMax?, unique? } |
Declares an open container that items join over time. Exclusive with batch |
See Batches and accumulators for the collection options.
Return values
Section titled “Return values”| Value | Cause | Row status |
|---|---|---|
T |
A resolver submitted a payload | resolved |
false |
timeout fired first |
expired. A late resolve answers 409 |
null |
The row was cancelled, or the workflow was terminated | cancelled |
Always check before reading the payload:
if (decision === false) return { data: { outcome: 'sla-expired' } };if (decision === null) return { data: { outcome: 'cancelled' } };if (decision.approved) await releasePayment(order);if (!decision) handles both when you do not need to tell them apart. An open accumulator never returns false: use conditionalAccumulator, whose type leaves it out.
Know who resolved
Section titled “Know who resolved”Interactive and webhook resolves deliver the resolver’s identity under the reserved $resolution key. Declare it in the generic:
import type { EscalationResolution } from '@hotmeshio/long-tail';
const decision = await conditional<{ approved: boolean; $resolution?: EscalationResolution }>( signalId, { role: 'order-reviewer', metadata: { orderId } },);
if (decision && decision.$resolution) { const { escalationId, resolvedBy, resolvedByEmail } = decision.$resolution; // assign the follow-on work to resolvedBy}$resolutionrides the signal only. The storedresolver_payloadholds the form payload as submitted.- The same resolve merges
resolved_byinto the row’s metadata, so “who resolved it” is a facet query. - Keys starting with
$are reserved for control data. Form fields never collide with them.
Signal ids
Section titled “Signal ids”The first argument is the signal id. It is stored on the row as signal_key, and it is unique per namespace and app.
- Make it deterministic. Derive it from the run and the step:
`review-${workflowId}`,`station-${workflowId}-${stationName}`. A wait inside a loop needs the iteration in the id. - Resolve by it. A caller that knows the id resolves without looking up the row:
POST /api/escalations/resolve-by-signal-key,lt.escalations.resolveBySignalKey, or theresolve_by_signal_keyMCP tool. - Signal early. A resolve that arrives before the wait registers (a fast webhook, a payload deposited before the workflow starts) is held as a pending signal and delivered when the wait registers. The hold lasts 10 minutes by default. Pass
expiretosignal()when you signal early on purpose.
Pin the form version
Section titled “Pin the form version”A role’s form is versioned. Every change appends an immutable snapshot, and the role’s first form is version 1. Pin the version your code is written for, and change the pin and the payload type together:
export const INTAKE_SCHEMA_VERSION = 3;export interface IntakeResolverV3 { approved: boolean; lotNumber: string }const decision = await conditional<IntakeResolverV3>(signalId, { role: 'intake-reviewer', description: instructions, schemaVersion: INTAKE_SCHEMA_VERSION,});- The pin is stored as
metadata.schema_version, a queryable facet. - The row renders and validates against that snapshot, whatever the role’s latest version becomes.
- A pin that names a missing version fails at creation. It never falls through to another version.
- With no pin, the row uses the role’s latest form when it is fetched.
The pin and the lookup refs are literals folded into the row before it is written. A pinned wait costs the same as an unpinned one.
The crash-safe single write
Section titled “The crash-safe single write”conditional writes the escalation row, all of its metadata and its timeout timer inside the same Postgres commit that checkpoints the workflow.
- Row and wait commit together. The row, its facets and its timer commit with the checkpoint or not at all, so every wait has its row.
- Complete from the first moment. Every facet commits with the row, so a router that claims by metadata never sees a row with missing facets.
- One statement to resolve. Resolving marks the row resolved, stores the payload, merges outcome metadata and delivers the signal in one transaction. The same run resumes in place.
- One terminal state. Resolve, expire and cancel each lock the row and check that it is still pending. Exactly one wins.
- Events fire automatically.
system.escalation.{role}.{id}.createdfires when the row is written and aresolvedevent when it is resolved.
Record the outcome
Section titled “Record the outcome”The row carries the intent from creation. Every resolve surface takes an optional metadata patch that stamps the outcome on the same row:
await lt.escalations.resolve({ id, resolverPayload: { approved: true }, metadata: { outcome: 'approved', durationMs: elapsed },});The payload resumes the workflow. The metadata patch is the indexed record of what was decided, so the escalation table answers what was asked, what was decided and how long it took.
Cancel
Section titled “Cancel”Cancellation is terminal and the wait returns null.
POST /api/escalations/:id/cancelor/bulk-cancelwith{ "ids": [...] }. Both answer409when the row is already resolved or cancelled.POST /api/workflows/:workflowId/terminatecancels the run’s pending rows.- The dashboard offers Cancel on the list’s selection bar and on the detail page.
conditionLT is a deprecated alias of conditional.
