Router
The Router is the dispatcher of MapexOS. It sits at the route stage — between transform upstream and automate/persist downstream — and answers one question for every event: where does this go? A single event arriving here can fan out to persistence, a data lake, a notification, an outbound trigger, and a workflow — all at once, or to none of them, decided rule by rule.
It is what makes the platform dynamic: routing behavior is data, not code. You change where events go by editing RouteGroups through the API — no redeploy, no restart. The same event stream feeds entirely different downstream topologies for two tenants, because each one's rules say so.
Port
5003· Go (DDD + Hexagonal) · NATS JetStream WorkQueue consumer · MongoDB (rules, source of truth) + Redis (cache-aside) + TieredCache (RAM → Disk → MinIO) · Prometheus instrumentation.
The model: RouteGroup → Routers → Match rules
| Concept | What it is |
|---|---|
| RouteGroup | A named, versioned bundle of Routers, attached to assets. An asset can carry several. |
| Router | One destination — a kind plus an optional match condition. If the condition passes, the event goes to that kind. |
| Match rule | One condition: { field, operator, value }. Routers combine rules with an all (AND) or any (OR) policy. A Router with no match config always fires. |
This is the whole dynamism in three layers: assets point at RouteGroups, RouteGroups hold Routers, Routers carry conditions. Edit any layer — as data — and the routing changes on the next event.
Match rules — the conditions, exactly as they evaluate
A rule is { field, operator, value }. The field is a dot-path into the event (data.temperature, metadata.site), resolved by walking nested maps — fast and predictable. The operator is one of eight:
| Operator | Meaning | How it compares |
|---|---|---|
eq |
equals | deep equality |
neq |
not equals | deep equality, negated |
gt |
greater than | numeric |
gte |
greater than or equal | numeric |
lt |
less than | numeric |
lte |
less than or equal | numeric |
in |
is one of | membership in a list |
nin |
is not one of | membership in a list, negated |
Rules combine under a policy:
| Policy | Behavior |
|---|---|
all |
every rule must pass (AND) |
any |
at least one rule must pass (OR) |
A field the event doesn't contain makes that rule fail with field not found — predictable behavior under partial data, never a crash. A Router with no rules at all is always allowed.
{
"name": "Cold-chain breach",
"routers": [
{
"kind": "workflow",
"match": {
"policy": "all",
"rules": [
{ "field": "data.temperature", "operator": "gt", "value": 8 },
{ "field": "data.doorState", "operator": "eq", "value": "open" }
]
}
}
]
}
This Router starts a workflow only when temperature exceeds 8 and the door is open — every other event flows past it untouched.
The five destinations
A matched Router publishes the enriched event to its kind's subject. Those five subjects are how the Router wires the ingest pipeline into every downstream capability:
| Kind | Subject | Goes to |
|---|---|---|
save_event |
events.save |
the Events service → ClickHouse (persistence) |
lake_house |
events.lake_house |
the analytical lake-house sink |
notification |
events.notification |
the notification path |
trigger |
trigger.router.execute |
the Triggers service (standalone outbound action) |
workflow |
workflow.execution.router |
the Workflow engine (start a durable run) |
One event can match several Routers and fan out to several kinds at once. Each dispatch is de-duplicated on JetStream by a {eventTrackerId}-{routerIndex} message id, so a redelivery never double-routes.
Routing is decided from trusted state, not the payload
The Router never reads which RouteGroups apply from the inbound message. It resolves the event's asset from cache and takes the RouteGroup ids from the asset itself. A sender cannot forge its own routing by stuffing ids into the payload — routing is governed by the asset's configuration, which only the platform controls.
event { assetUUID } → resolve asset (cache) → asset.RouteGroupIds → evaluate
(source of truth — never the payload)
Health-aware routing
The Router distinguishes two kinds of input through an eventSource discriminator:
assetEvent— a normal data event → uses the asset'sRouteGroupIds.healthStatus— an online/offline transition → uses the asset'sHealthMonitor.OfflineRouteGroupIdsorOnlineRouteGroupIds, chosen by the transition.
A device going offline can therefore drive a different set of routes than its telemetry does. Health transitions are deliberately allowed to reach only the trigger and workflow kinds — a presence change escalates or automates; it does not flood the lake-house or the notification feed.
Every decision is explainable
For each RouteGroup it evaluates, the Router emits a routing-history event to events.router recording every Router's outcome and the condition-by-condition reasoning behind it:
"data.temperature" greater than 8 → allowed
"data.doorState" equals "open" → denied (field not found)
policy: all → 1/2 rules passed → denied
You can always answer why did — or didn't — this event fire that workflow? without re-running anything. Routing stops being a black box.
How a batch routes
The Router pulls events in batches (default 8000, NATS_BATCH_SIZE) from a JetStream WorkQueue and processes each batch in three phases:
Phase 1 — route in parallel a worker pool (NumCPU × 2) resolves each asset,
evaluates its RouteGroups, and buffers the
fan-out publishes
Phase 2 — flush once a single FlushConnection pushes every buffered
publish to the wire in one round
Phase 3 — settle sequentially Ack / Nack / Reject each message by its result
Outcomes map onto JetStream semantics: a malformed message or one missing orgId / assetUUID / event is Rejected straight to the dead-letter queue (retrying can't fix it); a transient failure such as a cache miss is Nacked and redelivered. WorkQueue delivery guarantees each event is routed by exactly one replica.
Asset context — a tiered cache kept coherent
Routing every event needs the full asset, fast. The Router resolves it through a TieredCache that falls through progressively cheaper-to-colder tiers:
L0 RAM (256 MB, 5 min) → L1 Disk (/tmp/mapexos/cache) → L2 MinIO/S3 → HTTP fallback → Assets
To keep every replica's cache honest, the Router runs FANOUT consumers: when the Assets service changes an asset or a template, it broadcasts an invalidation that each replica applies to its local L0/L1. No replica routes on a stale asset.
Governance & multi-tenancy
RouteGroups are managed through a governed REST surface. Every route is permission-gated and coverage-aware — listings are scoped to the slice of the organization tree the caller may see, by pathKey. Like triggers, a RouteGroup can be a system template (global), a vendor/customer template inherited by descendants (isTemplate), or org-local — so a routing policy defined once high in the hierarchy applies to every site beneath it. Configs are read through Redis cache-aside (60-minute TTL) and warmed on write, so the routing hot path rarely touches MongoDB.
Inputs & outputs
Consumes (NATS JetStream):
| Subject | Stream | Purpose |
|---|---|---|
route.execute |
ROUTE-GROUPS (WorkQueue + DLQ) |
One asset event or health transition to route. |
mapexos.fanout.asset.invalidate |
FANOUT | Evict an asset from local L0/L1. |
mapexos.fanout.template.invalidate |
FANOUT | Evict a template from local L0/L1. |
Produces (NATS): events.save, events.lake_house, events.notification, trigger.router.execute, workflow.execution.router, and events.router (routing history). Dispatches are de-duplicated by message id.
HTTP (port 5003):
GET/POST /api/v1/route_groups,GET /api/v1/route_groups/counter,GET/PATCH/DELETE /api/v1/route_groups/:id— JWT, permission-gated, coverage-scoped.GET /internal/route_groups?ids=…&projection=…— API-key, service-to-service bulk lookup.
Observability
Every stage is measured in Prometheus: events received / routed / failed with latency histograms, TieredCache hit/miss per tier, RouteGroup cache hit ratio, match evaluations and outcomes, and publish counts/latency per downstream subject. Paired with the per-decision routing history, you can see both the aggregate flow and the reason behind any single routing decision.
Where to go next
| To dive into… | Go to |
|---|---|
| Where matched outbound actions run | Triggers |
| Where matched workflows execute | Workflow Engine |
| Where routed events are stored | Events |
| Full platform context | Architecture Overview |
MapexOS