# -*- coding: utf-8 -*-
"""
privacy_gate.py — refuse to publish a named private individual.

Round-six review found four people named in the published Source Log who are not
candidates, not subjects of any finding, and serve no analytic purpose: the other
party to a candidate's admitted relationship (with her age at the time), a family
member suing a candidate, a campaign treasurer, and an intra-party rival named
inside a quote about an accusation the entry itself calls "not a finding".

The evidentiary standard governs what may be RECORDED about a SUBJECT. It never
licensed naming non-subjects, and a rule nobody enforces is not a rule. This gate
enforces it: any personal name appearing near allegation, relationship or private-
matter language must be a candidate, a public official acting in that capacity, or
an explicitly allowlisted source byline. Anything else fails the build.

WHAT THIS DOES NOT CATCH, so nobody mistakes a clean run for a guarantee:
a single-word name; a name with an unpunctuated middle initial ("Jane Q Doe");
a person described but not named; a private individual named more than ~120
characters from the sensitive word; and anything in a file other than the two it
reads (phase2.py and make_pages.py -- make_pages.py was added 2026-08-30 after a
red-teamer typed a private name into page prose, which the gate never looked at).
It is a tripwire for the pattern that actually shipped, not a certification.
Proved against a planted name before being trusted.

Run: python3 scripts/privacy_gate.py
"""
import os, re, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, ".."))
sys.path.insert(0, HERE)
import phase2  # noqa: E402

# Language that makes naming a third party a privacy question rather than a citation.
# Deliberately NARROW. A first pass keyed to every legal word flagged 90 outlets,
# courthouses and counties — and a checker that cries wolf is worse than none, which
# is a lesson this project already paid for. These are the words that make naming a
# non-candidate a privacy question rather than a citation.
SENSITIVE = re.compile(
    r"\b(extramarital|affairs?|mistress|interns?|divorce\w*|ex-wife|ex-husband|"
    r"mother-in-law|father-in-law|girlfriend|boyfriend|consensual|"
    r"sexual\w*|harass\w*|victims?|accusers?|complainants?|treasurers?|"
    # Added 2026-08-30 after a review session briefed as a media lawyer found a private individual named in the
    # published bundle with a 2015 arrest attached, as a disambiguation note. An adverse
    # legal fact about a non-subject is a privacy question whatever the verb.
    r"arrest\w*|charged|indicted|indictment|convict\w*|pleaded|plea\b|"
    r"embezzl\w*|assault\w*|abuse\w*|misconduct|allegation\w*|sued|lawsuit)\b", re.I)

# Roles that make a name a public act, not a private fact.
ROLE_BEFORE = re.compile(
    r"\b(Judge|Justice|Rep\.|Sen\.|Gov\.|Mayor|Sheriff|Attorney General|Commissioner|"
    r"Speaker|Chair|President|Secretary|Councilman|Councilwoman|Assemblyman|"
    r"Assemblywoman|Prosecutor|Solicitor|Magistrate)\s+$")
