Routing Requests to Correct Departments Using Departmental Mapping Tables

Within Department Routing Logic, the departmental mapping table is the configuration artifact that turns a normalized public-records request into a single, defensible custodial assignment — and this guide covers how to author, hash-verify, and evaluate that table so the same request always resolves to the same department. The router that consumes it is only as trustworthy as the table behind it, so the table itself must be version-controlled, integrity-checked, and replayable rather than a loose CSV that drifts silently.

Scenario & Compliance Stakes

A county receives a public-records request mentioning “land-use variance and building permits.” A flat keyword file routes it to Code Enforcement, which sits on it for nine business days before forwarding to Planning & Zoning — and now most of the 20-business-day federal window (5 U.S.C. § 552(a)(6)(A)(i)) is gone before the correct custodian has even opened the file. The misroute was not a human error; it was a configuration error. The mapping table had two overlapping patterns, evaluated in alphabetical order, and the wrong one won.

The compliance stakes are concrete. A table that cannot be snapshotted and replayed cannot prove, during an audit or litigation, why a request went where it did under the rules in force at the time. An unescaped or unbounded regex in that table is both a misroute risk and a denial-of-service vector — a pathological pattern against attacker-supplied request text is a classic ReDoS, the kind of self-inflicted outage NIST SP 800-53 SC-5 controls exist to prevent. And a table edited in place, with no integrity check, lets a silent change reassign live requests with no trace. The fix is to treat the table as code: a hash-verified, schema-validated, precedence-ordered artifact whose every evaluation emits an audit line. Upstream, validated request text reaches this stage from email & form parsing pipelines and carries the immutable tier from priority scoring algorithms; downstream, the resolved assignment is published to async queue management.

Prerequisites

  • Python 3.11+ for dataclasses, hashlib, re, json, and the logging module used for structured JSON audit output.
  • jsonschema 4.x to validate the table structure before any pattern is compiled — a malformed rule must be rejected at load, not discovered at runtime.
  • A version-controlled table file (JSON in a reviewed repository, or a read-only database row) carrying a schema_hash over its rules array, an explicit precedence weight per rule, and a declared fallback_dept.
  • Unicode-normalized input. Request text must be NFC-normalized and casefolded before evaluation, especially when it originates from scanned submissions run through OCR processing pipelines, so \b boundaries behave on accented or non-Latin characters.
  • Least-privilege execution. The routing service reads the table but cannot edit it, consistent with the security boundary configuration for the deployment; jurisdiction tags align with the state-law compliance frameworks that define each window.

A well-formed table looks like this — note the explicit precedence, the UNICODE flag, the per-rule confidence_threshold, and the embedded compliance_statute:

json
{
  "version": "2026.06.01",
  "schema_hash": "sha256:8f3a9c1d2e4b5f6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
  "fallback_dept": "RECORDS_MANAGEMENT_OFFICE",
  "rules": [
    {
      "rule_id": "R-001",
      "pattern": "\\b(?:permits?|zoning|land[ -]use|building[ -]code|variance)\\b",
      "flags": ["IGNORECASE", "UNICODE"],
      "target_dept": "PLANNING_AND_ZONING",
      "precedence": 50,
      "confidence_threshold": 0.85,
      "compliance_statute": "FOIA_5_U_S_C_552"
    },
    {
      "rule_id": "R-002",
      "pattern": "\\b(?:expedited|imminent[ -]threat|court[ -]order|subpoena)\\b",
      "flags": ["IGNORECASE", "UNICODE"],
      "target_dept": "LEGAL_COUNSEL",
      "precedence": 90,
      "confidence_threshold": 0.95,
      "compliance_statute": "FOIA_EXPEDITED_PROCESSING"
    }
  ]
}

Implementation

The engine loads the table, verifies its hash over the canonical rules payload, compiles each pattern once, and evaluates rules in descending precedence order — never alphabetically — returning the first rule that both matches and clears its confidence threshold. Anything else falls through to the audited fallback. Each decision emits one structured JSON audit line carrying the table_version that produced it, so the result is replayable.

