ADR-0019: Automation triggers & scheduling — patterns over two verbs, poll for the push-less
ADR-0019: Automation triggers & scheduling — patterns over two verbs, poll for the push-less
Status
Accepted
Date
2026-07-03
Context
Workflows must start, and the hard case is driving external systems that expose no outbound webhooks — SmartInventory is pull/poll only, and it is representative of the legacy OSS/BSS estate. NETW-42 asks for the triggers-and-scheduling architecture; its acceptance criterion is to answer the automation-pattern checklist that NETW-37 Part B enumerates (triggers manual / scheduled / webhook / event; branching; loops and fan-out; joins; sub-workflows; retries and error handling; human-in-the-loop; secrets and external HTTP; observability). NETW-37 is still open, so that gaps doc does not yet exist — this ADR treats NETW-37’s Part-B list as the requirement set and answers the triggers/scheduling slice of it directly, mapping the rest to existing canon.
Two facts bound the design. The trigger surface is already closed at two verbs — Subscribe
(react to an Event) and Schedule (a timer fires) — per the-seam §V and
ADR-0002. And the durable job + worker pipeline
already exists (process.job_request, vinxi-worker, the at-least-once worker and stale-claim
reaper of ADR-0013). Scheduling and polling
themselves are greenfield in code.
Decision
-
Triggers are patterns over the two existing verbs — zero new kernel surface. The four trigger types reduce onto three mechanisms: manual → an
Act(an Actor starts the run, audited); scheduled / cron →Schedule; event / inbound-webhook / message-queue →Subscribe, because all three are a driveremit→ Event →Subscribe. “Webhook” and “queue” are driver-emit sources, not primitives. Adding a first-class Webhook or Queue trigger is rejected — it would reopen the closed trigger surface for no new capability. -
The missing webhook is a scheduled driver poll. For a push-less system the driver runs in poll mode: on a
Schedule, itCalls the system’s status/list endpoint (a driver read is aCallthrough the egress gateway), diffs against a last-seen cursor (updated-at / sequence / content hash), andemitsAsserts only for changed items. Downstream automationsSubscribeto those Asserts, so a polled system is indistinguishable from a push system to every workflow. The cadence and cursor are apollSpecon the driver manifest (per Tenant/Universe), not code. The pattern is idempotent by construction — Asserts are last-writer-wins (ADR-0011) and upsert by(source, key), so re-polling unchanged state emits nothing. Declared limitation: polling observes current state, so a transientA→B→Abetween two polls is lost. Poll is state-sync, not every-transition; a system that needs every transition needs a real feed, which cannot be synthesized. This is a stated property, not a bug. -
Scheduling lives in P10 — no separate scheduler service. The
Scheduleverb is backed by P10 durable timers. In the Lean profile the existing job + worker pipeline is the scheduler (a fire enqueues a fire-timed job; the worker claims and runs it) — no new infra, honoring ADR-0007. Temporal schedules are an optional Scale-tier adapter behind the same port, adopted only when signals/timers/sagas at scale justify it — not a commitment. A Schedule is a declared, versioned thing and its runs are process-instances-as-entities (LLD §12): “list every schedule and whether it fired on time” is answered byQuery, never hidden inside the engine. -
Scheduling semantics are declared per Schedule. Idempotency: each fire carries a deterministic run-key
(schedule-id, scheduled-fire-time); at-least-once delivery plus an idempotent handler (ADR-0013 replay) yields effectively-once. Catch-up after missed windows is acatchUppolicy —skip(default for state-sync polls: re-polling current state is the catch-up),coalesce(one run for all missed), orrun-all(every missed window, for semantically-distinct windows such as a daily scorecard); backfilled runs are bitemporally honest (validTime = the window, systemTime = now,reprocessing-flagged, LLD §13.1). Concurrency is anoverlappolicy —skip-if-running(default),queue, orallow-parallel(max)— bounded by per-Tenant verb budgets (LLD §13.2) and the driver’srateLimits. -
Inbound webhooks (for systems that can push) map to the same Subscribe surface. An inbound webhook is a driver
emitcrossing: the external system POSTs, the driver authenticates the request with an inbound Credential (ADR-0018), translates the payload into Asserts (or an Event), andSubscribefires. ROUND_TRIP correlation closes async request/response loops — aCallgoes out with a correlationId, the system webhooks back later, the driveremits the receipt, the correlationId matches, and the waiting P10 run resumes via signal. An unauthenticated webhook is rejected at the boundary. -
Observability rides existing machinery. Run history = P10
history(run)plus the Truth-plane logs (DecisionLog for Acts, CallLog for every external and poll Call); each run is a process-instance-entity, so “all failed polls in the last hour” is aQuery, and a Schedule’s own fire-history (fired / skipped / missed / caught-up, declared-vs-actual fire time) is recorded. Retries = P10retryPolicy+ at-least-once; failures are cataloged errors (ADR-0014) with cause chains, and retry-exhaustion dead-letters into a WorkItem — never silent. Replay is deterministic: Acts replay, Calls are fed from recorded receipts (never re-hitting the external system), Asserts are taken as-of.
Consequences
- No new kernel surface. The only new declarations are the driver
pollSpecand the SchedulecatchUp/overlappolicy fields — manifest/definition config, not engine code. - Map for the rest of NETW-37’s Part-B checklist (so the engine-eval has a home for each
pattern): branching / loops / fan-out / joins / sub-workflows are the software model — code-mode
over the SDK, orchestrated by P10; human-in-the-loop is
Pending(approval)on Acts + WorkItems (LLD §12); secrets & external HTTP are ADR-0018 + theCall/egress boundary; retries & observability are Decision 6. This ADR closes the triggers/scheduling column; the others are already canon. - The state-sync-only limitation of polling is explicit; capturing every transition requires a real event feed and is out of scope for synthesis.
- Ties to ADR-0013 (idempotent replay, at-least-once), ADR-0018 (inbound/outbound credentials), ADR-0014 (failure surfacing). Feeds NETW-37 (Stage-1 engine gaps) and the workflow-engine convergence work (NETW-48).
- Revisit triggers: the first system that genuinely needs every-transition capture (forces a retained-snapshot or real-feed path); the point at which Temporal graduates in behind P10.
Alternatives Considered
- A dedicated scheduler service/port. Rejected: a second source of truth for “what runs when” and an ungoverned side-channel; P10 plus the existing job pipeline already provide durable timers on the governed path.
- First-class Webhook / Queue trigger primitives. Rejected: both are driver-emit →
Subscribe; adding surface violates the closed two-verb trigger model for no new capability. - Poll that captures every transition (retained per-poll snapshots). Rejected as the default — heavy, and still lossy between polls; a real feed is the honest answer when every transition matters.
- Emitting a decision-Event per poll reading. Rejected: it floods the DecisionLog with
observation noise. Polls emit
Asserts (declarations, recorded-not-replayed), neverActs — the Assert-vs-Act distinction (the-seam §VI) is exactly what keeps the decision log lean at poll volume.