# A LOOKAHEAD, so capitalised pairs can overlap. Without it re.finditer consumes
# "Divorced Deborah" and never sees "Deborah Mullins" -- the leading verb swallows the
# first half of the name it precedes, and the plant walks through. Caught by probing the
# gate with one planted instance per new vocabulary word rather than assuming the widened
# regex worked.
NAME = re.compile(r"\b(?=([A-Z][a-z]{2,}(?:\s+[A-Z]\.)?\s+[A-Z][a-z'\-]{2,})\b)")
# Words that begin a capitalised pair without beginning a name: verbs that open a clause,
# compass points, and institution heads. "Born Hermosillo", "Divorced Dan", "West Point",
# "Air National", "Special Operations", "Bungie April" all matched the pattern above.
NOT_A_FIRST_NAME = {
    "The", "Roman", "French", "Irish", "Italian", "Polish", "German", "Greek",
    "Presidential", "Congressional", "Senate", "House", "Supreme", "Election",
    # Titles: the pair starting at the title is the ROLE plus a first name, and the real
    # name follows. ROLE_BEFORE anchors to end-of-string and cannot see this overlap.
    "Speaker", "Judge", "Justice", "Mayor", "Sheriff", "Governor", "Senator",
    "Representative", "Commissioner", "Prosecutor", "Attorney", "Councilman",
    "Councilwoman", "Assemblyman", "Assemblywoman", "Magistrate", "Solicitor",
    "Victims", "Victim", "Special",
    "Carter", "Reagan", "Clinton", "Obama", "Trump", "Biden", "Bush",   # administrations
    "Urban", "Rural", "Greater", "Central", "Metro", "Regional",
    "Born", "Divorced", "Married", "Terminated", "Elected", "Appointed", "Served",
    "Graduated", "Founded", "Retired", "Raised", "Convicted", "Charged", "Arrested",
    "West", "East", "North", "South", "New", "Old", "Fort", "Saint", "Air", "Special",
    "Ball", "Bungie", "United", "American", "National", "Federal", "State", "Grand",
    "First", "Second", "Third", "Former", "Deputy", "Chief", "Senior", "Junior",
    "Both", "Each", "Every", "After", "Before", "During", "Under", "Since", "While",
}
# A sensitive word used as a COLUMN KEY (harass="None found") is a field name, not a fact.
FIELD_KEY = re.compile(r"^\s*=")
# Second words that make a capitalised pair an institution, a place or a masthead.
NOT_A_PERSON = re.compile(
    r"\b(News|Journal|Times|Post|Herald|Press|Beacon|Globe|Sentinel|Dispatch|Media|"
    r"Public|Independent|Current|Reflector|Mercury|Republic|Advocate|Lantern|Review|"
    r"County|Cty|City|Court|Justice|University|College|School|Board|Council|Committee|"
    r"Church|Temple|Parish|Guard|Force|Army|Navy|Corps|Academy|House|Office|Center|"
    r"Centre|Bank|Casino|Strategies|Movement|Foundation|Association|Department|"
    r"Bureau|Commission|Institute|Hospital|Airport|Station|Group|Partners|LLC|Inc|"
    r"Ranch|Farm|Farms|Blanc|Prayer|Breakfast|Documented|Hinckley|Will|Notes|"
    r"History|Fund|Trust|Alliance|Coalition|Network|Society|Union|League|"
    # added 2026-08-30 when the widened vocabulary surfaced them
    r"Craft|Beacon|Examiner|Party|Rehabilitative|Rapids|Falls|Heights|Springs|"
    r"Valley|Grove|Creek|River|Lake|Island|Harbor|Bay|Point|Hills|Township|"
    r"Borough|Village|Municipal|District|Circuit|Superior|Appeals|Tribune|"
    r"Independent|Free|Star|Sun|Daily|Weekly|Gazette|Record|Chronicle|Ledger|"
    r"Enquirer|Inquirer|Observer|Standard|Signal|Courier|Bulletin|Register|"
    r"Advisory|Integrity|Catholic|Protestant|Baptist|Methodist|Lutheran|Orthodox|"
    r"Presbyterian|Episcopal|Evangelical|Jewish|Muslim|Reform|Conservative|"
    r"Resources|Affairs|Ethnic|Services|Systems|Solutions|Holdings|Energy|"
    r"Consolidated|Industries|Technologies|Logistics|Capital|Ventures|Unit|Squad|"
    r"Division|Section|Task|Team|Precinct|Battalion|Brigade|Wing|Command)\b")

# Named non-candidates that are legitimately in the record. Every entry is a decision.
ALLOW = {
    # A sitting member of Congress, named in his public capacity for a publicly
    # reported House Ethics matter, because it is the reason a ballot line changed
    # and therefore load-bearing for a candidate who IS in the index. The two
    # staffers are not named, and the entry says the conduct was his, not hers.
    "Chuck Edwards",
    # reporter bylines — attribution is the point of a source log
    "Michelle Rindels", "Jeniffer Solis", "Tom Shortell", "Bill Dentzer",
    # public officials acting in that capacity, named by the record itself
    "Regina Cahan",
    # The Speaker of the Maine House, named in his public capacity, in a matter a federal
    # court adjudicated and reported by name. He is the party whose funding was
    # threatened; the entry records the adjudicated act, not a private fact about him.
    "Mark Eves",
    # A White House aide who gave SWORN PUBLIC TESTIMONY to a congressional committee.
    # The testimony is the record; naming the witness is what makes it checkable, and it
    # is recorded here as testimony against a denial, not as a finding.
    "Cassidy Hutchinson",
}


# The gate read only phase2.py. A private name typed into the page generator's prose goes
# straight onto the published page without passing through the Source Log at all -- the
# one file where a leak is certain to be published was the one file not scanned. Found by
# a red-teamer. Both are read now, and the file each hit came from is named.
SCANNED = ("phase2.py", "make_pages.py")



