Building fallback MCC taxonomy rules for unmapped merchants

When a transaction arrives with a missing or unrecognized merchant category code, the router has nothing to look up, and without a deterministic fallback the record either stalls in a review queue or silently inherits a permissive default. This guide is the missing-code detail delegated by the Merchant Category Code Routing stage inside the broader Automated Policy Validation & Anomaly Flagging framework. Here we build the fallback taxonomy the router reaches for when a code is absent or unknown: an ordered set of exact-override, prefix, and numeric-range rules backed by a mandatory root default, so every unmapped merchant still leaves with an explainable category and full provenance.

The design principle is total coverage with honest specificity: a fallback never claims more precision than its rule warrants, and the least-specific catch-all is a review category, not a permissive pass.

Specificity-ordered fallback taxonomy resolving an unmapped MCC to a category with provenance An unmapped merchant whose MCC is missing or unknown enters a fallback resolver evaluated most-specific-first. Tier one is an exact-code override mapping an explicit code to a category. Tier two is a prefix or wildcard rule such as 58 mapping to a dining group. Tier three is a numeric-range rule such as 5800 to 5899 mapping to meals. Tier four is a hierarchical root default that always matches and routes to an uncategorized-review bucket. A record cascades down the tiers until one matches, then emits the resolved category plus provenance: the matching rule id, its specificity tier, and the matched pattern. That output, with a SHA-256 decision hash and pinned policy version, is appended to an append-only audit ledger. Specificity-ordered fallback, with a guaranteed root default A record cascades down tiers until one matches; the least-specific default always catches it. Unmapped merchant MCC missing or unknown to router 1 · Exact-code override explicit code → category 2 · Prefix / wildcard rule 58__ → dining group 3 · Numeric-range rule 5800–5899 → meals 4 · Hierarchical default root → UNCATEGORIZED_REVIEW no match no match no match match → Resolved category + provenance rule_id specificity tier matched_pattern Append-only audit ledger resolved_category · fallback_rule_id · specificity · matched_pattern · decision_hash · policy_version

Why Standard Approaches Fail

A missing or unknown code is not a rare edge — acquirers routinely omit codes on card-not-present charges, new merchants surface before your table knows them, and OCR-derived records often carry no code at all. Three named failure modes turn that gap into leaked spend:

  • Permissive default bucket. Dropping unmapped merchants into a generic “other” category that happens to be allowlisted lets out-of-policy spend post unreviewed, exactly the catch-all drift the parent Merchant Category Code Routing stage escalates rather than approves. A fallback that resolves to a permissive category is worse than no fallback.
  • Flat lookup with no hierarchy. A single dictionary of known codes returns nothing for 5811 when only 5812 is registered, even though both are dining codes one digit apart. Without prefix and range rules, near-neighbours of known codes fall through to review in volumes large enough to swamp the queue, and the taxonomy never generalizes.
  • Non-deterministic tie handling. When several fallback patterns could match — a prefix rule and a range rule both covering 5814 — an implementation that picks whichever was inserted first produces category assignments that depend on config load order. The same code resolves differently across deploys, and the audit trail cannot explain why.

The remedy is an ordered taxonomy where every rule declares its own specificity, the resolver always applies the most specific match, and a mandatory root default guarantees total coverage so no record can escape without an auditable category.

Architecture & Algorithm

A robust fallback taxonomy ranks rules by an explicit specificity tier — exact code, then prefix, then numeric range, then a root default that always matches — and evaluates them most-specific-first so the result never depends on config order. The constructor refuses to build a taxonomy that lacks a root default, which is what makes total coverage a structural guarantee rather than a hope. Every resolution carries the matching rule’s id, its specificity, and the concrete matched pattern, plus a deterministic hash for audit replay.

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import IntEnum
from typing import Optional

import structlog

logger = structlog.get_logger("expense.mcc_fallback")

POLICY_VERSION = "mcc-fallback/2026.07"


class Specificity(IntEnum):
    """Higher tiers are evaluated first; DEFAULT always matches last."""

    EXACT = 4
    PREFIX = 3
    RANGE = 2
    DEFAULT = 1


