diff --git a/playbook_engine/publisher.py b/playbook_engine/publisher.py index 8e2777b..5a73495 100644 --- a/playbook_engine/publisher.py +++ b/playbook_engine/publisher.py @@ -42,6 +42,17 @@ ``leaked`` raises :class:`PublishError` unless the caller passes ``accept_residue_risk=True``. The report always lists every finding either way. + 5.5. Final institution-identity gate (:func:`_institution_identity_hits`): + re-scans the WHOLE surface — every string value AND dict key — for + high-confidence institution-name shapes ("University of X", "X University", + "X College", "Regents of ...", "Board of Trustees/Regents") and any postal + address that survived the scrub. ANY hit raises :class:`PublishError`, + unconditionally (like step 4). This is the layer the step-4 backstop + (list-dependent) and the #211 proper-noun sweep (advisory, samples-only) + both miss: an unregistered counterparty name hiding in a signature block, + a ``corpus.stats`` dict key, or a filename-derived ``document_id`` slug — + the exact class of leak that shipped in a public example-playbook publish + (2026-07-22). Governing-law states and generic descriptors do not match. 6. Recomputes ``identity`` on the transformed document — it is a different artifact. ``identity.supersedes`` is set to the PRIVATE document's own ``content_hash`` (before any of the above ran). @@ -204,6 +215,13 @@ def _strip_source_paths(doc: dict[str, Any]) -> None: for document in corpus.get("documents", []): for version_file in document.get("version_files", []): version_file.pop("source_uri", None) + # version_ingest[].version carries raw source filename stems — + # DMS structure (and, when a counterparty acronym is missing from + # the entity registry, an identity leak — issue #234/#189). The + # public artifact needs only the ordinal. + for i, ingest in enumerate(document.get("version_ingest", [])): + if isinstance(ingest, dict) and "version" in ingest: + ingest["version"] = f"v{i + 1}" baseline = doc.get("baseline") if isinstance(baseline, dict): @@ -239,18 +257,206 @@ def _coarsen_dates(doc: dict[str, Any]) -> None: obs["observed_at"] = _coarsen_to_quarter(observed_at) +# --------------------------------------------------------------------------- +# Step 3.5: publication-noise scrub + GC redact list (issue #234) +# --------------------------------------------------------------------------- + +#: E-signature/CLM audit-trail noise (DocuSign/Adobe Sign): signature pages +#: extract into full_text as event lines, envelope/transaction ids, and IP +#: labels. None of it is contract content, and audit trails carry signatory +#: names and network metadata — strip the LINE, conservatively (a line must +#: match one of these unambiguous markers to be dropped). +_ESIGN_LINE_RE = re.compile( + r"(" + r"docusign|adobe\s*sign|envelopeid|envelope\s+id|source\s+envelope|" + r"transaction\s+id|ip\s+address|final\s+audit\s+report|record\s+tracking|" + r"autonav|time\s+source|timestamp|holder:|signer\s+events|carbon\s+copy\s+events|" + r"notary\s+events|payment\s+events|envelope\s+(sent|summary|originator)|" + r"certified\s+delivered|signing\s+complete|envelope\s+stamping|" + r"^\s*(signed|viewed|sent|delivered|created|completed|resent|read)\b.*" + r"\b\d{1,2}:\d{2}(:\d{2})?\s*(am|pm)?" + r")", + re.IGNORECASE, +) + +#: Long opaque tokens (envelope ids, base64-ish audit hashes) anywhere in a +#: line — redacted in place rather than dropping the line. Requires mixed +#: case so lowercase-hex content addresses (sha256) never match; strings +#: beginning with "sha256:" are additionally exempted wholesale in the +#: scrubber. +_OPAQUE_TOKEN_RE = re.compile( + r"\b(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])[A-Za-z0-9_-]{18,}\b" +) + +#: Street-address spans: "123 Rose Garden Lane", "P.O. Box 6186", +#: "Suite 400", and City, ST 12345 tails. Addresses in notice/signature +#: blocks uniquely identify a counterparty even after its name is aliased. +_ADDRESS_SPAN_RE = re.compile( + r"(" + r"\b\d{1,6}(\s+[A-Za-z][A-Za-z.'-]*){1,5}\s+" + r"(Road|Rd|Street|St|Avenue|Ave|Boulevard|Blvd|Lane|Ln|Drive|Dr|Parkway|Pkwy|" + r"Highway|Hwy|Way|Circle|Court|Ct|Place|Pl|Trail|Terrace)\b\.?" + r"|\bP\.?\s*O\.?\s+Box\s+\d+\b" + r"|\b(Suite|Ste|Room|Rm|Bldg|Building)\s*#?\s*[A-Za-z0-9-]+\b" + r"|\b[A-Z][A-Za-z.-]+,?\s+[A-Z]{2}\s+\d{5}(-\d{4})?\b" + r")" +) + +_ADDRESS_LABEL = "[address redacted]" +_REDACT_LABEL = "[redacted]" + +#: Full US state names — for the "City, " postal form that the +#: two-letter ``[A-Z]{2}`` rule in ``_ADDRESS_SPAN_RE`` misses (e.g. a notice +#: block written "New York, New York 10017"). Kept separate so the address +#: scrub and the final address backstop share one authority. +_US_STATE_NAMES = ( + "Alabama|Alaska|Arizona|Arkansas|California|Colorado|Connecticut|Delaware|" + "Florida|Georgia|Hawaii|Idaho|Illinois|Indiana|Iowa|Kansas|Kentucky|" + "Louisiana|Maine|Maryland|Massachusetts|Michigan|Minnesota|Mississippi|" + "Missouri|Montana|Nebraska|Nevada|New\\s+Hampshire|New\\s+Jersey|New\\s+Mexico|" + "New\\s+York|North\\s+Carolina|North\\s+Dakota|Ohio|Oklahoma|Oregon|Pennsylvania|" + "Rhode\\s+Island|South\\s+Carolina|South\\s+Dakota|Tennessee|Texas|Utah|Vermont|" + "Virginia|Washington|West\\s+Virginia|Wisconsin|Wyoming" +) +_STATE_ZIP_RE = re.compile( + r"\b[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+)*,?\s+" + rf"(?:{_US_STATE_NAMES})\s+\d{{5}}(?:-\d{{4}})?\b" +) + +#: E-mail addresses: NAME@institution-domain is a double identity leak +#: (person + counterparty domain), never contract content. +_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b") + +#: UUIDs (e-sign transaction/document ids) — lowercase hex evades the +#: mixed-case opaque-token rule, so match the canonical shape directly. +_UUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) + +#: URLs: portal/DMS links (and institution web addresses) are structure, not +#: contract content. +_URL_RE = re.compile(r"\b(?:https?://|www\.)\S+", re.IGNORECASE) + + +def _transform_all_strings(node: Any, fn: Any, *, include_keys: bool = False) -> Any: + """Recursively apply *fn* to every string in *node* (returns new tree). + + ``include_keys=True`` also rewrites dict KEYS — needed by the redact-term + pass, because document_id slugs appear as map keys (``corpus.stats`` + tallies) and a redacted id must transform identically everywhere it + occurs, key or value, to keep cross-references consistent. The noise + scrub deliberately leaves keys alone (structural key names are not + prose). + """ + if isinstance(node, str): + return fn(node) + if isinstance(node, dict): + return { + (fn(k) if include_keys and isinstance(k, str) else k): _transform_all_strings( + v, fn, include_keys=include_keys + ) + for k, v in node.items() + } + if isinstance(node, list): + return [_transform_all_strings(item, fn, include_keys=include_keys) for item in node] + return node + + +def _scrub_publication_noise(doc: dict[str, Any]) -> dict[str, Any]: + """Strip e-sign audit lines and redact address spans, doc-wide. + + Operates on EVERY string (the same surface ``proper_noun_residue`` + sweeps) so the scrub and the residue report cannot disagree about + coverage. Deterministic; never raises. + """ + + def scrub(text: str) -> str: + if text.startswith("sha256:"): + return text # content addresses are never publication noise + lines_out = [] + for line in text.split("\n"): + if _ESIGN_LINE_RE.search(line): + continue + line = _EMAIL_RE.sub(_REDACT_LABEL, line) + line = _UUID_RE.sub(_REDACT_LABEL, line) + line = _URL_RE.sub(_REDACT_LABEL, line) + line = _OPAQUE_TOKEN_RE.sub(_REDACT_LABEL, line) + line = _ADDRESS_SPAN_RE.sub(_ADDRESS_LABEL, line) + line = _STATE_ZIP_RE.sub(_ADDRESS_LABEL, line) + lines_out.append(line) + return "\n".join(lines_out) + + identity = doc.pop("identity", None) # recomputed in step 6; never scrub it + scrubbed = _transform_all_strings(doc, scrub) + if identity is not None: + scrubbed["identity"] = identity + return scrubbed # type: ignore[no-any-return] + + +def _apply_redact_terms(doc: dict[str, Any], terms: Sequence[str]) -> dict[str, Any]: + """Replace every GC-supplied redact term with ``[redacted]``, doc-wide. + + The redaction list is the GC's residue-review output (signatory names, + institution name fragments, campus towns — whatever the residue report + surfaced that the entity registry did not know). Matching is + case-insensitive and whitespace-flexible (extraction doubles spaces); + the list itself is sensitive and stays out of the repo — pass it via + ``--redact-terms`` from a local, gitignored file. + """ + cleaned = [t.strip() for t in terms if t.strip()] + if not cleaned: + return doc + + # Terms tokenize on \w+ runs and join on ANY non-word separator run + # ([\W_]+) — the same normalization class as the step-4 backstop. So + # "Chapel Hill" also redacts "chapel-hill" inside a document_id slug and + # "amanda.wynn" inside an e-mail localpart, and a term written with + # punctuation ("Kansas City, Missouri") matches the text however the + # punctuation/casing/spacing came out of extraction. Slug redaction is + # safe: every reference to the same document_id transforms identically, + # so citations keep resolving. + def _term_pattern(term: str) -> re.Pattern[str] | None: + words = re.findall(r"\w+", term) + if not words: + return None + return re.compile( + r"\b" + r"[\W_]+".join(re.escape(w) for w in words) + r"\b", re.IGNORECASE + ) + + patterns = [p for term in sorted(cleaned, key=len, reverse=True) if (p := _term_pattern(term))] + + def redact(text: str) -> str: + for pat in patterns: + text = pat.sub(_REDACT_LABEL, text) + return text + + identity = doc.pop("identity", None) + redacted = _transform_all_strings(doc, redact, include_keys=True) + if identity is not None: + redacted["identity"] = identity + return redacted # type: ignore[no-any-return] + + # --------------------------------------------------------------------------- # Step 4: deterministic no-known-entity backstop # --------------------------------------------------------------------------- def _walk_strings(node: Any, path: str = "$") -> list[tuple[str, str]]: - """Return ``(path, value)`` for every string leaf reachable from *node*.""" + """Return ``(path, value)`` for every string reachable from *node*. + + Dict KEYS are included (path ``{key}``), not just values: document_id + slugs appear as map keys in ``corpus.stats``-style tallies, and a + counterparty fragment hiding in a key must trip the backstop and the + residue sweep exactly like one in a value (issue #234 follow-up). + """ found: list[tuple[str, str]] = [] if isinstance(node, str): found.append((path, node)) elif isinstance(node, dict): for key, value in node.items(): + if isinstance(key, str): + found.append((f"{path}.{{{key}}}", key)) found.extend(_walk_strings(value, f"{path}.{key}")) elif isinstance(node, list): for idx, value in enumerate(node): @@ -282,6 +488,138 @@ def _entity_backstop_scan( return hits +# --------------------------------------------------------------------------- +# Step 5.5: final institution-identity gate (deterministic, full-surface) +# --------------------------------------------------------------------------- +# +# The step-4 backstop only catches names it was GIVEN (``known_entity_names``); +# the #211 proper-noun sweep is list-independent but ADVISORY and only sees the +# free-text *samples*. A real counterparty that was never registered — its name +# surviving in a signature block, a notices address, a dict KEY, or a +# filename-derived ``document_id`` slug — slips past both. That is exactly the +# class of leak that shipped in a public example-playbook publish (2026-07-22): +# a real institution name (a public university, a college-of-health, a +# community-college district) survived in extracted text, stats keys, and slugs +# the pseudonymizer never rewrote. +# +# This is a deterministic, list-INDEPENDENT, FULL-SURFACE gate. It walks every +# string — values AND dict keys, via :func:`_walk_strings` — for high-confidence +# institution-name shapes and any postal address that survived the scrub, and +# HARD-FAILS the publish on a hit. The fix path for a real survivor is the same +# as the step-4 backstop: add the name to ``--redact-terms`` (or register it), +# and re-run. Governing-law states ("the laws of the State of New York") and +# generic descriptors ("College of Health Professions", bare "the University") +# deliberately do NOT match, so the gate stays fail-closed without tripping on +# benign content — those remain the advisory proper-noun sweep's job. + +# Distinctive-token guard: a token from one of these is a role/qualifier word, +# never the identifying part of an institution name, so a match carrying only +# these is dropped ("the University", "State University", "of the ..."). +_INSTITUTION_TOKEN_STOP = frozenset( + { + "the", + "a", + "an", + "our", + "its", + "their", + "this", + "that", + "these", + "those", + "each", + "any", + "all", + "such", + "state", + "community", + "technical", + "international", + "public", + "private", + "national", + "american", + "new", + "other", + "same", + "said", + "and", + "or", + "of", + "for", + "by", + "to", + "at", + "in", + "on", + "from", + "counterparty", + "company", + "provider", + "institution", + "party", + "parties", + "educational", + "academic", + "affiliated", + "affiliate", + } +) + +# All matched against the case/punctuation-normalized string (``_normalize_for_scan``), +# so "University of Westmoor", "university-of-westmoor" (a slug), and +# "UNIVERSITY OF WESTMOOR" (double-spaced extraction) all reduce to the same +# "university of westmoor" and match identically. +_INST_UNIVERSITY_OF_RE = re.compile(r"\buniversity\s+of\s+(?:the\s+)?([a-z][a-z0-9]*)") +_INST_X_UNIVERSITY_RE = re.compile( + r"\b([a-z][a-z0-9]*)\s+" + r"(?:state\s+|community\s+|technical\s+|international\s+|memorial\s+)?university\b" +) +_INST_X_COLLEGE_RE = re.compile( + # "junior" is deliberately NOT a qualifier: it is itself the distinctive + # token of "Junior College [District of ...]", so treating it as a skip + # word would let a leading stopword ("... Institution junior college ...") + # absorb the match and hide the name. + r"\b([a-z][a-z0-9]*)\s+" + r"(?:community\s+|technical\s+|city\s+|state\s+)?college\b" +) +_INST_REGENTS_RE = re.compile(r"\bregents\s+of\b") +_INST_BOARD_RE = re.compile(r"\bboard\s+of\s+(?:trustees|regents)\b") + + +def _institution_identity_hits(doc: dict[str, Any]) -> list[tuple[str, str, str]]: + """Return every ``(path, matched_text, kind)`` institution-name or postal- + address pattern surviving anywhere in *doc* (values AND dict keys). + + ``kind`` is ``"institution"`` or ``"address"``. List-independent and + LLM-free — see the section header for why this exists. + """ + hits: list[tuple[str, str, str]] = [] + for path, text in _walk_strings(doc): + if not text or text.startswith("sha256:"): + continue + normalized = _normalize_for_scan(text) + # Token-bearing rules: flag only when the distinctive token is a real + # name, not a role/qualifier word (so "the University" never trips). + for pattern in (_INST_UNIVERSITY_OF_RE, _INST_X_UNIVERSITY_RE, _INST_X_COLLEGE_RE): + for match in pattern.finditer(normalized): + token = match.group(1) + if token and token not in _INSTITUTION_TOKEN_STOP: + hits.append((path, match.group(0).strip(), "institution")) + # Unambiguous org markers: a governing body is always a specific + # (usually public) institution, whatever token follows. + for pattern in (_INST_REGENTS_RE, _INST_BOARD_RE): + marker = pattern.search(normalized) + if marker: + hits.append((path, marker.group(0).strip(), "institution")) + # Postal address that survived the scrub (address regexes are + # case-sensitive, so match the ORIGINAL text, not the normalized form). + for pattern in (_ADDRESS_SPAN_RE, _STATE_ZIP_RE): + for match in pattern.finditer(text): + hits.append((path, match.group(0).strip(), "address")) + return hits + + # --------------------------------------------------------------------------- # List-independent proper-noun residue sweep (issue #211) # --------------------------------------------------------------------------- @@ -555,6 +893,7 @@ def publish_playbook( counterparty_label: str = DEFAULT_COUNTERPARTY_LABEL, keep_dates: bool = False, accept_residue_risk: bool = False, + redact_terms: Sequence[str] = (), ) -> PublishReport: """Run the six-step party-anonymous publication transform (issue #188). @@ -592,9 +931,12 @@ def publish_playbook( Raises: PublishError: step 4 found a known real entity name surviving in the - output, OR step 5's verify pass flagged residual - semantic residue and ``accept_residue_risk`` is - ``False``. + output; OR step 5's verify pass flagged residual semantic + residue and ``accept_residue_risk`` is ``False``; OR the + final step-5.5 institution-identity gate found an + institution-name shape or postal address surviving + anywhere (value or dict key) — unconditional, fixed by + naming the survivor in ``redact_terms``. """ published = copy.deepcopy(doc) @@ -612,8 +954,17 @@ def publish_playbook( if not keep_dates: _coarsen_dates(published) + # --- step 3.5: publication-noise scrub + GC redact list (issue #234) --- + # E-sign audit trails and notice-block street addresses identify parties + # and people even after every entity name is aliased; the redact list is + # the GC's residue-review output for anything the registry didn't know. + published = _scrub_publication_noise(published) + published = _apply_redact_terms(published, redact_terms) + # --- step 4: deterministic no-known-entity backstop (hard, unconditional) --- - hits = _entity_backstop_scan(published, known_entity_names) + # Redact terms join the backstop: a term the GC ordered redacted + # surviving anywhere is as blocking as a known entity name. + hits = _entity_backstop_scan(published, [*known_entity_names, *redact_terms]) if hits: listing = "; ".join(f"{path} matches {name!r}" for path, name in hits) raise PublishError( @@ -636,6 +987,29 @@ def publish_playbook( "(--accept-residue-risk) to publish anyway." ) + # --- step 5.5: final institution-identity gate (deterministic, hard) --- + # After EVERY transform above, re-scan the whole surface (values AND keys) + # for institution-name shapes and surviving addresses the born-safe + # pseudonymizer / redact list never covered. Unconditional — no flag + # suppresses it (like step 4); the fix is to name the survivor in + # redact_terms. Catches the class of leak that shipped in a public + # example-playbook publish: a real counterparty name in a signature block, + # a stats dict key, or a filename-derived document_id slug. + identity_hits = _institution_identity_hits(published) + if identity_hits: + listing = "; ".join( + f"{path}: {matched!r} ({kind})" for path, matched, kind in identity_hits[:25] + ) + more = "" if len(identity_hits) <= 25 else f" (+{len(identity_hits) - 25} more)" + raise PublishError( + f"publish blocked: {len(identity_hits)} institution-identity / address " + f"pattern(s) survived every transform — a real counterparty name or postal " + f"address the born-safe pseudonymizer and redact list did not cover: " + f"{listing}{more}. Add each offending name to --redact-terms (or register it " + "in the entity registry) and re-run. Deterministic backstop — no flag " + "suppresses it (identity-residue backstop; 2026-07-22 publish incident)." + ) + # --- step 6: recompute identity — a published doc is a different artifact --- private_identity = doc.get("identity") private_content_hash = ( diff --git a/tests/test_publish.py b/tests/test_publish.py index 944b76c..2509b09 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -545,10 +545,12 @@ def test_publish_report_carries_proper_noun_findings() -> None: """End to end: a surviving unknown name shows up on the PublishReport (it is advisory — it does NOT block, unlike a KNOWN-name backstop hit).""" doc = _make_doc() - # Inject an unknown name into a free-text surface the sweep scans. It is - # NOT in known_entity_names, so step-4's backstop cannot catch it — the - # proper-noun sweep is the layer that surfaces it. - doc["evidence"]["clauses"][0]["our_standard"]["text"] += " Ashland University drafted this." + # Inject an unknown name into a free-text surface the sweep scans. A + # signatory-style PERSONAL name is advisory: not in known_entity_names, and + # not an institution shape, so neither step-4 nor the step-5.5 gate blocks — + # the proper-noun sweep is the layer that surfaces it. (An institution name + # like "Ashland University" would now HARD-block; see the gate tests below.) + doc["evidence"]["clauses"][0]["our_standard"]["text"] += " Dana Ashland drafted this." report = publish_playbook( doc, @@ -559,7 +561,276 @@ def test_publish_report_carries_proper_noun_findings() -> None: ) texts = [f.text for f in report.proper_noun_findings] - assert "Ashland University" in texts + assert "Dana Ashland" in texts # Advisory only — publish still succeeded and produced a doc. assert report.doc is not None assert "proper_noun_findings" in report.to_dict() + + +# --------------------------------------------------------------------------- +# Issue #234: publication-noise scrub + GC redact list +# --------------------------------------------------------------------------- + + +def _publish_with_text(text: str, redact_terms: list[str] | None = None) -> dict: + doc = _make_doc() + doc["evidence"]["clauses"][0]["observed_positions"][0]["full_text"] = text + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], + published_at="2026-07-16T00:00:00Z", + redact_terms=redact_terms or (), + ) + return report.doc["evidence"]["clauses"][0]["observed_positions"][0]["full_text"] # type: ignore[no-any-return] + + +def test_publish_strips_esign_audit_lines() -> None: + text = ( + "Notices shall be sent to the parties.\n" + "DocuSigned by: Pat Example\n" + "Envelope Id: CBJCHBCAABAAdvXXQkglEeVYM\n" + "Signed 07/01/2024 10:32:11 AM PDT\n" + "IP Address: 10.0.0.1\n" + "The remainder of the clause survives." + ) + out = _publish_with_text(text) + assert "Notices shall be sent" in out + assert "remainder of the clause survives" in out + assert "DocuSigned" not in out + assert "Envelope" not in out + assert "IP Address" not in out + assert "10:32:11" not in out + + +def test_publish_redacts_address_spans() -> None: + text = ( + "Notices to the Institution at 1234 Campus Garden Lane, Springfield, " + "IL 62704, Suite 400, or P.O. Box 6186, with a copy to Legal." + ) + out = _publish_with_text(text) + assert "1234" not in out + assert "Campus Garden Lane" not in out + assert "62704" not in out + assert "P.O. Box 6186" not in out + assert "[address redacted]" in out + assert "with a copy to Legal" in out + + +def test_publish_never_scrubs_content_addresses() -> None: + doc = _make_doc() + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], + published_at="2026-07-16T00:00:00Z", + ) + published = report.doc + for document in published["corpus"]["documents"]: + for vf in document.get("version_files", []): + assert vf["sha256"].startswith("sha256:") + assert "[redacted]" not in vf["sha256"] + assert published["identity"]["content_hash"].startswith("sha256:") + + +def test_publish_redact_terms_replace_and_join_backstop() -> None: + # The institution name must be on the redact list now — the step-5.5 gate + # hard-blocks a surviving "... University". With it listed, redaction takes + # it out (joining the backstop) while a non-listed term ("Provost") stays. + text = "Signature: Pat Q. Example, Provost, Example State University (KSU)." + out = _publish_with_text( + text, redact_terms=["Pat Q. Example", "Example State University", "KSU"] + ) + assert "Pat Q. Example" not in out + assert "KSU" not in out + assert "State University" not in out + assert out.count("[redacted]") >= 2 + assert "Provost" in out # a non-listed term still survives + + +def test_publish_version_ingest_stems_become_ordinals() -> None: + doc = _make_doc() + doc["corpus"]["documents"][0]["version_ingest"] = [ + {"version": "01__Real Filename Stem- Some University (002)", "status": "ok"}, + {"version": "02__Real Filename Stem- Some University (002)", "status": "ok"}, + ] + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], + published_at="2026-07-16T00:00:00Z", + ) + ingests = report.doc["corpus"]["documents"][0]["version_ingest"] + assert [i["version"] for i in ingests] == ["v1", "v2"] + + +def test_publish_scrubs_emails_uuids_urls() -> None: + text = ( + "Contact pat.example@someuniversity.edu with questions.\n" + "Transaction ref 21345135-f8f5-4497-a929-6e64db805306 follows.\n" + "See https://dms.example.edu/contracts/123 and www.example.edu for details.\n" + "The clause text itself survives." + ) + out = _publish_with_text(text) + assert "someuniversity" not in out + assert "pat.example" not in out + assert "21345135" not in out + assert "https://" not in out and "www.example.edu" not in out + assert "The clause text itself survives." in out + + +def test_publish_redact_terms_match_across_punctuation() -> None: + """A redact term must hit slugs and e-mail localparts, not just prose — + the same normalization class as the step-4 backstop.""" + text = ( + "Deal id fwd-legaladmin-counterparty-9-chapel-hill-agreem-773a " + "and mail to amanda.wynn@example.edu about it." + ) + out = _publish_with_text(text, redact_terms=["Chapel Hill", "Amanda Wynn"]) + assert "chapel-hill" not in out and "Chapel Hill" not in out + assert "amanda" not in out.lower() + assert "counterparty-9" in out # surrounding slug context survives + + +def test_backstop_and_redaction_cover_dict_keys() -> None: + """document_id slugs appear as dict KEYS in stats tallies — a + counterparty fragment there must trip the backstop, and a redact term + must rewrite it consistently.""" + doc = _make_doc() + doc["corpus"]["stats"] = {"observations_by_document": {"deal-somewhere-chapel-hill-773a": 3}} + + # 1. backstop sees the key + with pytest.raises(PublishError, match="chapel"): + publish_playbook( + doc, + redaction_judge=_NeverCallJudge(), + verify_judge=_NeverCallJudge(), + known_entity_names=["Chapel Hill"], + published_at="2026-07-17T00:00:00Z", + ) + + # 2. a redact term rewrites the key, so the backstop then passes + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=["Chapel Hill"], + published_at="2026-07-17T00:00:00Z", + redact_terms=["Chapel Hill"], + ) + keys = list(report.doc["corpus"]["stats"]["observations_by_document"]) + assert keys == ["deal-somewhere-[redacted]-773a"] + + +def test_redact_terms_with_punctuation_match_normalized_text() -> None: + """A term written with punctuation ('Fairview, Westland') must match + however the extraction rendered it (double spaces, no comma, case).""" + text = "THE JUNIOR COLLEGE DISTRICT OF METROPOLITAN FAIRVIEW WESTLAND agrees." + out = _publish_with_text( + text, redact_terms=["Junior College District of Metropolitan Fairview, Westland"] + ) + assert "FAIRVIEW" not in out and "Fairview" not in out + assert "[redacted] agrees." in out + + +# --------------------------------------------------------------------------- +# Final institution-identity gate (list-independent, full-surface, hard): +# the fix for the 2026-07-22 public example-playbook leak class — a real +# counterparty name that was never registered, surviving in extracted prose, +# a stats dict key, or a filename-derived document_id slug. +# --------------------------------------------------------------------------- + +_INST_NAME = "Wexford University" # fictional; registered NOWHERE + + +def _doc_leaking_institution_everywhere() -> dict[str, Any]: + """A clean base doc with a fictional institution name planted in the three + surfaces the born-safe pass historically missed: signature-block prose, a + corpus.stats dict KEY, and a filename-derived document_id slug value.""" + doc = _make_doc() + doc["evidence"]["clauses"][0]["observed_positions"][0]["full_text"] = ( + f"IN WITNESS WHEREOF, signed on behalf of {_INST_NAME} by its officer." + ) + slug = "affiliation-agreement-wexford-university-0212e146" + doc["corpus"]["documents"][0]["document_id"] = slug + doc["corpus"]["stats"]["observations_by_document"] = {slug: 3} + return doc + + +def test_institution_gate_blocks_unregistered_name_across_surfaces() -> None: + """An institution name never given to the backstop still HARD-blocks — + whether it hides in prose, a dict key, or a document_id slug.""" + doc = _doc_leaking_institution_everywhere() + + with pytest.raises(PublishError, match="institution") as exc_info: + publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], # NOT registered — step-4 backstop is blind to it + published_at="2026-07-22T00:00:00Z", + ) + message = str(exc_info.value) + assert "wexford university" in message.lower() + # every leaking surface is named in the failure + assert "observed_positions" in message + assert "document_id" in message + assert "observations_by_document" in message + + +def test_institution_gate_passes_once_name_is_redacted() -> None: + """Naming the survivor in redact_terms clears the gate on every surface — + prose, key, and slug transform identically, so publish succeeds clean.""" + doc = _doc_leaking_institution_everywhere() + + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], + published_at="2026-07-22T00:00:00Z", + redact_terms=[_INST_NAME], + ) + blob = " ".join(_walk_strings(report.doc)) + assert "wexford" not in blob.lower() + # the counterparty fragment is gone from BOTH the slug value and the key + stats_keys = list(report.doc["corpus"]["stats"]["observations_by_document"]) + assert stats_keys == ["affiliation-agreement-[redacted]-0212e146"] + assert ( + report.doc["corpus"]["documents"][0]["document_id"] + == "affiliation-agreement-[redacted]-0212e146" + ) + + +def test_institution_gate_ignores_governing_law_and_generic_descriptors() -> None: + """The gate must NOT fire on governing-law states or generic org + descriptors — those stay the advisory sweep's job, so a clean publish is + never falsely blocked.""" + doc = _make_doc() + doc["evidence"]["clauses"][0]["our_standard"]["text"] = ( + "This Agreement is governed by the laws of the State of New York. " + "The University shall provide services through its College of Health " + "Professions and the Board of Directors shall meet annually." + ) + report = publish_playbook( + doc, + redaction_judge=_CleanRedactionJudge(), + verify_judge=_CleanVerifyJudge(), + known_entity_names=[], + published_at="2026-07-22T00:00:00Z", + ) + assert report.doc is not None # no PublishError + + +def test_publish_scrub_redacts_city_full_state_zip() -> None: + """A notice block written 'City, ' (which the + two-letter ``ST`` rule misses) is redacted; the gate confirms none + survives.""" + text = "Notices to the Institution at New York, New York 10017, attn: Legal." + out = _publish_with_text(text) + assert "10017" not in out + assert "[address redacted]" in out + assert "attn: Legal" in out