Skip to content

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>;
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
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
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.

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.

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
}
  • $resolution rides the signal only. The stored resolver_payload holds the form payload as submitted.
  • The same resolve merges resolved_by into 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.

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 the resolve_by_signal_key MCP 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 expire to signal() when you signal early on purpose.

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:

src/forms/intake.ts
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.

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}.created fires when the row is written and a resolved event when it is resolved.

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.

Cancellation is terminal and the wait returns null.

  • POST /api/escalations/:id/cancel or /bulk-cancel with { "ids": [...] }. Both answer 409 when the row is already resolved or cancelled.
  • POST /api/workflows/:workflowId/terminate cancels 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.