@dataclass(frozen=True)
class FallbackRule:
    rule_id: str
    specificity: Specificity
    category: str
    exact_code: Optional[str] = None
    prefix: Optional[str] = None
    range_lo: Optional[int] = None
    range_hi: Optional[int] = None

    def matches(self, code: Optional[str]) -> bool:
        if self.specificity is Specificity.DEFAULT:
            return True
        if code is None:
            return False
        if self.specificity is Specificity.EXACT:
            return code == self.exact_code
        if self.specificity is Specificity.PREFIX:
            return self.prefix is not None and code.startswith(self.prefix)
        if self.specificity is Specificity.RANGE:
            if self.range_lo is None or self.range_hi is None or not code.isdigit():
                return False
            return self.range_lo <= int(code) <= self.range_hi
        return False


@dataclass(frozen=True)
class FallbackDecision:
    mcc_code: Optional[str]
    category: str
    rule_id: str
    specificity: int
    matched_pattern: str
    detail: str
    policy_version: str
    decision_hash: str
    decided_at_utc: str

The resolver freezes traversal order once, verifies the root default exists at construction time, and returns the first matching rule. Because tiers are ordered by specificity and ties within a tier break on rule_id, the outcome is fully determined by the rule set and the code — never by insertion order.

class FallbackTaxonomy:
    """Deterministic default taxonomy for missing or unknown MCCs.

    Rules are evaluated most-specific-first; a mandatory root default
    guarantees every record leaves with an auditable category.
    """

    def __init__(
        self,
        rules: list[FallbackRule],
        *,
        policy_version: str = POLICY_VERSION,
    ) -> None:
        ordered = sorted(rules, key=lambda r: (-int(r.specificity), r.rule_id))
        if not any(r.specificity is Specificity.DEFAULT for r in ordered):
            raise ValueError("taxonomy must define a DEFAULT root rule for total coverage")
        self._rules = ordered
        self._policy_version = policy_version

    def resolve(self, mcc_code: Optional[str]) -> FallbackDecision:
        for rule in self._rules:
            if rule.matches(mcc_code):
                pattern = self._pattern(rule)
                return self._decide(mcc_code, rule, pattern,
                                    f"matched {rule.specificity.name} rule {rule.rule_id}")
        # Unreachable: the constructor guarantees a DEFAULT rule that always matches.
        raise RuntimeError("no fallback rule matched and no root default present")

    @staticmethod
    def _pattern(rule: FallbackRule) -> str:
        if rule.specificity is Specificity.EXACT:
            return str(rule.exact_code)
        if rule.specificity is Specificity.PREFIX:
            return f"{rule.prefix}*"
        if rule.specificity is Specificity.RANGE:
            return f"{rule.range_lo}-{rule.range_hi}"
        return "root"

    def _decide(
        self,
        mcc_code: Optional[str],
        rule: FallbackRule,
        pattern: str,
        detail: str,
    ) -> FallbackDecision:
        fingerprint = "|".join([str(mcc_code), rule.rule_id, rule.category,
                                pattern, self._policy_version])
        decision_hash = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
        decision = FallbackDecision(
            mcc_code=mcc_code, category=rule.category, rule_id=rule.rule_id,
            specificity=int(rule.specificity), matched_pattern=pattern,
            detail=detail, policy_version=self._policy_version,
            decision_hash=decision_hash,
            decided_at_utc=datetime.now(timezone.utc).isoformat(),
        )
        logger.info("mcc_fallback_resolved", mcc_code=mcc_code,
                    category=rule.category, rule_id=rule.rule_id,
                    specificity=int(rule.specificity), matched_pattern=pattern,
                    decision_hash=decision_hash, policy_version=self._policy_version)
        return decision

Modeling specificity as an IntEnum keeps the ordering explicit and self-documenting, and folding the category and pattern into the decision_hash means a corrected taxonomy under a new policy_version produces a distinct, reconstructable fingerprint while an unchanged one replays identically.

