This guide extends the Data Security & Access Boundaries module, and it solves one narrow problem: how to keep Personally Identifiable Information (PII) in a grant database from leaking across a pipeline stage it was never scoped for. Exposure rarely arrives as a single catastrophic breach — it accretes as schema drift, unmasked identifiers in aggregated exports, or silent type coercion during an ETL handoff, so every defence here is built as a deterministic, stage-isolated checkpoint rather than a perimeter firewall.
The approach maps each control to a named regulatory obligation — 2 CFR §200.303 internal controls, IRS Form 990 Schedule B contributor confidentiality, and the state charity thresholds enforced by California Form RRF-1 and New York CHAR500 — so an auditor can trace any masked field back to the rule that required it.
When to use this approach
Reach for this stage-isolated pattern when all of the following hold:
- You ingest heterogeneous PII from federal portals, state registries, and private foundations, where each source uses a different field shape for the same identifier (SSN, EIN, beneficiary date of birth, donor address).
- Inputs are structured records — JSON, CSV rows, or API payloads — that can be validated against a contract before persistence. Free-text intake (scanned PDFs) must first pass through PDF Grant Application Parsing and be normalized into structured records upstream.
- A masked or tokenized export is a hard requirement, not a nice-to-have — for example a public Form 990 derived from a database that also holds Schedule B donor names that must never appear in the public artifact.
- You need an audit trail that conforms to Compliance Metadata Standards so retention, classification, and masking decisions are reconstructable years later.
If you only need raw field-name standardization with no confidentiality boundary, use Field Mapping & Normalization instead — that layer renames columns; it does not gate on classification.
Step-by-step implementation
Step 1 — Gate intake on a strict classification contract
The ingestion layer is a stateless validation gate. Every record must declare a pii_classification, and any record tagged above PUBLIC must carry an active encryption_status or the batch is rejected before it ever touches the relational store. Strict types (StrictStr, conint) block implicit casting of a malformed identifier into a “valid-looking” value.
from __future__ import annotations
import logging
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError, StrictStr, conint
logger = logging.getLogger("grant_pipeline.ingestion")
class GrantIntakePayload(BaseModel):
grantor_id: StrictStr
submission_timestamp: datetime
beneficiary_count: conint(ge=0)
pii_classification: str = Field(pattern=r"^(PUBLIC|INTERNAL|RESTRICTED|CONFIDENTIAL)$")
encryption_status: Optional[str] = None
def validate_boundary(self) -> bool:
if self.pii_classification != "PUBLIC" and self.encryption_status is None:
logger.error(
"BOUNDARY_VIOLATION | grantor_id=%s | classification=%s | encryption_status=%s",
self.grantor_id, self.pii_classification, self.encryption_status,
)
return False
return True
def process_intake_batch(raw_records: list[dict]) -> tuple[list[GrantIntakePayload], list[dict]]:
validated: list[GrantIntakePayload] = []
quarantine: list[dict] = []
for idx, record in enumerate(raw_records):
try:
payload = GrantIntakePayload(**record)
if not payload.validate_boundary():
raise ValueError("Encryption status missing for restricted classification")
validated.append(payload)
except ValidationError as e:
logger.warning("SCHEMA_DRIFT | record_idx=%d | error=%s", idx, e.json())
quarantine.append(record)
except Exception as e: # structured catch-all: nothing is swallowed silently
logger.error("INGESTION_FAILURE | record_idx=%d | exception=%s", idx, repr(e))
quarantine.append(record)
return validated, quarantine
Parameters that matter: the pii_classification regex is the contract — extending the taxonomy means editing one pattern, not scattered if statements. quarantine is returned alongside the clean set rather than raised, so a single bad row never halts an otherwise valid batch. This gate is where jurisdictional routing rules and retention policies from the Core Architecture & Compliance Mapping registry first attach to a record.
Step 2 — Scan plaintext fields for unencrypted PII
After validation, records enter the transformation stage, where funder-specific schemas are merged into a canonical model. A row-level boundary scanner enforces column-level segregation: sensitive identifiers may persist only inside the designated encrypted_payload JSONB column. A regex match for an SSN, EIN, or date of birth anywhere outside that container triggers rejection.
import re
from typing import Any
PII_PATTERNS: dict[str, re.Pattern[str]] = {
"SSN": re.compile(r"\b(?!000|666|9\d{2})\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b"),
"EIN": re.compile(r"\b\d{2}-\d{7}\b"),
"DOB": re.compile(r"\b(19|20)\d{2}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\b"),
}
def enforce_pii_boundary(record: dict[str, Any], encrypted_col: str = "encrypted_payload") -> bool:
"""Return False if any plaintext field outside encrypted_col contains a PII pattern."""
for key, value in record.items():
if key == encrypted_col or not isinstance(value, str):
continue
for pattern_name, regex in PII_PATTERNS.items():
if regex.search(value):
logger.critical(
"PII_EXPOSURE_DETECTED | field=%s | pattern=%s | record_id=%s",
key, pattern_name, record.get("grantor_id", "UNKNOWN"),
)
return False
return True
Parameters that matter: encrypted_col is configurable so the same scanner protects multiple tables. The SSN pattern excludes structurally invalid area numbers (000, 666, 9xx) to cut false positives on lookalike strings. A failure logs at CRITICAL because an unmasked identifier in a plaintext column is a reportable confidentiality event under 2 CFR §200.303(e).
Step 3 — Apply masking and tokenization from a runtime policy
Masking thresholds vary by jurisdiction and funder, so they live in YAML read at runtime — never hardcoded. This is what lets a single codebase satisfy IRS Form 990 Schedule B (contributor names stripped before public aggregation), the state thresholds owned by State Charity Registration Compliance, and the per-funder rules owned by Grantor-Specific Rule Taxonomies without a redeploy.
import yaml
from pathlib import Path
from typing import Any, Dict
class CompliancePolicyEngine:
def __init__(self, config_path: Path) -> None:
self._policies: Dict[str, Dict[str, Any]] = self._load_policies(config_path)
def _load_policies(self, path: Path) -> Dict[str, Dict[str, Any]]:
try:
with path.open("r") as f:
return yaml.safe_load(f) or {}
except Exception as e:
logger.error("POLICY_LOAD_FAILURE | path=%s | error=%s", path, repr(e))
raise RuntimeError("Compliance policy engine failed to initialize") from e
def get_masking_rules(self, jurisdiction: str, funder_id: str) -> Dict[str, int]:
return self._policies.get(jurisdiction, {}).get(funder_id, {}).get(
"masking_rules", {"default": 0}
)
def apply_tokenization(self, record: Dict[str, Any], rules: Dict[str, int]) -> Dict[str, Any]:
tokenized = record.copy()
for field, depth in rules.items():
if field in tokenized and isinstance(tokenized[field], str):
tokenized[field] = f"[REDACTED_{depth}]"
return tokenized
Parameters that matter: jurisdiction keys to a state code (for example CA for Form RRF-1, NY for CHAR500) and funder_id keys to a foundation profile, so a California submission to a private funder composes both rule sets. The engine resolves field-name aliases through the canonical model defined in IRS 990 Data Schema Mapping, so donor_name and contributor collapse to the same masking rule.
Step 4 — Persist behind encryption and retry transient failures
The store applies AES-256-GCM column encryption to encrypted_payload and deterministic hashing to join keys so reports can be assembled without exposing raw identifiers. Encryption services and policy loaders fail transiently, so persistence is wrapped in idempotent retry with exponential backoff and a dead-letter route — never a silent unmasked fallback. Endpoint-level continuity is governed by Pipeline Fallback & Retry Logic.
import time
from functools import wraps
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class PipelineRetryError(Exception):
pass
def retry_with_audit(
max_retries: int = 3, backoff_factor: float = 1.5, operation_name: str = "unknown"
) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
attempt = 0
while attempt < max_retries:
try:
result = func(*args, **kwargs)
logger.info("RETRY_SUCCESS | operation=%s | attempts=%d", operation_name, attempt + 1)
return result
except Exception as e:
attempt += 1
logger.warning(
"RETRY_ATTEMPT | operation=%s | attempt=%d/%d | error=%s",
operation_name, attempt, max_retries, repr(e),
)
if attempt == max_retries:
logger.critical("RETRY_EXHAUSTED | operation=%s | routing_to_dead_letter", operation_name)
raise PipelineRetryError(f"{operation_name} failed after {max_retries} attempts") from e
time.sleep(backoff_factor ** attempt)
raise PipelineRetryError("Unreachable retry state")
return wrapper
return decorator
@retry_with_audit(max_retries=3, operation_name="encrypt_payload")
def secure_persist_payload(payload: bytes) -> str:
return "aes256_gcm_ciphertext_placeholder" # real impl returns ciphertext handle
Parameters that matter: an exhausted retry raises PipelineRetryError and lands the payload in a dead-letter queue with full context, so a failed encryption call can never degrade into writing the plaintext. Every row carries pii_retention_until, data_origin_fips, and classification_level columns that drive automated archival and deletion downstream.
Verification
Confirm the boundary actually holds before trusting it in production:
- Round-trip a known-bad batch. Feed
process_intake_batcha record taggedRESTRICTEDwithencryption_status=None. Assert it lands inquarantine, notvalidated, and that aBOUNDARY_VIOLATIONline appears in the log. - Probe the scanner with a live-format SSN. Call
enforce_pii_boundary({"notes": "123-45-6789"})and assert it returnsFalsewith aPII_EXPOSURE_DETECTEDCRITICALentry — then move the same string intoencrypted_payloadand assert it returnsTrue. - Diff a masked export against the source. Run the policy engine over a Schedule B sample and assert no contributor name survives in the public artifact (
grepthe output for a known donor surname; expect zero hits). - Force a persistence failure. Patch
secure_persist_payloadto raise, and assert the payload reaches the dead-letter queue after exactlymax_retriesattempts with aRETRY_EXHAUSTEDlog — and that nothing unmasked was written.
def test_restricted_without_encryption_is_quarantined() -> None:
bad = {
"grantor_id": "G-001", "submission_timestamp": "2026-01-04T00:00:00Z",
"beneficiary_count": 12, "pii_classification": "RESTRICTED", "encryption_status": None,
}
validated, quarantine = process_intake_batch([bad])
assert validated == []
assert len(quarantine) == 1
A clean run emits one structured audit line per stage; those lines are the evidence an auditor reconstructs data lineage from, so treat a missing log as a failed test even when the data looks correct.
Common errors & fixes
| Error | Cause | Fix |
|---|---|---|
BOUNDARY_VIOLATION on every restricted row |
Source system sends classification but the encryption step runs after intake | Move encryption before the gate, or stamp encryption_status at the producer; the gate must see it already set |
PII_EXPOSURE_DETECTED on a column that is “supposed” to be safe |
A funder packed an SSN into a free-text notes field |
Route the offending column through tokenization in Step 3 before it reaches the scanner, or add it to encrypted_payload |
| Masking silently does nothing | jurisdiction/funder_id keys miss the YAML, so get_masking_rules returns {"default": 0} |
Assert the resolved rule set is non-empty before persisting; fail closed when a policy is absent |
RETRY_EXHAUSTED floods the dead-letter queue |
Encryption service is down, not the data — backoff is masking an outage | Add a circuit breaker around secure_persist_payload; alert on dead-letter depth, do not auto-replay |
SSN regex misses 123456789 (no dashes) |
PII_PATTERNS only matches the hyphenated format |
Normalize separators before scanning, or add an unformatted nine-digit pattern with stricter context to limit false positives |
Related
- Parent: Data Security & Access Boundaries — the credential-scope and egress-signing model this page sits inside.
- Building a Fallback Routing System for Grant APIs — the retry and dead-letter patterns from Step 4, at endpoint scale.
- Compliance Metadata Standards — the immutable lineage and retention tagging the audit logs feed into.
- Implementing Automated Error Logging for Grant Pipelines — structuring the quarantine and
CRITICALevents emitted here.
For external baselines, align column-level controls with NIST SP 800-53 Rev. 5, strict type contracts with the Pydantic documentation, and contributor-confidentiality masking with the IRS Instructions for Form 990 Schedule B rules.