# ── Permanent redactions ──────────────────────────────────────────────────────
# Every private individual this project has decided must not be named. Once an entry is
# here it stays here: this list is how a redaction survives the next export, the next
# spreadsheet, the next bundle built for somebody else.
#
# It is a list of SHA-256 DIGESTS of normalised names, not of names. An earlier version
# held the names in plain text, with a comment beside each saying why it was redacted —
# and this file ships in the public bundle so a reader can run the gate. The final
# pre-publication reviewer pointed out what that meant: the project had deleted nine
# associations from the record on privacy grounds and then republished them, name and
# reason together, in the one file it tells readers to open. The excuse written here at
# the time ("there is no way to check that a name is absent without holding the name") was
# false. The gate extracts every name-shaped token from a file, normalises it, hashes it,
# and compares digests; the names themselves appear nowhere in the distributed tree, and
# nothing here says who any of them are or why. To add one:
#     python3 privacy_gate.py --hash "First Last"
# and paste the digest below. Nine entries as of 2026-09-01.
REDACTED_SHA256 = {
    "f92ce95ca3c0e2f21c3dd27f2d77510d034580ec4c160aa399739a6ce3174a35",
    "429a97f57f0bb7b47969f02a2a95e2d26a6df37d7114a28e312c93dc31f775b1",
    "5e2c7ed6d6c0d0b3f5d732ff36f10164c1d2ee63696730ca62988af1df3ddc37",
    "ce90497dd1d814196953ad0135744bc238b3dd65906406c841207382fefe3589",
    "596ef167b626bf47841cdfcb035a9c5a12b71837228acd83de56a05b37e190ae",
    "9ce8558bbfc55f90e10cc33ca2e9bbaab337c449095b9e34073d7a329a7216d5",
    "4a8156c0c774a55e9c6c1b17ed2a97d9f8a42ab6f9694599b7ba7540e3ae7baa",
    "ecf2ccd0b2ee359cde4a18ae61f582e299971716365788b51e4d93ba33e135d5",
    "1be50b331a3762ac8ca178f32fab082044a670474dcf253b8d44a68e33a7d680",
}
N_REDACTED = len(REDACTED_SHA256)

# A name-shaped token: First [M.] Last, allowing a hyphenated first name and an
# apostrophe/hyphen in the surname. Wider than NAME above on purpose — that one is tuned
# to avoid false positives in the sensitive-context scan; this one only has to feed a hash
# comparison, where a false candidate costs nothing.
NAME_TOKEN = re.compile(
    r"\b(?=([A-Z][a-z]+(?:-[A-Z][a-z]+)?(?:\s+[A-Z]\.)?\s+[A-Z][a-z'\-]+)\b)")


def _norm(name):
    import hashlib  # noqa: F401  (kept local so the module stays standard-library only)
    return re.sub(r"\s+", " ", re.sub(r"[^\w\s-]", " ", name)).strip().lower()


def name_digest(name):
    import hashlib
    return hashlib.sha256(_norm(name).encode("utf-8")).hexdigest()


def redacted_hits(text):
    """Digests of every redacted name present in `text`, found by hashing each
    name-shaped token — the text is never compared against a plaintext list."""
    return {name_digest(m.group(1)) for m in NAME_TOKEN.finditer(text)} & REDACTED_SHA256


def scan_tree(root, label):
    """No redacted name may appear anywhere in a tree about to be distributed."""
    import os as _os
    hits = []
    for dirpath, dirnames, filenames in _os.walk(root):
        dirnames[:] = [d for d in dirnames
                       if d not in ("__pycache__", ".git") and not d.startswith("_quarantine")]
        for fn in filenames:
            fp = _os.path.join(dirpath, fn)
            try:
                text = open(fp, "rb").read().decode("utf-8", "ignore")
            except OSError:
                continue
            for d in redacted_hits(text):
                hits.append((_os.path.relpath(fp, root), d[:12]))
    if hits:
        print("PRIVACY GATE FAILED — a redacted name is present in %s:\n" % label)
        for f, d in sorted(set(hits)):
            print("  %-56s (redaction %s…)" % (f, d))
        print("\nThese names were removed from the record on purpose. Regenerate the file")
        print("from the current phase2.py, or quarantine it. A redaction that reaches one")
        print("file and not its exports is not a redaction.")
        raise SystemExit(1)
    return N_REDACTED


