Skip to main content

Automation engine: Event-Condition-Action (ECA) rules

Built 2026-08-03 across four reviewed stages (design → scaffolding → execution/persistence → API), replacing legacy's trigger/action/scenario "IFTTT" model — same ECA mental model, but data-driven and plugin-extensible instead of a hardcoded switch-case.

Legacy's real shape (Logiswift.Framework.Common.IFTTT.*, Logiswift.Services.Queues.IFTTT): a business object calls IftttTrigger.Trigger(...), which loads matching MAS_SCENARIOS rows and runs LSRuleAction.InitializeLSRuleAction(ActionName) — a switch over ~12 fixed action-name constants. The switch-case is exactly what Eos's DI-registered plugin model replaces; the rest of this page is the "how."

Triggers are domain events, not a parallel system

There is no ITrigger/trigger-registry abstraction — a "trigger" is simply the (EntityType, Action) pair already carried by DomainEvent (see the Eventing Pattern page). The engine's entry point is a RabbitMQ consumer bound to the existing eos.domain-events<suffix> topic exchange with routing key mas.#, so it automatically covers every entity that publishes a DomainEvent today (City, Choices) and any future one with zero code changes here.

Real trigger coverage is only as wide as publish-side eventing's own rollout — extending it to Operations-module entities (MasterOrder is the obvious next candidate) is separate, already-tracked work, not part of this engine.

Actions are DI-registered IAutomationAction plugins

Application/Common/Interfaces/IAutomationAction.cs, each self-describing its config fields via GetConfigFields() (a flat AutomationActionConfigField[] — name/label/type/required, not a full JSON Schema document; kept minimal on purpose, see the condition-expressiveness reasoning below).

Multiple registrations of the same interface — no separate "registry" type; callers resolve the full set via IEnumerable<IAutomationAction>, plain ASP.NET Core DI already supports this. Two shipped today:

  • SendEmailAutomationAction — delegates to the existing IEmailSender.
  • CallWebhookAutomationAction — a named HttpClient, "AutomationWebhook".

Adding a third never touches the engine, the condition evaluator, or either existing action — just a new class + one AddScoped<IAutomationAction, T>() line in Infrastructure/DependencyInjection.cs.

Conditions are a flat AND-of-equality list