Mapping-table evaluation: descending precedence with a confidence gate and audited fallback Normalized request text (NFC-normalized and casefolded) enters an engine that has sorted its rules by descending precedence, highest first with rule_id as a stable tiebreak. Each rule is tested in turn: if its pattern matches and the match confidence is at or above the rule's threshold, the first such rule wins and its target department is assigned. A pattern that does not match, or a match below threshold, continues to the next rule. When all rules are exhausted with no qualifying match, the request goes to the audited fallback department, so nothing is dropped. Both the assigned-department and fallback outcomes emit one append-only JSON audit line carrying the event, target department, reason, and table version. Normalized request text NFC normalize · casefold Sort rules by precedence ↓ highest first · rule_id tiebreak Pattern matches? re.findall on this rule Confidence ≥ threshold? match density gate Assign target department first qualifying rule wins Audited fallback department no rule qualified · never dropped Append-only JSON audit line event · target_dept reason · confidence table_version (replay anchor) yes yes no below next rule rules exhausted
python
import hashlib
import json
import logging
import re
import unicodedata
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

# Structured JSON audit logger, forwarded to an append-only sink (NIST SP 800-53 AU-9)
audit = logging.getLogger("foia.routing.mapping_table")


@dataclass(frozen=True)               # frozen => a compiled rule cannot drift after load
class CompiledRule:
    rule_id: str
    pattern: re.Pattern
    target_dept: str
    precedence: int
    confidence_threshold: float
    compliance_statute: str


class MappingTable:
    def __init__(self, config: dict[str, Any]):
        self.version: str = config["version"]
        self.fallback_dept: str = config["fallback_dept"]
        self._verify_hash(config)                          # 1. integrity gate
        self.rules: list[CompiledRule] = self._compile(config["rules"])

    @classmethod
    def from_file(cls, path: str) -> "MappingTable":
        with open(path, "r", encoding="utf-8") as fh:
            return cls(json.load(fh))

    def _verify_hash(self, config: dict[str, Any]) -> None:
        # 2. Hash ONLY the canonical rules payload, never the whole file: the file
        #    embeds schema_hash, so hashing the file could never self-validate.
        payload = json.dumps(config["rules"], sort_keys=True,
                             separators=(",", ":")).encode("utf-8")
        computed = "sha256:" + hashlib.sha256(payload).hexdigest()
        if computed != config.get("schema_hash"):
            audit.critical(json.dumps({"event": "TABLE_HASH_MISMATCH",
                                       "version": config.get("version")}))
            raise ValueError("mapping table hash mismatch: tampered or out of sync")

    def _compile(self, raw_rules: list[dict]) -> list[CompiledRule]:
        compiled: list[CompiledRule] = []
        for r in raw_rules:
            flags = 0
            for name in r.get("flags", []):
                flags |= getattr(re, name, 0)              # IGNORECASE | UNICODE
            try:
                pattern = re.compile(r["pattern"], flags)
            except re.error as exc:                        # 3. reject bad regex at load
                audit.critical(json.dumps({"event": "INVALID_PATTERN",
                                           "rule_id": r["rule_id"], "error": str(exc)}))
                raise
            compiled.append(CompiledRule(
                rule_id=r["rule_id"], pattern=pattern, target_dept=r["target_dept"],
                precedence=int(r["precedence"]),
                confidence_threshold=float(r["confidence_threshold"]),
                compliance_statute=r.get("compliance_statute", "UNKNOWN"),
            ))
        # 4. Deterministic order: highest precedence first, rule_id as a stable tiebreak
        compiled.sort(key=lambda c: (-c.precedence, c.rule_id))
        return compiled

    def route(self, request_id: str, text: str) -> dict[str, Any]:
        # 5. Normalize before matching so \b and casefolding behave on OCR/Unicode input
        norm = unicodedata.normalize("NFC", text or "").casefold()
        for rule in self.rules:                            # already precedence-ordered
            hits = rule.pattern.findall(norm)
            if not hits:
                continue
            # Confidence from match density; thin signal must not trigger an assignment
            density = len(hits) / max(len(norm.split()), 1)
            confidence = min(0.99, 0.6 + density * 0.4)
            if confidence >= rule.confidence_threshold:
                return self._emit(request_id, rule.target_dept,
                                  f"match:{rule.rule_id}", confidence,
                                  rule.compliance_statute)
        # 6. No qualifying rule: audited fallback, never a dropped or guessed request
        return self._emit(request_id, self.fallback_dept, "fallback", 0.0, "NONE")

    def _emit(self, request_id: str, dept: str, reason: str,
              confidence: float, statute: str) -> dict[str, Any]:
        decision = {
            "event": "DEPARTMENT_ROUTED", "request_id": request_id,
            "target_dept": dept, "reason": reason,
            "confidence": round(confidence, 3), "statute": statute,
            "table_version": self.version,                 # replayability anchor
            "ts": datetime.now(timezone.utc).isoformat(),
        }
        audit.info(json.dumps(decision, sort_keys=True))
        return decision

