"""Case ledger: opt-out state machine + append-only audit log. A "case" is one (subject x broker) record. State changes are validated against TRANSITIONS and mirrored into audit.jsonl so every action is auditable. """ from __future__ import annotations import datetime as _dt from pathlib import Path import paths import storage STATES = [ "new", "searching", "not_found", "found", "indirect_exposure", "action_selected", "submitted", "verification_pending", "awaiting_processing", "confirmed_removed", "reappeared", "human_task_queued", "blocked", ] TRANSITIONS: dict[str, set[str]] = { "new": {"searching", "found", "not_found", "indirect_exposure", "blocked"}, "searching": {"not_found", "found", "indirect_exposure", "blocked"}, "not_found": {"searching", "found", "indirect_exposure", "blocked"}, # found -> not_found: a parent re-verification (or re-scan) found the "found" was a false # positive (namesake, or an address-only property-record match) -- retract it with evidence. "found": {"action_selected", "submitted", "human_task_queued", "indirect_exposure", "blocked", "not_found"}, # indirect_exposure: subject's PII (email/phone/name) sits on a THIRD PARTY's record. The # self-service opt-out form does not apply; the lever is a targeted CCPA/GDPR delete-my-PII # request (-> submitted) or a human task. Re-scan can clear it (-> not_found) or upgrade it to a # direct listing (-> found). "indirect_exposure": {"submitted", "human_task_queued", "not_found", "found", "blocked"}, "action_selected": {"submitted", "human_task_queued", "blocked"}, "submitted": {"verification_pending", "awaiting_processing", "human_task_queued", "blocked"}, # verification_pending -> awaiting_processing: the verify link was opened/acknowledged and the # broker is now processing the removal (their stated window). confirmed_removed still requires a # verifying re-scan, never the submission flow's own say-so. "verification_pending": {"awaiting_processing", "confirmed_removed", "human_task_queued", "blocked"}, "awaiting_processing": {"confirmed_removed", "human_task_queued", "blocked"}, "confirmed_removed": {"reappeared", "confirmed_removed"}, "reappeared": {"found", "indirect_exposure"}, "human_task_queued": { "found", "indirect_exposure", "action_selected", "submitted", "verification_pending", "awaiting_processing", "confirmed_removed", "blocked", }, # blocked: automated tools (web_extract/proxyless browser) couldn't read the site. A later pass # -- a stealth/cloud browser OR guiding the operator's own (residential) browser -- can resolve it # to any real scan verdict, so blocked reaches not_found / indirect_exposure too, not just found. # blocked -> human_task_queued: some blocked sites need an operator step to proceed at all # (face-recognition sites needing a selfie/gov-ID, etc.), so route them to the digest. "blocked": {"searching", "found", "not_found", "indirect_exposure", "action_selected", "human_task_queued"}, } def now() -> str: return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def load(subject_id: str) -> dict: return storage.read_json(paths.ledger_path(subject_id), {}) or {} def save(subject_id: str, ledger: dict) -> Path: return storage.write_json(paths.ledger_path(subject_id), ledger) def new_case(subject_id: str, broker_id: str) -> dict: return { "case_id": f"case_{subject_id}_{broker_id}", "subject_id": subject_id, "broker_id": broker_id, "state": "new", "found": None, "evidence": {}, "disclosure_log": [], "history": [], } def get_case(subject_id: str, broker_id: str) -> dict: return load(subject_id).get(broker_id) or new_case(subject_id, broker_id) def can_transition(old: str, new: str) -> bool: return new == old or new in TRANSITIONS.get(old, set()) def transition(subject_id: str, broker_id: str, new_state: str, **fields) -> dict: if new_state not in STATES: raise ValueError(f"unknown state {new_state!r}") # Lock the whole load-modify-save so a concurrent cron re-scan / other tenant # can't read a stale ledger and clobber this transition. with storage.locked(paths.ledger_path(subject_id)): ledger = load(subject_id) case = ledger.get(broker_id) or new_case(subject_id, broker_id) old = case.get("state", "new") if not can_transition(old, new_state): raise ValueError(f"illegal transition {old!r} -> {new_state!r} for broker {broker_id!r}") case["state"] = new_state for key, value in fields.items(): case[key] = value stamp = now() case.setdefault("history", []).append({"at": stamp, "from": old, "to": new_state}) ledger[broker_id] = case save(subject_id, ledger) storage.append_jsonl( paths.audit_path(subject_id), {"at": stamp, "broker_id": broker_id, "event": "transition", "from": old, "to": new_state}, ) return case DEFAULT_PROCESSING_DAYS = 14 # when a broker record doesn't state est_processing_days VERIFICATION_POLL_DAYS = 1 # how soon to re-poll for an unarrived verification email def _plus_days(days: int, start: str | None = None) -> str: base = _dt.datetime.strptime(start, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_dt.timezone.utc) \ if start else _dt.datetime.now(_dt.timezone.utc) return (base + _dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") def followup_fields(new_state: str, broker: dict | None = None, dossier: dict | None = None) -> dict: """Auto-scheduling stamps for a transition, so nobody has to remember follow-ups. submitted / awaiting_processing -> recheck after the broker's stated processing window; verification_pending -> re-poll the inbox quickly; confirmed_removed -> periodic reappearance re-scan per subject preference. """ if new_state in ("submitted", "awaiting_processing"): days = ((broker or {}).get("optout") or {}).get("est_processing_days") or DEFAULT_PROCESSING_DAYS return {"next_recheck_at": _plus_days(int(days))} if new_state == "verification_pending": return {"next_recheck_at": _plus_days(VERIFICATION_POLL_DAYS)} if new_state == "confirmed_removed": interval = ((dossier or {}).get("preferences") or {}).get("rescan_interval_days") or 120 return {"removal_confirmed_at": now(), "next_recheck_at": _plus_days(int(interval))} return {} def due(subject_id: str, at: str | None = None, ledger: dict | None = None) -> list[dict]: """Cases whose next_recheck_at has arrived - the autonomous follow-up queue.""" stamp = at or now() out = [] for case in (ledger if ledger is not None else load(subject_id)).values(): when = case.get("next_recheck_at") if when and when <= stamp: out.append(case) out.sort(key=lambda c: c.get("next_recheck_at") or "") return out def log_disclosure(subject_id: str, broker_id: str, fields: list[str], channel: str) -> dict: """Record exactly which PII field *names* were disclosed to a broker.""" with storage.locked(paths.ledger_path(subject_id)): ledger = load(subject_id) case = ledger.get(broker_id) or new_case(subject_id, broker_id) stamp = now() record = {"at": stamp, "fields": sorted(fields), "channel": channel} case.setdefault("disclosure_log", []).append(record) ledger[broker_id] = case save(subject_id, ledger) storage.append_jsonl( paths.audit_path(subject_id), {"at": stamp, "broker_id": broker_id, "event": "disclosure", "fields": record["fields"], "channel": channel}, ) return record