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 existingIEmailSender.CallWebhookAutomationAction— a namedHttpClient,"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).
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:
- Skip/flag legacy scenarios whose trigger has no Eos equivalent yet rather than silently drop them.
- 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).
- Decommission legacy's
Queues.IFTTTconsumer 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 aDomainEventfor (City/Choices × Created/Updated/Deleted, six entries today).EosTriggerRepository.SyncKnownTriggersAsyncupserts oneMAS_TRIGGERS_V2row per entry.EosActionCatalogRepository.SyncRegisteredActionsAsyncupserts oneMAS_ACTIONS_V2row per DI-registeredIAutomationAction, serializing itsGetConfigFields()intoConfigSchemaJson.
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_V2row, 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 mirrorsEmailDispatcher'seos.outbound-emailshape.
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 tables —
COM_SCENARIOS_LOG_V2/COM_SCENARIOS_LOG_DL_V2are 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.