Skip to content

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 verbsSubscribe (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

  1. 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 / cronSchedule; event / inbound-webhook / message-queueSubscribe, because all three are a driver emit → 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.

  2. The missing webhook is a scheduled driver poll. For a push-less system the driver runs in poll mode: on a Schedule, it Calls the system’s status/list endpoint (a driver read is a Call through the egress gateway), diffs against a last-seen cursor (updated-at / sequence / content hash), and emits Asserts only for changed items. Downstream automations Subscribe to those Asserts, so a polled system is indistinguishable from a push system to every workflow. The cadence and cursor are a pollSpec on 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 transient A→B→A between 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.

  3. Scheduling lives in P10 — no separate scheduler service. The Schedule verb 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 by Query, never hidden inside the engine.

  4. 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 a catchUp policy — skip (default for state-sync polls: re-polling current state is the catch-up), coalesce (one run for all missed), or run-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 an overlap policy — skip-if-running (default), queue, or allow-parallel(max) — bounded by per-Tenant verb budgets (LLD §13.2) and the driver’s rateLimits.

  5. Inbound webhooks (for systems that can push) map to the same Subscribe surface. An inbound webhook is a driver emit crossing: the external system POSTs, the driver authenticates the request with an inbound Credential (ADR-0018), translates the payload into Asserts (or an Event), and Subscribe fires. ROUND_TRIP correlation closes async request/response loops — a Call goes out with a correlationId, the system webhooks back later, the driver emits the receipt, the correlationId matches, and the waiting P10 run resumes via signal. An unauthenticated webhook is rejected at the boundary.

  6. 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 a Query, and a Schedule’s own fire-history (fired / skipped / missed / caught-up, declared-vs-actual fire time) is recorded. Retries = P10 retryPolicy + 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 pollSpec and the Schedule catchUp/overlap policy 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 + the Call/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), never Acts — the Assert-vs-Act distinction (the-seam §VI) is exactly what keeps the decision log lean at poll volume.