triggers/ingress

Triggers are lash's typed inbound channel from the outside world. A trigger occurrence is session-agnostic: it is declared, emitted, recorded, and matched against registered trigger subscriptions, each of which names its own target. Session identity and turn-ordering enter exactly once: at the wake a matched process produces. This note consolidates the trigger model; the reactive substrate it feeds is covered in durability and the deployment view in scaling.

The Model

A trigger occurrence flows through one pipeline regardless of origin (a UI signal, an inbound webhook, a scheduled timer tick): declare → emit → match → deliver → wake. Only the final wake is session-scoped and turn-ordered; everything before it is a runtime-level, session-agnostic ingress.

Declare → emit → match → deliver → wake
flowchart TB Decl["Host declares: TriggerEventCatalog
+ configured source constructors (cron.Schedule)"] Reg["triggers.register(source, target process, inputs)
durable subscription, keyed by source_type + source_key"] Emit["source owner emits occurrence
TriggerStore.record_occurrence (session-agnostic)"] Match["reserve_matching_deliveries
match by source_type + source_key"] Start["start target process (idempotent effect)
with stored registrant + env_ref"] Wake["process wake → queued work
EarliestSafeBoundary"] Turn["turn runs → commit to session graph"] Decl --> Reg Reg -.subscription.-> Match Emit --> Match --> Start --> Wake --> Turn

Declare And Subscribe

A host declares the events it can emit and the trigger sources it exposes; programs subscribe by registering triggers against those sources.

Emit, Match, Deliver

A source owner emits an occurrence; the router records it and fans out to every matching subscription. The event itself names no session; the match supplies the targets.

StepWhat happens
recordTriggerStore::record_occurrence stores the occurrence with a deterministic id derived from source_type, source_key, and idempotency key. The occurrence is session-agnostic.
matchreserve_matching_deliveries looks up subscriptions by the occurrence's source_type and source_key, then reserves one delivery per match. A single occurrence may fan out to 0..N subscribers, including host-registered subscriptions with no session edges.
deliverEach reservation starts the subscription's target process through the effect controller with the subscription's registrant as provenance, its captured env_ref, and its wake target; a wake target also earns that session a handle grant on the started process. The delivery process id is deterministic (occurrence_id + subscription_id). Delivery is therefore idempotent / exactly-once and replay-safe; the started process carries CausalRef::TriggerOccurrence so causality is referenced, not copied.

A matched trigger occurrence does not write the conversation graph and is not a session command. It references the started process as its consequence; the event log and the conversation graph stay separate.

Only The Wake Is Session-Ordered

Starting a process is safe at any time. It is independent background work. The one place a trigger occurrence touches a session's turn state is an optional wake the matched process emits.

A trigger-started process is durable background work — it declares the Rerunnable recovery disposition, so it is lease-protected and re-executed on crash through DurableProcessWorker (ADR 0019); see durability. When it has a wake target and wakes an agent, the wake is written to that session's queued work and delivered at EarliestSafeBoundary: immediately when the session is idle, or at the next safe boundary while a turn runs. With no live wake target, the wake remains a process event and no delivery is materialized. Triggers are therefore a separate runtime-level ingress and never appear as queued user input themselves; only the resulting wake enters queued work. How queued work and pending turn input are persisted and claimed is detailed under persistence.

The Store Is A Backend Seam

Subscriptions, occurrences, and delivery reservations live behind a TriggerStore trait, parallel to RuntimePersistence, and separate from it.

The default implementation is in-memory. Local durable hosts can use the first-party SqliteTriggerStore; shared-worker deployments can use PostgresTriggerStore; hosts can also provide another TriggerStore implementation when it preserves occurrence idempotency, matching, reservation, and delivery semantics. The distributed Restate/Postgres/MinIO E2E is one exercised deployment, not the interface contract. The trigger store is durable intent for delivery, not conversation history; durable session state remains in RuntimePersistence.

Timers And Cron

Timers and recurring jobs are host-owned source policies, not Lashlang syntax and not queued work.

A source owner keeps its own schedules and, when a schedule fires, emits a declared trigger occurrence with a stored source key. From there it is an ordinary occurrence: matched by source_type plus source_key and delivered to subscribed processes through the same durable worker path. A cron firing is thus the same shape as a button press; there is no separate by-handle activation path.

read on ·
previoustypes / contracts nextusing / cli