Deliberately not a JSON condition tree — ConditionEvaluator (Application/Common/ConditionEvaluator.cs), a concrete static class (not an interface — same precedent as TerritoryFilter/DtoDiff: a pure, no-I/O algorithm with one implementation doesn't need DI substitution). Matches legacy's own MAS_SCENARIO_TRIGGER_FIELD_VALUES shape.

Field names resolve only against the DomainEvent envelope (EntityType/Action/Key/Actor) or, for Updated events, a changed property's NewValue in DomainEvent.Changes — there is no "fetch the current record and check any field" step, since DomainEvent is deliberately thin (see the Eventing Pattern page).

note

Revisit only if a real client need for OR/comparison-operator logic shows up — don't build for a hypothetical.

Schema: new parallel tables, not a migration of legacy's

Legacy already has a full Trigger/Action/Scenario schema (MAS_TRIGGERS/MAS_ACTIONS/MAS_SCENARIOS/MAS_SCENARIO_*_FIELD_VALUES/ COM_SCENARIOS_LOG(_DL)), already scaffolded into EosDbContext.

:::danger Legacy's own IFTTT consumer is still live Legacy's own Queues.IFTTT consumer is confirmed still live against MAS_SCENARIOS for at least one client, so writing into those same rows risks the legacy engine picking up an Eos-only scenario it can't execute (or vice versa) during the transition period where both engines run side by side.

A discriminator column was considered and rejected — it would still leave both engines reading the same rows, relying on legacy's switch-case correctly skipping every Eos-only scenario, with one missed filter meaning a silent failure or a legacy attempt to run an action it doesn't know. :::

The fix: brand-new, Eos-owned tables — MAS_TRIGGERS_V2, MAS_ACTIONS_V2, MAS_SCENARIOS_V2, MAS_SCENARIO_TRIGGER_FIELD_VALUES_V2, MAS_SCENARIO_ACTION_FIELD_VALUES_V2, COM_SCENARIOS_LOG_V2, COM_SCENARIOS_LOG_DL_V2 — DDL in backend/scripts/automation-engine/01-create-eos-automation-tables.sql.

Table names deliberately mirror legacy's exactly, just with a _V2 suffix rather than an EOS_ prefix, so they read as "the new version of the same concept," not a separately-branded parallel schema — still unambiguous from legacy's live tables.

No MAS_TRIGGER_FIELD/MAS_ACTION_FIELD equivalent exists on purpose: FieldName on the two field-value tables is a free string, matched against the DomainEvent envelope or a resolved action's own config fields at evaluation/execution time, not normalized against a separate field-metadata catalog.

C# type names (EosTrigger, EosScenario, IEosTriggerRepository, the Persistence/Entities/Automation/Persistence/Configurations/Automation folders) were deliberately left as-is when the table-naming decision landed — that request was scoped to SQL table names only, hand-written entities/configs still live outside Persistence/Scaffolded/ since there's no legacy table to scaffold from (wired into EosDbContext via EosDbContext.partial.cs's OnModelCreatingPartial hook, same mechanism as any other hand-written addition).

Clean cutover, no legacy interop: the Eos consumer only ever binds to eos.domain-events<suffix>, never legacy's IFTTT queue/IFTTTMessage format. The two engines run fully independently until a client is cut over.

Migrating legacy scenario data into these tables per client, at that client's cutover, is a separate, later, not-yet-built task — the migration tool will need to:

  1. Skip/flag legacy scenarios whose trigger has no Eos equivalent yet rather than silently drop them.
  2. Apply an explicit legacy-action-name → Eos-action-name + config-transform mapping (legacy's ~12 fixed actions don't line up 1:1 with Eos's plugin set).
  3. Decommission legacy's Queues.IFTTT consumer for that client as the last step, not the first — otherwise there's a window with live scenarios in both tables and no single authoritative engine.

Eos's multi-tenancy is physical (one deployment/DB per client), not row-level — there is no TenantId column anywhere in this schema, unlike a shared-DB SaaS model. Every table here follows the same "no tenant column" shape as every other Mas*/Com* table in this codebase.

Catalog sync — code is the source of truth

MAS_TRIGGERS_V2/MAS_ACTIONS_V2 are kept in lockstep with what code can actually fire/execute, the same "warm the cache at startup" spirit as the City cache warm-up (see the Reference CRUD Pattern page) but for correctness, not performance:

  • Application/Modules/Automation/KnownTriggers.cs — the fixed catalog of (EntityType, Action) pairs Eos currently publishes a DomainEvent for (City/Choices × Created/Updated/Deleted, six entries today). EosTriggerRepository.SyncKnownTriggersAsync upserts one MAS_TRIGGERS_V2 row per entry.
  • EosActionCatalogRepository.SyncRegisteredActionsAsync upserts one MAS_ACTIONS_V2 row per DI-registered IAutomationAction, serializing its GetConfigFields() into ConfigSchemaJson.

Both run at Api and Workers startup (non-blocking try/catch, mirrors the City cache warm-up pattern exactly) — Api needs it fresh for Scenario CRUD, Workers needs it independently since AutomationTriggerConsumerJob might start before Api has ever run on a given deploy. A Scenario can never reference a trigger nothing publishes or an action nothing implements, because the catalog tables only ever contain what's actually deployed.

Execution pipeline — two independent queue hops

A few details worth calling out explicitly:

  • eos.domain-events<suffix> is the existing topic exchange from the Eventing Pattern page — the automation engine is just another subscriber to it, not a new publish path.
  • The dispatch step always writes a COM_SCENARIOS_LOG_V2 row, even on zero matches — "was this trigger even evaluated" needs an answer independent of "what matched."
  • eos.automation-actions<suffix> is a plain durable queue on the default exchange — not a topic exchange like the domain-events one, since exactly one consumer type ever reads it; this mirrors EmailDispatcher's eos.outbound-email shape.

Both consumer jobs follow the same event-driven shape as OutboundEmailConsumerJob: connect once, consume until shutdown, ack after attempting the work regardless of outcome.

:::note No retry/dead-letter, by design This matches every other consumer in this codebase (ack-and-drop on failure); revisit only if the audit trail this pipeline now writes surfaces enough real failures to justify the added complexity, and only with an explicit decision to reopen it, not by default. :::

Splitting into two queue hops (rather than executing the action in-process right after matching, which is what the first build pass did before the audit-log write path existed) mirrors legacy's own two-phase logging (COM_SCENARIOS_LOG fired- vs. _LOG_DL executed are already separate concepts there too) and means a flaky action retry-in-the-future wouldn't need to re-run condition evaluation.

Fully async condition evaluation and action execution — no synchronous write-path hook exists or is planned; a scenario that needed to block its triggering write (e.g. reject an order outright) would need a fundamentally different mechanism, not this pipeline.

Scenario CRUD API

ScenariosController (Api/Controllers/Operations) — mechanism 1 only, no TerritoryFilter (see the Authorization Model page): a real admin screen, but no per-record ownership concept, same bucket as City/Country/Choices. TriggersController/ActionCatalogController are the read-only lookups the Trigger/Action dropdowns need — pure combobox sources with no admin screen of their own, [Authorize]-only, same bucket as AddressTypes/ChoiceTypes.

IScenarioRepository.CreateAsync/UpdateAsync return null/bool? (not an exception) when the payload's (TriggerEntityType, TriggerAction)/ ActionName doesn't resolve against the synced catalog — a real, caller-correctable 400 now that these are reachable over HTTP, not the InvalidOperationException an earlier internal-only pass threw.

UpdateAsync also re-resolves the trigger/action from the payload rather than only touching ScenarioName/ScenarioOrder/Enabled/conditions/config — a PUT represents the full desired state, so ignoring three of its fields would be a real bug, not a deliberate scope cut.

DeleteAsync soft-deactivates (Enabled=false) — see the Delete Semantics page for why this is required, not just a stylistic choice.

ScenarioDtoValidator (structural rules only, per the FluentValidation page) shipped before the controller did and sat unused — registering it costs nothing since AddValidatorsFromAssemblyContaining<ChoiceDto>() already scans the whole Application assembly, so there was no extra wiring needed once ScenariosController landed.

backend/scripts/automation-engine/02-seed-scenarios-menu.sql seeds the MAS_MENU row under the MASTER_DATA app group (AppId 10015 — same group as City/Choices, not OPS/SECURITY, since this is admin-configured reference data, not a day-to-day Operations workflow or a privilege-management screen) plus an ADMIN role grant, the same "goes through MAS_ManMenuDt/MAS_ManRoleMenuDt, never a raw INSERT" pattern as every other menu-seed script.

:::tip Frontend config-form contract What a future Eos.Web admin page needs from GET /action-catalog's configFields to render a dynamic per-action config form (no UI has been built yet) is documented in docs/automation-engine-frontend-contract.md, not repeated here. :::

Verified end-to-end against a real sandbox, not just build-green

Applied the DDL to the ceva-eos sandbox, briefly flipped MAS_SYSCONFIG.EnableQueues on (confirming EmailAlert wouldn't also change behavior first) and reverted it after, ran Eos.Workers for real, seeded one test Scenario via direct SQL (no CRUD API existed at that point in the build), and published two synthetic DomainEvents via a throwaway console harness resolving IDomainEventPublisher directly.

Both a failure (a real webhook call that hit a transient upstream 503) and a success (a real 200) flowed through to correct COM_SCENARIOS_LOG_V2/ _LOG_DL_V2 rows, with CorrelationId propagated end-to-end through every log line in this pipeline.

Test data cleaned up afterward; the synced MAS_TRIGGERS_V2/MAS_ACTIONS_V2 catalog rows were left in place (not test debris — legitimate, idempotent to re-sync).

Still open

  • Query/UI over the audit-log tablesCOM_SCENARIOS_LOG_V2/ COM_SCENARIOS_LOG_DL_V2 are written but nothing reads them back yet; "why did/didn't this fire" is only answerable via raw SQL today. Tracked in TASKS.md, deliberately left out of the API pass above since it wasn't part of that stage's original scope.
  • Retry/dead-letter handling — explicitly out of scope by design (see above); don't add without an explicit decision to reopen it.
  • Wider trigger coverage (Operations-module entities, MasterOrder first) and the legacy→Eos scenario-data migration tool (see "Schema" above) — both separately tracked, neither blocking.
  • The Eos.Web admin page itself — only the contract is written (see above), no UI exists.

Reference files

IAutomationAction.cs, ConditionEvaluator.cs, KnownTriggers.cs, AutomationTriggerDispatchService.cs, AutomationActionExecutionService.cs, ScenarioRepository.cs, EosTriggerRepository.cs, EosActionCatalogRepository.cs, AutomationTriggerConsumerJob.cs, AutomationActionExecutorJob.cs, RabbitMqAutomationActionQueuePublisher.cs, ScenariosController.cs, TriggersController.cs, ActionCatalogController.cs, backend/scripts/automation-engine/*.sql, docs/automation-engine-frontend-contract.md.