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 theloggingmodule used for structured JSON audit output. jsonschema4.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_hashover itsrulesarray, an explicitprecedenceweight per rule, and a declaredfallback_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
\bboundaries 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:
{
"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.
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:
table = MappingTable.from_file("routing_table.json")
print(table.route("REQ-4471", "Requesting all land-use variance and building permit files"))
{"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:
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 integerprecedence(descending) at load time, as_compiledoes, withrule_idas 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+)+\bagainst 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 inre. 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. \bfailing on Unicode or OCR text. Without theUNICODEflag 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 theUNICODEflag,unicodedata.normalize("NFC", ...).casefold()before matching (asroutedoes), 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.
Related
- Department Routing Logic — the parent decision layer that consumes this table and dispatches the assignment.
- Priority Scoring Algorithms — produces the immutable urgency tier that travels alongside the routing decision.
- Async Queue Management — the durable queue the resolved department assignment is published into.
- Email & Form Parsing Pipelines — normalizes and integrity-stamps the request text this table evaluates.
← Back to all public records automation topics