Step-by-Step Integration

  1. Invoke only after a MISSING_MCC or unknown-code outcome. The Merchant Category Code Routing engine calls the taxonomy only when it has no code to route; a present, known code never touches this path. Where a code is present but maps to several categories, use resolving ambiguous MCC mappings instead.

  2. Anchor categories to the canonical taxonomy. Every category a fallback rule emits must be a real node in Expense Category Taxonomies, including the UNCATEGORIZED_REVIEW root, so downstream policy checks recognize the label. Keep that taxonomy the source of truth; the fallback only selects from it.

  3. Order rules by specificity, not by hand. Let the constructor sort by tier; author exact overrides for codes you know, prefix rules for code families, and range rules for contiguous blocks, then a single root default. Confirm the resolver honours the cascade and stays deterministic:

    taxonomy = FallbackTaxonomy([
        FallbackRule("exact-rideshare", Specificity.EXACT, "GROUND_TRANSPORT",
                     exact_code="4121"),
        FallbackRule("prefix-58xx", Specificity.PREFIX, "MEALS_UNVERIFIED",
                     prefix="58"),
        FallbackRule("range-airlines", Specificity.RANGE, "AIR_TRAVEL",
                     range_lo=3000, range_hi=3299),
        FallbackRule("root", Specificity.DEFAULT, "UNCATEGORIZED_REVIEW"),
    ])
    
    assert taxonomy.resolve("4121").category == "GROUND_TRANSPORT"   # exact override
    assert taxonomy.resolve("5814").category == "MEALS_UNVERIFIED"   # prefix beats range
    assert taxonomy.resolve("3005").category == "AIR_TRAVEL"         # numeric range
    assert taxonomy.resolve(None).category == "UNCATEGORIZED_REVIEW" # missing code
    assert taxonomy.resolve("9999").rule_id == "root"               # unknown -> default
    assert taxonomy.resolve("5814").decision_hash == taxonomy.resolve("5814").decision_hash
  4. Never allowlist the root default. Wire UNCATEGORIZED_REVIEW to a reviewer queue, not an auto-approve path; the default exists to guarantee coverage, not to wave spend through. Anything that lands there is an unmapped merchant a human must classify.

  5. Feed reviewer decisions back as new exact overrides. When a reviewer categorizes a formerly unmapped merchant, add an EXACT rule so the next occurrence resolves without review, shrinking the default bucket over time.

  6. Append every resolution to the ledger. Mirror each structlog event into the same append-only ledger the router writes, so rule_id, specificity, matched_pattern, and decision_hash explain exactly which fallback fired and let an auditor reconstruct the taxonomy active at processing time.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Missing root default A code matching no rule raises at runtime instead of routing Constructor rejects any taxonomy without a DEFAULT rule — total coverage is enforced structurally
Overlapping prefix and range Both cover 5814, and order-dependent code would pick either Specificity tiers decide: PREFIX outranks RANGE, so the outcome is deterministic
Non-numeric or malformed code int(code) would raise inside a range check matches guards with code.isdigit() and returns False on malformed input, cascading down
Over-broad prefix rule A 5 prefix swallows every retail code into one bucket Keep prefixes at family granularity (two–three digits); prefer explicit ranges for wide blocks
Growing default bucket An expanding UNCATEGORIZED_REVIEW share signals table rot Alert on default-rate drift and promote recurring reviewer decisions to exact overrides
Silent taxonomy edits Changing a rule without bumping the version breaks audit reconstruction Fold category and matched_pattern into policy_version; never edit rules in place without a bump

FAQ

When does the fallback taxonomy run instead of normal routing?

It runs only when the routing engine has no usable code — the transaction’s MCC is null, malformed, or absent from every routing rule. If a code is present and valid but maps to more than one category, that is a disambiguation problem handled by resolving ambiguous MCC mappings, not the fallback taxonomy. The two paths are mutually exclusive by input.

Why order rules by specificity instead of by insertion?

Insertion order makes resolution depend on how the config file was assembled, so the same code can resolve differently across deploys and the audit trail cannot explain the change. Ranking by an explicit specificity tier — exact, then prefix, then range, then default — makes the outcome a pure function of the rule set and the code. It also matches intuition: a rule that names one code should always beat a rule that covers a whole family.

What category should the root default map to?

Map it to a review category such as UNCATEGORIZED_REVIEW that is never allowlisted, so unmapped merchants reach a human rather than posting unreviewed. The root default guarantees total coverage — every record leaves with a category — but its purpose is to route the unknown to triage, not to grant a permissive pass. Treating the default as auto-approvable reintroduces exactly the catch-all leak the taxonomy exists to close.

How do I keep the default bucket from growing unbounded?

Monitor the share of records resolving to the root default over time and alert on upward drift, which usually signals a stale rule set or a new acquirer feed. Each time a reviewer classifies a merchant that fell through, promote that decision into an exact-code override so the next occurrence resolves automatically. Over a few cycles the default bucket shrinks to genuinely novel merchants only.

Can prefix and range rules both cover the same code?

Yes, and that overlap is expected — a 58 prefix and a 5800–5899 range can both match 5814. The specificity ordering resolves it deterministically: the prefix tier outranks the range tier, so the prefix rule wins regardless of config order. If you want the range to win in some sub-case, express it as a narrower prefix or a more specific exact override rather than relying on ordering.