Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions docs/adr/0041-flow-trigger-family.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# ADR-0041: Flow trigger family — taxonomy, naming, and the open-source core set

**Status**: Proposed (2026-06-12)
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0018](./0018-unified-node-action-registry.md) (open node/executor registry — descriptors as the engine-owned contract surface), [ADR-0030](./0030-notification-platform-convergence.md) (outbox-backed delivery), [ADR-0012](./0012-notification-platform.md) (notification platform / messaging)
**Consumers**: `@objectstack/service-automation` (the `FlowTrigger` seam), `@objectstack/cli` (`serve` auto-wiring), runtime presets, Studio flow designer (trigger pickers), future marketplace packaging

---

## TL;DR

A **trigger** is an event-source adapter that wakes the *one* automation
engine: it normalizes an external or internal signal into a flow execution,
via the engine's `FlowTrigger` extension seam. Triggers are how **business
events become user-editable logic** — their downstream is a flow a
non-developer drew in Studio, which is what distinguishes them from the two
other wake-up mechanisms the platform already has (kernel hooks: in-process
lifecycle code; `jobs`: a clock for plugin-owned code).

This ADR decides three things:

1. **Taxonomy & naming** — triggers become a first-class package family
`trigger-*` (`@objectstack/trigger-schedule`, …), dropping the `plugin-`
prefix, consistent with the existing `driver-*` / `connector-*` /
`embedder-*` / `knowledge-*` families.
2. **The open-source core set** — three triggers are *table stakes for every
project* and ship Apache-2.0 in the default runtime preset:
`trigger-record-change` (shipped), `trigger-schedule` (shipped), and
**`trigger-api` (inbound webhook/HTTP — the one gap to build now**; the
flow schema already reserves `type: 'api'` with no runtime behind it).
3. **Everything else is deliberately deferred** — to an on-demand
open-source tier or to marketplace distribution — with the criteria for
promotion written down, so the family grows by demand instead of by
speculation.

## Context — current state (verified 2026-06-12, against the code)

**The seam exists and two triggers are real.** The engine exposes
`registerTrigger({type, start(binding, cb), stop(flowName)})`
(`service-automation/src/engine.ts`); when a flow is registered/enabled it
parses the start node into a `FlowTriggerBinding`
(`{flowName, object?, event?, condition?, schedule?, config?}`) and hands it
to the trigger registered for that `type`. The CLI's `serve` auto-loads two
concrete triggers (`cli/src/commands/serve.ts`):

- `plugin-trigger-record-change` — data events (`record-after-update`, …).
Battle-tested: the showcase budget-approval flow runs on it.
- `plugin-trigger-schedule` — bridges the seam to `IJobService`
(`spec/contracts/job-service.ts`), normalizing authoring shorthands (bare
cron string, `{every}`, `{at}`) into canonical `JobSchedule`s and managing
job lifecycle with the flow (`flow-schedule:<flowName>`, cancel on
disable).

Both follow the same decoupling pattern: structural mirrors of the engine
types (no import of `service-automation`) and structural service surfaces
(no import of the job adapter). Triggers, the engine, and their backing
services version independently; a missing trigger means the flow simply
never activates.

**The declared-but-unwired surface.** The flow schema's type enum is
`'autolaunched' | 'record_change' | 'schedule' | 'screen' | 'api'` — `api`
has **no trigger implementation and no inbound REST route**. This is the
platform's normal contract-first pattern (cf. `recalled` before `recall()`,
`ApprovalEscalationSchema` before any scheduler), but it is also a Studio
trap: the designer renders from engine descriptors, and a user can author an
`api` flow that will never run.

**Naming is inconsistent.** `packages/plugins/` already contains four
families named by *capability domain* rather than by *loading mechanism*
(`driver-*`, `connector-*`, `embedder-*`, `knowledge-*`); the two triggers
are the outliers carrying a `plugin-` prefix.

## Decision

### 1. What is (and is not) a trigger

A trigger adapts **one class of business event** into flow executions. The
test for "should X be a trigger": *does this event need a non-developer to
orchestrate the response in Studio?*

Explicit non-goals, recorded to keep the family honest:

