Severity tells you how urgent an alert is; asset ownership tells you who should work it — and routing on only one of those sends critical cases to the wrong desk. This guide implements a routing rule that combines both deterministically, as one precise technique within alert routing workflows and the broader SOAR playbook & response automation discipline.

Root-Cause Context

A routing decision has two independent inputs that a single-axis rule collapses badly. Route on severity alone and every high-severity case lands in one shared tier-1 queue regardless of which team owns the affected system, so the finance-database team never sees the ransomware alert on their own asset until someone in the shared queue reassigns it. Route on owner alone and a low-severity informational alert pages the on-call at 3 a.m. with the same urgency as an active breach.

The correct rule is a two-dimensional decision: resolve the asset’s owning team from an authoritative inventory, then select the destination and urgency from the combination of that team and the severity band. A crown-jewel-owning team’s high-severity case goes straight to their senior on-call with a tight acknowledgement SLA; the same team’s low-severity case goes to their backlog; another team’s high-severity case goes to their on-call. Making the rule table-driven — team × severity band → destination — keeps it explainable and testable instead of a nest of conditionals.

Routing by the combination of asset owner and severity band A case resolves its owning team, then a two-dimensional routing table keyed by team and severity band selects the destination. High severity for a crown-jewel team routes to their senior on-call with a tight SLA; medium routes to their standard queue; low routes to their backlog. Each cell names a distinct destination so routing is explainable. Routing table · team × severity band High Medium Low Crown-jewel team senior on-call SLA 5 min team queue SLA 30 min team backlog next business day Standard team team on-call SLA 15 min team queue SLA 1 h team backlog weekly first match on (team, band); no cell is a silent default

Prerequisites

The rule targets Python 3.11+ and uses only the standard library plus pydantic. It resolves ownership from an inventory keyed by the ECS host.id, and plugs into the parent alert routing workflows dispatcher.

pip install "pydantic>=2.6,<3.0"

Production-Ready Implementation

The router resolves the owner, bands the severity, and looks up the destination in a two-dimensional table. A missing owner or an empty cell is a routing failure, never a silent default.

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Optional

from pydantic import BaseModel, Field

logger = logging.getLogger("soc.soar.route.so")


class Case(BaseModel):
    case_id: str = Field(min_length=1)
    host_id: str = Field(min_length=1)
    severity: int = Field(ge=0, le=100)


@dataclass(frozen=True)
class Destination:
    queue: str
    ack_seconds: int


def severity_band(severity: int) -> str:
    if severity >= 80:
        return "high"
    if severity >= 40:
        return "medium"
    return "low"


# (team, band) -> Destination. Explicit; no cell defaults silently.
ROUTING: dict[tuple[str, str], Destination] = {
    ("crown_jewel", "high"): Destination("fin-ir-senior", 300),
    ("crown_jewel", "medium"): Destination("fin-ir-queue", 1800),
    ("crown_jewel", "low"): Destination("fin-ir-backlog", 86400),
    ("standard", "high"): Destination("soc-oncall", 900),
    ("standard", "medium"): Destination("soc-queue", 3600),
    ("standard", "low"): Destination("soc-backlog", 604800),
}


def route(case: Case, owner_team: Optional[str]) -> tuple[Optional[Destination], str]:
    """Return (destination, status). Fail closed on missing owner or cell."""
    if owner_team is None:
        logger.warning("ERR_ROUTE_001 no owner case=%s", case.case_id)
        return None, "ERR_ROUTE_001"
    band = severity_band(case.severity)
    dest = ROUTING.get((owner_team, band))
    if dest is None:
        logger.warning("ERR_ROUTE_011 no cell team=%s band=%s", owner_team, band)
        return None, "ERR_ROUTE_011"
    logger.info('{"msg":"routed","case":"%s","team":"%s","band":"%s","dest":"%s"}',
                case.case_id, owner_team, band, dest.queue)
    return dest, "OK"


if __name__ == "__main__":
    case = Case(case_id="C-3", host_id="FIN-DB-1", severity=90)
    dest, status = route(case, owner_team="crown_jewel")
    assert status == "OK" and dest.queue == "fin-ir-senior" and dest.ack_seconds == 300

Keying the table on the (team, band) tuple keeps the decision a single dictionary lookup and makes every routing outcome explainable: the logged team and band pinpoint exactly which cell fired. An unknown owner or an unpopulated cell returns a distinct error rather than falling through to a shared queue — the shared-queue default is precisely the failure this rule exists to prevent.

Error-Code Reference

Code Meaning Action
ERR_ROUTE_001 Asset owner unresolved from inventory DLQ to manual triage; fix the CMDB entry
ERR_ROUTE_011 No routing cell for the (team, band) combination DLQ; add the cell — never add a catch-all default
ERR_ROUTE_021 Acknowledgement SLA elapsed Escalate per policy for that destination
ERR_ROUTE_051 Owner resolved but team not in the routing table DLQ; onboard the team into the table

Operational Notes

  • Both axes matter. Resolve the owner and band the severity; a one-dimensional rule mis-routes on the other axis.
  • Every cell is explicit. Populate the full team × band grid; a missing cell is a routing failure that surfaces, not a silent fall-through to a shared queue.
  • Log the winning cell. Record the team and band on the case so a mis-route is a one-line diagnosis, mirroring the recorded-decision discipline in the parent alert routing workflows guide.
  • Tie SLAs to the cell. The acknowledgement deadline belongs in the destination, so escalation timers are driven by the same table that chose the queue.

Verification Checklist

FAQ

Why route on both severity and owner rather than just severity?

Because severity and ownership answer different questions — how urgent versus whose responsibility — and collapsing them loses one. Routing on severity alone sends every high-severity case to one shared queue, so the team that owns the affected system does not see their own critical alert until someone reassigns it. Combining both sends the right urgency to the right desk, which is the whole point of routing.

What if an asset's owning team is not in the routing table?

Treat it as an explicit failure (ERR_ROUTE_051) and send the case to manual triage, not a default queue. An owner that resolves but has no routing cell means the team was never onboarded into the routing table — a gap to fix by adding their row, not to paper over with a catch-all. Surfacing it forces the onboarding rather than silently burying the team’s cases in a shared bucket.