def main():
    src = "\n".join(open(os.path.join(HERE, f), encoding="utf-8").read()
                    for f in SCANNED if os.path.exists(os.path.join(HERE, f)))
    # A PERMANENTLY REDACTED name, anywhere in the source that generates the page, in ANY
    # sentence. The prose scan below only inspects names within 120 characters of a
    # sensitive word — a red-teamer put a redacted name in a neutral "research assistance"
    # credit with no sensitive word nearby, and it rendered onto the page while the prose
    # scan stayed clean. The permanent-redaction list is absolute: those names do not appear
    # in the generator at all, sensitive context or not. Same hash comparison as scan_tree,
    # applied to the source that writes the page, so a leak is caught before it is built.
    red_hits = sorted({(f, d[:12]) for f in SCANNED
                       if os.path.exists(os.path.join(HERE, f))
                       for d in redacted_hits(open(os.path.join(HERE, f),
                                                   encoding="utf-8").read())})
    if red_hits:
        print("PRIVACY GATE FAILED — a permanently-redacted name is present in the page "
              "generator source:\n")
        for f, d in red_hits:
            print("  %-24s (redaction %s…)" % (f, d))
        print("\nThese names were removed from the record on purpose and may never appear in "
              "the source that builds the page, in any context. Remove the name.")
        raise SystemExit(1)
    cands = set(phase2.AGE_TENURE)
    surnames = {c.split()[-1] for c in cands}
    bad = []
    for sent in re.split(r"(?<=[.;])\s+", src):
        if not SENSITIVE.search(sent):
            continue
        window = None
        for w in SENSITIVE.finditer(sent):
            if not FIELD_KEY.match(sent[w.end():]):
                window = w
                break
        if window is None:
            continue
        for m in NAME.finditer(sent):
            if abs(m.start() - window.start()) > 120:
                continue
            n = m.group(1)
            if n in cands or n in ALLOW:
                continue
            # The candidate under discussion, under any of the forms their name takes.
            # Two live cases the surname test alone missed: a middle name printed in a
            # bounded-search string ("Jennifer Capps Balkcom" for candidate Jennifer
            # Balkcom), and a two-part surname where the pattern captures only its first
            # word ("Derrick Van" for Derrick Van Orden).
            if n.split()[-1] in surnames:
                continue
            toks = set(n.replace("'s", "").split())
            if any(toks <= set(c.split()) for c in cands):
                continue
            # A candidate's fuller legal name, as it appears in a bounded-search string:
            # the first and last tokens both belong to one candidate, with a middle name
            # between. "Jennifer Capps Balkcom" is Jennifer Balkcom.
            if any(n.split()[0] in c.split() and n.split()[-1] in surnames for c in cands):
                continue
            near = sent[max(0, m.start() - 90):m.start()]
            if any(c.split()[-1] in near for c in cands if c.split()[-1] in surnames) \
                    and n.split()[0] in {t for c in cands for t in c.split()}:
                continue
            if any(n.replace("'s", "") in c for c in cands):
                continue
            if ROLE_BEFORE.search(sent[:m.start()]):
                continue
            if NOT_A_PERSON.search(n) or n.split()[0] in NOT_A_FIRST_NAME:
                continue
            # a byline sits inside parentheses right after an outlet name
            if re.search(r"\(\s*$", sent[:m.start()]):
                continue
            bad.append((n, sent[max(0, m.start() - 70):m.start() + 70].strip()))
    seen, uniq = set(), []
    for n, ctx in bad:
        if n not in seen:
            seen.add(n)
            uniq.append((n, ctx))
    if uniq:
        print("PRIVACY GATE FAILED — non-candidate named beside sensitive language:\n")
        for n, ctx in uniq:
            print(f"  {n}\n      …{ctx}…\n")
        print("Redact the name, or add it to ALLOW with a reason if it is a byline or")
        print("a public official acting in that capacity.")
        raise SystemExit(1)
    n_red = 0
    # THE BUNDLE BEING BUILT, not the last one that shipped. Scanning deploy/ here checks
    # the PREVIOUS build's artifact -- the same ordering defect a red-teamer found in the
    # commitment gate on the same day. build_all.py re-runs `--tree deploy` after the
    # deploy artifact is written, which is the only moment that check means anything.
    tree = os.path.join(ROOT, "public_data")
    if os.path.isdir(tree):
        n_red = scan_tree(tree, "the published data bundle")
    print(f"    privacy gate: clean across {len(SCANNED)} files, "
          f"{n_red} permanent redactions absent from every distributed tree "
          f"({len(cands)} candidates, {len(ALLOW)} allowlisted non-candidates)")


if __name__ == "__main__":
    if "--hash" in sys.argv:
        # Print the digest for a name so it can be added to REDACTED_SHA256 without the
        # name ever being written into this file.
        print(name_digest(sys.argv[sys.argv.index("--hash") + 1]))
    elif "--tree" in sys.argv:
        t = sys.argv[sys.argv.index("--tree") + 1]
        root = t if os.path.isabs(t) else os.path.join(ROOT, t)
        n = scan_tree(root, t)
        print(f"    privacy gate: {n} permanent redactions absent from {t}/")
    else:
        main()