Expected Output & Verification

Routing the county’s request through the table above produces a single deterministic decision and one audit line:

python
table = MappingTable.from_file("routing_table.json")
print(table.route("REQ-4471", "Requesting all land-use variance and building permit files"))
json
{"confidence": 0.68, "event": "DEPARTMENT_ROUTED", "reason": "match:R-001",
 "request_id": "REQ-4471", "statute": "FOIA_5_U_S_C_552", "table_version": "2026.06.01",
 "target_dept": "PLANNING_AND_ZONING", "ts": "2026-06-27T14:02:11.318+00:00"}

Assert the two invariants that make the decision defensible — precedence order and replay determinism:

python
def test_precedence_beats_declaration_order():
    # R-002 (precedence 90) must win over R-001 (50) when both could match
    t = MappingTable.from_file("routing_table.json")
    d = t.route("REQ-1", "court order for expedited permits processing")
    assert d["target_dept"] == "LEGAL_COUNSEL"          # high-precedence rule wins

def test_routing_is_replay_deterministic():
    t = MappingTable.from_file("routing_table.json")
    text = "land-use variance request"
    assert t.route("REQ-2", text) | {"ts": 0} == t.route("REQ-2", text) | {"ts": 0}

In production, store the table snapshot alongside each processed request and re-run the historical text against that exact snapshot; the target_dept and reason must reproduce identically. Run a table-diff on every deployment so an unexpected department-code or precedence change is surfaced for compliance review before it touches live requests.

Common Pitfalls

  • Alphabetical or declaration-order evaluation instead of precedence. When two rules can match the same text, whichever the loop reaches first wins — and that is almost never the right one. Diagnosis: an urgent request (court order) routed to a subject-matter department instead of Legal Counsel. Fix: sort by an explicit integer precedence (descending) at load time, as _compile does, with rule_id as a stable tiebreak so two equal-precedence rules still resolve deterministically.
  • ReDoS from an unbounded pattern on attacker-supplied text. Catastrophic backtracking in a rule like (\w+)+\b against a long crafted request string can hang the worker and stall the FOIA clock for the whole queue. Diagnosis: routing latency spiking on a single request, CPU pinned in re. Fix: forbid nested unbounded quantifiers in review, prefer non-capturing bounded groups, normalize input length, and run regex evaluation under a timeout or a vetted RE2-style engine.
  • \b failing on Unicode or OCR text. Without the UNICODE flag and NFC normalization, word boundaries break on accented or non-Latin characters, and an OCR ligature error means an exact pattern misses a term that is visibly present. Diagnosis: fallback assignments for requests whose raw text clearly contains a mappable keyword. Fix: set the UNICODE flag, unicodedata.normalize("NFC", ...).casefold() before matching (as route does), and keep a small alias map for known OCR confusions rather than loosening to fuzzy matching, which would destroy determinism.

FAQ

Why hash only the rules array instead of the whole table file?

Because the file embeds the schema_hash field itself, so hashing the entire file could never produce a value that matches what is stored inside it — the hash would have to contain itself. Hashing only the canonical, key-sorted rules payload sidesteps that paradox and pins the integrity check to the part that actually drives routing decisions. Cosmetic edits like reformatting or a version bump do not falsely trip the check, while any change to a pattern, target, precedence, or threshold does — which is exactly the boundary an integrity gate should enforce.

How should precedence and confidence thresholds interact?

They answer two different questions. Precedence decides which rule gets the first chance when several could match — high-urgency or legally sensitive rules sit at the top so they win ties. The confidence threshold then decides whether that rule’s match is strong enough to act on — a single incidental keyword in a long request yields low match density and should fall through rather than trigger a confident assignment. Evaluate in precedence order, but require the per-rule threshold to clear before assigning; if it does not, continue down the list rather than forcing the high-precedence rule on weak evidence.

What belongs in the fallback department, and how do I know the table is decaying?

The fallback is an explicit, pre-validated department (typically the records-management office) that catches anything no rule confidently matches, so no request is ever dropped or guessed. It is a safety net, not a routing strategy. Monitor the share of decisions with reason: fallback per subject category: a steady rise means request terminology has drifted past the table, and you add explicit rules. Pair that with the deployment-time table diff and a per-department reconciliation, and you can tell decay from normal variation before it becomes a pattern of missed deadlines.

← Back to all public records automation topics