- **`screen` flows** are user-*launched*, not event-*triggered* — no trigger.
- **Approval outcomes** continue down the approval node's out-edges — no
"approval trigger".
- **Plugin-internal clocks** (SLA scanner #1742, cleanup jobs) call
`IJobService` directly — a trigger's downstream must be a flow, not plugin
code.

### 2. Naming: the `trigger-*` family

Rename, keeping class names (the `*Plugin` suffix accurately describes the
kernel loading mechanism):

| today | becomes |
|---|---|
| `plugin-trigger-record-change` | `@objectstack/trigger-record-change` |
| `plugin-trigger-schedule` | `@objectstack/trigger-schedule` |

Migration: workspace rename + final `plugin-trigger-*` versions published as
deprecation stubs re-exporting the new packages; `serve` `nameMatch` lists
already match both spellings.

### 3. The tiers

**Tier 1 — core, Apache-2.0, ships in the default runtime preset.**
Every project needs these; they are the Zapier/Power-Automate baseline trio
(*data changed / time passed / someone called us*):

| trigger | status | scope |
|---|---|---|
| `trigger-record-change` | ✅ shipped | data events |
| `trigger-schedule` | ✅ shipped | cron / interval / once |
| `trigger-api` | **build next — the only Tier-1 gap** | inbound webhook/HTTP starts a flow |

`trigger-api` acceptance criteria (the short-term work item):

- Per-flow inbound endpoint (`POST /api/v1/automation/hooks/:flowName/:hookId`)
with a per-flow secret; HMAC signature verification (GitHub/Stripe style)
and a constant-time compare.
- Request body → flow variables via the binding's `config` mapping; the
raw payload available as `$payload`.
- **Queue-backed ingestion from day one**: inbound HTTP is the first trigger
whose event rate is not under our control. The handler enqueues
(`service-queue` / outbox per ADR-0030) and ACKs 202; a consumer executes
the flow. At-least-once delivery; flows must be authored idempotently
(documented; a `dedupKey` passthrough supported).
- Activates the reserved `type: 'api'` — closing the Studio trap (§4).

**Tier 2 — open source, on demand.** Built when a driving scenario lands,
not before. Each bridges an existing service, so the cost is one adapter:

- `trigger-email-inbound` — email-to-case; needs per-deployment inbound mail
infra (plugin-email is outbound-only today).
- `trigger-queue` — consume integration events from `service-queue` /
external brokers; becomes urgent the moment two ObjectStack deployments
(or an external ESB) need to talk.
- `trigger-lifecycle` — platform events (user created, package installed)
promoted from kernel hooks to Studio-orchestratable events.

**Tier 3 — marketplace / vertical (future, possibly commercial).**
Naturally per-ecosystem packages, distributed and versioned like connectors:

- `trigger-connector-event` family — third-party SaaS events (Slack message,
Stripe payment, GitHub PR), one package per ecosystem, pairing with the
outbound `connector-*` actions (ADR-0022); supports both webhook-subscribe
and poll modes.
- `trigger-threshold` — metric-crossing alerts on `service-analytics`.
- `trigger-file` — storage-bucket events.
- `trigger-iot` — MQTT/device telemetry (vertical).

**Promotion rule**: a Tier-2/3 trigger moves up when (a) two independent
real projects request it, or (b) it unblocks a Tier-1 acceptance criterion.
Nothing is built speculatively.

### 4. Discoverability: descriptors carry maturity

Triggers self-describe to the engine the way node executors do (ADR-0018):
a descriptor with `type`, display name, config schema for the start-node
form, and a **`maturity: 'ga' | 'beta' | 'reserved'`** field. Studio renders
trigger pickers from the live registry and greys out `reserved` — flow
types whose runtime hasn't shipped (today: `api`) become visible-but-
disabled instead of silent traps. The same audit marks `escalation` and
other contract-ahead-of-runtime surfaces.

### 5. Delivery guarantees, stated per tier

- In-process, low-rate triggers (record-change, schedule, lifecycle):
direct engine callback, as today.
- Boundary triggers (api, queue, connector-event, email): **must** ingest
through the outbox/queue path — a slow flow execution may not drop or
block inbound events. This is decided now so `trigger-api` is built toward
it rather than retrofitted.

## Consequences

- Two package renames (deprecation stubs, one changeset) — mechanical.
- One real build item short-term: `trigger-api`, including the queue-backed
ingestion seam it forces us to establish for every future boundary
trigger.
- Studio gains a registry-driven trigger picker and loses the `api` trap;
the `maturity` field gives the platform a standing way to ship contracts
ahead of runtimes without misleading authors.
- The marketplace gets a well-shaped unit of distribution
(`trigger-<ecosystem>`), aligned with how connectors are already framed.
- Deferred lists are recorded with promotion criteria, so "why don't we
have an email trigger?" has a written answer instead of a re-litigated
debate.

## References

- Engine seam: `packages/services/service-automation/src/engine.ts`
(`FlowTrigger`, `registerTrigger`, `activateFlowTrigger`)
- Shipped triggers: `packages/plugins/plugin-trigger-record-change`,
`packages/plugins/plugin-trigger-schedule`
- Reserved surface: `FlowSchema.type` enum (`packages/spec/src/automation/flow.zod.ts`)
- Auto-wiring: `packages/cli/src/commands/serve.ts` (trigger `nameMatch` table)
- Related: ADR-0018 (descriptor pattern this extends), ADR-0030 (outbox),
approvals SLA scanner issue #1742 (the "jobs, not trigger" precedent)