Classifying FOIA Privacy Exemptions (b)(6) and (b)(7)© in Python
Within FOIA Request Taxonomy Design, the two personal-privacy exemptions — Exemption 6 and Exemption 7© — are the pair most often confused at the review stage, and confusing them produces both wrongful disclosures and unlawful over-redaction. This guide builds a rule-based classifier in Python that distinguishes the two, flags candidate records for a human reviewer, and audit-logs every exemption decision with its statutory rationale. It sits inside the broader Core Architecture & Compliance Mapping model and hands its flags to the PII Redaction Pipelines stage only after a reviewer has applied the balancing test.
The Statutory Distinction
The Freedom of Information Act protects personal privacy through two separate provisions, and their texts differ in a way that is easy to gloss over and consequential to get wrong.
Exemption 6, at 5 U.S.C. § 552(b)(6), covers “personnel and medical files and similar files the disclosure of which would constitute a clearly unwarranted invasion of personal privacy.” The reach of the exemption is broad — courts read “similar files” to cover any government record containing information that applies to a particular individual — but its threshold is demanding. Disclosure must be a clearly unwarranted invasion, and the word “clearly” tips the balance toward disclosure when the privacy and public interests are close.
Exemption 7©, at 5 U.S.C. § 552(b)(7)©, covers “records or information compiled for law enforcement purposes” but only “to the extent that the production of such [records] could reasonably be expected to constitute an unwarranted invasion of personal privacy.” Two things change. First, the record must have a law-enforcement provenance to qualify at all. Second, and critically, the threshold is lower: 7© drops the word “clearly” and asks only whether disclosure could reasonably be expected to constitute an unwarranted invasion. For the same piece of personal information, 7© is the more protective screen — it withholds on a weaker showing of privacy harm than 6 requires.
Both exemptions turn on a balancing test that weighs the individual’s privacy interest against the public interest in disclosure — and that balance is a legal judgment, not a mechanical output. The public-interest side of the scale is itself narrowly defined: the interest that counts is the public’s interest in knowing what its government is up to, not a requester’s private curiosity about a named individual. A record can identify a person and still be disclosable if releasing it would meaningfully illuminate agency conduct, and it can be withheld even when the requester has a compelling personal reason if disclosure reveals nothing about how the government operates. That asymmetry is exactly why the decision cannot be reduced to a lookup table.
The engineering goal, therefore, is narrow and deliberate: a classifier that flags which exemption a reviewer should consider, surfaces the correct statutory threshold, and records the decision — never one that withholds on its own authority. A rule engine can reliably identify that a record names an individual and where it came from; it cannot weigh a privacy interest against a public one. Encoding that boundary into the software — flags on one side, human judgment on the other — is what keeps an automated intake pipeline defensible on administrative appeal.
Architecture Overview
The classifier is a decision tree over three properties of a record: whether it identifies an individual at all, whether it was compiled for a law-enforcement purpose, and whether it is a personnel, medical, or similar file. Law-enforcement provenance is tested first, because when it is present the lower 7© threshold is the more protective screen for the same personal information. Every path ends at a human reviewer who applies the balancing test.
The validated taxonomy record that reaches this stage already carries a record class and subject domain, so the classifier reasons over structured inputs rather than raw text. The output it produces is a candidate flag and a statutory threshold, both of which are handed forward for a human to resolve. Keeping the decision tree small and explicit is deliberate: three booleans and two ordered tests are auditable in a way that an opaque scoring model is not, and every branch maps directly to a sentence of statutory text a reviewer can be pointed to. When a record satisfies neither exemption’s predicate, the classifier says so plainly rather than guessing, so a reviewer’s attention is spent only where the statute actually creates a privacy question.
Prerequisites
- Python 3.11+ for
StrEnum-style enums, dataclasses, anddatetime.timezone.utc. - Standard library only —
enum,dataclasses,json,logging,datetime. Keeping the privacy path free of third-party dependencies limits the supply-chain surface on code that decides what the public sees. - Structured inputs, not free text. The classifier consumes a small context object describing the record; deriving
identifies_individualandcompiled_for_law_enforcementupstream is where entity extraction and provenance metadata belong, out of scope here. - An append-only audit sink so every exemption decision and its rationale are preserved for administrative appeal, consistent with the FOIA Request Taxonomy Design contract that classification is always logged.
Step-by-Step Implementation
1. Model the exemptions and the record context
Each exemption is a closed enum value, and the record context carries exactly the three booleans the decision tree needs. A Flag value makes explicit that the classifier output is a candidate signal, never a disposition.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
# Structured JSON audit logging (NIST SP 800-53 AU-3: content of audit records).
class _JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
line = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"event": record.getMessage(),
}
for key in ("record_id", "exemption", "disposition", "rationale"):
if hasattr(record, key):
line[key] = getattr(record, key)
return json.dumps(line, sort_keys=True)
logger = logging.getLogger("privacy_exemption_classifier")
logger.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(_JsonFormatter())
logger.addHandler(_handler)
class Exemption(str, Enum):
B6 = "b6" # 5 U.S.C. 552(b)(6): personnel, medical, and similar files
B7C = "b7c" # 5 U.S.C. 552(b)(7)(C): law-enforcement records, privacy
NONE = "none"
class Flag(str, Enum):
CANDIDATE = "candidate" # a rule-based signal only, NOT a decision
NO_SIGNAL = "no_signal"
@dataclass
class DocumentContext:
record_id: str
is_similar_personal_file: bool # personnel / medical / "similar file"
compiled_for_law_enforcement: bool
identifies_individual: bool
2. Flag the candidate exemption — never withhold
The classifier returns a Candidate carrying the proposed exemption, the statutory threshold the reviewer must apply, and a short rationale. It withholds nothing. Law-enforcement provenance is checked before the “similar file” test so the lower 7© bar screens the same personal information first.
@dataclass
class Candidate:
record_id: str
exemption: Exemption
flag: Flag
threshold: str # the statutory standard the reviewer must apply
rationale: str
def flag_privacy_exemption(doc: DocumentContext) -> Candidate:
"""Rule-based screen that FLAGS a candidate exemption. It never withholds.
The distinction that matters:
* (b)(6) reaches personnel, medical, and "similar files" and asks whether
disclosure would be a *clearly unwarranted* invasion of personal privacy.
* (b)(7)(C) reaches records compiled for law-enforcement purposes and asks
whether disclosure *could reasonably be expected to* constitute an
unwarranted invasion — a lower bar than (b)(6).
"""
try:
if not doc.identifies_individual:
return Candidate(doc.record_id, Exemption.NONE, Flag.NO_SIGNAL,
threshold="n/a",
rationale="No individual is identifiable in the record.")
# Law-enforcement provenance routes to 7(C) first: its lower threshold
# is the more protective screen for the same personal information.
if doc.compiled_for_law_enforcement:
return Candidate(
doc.record_id, Exemption.B7C, Flag.CANDIDATE,
threshold="could reasonably be expected to constitute an "
"unwarranted invasion of personal privacy",
rationale="Record compiled for law-enforcement purposes and "
"identifies an individual; apply 7(C) balancing.")
if doc.is_similar_personal_file:
return Candidate(
doc.record_id, Exemption.B6, Flag.CANDIDATE,
threshold="clearly unwarranted invasion of personal privacy",
rationale="Personnel, medical, or similar file identifying an "
"individual; apply (b)(6) balancing.")
return Candidate(doc.record_id, Exemption.NONE, Flag.NO_SIGNAL,
threshold="n/a",
rationale="Identifies an individual but is neither a "
"similar file nor a law-enforcement record.")
except Exception as exc: # fail safe: flag for human review, never release
logger.error("classifier_error", extra={"record_id": doc.record_id,
"rationale": repr(exc)})
return Candidate(doc.record_id, Exemption.NONE, Flag.CANDIDATE,
threshold="manual review",
rationale=f"Classifier error, routed to reviewer: {exc!r}")
Expected behaviour: a record compiled for law enforcement that identifies an individual returns a B7C candidate even when it is also a personnel file, because the lower threshold governs; a personnel file with no law-enforcement provenance returns a B6 candidate; a record identifying no individual returns NO_SIGNAL. On any internal error the function fails safe by flagging for a human rather than releasing.
3. Record the reviewer’s balancing decision
The balancing test is a human judgment. This function persists the reviewer’s outcome and rationale, audit-logging both. The classifier proposes; a named reviewer disposes.
def record_reviewer_decision(candidate: Candidate, *, withhold: bool,
reviewer: str, balancing_note: str) -> dict:
"""Persist the human balancing decision. The classifier proposes; a named
reviewer disposes. Both the outcome and its rationale are audit-logged
(NIST SP 800-53 AU-2 events and AU-3 record content).
"""
disposition = "withheld" if withhold else "released"
decision = {
"record_id": candidate.record_id,
"exemption": candidate.exemption.value,
"statutory_threshold": candidate.threshold,
"disposition": disposition,
"reviewer": reviewer,
"balancing_note": balancing_note,
}
logger.info("privacy_exemption_decision",
extra={"record_id": candidate.record_id,
"exemption": candidate.exemption.value,
"disposition": disposition,
"rationale": balancing_note})
return decision
Expected output: a single JSON audit line such as {"disposition": "withheld", "event": "privacy_exemption_decision", "exemption": "b7c", "rationale": "...", "record_id": "R-4", ...}, tying the withholding to a named reviewer and the exact statutory threshold they applied — the record an appeals officer will expect to see.
Validation & Verification
Assert the routing logic and the human-in-the-loop guarantee directly:
def test_law_enforcement_record_routes_to_7c():
doc = DocumentContext("R-1", is_similar_personal_file=True,
compiled_for_law_enforcement=True,
identifies_individual=True)
c = flag_privacy_exemption(doc)
# Law-enforcement provenance wins: 7(C)'s lower bar screens the same PII.
assert c.exemption is Exemption.B7C and c.flag is Flag.CANDIDATE
def test_personnel_file_routes_to_b6():
doc = DocumentContext("R-2", is_similar_personal_file=True,
compiled_for_law_enforcement=False,
identifies_individual=True)
assert flag_privacy_exemption(doc).exemption is Exemption.B6
def test_no_individual_is_not_flagged():
doc = DocumentContext("R-3", is_similar_personal_file=True,
compiled_for_law_enforcement=False,
identifies_individual=False)
c = flag_privacy_exemption(doc)
assert c.flag is Flag.NO_SIGNAL and c.exemption is Exemption.NONE
def test_reviewer_makes_the_final_call():
doc = DocumentContext("R-4", is_similar_personal_file=False,
compiled_for_law_enforcement=True,
identifies_individual=True)
c = flag_privacy_exemption(doc)
decision = record_reviewer_decision(
c, withhold=True, reviewer="foia.officer@agency.gov",
balancing_note="Third-party privacy interest outweighs public interest.")
assert decision["disposition"] == "withheld"
assert decision["exemption"] == "b7c"
The verification that matters most to an auditor is the human-in-the-loop invariant: flag_privacy_exemption produces only a Candidate, and no code path lets that candidate become a withholding without a record_reviewer_decision call carrying a named reviewer. Capture the audit stream in a test and confirm every withheld disposition has a matching reviewer and balancing note.
Troubleshooting & Edge Cases
- Treating the classifier as authoritative. The most dangerous failure is wiring
flag_privacy_exemptionstraight into redaction so a candidate flag auto-withholds. Diagnosis: withholdings in the audit log with no reviewer field. Fix: makerecord_reviewer_decisionthe only path to a disposition and reject any redaction request lacking a reviewer and balancing note. - Missing the 7© threshold nuance. Applying Exemption 6’s “clearly unwarranted” standard to a law-enforcement record under-protects privacy, because 7© withholds on the lower “could reasonably be expected to” showing. Diagnosis: released law-enforcement records whose privacy interest would have failed only the stricter test. Fix: route law-enforcement provenance to 7© first, as the classifier does, and surface the applicable threshold string to the reviewer so they apply the correct bar.
- Over-redaction. Reflexively withholding every record that names a person inverts FOIA’s disclosure presumption and invites an appeal. Diagnosis: a withholding rate near 100% on flagged records. Fix: require the balancing note to articulate the specific privacy interest, and treat
NO_SIGNALand released decisions as normal outcomes rather than failures. - Law-enforcement provenance that is assumed rather than established. Tagging a record 7© because it merely mentions an investigation stretches the “compiled for law enforcement purposes” predicate. Fix: derive
compiled_for_law_enforcementfrom authoritative provenance metadata upstream, not from keyword matching in the body. - Segregability ignored. Withholding an entire document because part of it is exempt violates FOIA’s duty to release reasonably segregable non-exempt portions. Fix: treat the exemption flag as applying to specific fields or passages that the PII Redaction Pipelines stage redacts, releasing the remainder.
Compliance Verification Checklist
FAQ
Why does the classifier check law-enforcement provenance before the personnel-file test?
Because when a record was compiled for a law-enforcement purpose, Exemption 7© applies and its threshold is lower than Exemption 6’s. 7© asks only whether disclosure “could reasonably be expected to” constitute an unwarranted invasion, while 6 requires a “clearly unwarranted” invasion. For the same personal information, routing to 7© first means the more protective standard screens it. A law-enforcement personnel file that might survive the stricter 6 balancing can still be withheld under 7©, so testing provenance first avoids under-protecting privacy.
Can the classifier withhold a record automatically?
No, and that constraint is deliberate. The balancing test that both exemptions require weighs an individual’s privacy interest against the public interest in disclosure, which is a legal judgment a rule engine cannot make. flag_privacy_exemption returns only a candidate flag and the statutory threshold; a withholding exists only after a named reviewer records a decision with a balancing note. Wiring the flag directly into redaction would turn a screening aid into an unlawful automated denial.
What audit trail does an exemption decision leave for an appeal?
Each decision emits one structured JSON line recording the record ID, the exemption applied, the disposition, the named reviewer, and the balancing note explaining why the privacy interest outweighed the public interest. On administrative appeal, that line ties the withholding to a specific statutory threshold and a specific person’s reasoning, which is exactly what an appeals officer needs to evaluate whether the balance was struck correctly.
Related
- FOIA Request Taxonomy Design — the parent classification layer whose validated record this classifier consumes.
- Mapping State-Specific FOIA Exemptions to Python Dictionaries — the state-code analogue to these federal privacy exemptions.
- PII Redaction Pipelines — the stage that redacts the specific passages a reviewer marks exempt.
- Priority Scoring Algorithms — where a sensitive-exemption flag raises a request’s handling priority.
- Core Architecture & Compliance Mapping — how privacy classification fits the wider compliance model.
← Back to all public records automation topics