Architecture¶
Runtime model¶
The runtime is built around four primitives:
EventEnvelope: the normalized fact published into the runtimeRuleSpec: declarative match + actions loaded from YAMLActionInvocation: a structured action request with optionalon_success/on_errorfollow-upsScheduleSpec: a persisted poller schedule
Functional division¶
Daemon¶
The daemon owns:
- loading config and plugin modules
- loading YAML rules from
rules.d - keeping the SQLite state
- serving HTTP control routes
- running ingress adapters, pollers, and action execution
CLI¶
The CLI is a thin operator surface. It can:
- operate locally against the same runtime implementation
- or speak HTTP to the daemon using
--daemon-url
This keeps the command surface stable while the daemon stays the source of truth when running as a long-lived service.
Context contracts¶
Each event definition declares a yields context schema.
Each action definition declares an expects context schema.
The runtime validates both before persisting or executing work. This provides a low-friction but explicit contract for:
- what a plugin promises to emit
- what an executor requires to run
Plugins¶
Plugins are discovered from an explicit list in the YAML config using importlib.import_module(...) and a register(registry) function.
This start version intentionally avoids depending on entry-point discovery, but leaves room for it later.
Configured plugin modules must expose a callable register() function. Event kinds, action kinds, ingress names, and poller names are unique runtime contracts; a second registration now rejects startup or reload rather than silently replacing the first implementation.
Deterministic rule policy¶
Rule matching is independent of rule-directory iteration and caller list order. Matching
candidates are evaluated by descending integer priority (default 0), with lexical
rule.id as the stable tie-breaker. Rules that do not opt into policy fields still all
execute when they match; only their relative order is made deterministic.
An optional conflict_group makes matching rules mutually exclusive for one event. The
first candidate in deterministic rule order owns the group, and later matches in that
group are recorded as conflict_skipped. Ownership is established before rate-limit
evaluation, so a throttled or malformed higher-priority policy cannot silently fall
through to a lower-priority action and create operator spam.
Optional rule-level rate_limit uses a fixed UTC window and a scalar value selected by
key_attr from event.attrs (dotted paths are supported). max_count successful policy
admissions are allowed per (rule id, typed key, window). Windows are aligned to Unix
epoch boundaries using runtime receipt time, not caller-controlled event timestamps.
Integer and non-empty string keys are supported and type-prefixed in storage; missing,
null, boolean, container, or otherwise invalid keys fail closed without creating an
action record. SQLite BEGIN IMMEDIATE serialization makes the counter shared across
daemon threads and processes rather than giving each worker an independent bucket.
Rules can define reusable variables. Variable definitions are resolved once from
event.* and other vars.* values before actions run. References are validated for
undefined names and cycles during rule load/reload, and runtime-missing event values
fail closed instead of leaving template text in an operator action. Exact substitutions
such as {{ vars.chat }} preserve integers, objects, lists, and null values; string
interpolation converts resolved values to text. Existing event.* and action-follow-up
result.* templates remain compatible.
Policy decisions are separately persisted as selected, conflict_skipped,
rate_limited, rate_limit_key_missing, or rate_limit_key_invalid. A selected
outcome means policy admitted the rule; action success or failure remains authoritative
in the existing durable action records.
Reload integrity¶
The runtime remembers the absolute configuration-file path. POST /v1/reload rereads that file, expands environment variables, loads every plugin, and strictly validates the full rule set before publishing any replacement state.
Reload uses a prepare/commit sequence:
- parse the new configuration;
- build a replacement registry and rule list;
- reconcile all schedule rows in one SQLite transaction;
- atomically swap the in-memory configuration, registry, and rules.
Malformed rules, duplicate rule ids, plugin-load errors, or schedule transaction failures leave the active runtime unchanged. Schedules removed from configuration are disabled instead of continuing from stale database rows.
Schedule execution leases¶
Due schedules are claimed in SQLite before their poller runs. The claim prevents the background loop, manual run-due calls, concurrent threads, or multiple daemon replicas from starting the same schedule simultaneously. Claims expire so another process can recover work after a crash. execution_lease_seconds can override the default lease for an individual schedule.
While a poller is running, a lightweight heartbeat renews the claim. The default heartbeat interval is the smaller of 30 seconds and one third of the lease. execution_heartbeat_seconds can shorten it, but it is capped at one third of the lease so multiple renewal opportunities remain. A claim cannot be renewed or completed after expiry, and a worker that loses ownership reports an execution error rather than marking the schedule complete.
The renewable lease provides at-most-one live owner while SQLite remains reachable. Backoff state is updated only after fenced completion, so an obsolete worker cannot clear or impose retry policy for a newer owner. It does not transactionally roll back arbitrary external side effects, so pollers should still use stable upstream offsets or idempotency keys. A database outage lasting longer than the lease is reported as a lease-loss error and may permit recovery by another replica.
SQLite initialization¶
WAL mode is enabled during serialized schema initialization with bounded lock retries. Ordinary database connections never attempt to change journal mode, avoiding first-use races between daemon replicas while retaining foreign-key enforcement and a five-second busy timeout.
Failure semantics¶
Ingress and poller event-publication errors propagate to the caller. Webhooks therefore receive an error response instead of a misleading success, and scheduled pollers enter their configured error backoff. Rule files are strict at runtime, while the standalone loader retains a lenient mode for diagnostics.
Parallel rules wait for every submitted action and propagate the first contract or template exception after collecting completed audit references. Executor-level failures remain normal ActionResult(ok=False) records.
For rules configured with both execution_mode: parallel and stop_on_error: true, actions execute sequentially. Starting every action concurrently would make the stop guarantee impossible to honor.
Action follow-ups are recursive. Each on_success or on_error action renders against
the result of its immediate parent, and its own follow-ups run in turn. A failed
follow-up marks the complete chain failed; this allows stop_on_error to stop later
top-level actions when delivery, acknowledgement, or recovery work did not succeed.