GET /hux/v1/conversations/{id}/privacy reports forgotten, memory_disabled,
topics, mode and memory_writes_allowed; the worker hook's memory gate reads it
and fails closed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
296 lines
15 KiB
Python
296 lines
15 KiB
Python
"""HUX-10 privacy behaviour: topic detection, notices, conversation forget and the retention job.
|
|
|
|
The policy itself lives in ``rules.privacy_policy``; this module records what
|
|
was shown, scopes detected topics to the conversation they appeared in, gates
|
|
memory writes for other families (``memory_write_allowed``) and runs the
|
|
daily retention audit that expires memory, decays topic context and purges
|
|
forgotten content while never touching the audit ledgers (SO-47).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from hux import contracts, rules
|
|
from hux.errors import Invalid, NotFound
|
|
from hux.http import Request, Response, Router, page
|
|
from hux.identity import Identity
|
|
from hux.store import TenantStore, check_id, new_id, now_iso
|
|
|
|
FAMILY = "privacy"
|
|
RETENTION_FAMILY = "retention"
|
|
TOPICS_DOC = "topics_conversations"
|
|
TOPICS_SCHEMA = "hux.conversation_topics.v1"
|
|
CONTROLS = ("forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss")
|
|
SENSITIVITY_RANK = {"public": 0, "personal": 1, "sensitive": 2, "restricted": 3}
|
|
AUDIT_STALE_HOURS = 48
|
|
SCHEMAS = contracts.load_all()
|
|
|
|
# Deliberately narrow: a false positive only asks the user before remembering,
|
|
# a false negative would store something it should not. Detection is a hint,
|
|
# never proof, so callers treat "uncertain" as sensitive (SO-21).
|
|
TOPIC_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
"credentials": re.compile(r"(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|token|2fa|otp|ssh key|login credentials?)\b"),
|
|
"biometric": re.compile(r"(?i)\b(fingerprint|face ?id|retina|iris scan|voice ?print|dna|genome|biometric)\b"),
|
|
"minors": re.compile(r"(?i)\b(my (son|daughter|kid|kids|child|children|toddler|baby)|(?:\d{1,2}|[a-z]+)[- ]year[- ]old (?:son|daughter|boy|girl|child)|school ?(?:pickup|run)|daycare|minor child)\b"),
|
|
"location": re.compile(r"(?i)\b(home address|my address|lives? at \d|gps|coordinates|\d{1,5} [A-Z][a-z]+ (?:street|st|avenue|ave|road|rd|lane|ln|drive|dr)\b|latitude|longitude|track(?:ing)? (?:my|his|her) (?:phone|location))")
|
|
,
|
|
"health": re.compile(r"(?i)\b(diagnos(?:is|ed)|prescription|medication|therapist|therapy|symptoms?|doctor|clinic|hospital|surgery|chronic|depression|anxiety|cancer|diabetes|pregnan(?:t|cy)|mental health|blood (?:pressure|test))\b"),
|
|
"finance": re.compile(r"(?i)\b(salary|income|mortgage|bank account|credit card|iban|routing number|debt|loan|tax return|net worth|investment|401k|savings account|bankrupt(?:cy)?)\b"),
|
|
"legal": re.compile(r"(?i)\b(lawsuit|attorney|lawyer|court (?:date|case|order)|subpoena|arrest(?:ed)?|custody|divorce filing|criminal (?:record|charge)|restraining order|litigation)\b"),
|
|
"relationships": re.compile(r"(?i)\b(my (?:wife|husband|partner|girlfriend|boyfriend|ex)|breakup|broke up|divorce|affair|dating|marriage counsel(?:l)?ing)\b"),
|
|
}
|
|
|
|
|
|
def detect_topics(text: str) -> list[str]:
|
|
"""Sensitive topics a piece of text looks like it touches, most restrictive first. Heuristic and uncertain by design."""
|
|
found = [topic for topic, pattern in TOPIC_PATTERNS.items() if pattern.search(text or "")]
|
|
return sorted(found, key=lambda topic: (-SENSITIVITY_RANK[rules.PRIVACY_TOPICS[topic]["sensitivity"]], topic))
|
|
|
|
|
|
def topic_sensitivity(topics: list[str]) -> str:
|
|
"""Highest sensitivity among detected topics; ``personal`` when nothing matched."""
|
|
ranked = sorted((rules.PRIVACY_TOPICS[t]["sensitivity"] for t in topics), key=SENSITIVITY_RANK.get, reverse=True)
|
|
return ranked[0] if ranked else "personal"
|
|
|
|
|
|
def _parse(stamp: str) -> datetime:
|
|
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
|
|
|
|
def _iso(when: datetime) -> str:
|
|
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def load_topics(store: TenantStore) -> dict[str, Any]:
|
|
"""The ``conversation_topics`` document: conversation id -> scoping state."""
|
|
if store.exists(FAMILY, TOPICS_DOC):
|
|
return store.get(FAMILY, TOPICS_DOC)
|
|
return {"id": TOPICS_DOC, "schema": TOPICS_SCHEMA, "items": {}}
|
|
|
|
|
|
def _save_topics(store: TenantStore, doc: dict[str, Any]) -> None:
|
|
store.put(FAMILY, doc)
|
|
|
|
|
|
def conversation_state(store: TenantStore, conversation_id: str) -> dict[str, Any]:
|
|
"""Scoping row for one conversation (empty when nothing was ever detected)."""
|
|
return load_topics(store)["items"].get(conversation_id, {})
|
|
|
|
|
|
def mark_topic(store: TenantStore, conversation_id: str, topic: str, now: str | None = None) -> dict[str, Any]:
|
|
"""Scope ``topic`` to ``conversation_id``; the first sighting fixes ``decay_at``."""
|
|
now = now or now_iso()
|
|
with store.lock(FAMILY):
|
|
doc = load_topics(store)
|
|
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now, "memory_disabled": False, "forgotten": False})
|
|
if topic not in row["topics"]:
|
|
row["topics"].append(topic)
|
|
decay = _parse(row["first_seen"]) + timedelta(days=min(rules.PRIVACY_TOPICS[t]["decay_days"] for t in row["topics"]))
|
|
row["decay_at"] = _iso(decay)
|
|
_save_topics(store, doc)
|
|
return row
|
|
|
|
|
|
def set_flag(store: TenantStore, conversation_id: str, flag: str, value: bool) -> None:
|
|
"""Set ``memory_disabled`` or ``forgotten`` for a conversation."""
|
|
with store.lock(FAMILY):
|
|
doc = load_topics(store)
|
|
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now_iso(), "memory_disabled": False, "forgotten": False})
|
|
row[flag] = value
|
|
_save_topics(store, doc)
|
|
|
|
|
|
def memory_write_allowed(store: TenantStore, conversation_id: str | None, entry: dict[str, Any]) -> tuple[bool, str]:
|
|
"""Gate every memory write (SO-21, SO-23, SO-28). Returns (allowed, reason)."""
|
|
from hux import events
|
|
|
|
if conversation_id:
|
|
if events.is_private(store, conversation_id):
|
|
return False, "private_mode"
|
|
state = conversation_state(store, conversation_id)
|
|
if state.get("forgotten"):
|
|
return False, "conversation_forgotten"
|
|
if state.get("memory_disabled"):
|
|
return False, "memory_disabled"
|
|
if entry.get("sensitivity") == "restricted":
|
|
return False, "restricted"
|
|
topic = entry.get("topic", "general")
|
|
if topic in rules.PRIVACY_TOPICS and rules.PRIVACY_TOPICS[topic]["memory_write"] == "deny":
|
|
return False, f"topic_{topic}"
|
|
return True, "allowed"
|
|
|
|
|
|
def blocked_conversations(store: TenantStore) -> set[str]:
|
|
"""Conversations that contribute nothing to retrieval: forgotten or memory disabled (SO-23)."""
|
|
return {cid for cid, row in load_topics(store)["items"].items() if row.get("forgotten") or row.get("memory_disabled")}
|
|
|
|
|
|
def latest_audit(store: TenantStore) -> dict[str, Any] | None:
|
|
"""Newest retention audit record, or None when the job never ran."""
|
|
rows = sorted(store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"])
|
|
return rows[-1] if rows else None
|
|
|
|
|
|
def audit_stale(store: TenantStore, now: datetime | None = None) -> bool:
|
|
"""SO-27: no retention audit in the last 48 hours."""
|
|
last = latest_audit(store)
|
|
now = now or datetime.now(timezone.utc)
|
|
return last is None or now - _parse(last["ran_at"]) > timedelta(hours=AUDIT_STALE_HOURS)
|
|
|
|
|
|
# -- retention ----------------------------------------------------------------
|
|
|
|
def run_retention(store: TenantStore, now: datetime | None = None, identity: Identity | None = None) -> dict[str, Any]:
|
|
"""Expire memory, decay topic context, purge forgotten content, report; write and return ``hux.retention_audit.v1``."""
|
|
from hux import events, memory
|
|
|
|
now = now or datetime.now(timezone.utc)
|
|
expired = memory.expire_due(store, now)
|
|
decayed = 0
|
|
with store.lock(FAMILY):
|
|
doc = load_topics(store)
|
|
for conversation_id, row in doc["items"].items():
|
|
if row.get("decayed") or not row.get("topics") or _parse(row.get("decay_at", "9999-01-01T00:00:00Z")) > now:
|
|
continue
|
|
notice = rules.privacy_policy()["topics"]
|
|
text = next(t["notice"] for t in notice if t["topic"] == row["topics"][0])
|
|
decayed += events.rewrite_full(store, conversation_id, text, "topic context decayed", lambda r: r.get("sensitivity") in {"sensitive", "restricted"})
|
|
row["decayed"] = True
|
|
_save_topics(store, doc)
|
|
purged = memory.purge_forgotten(store)
|
|
record = {
|
|
"schema": "hux.retention_audit.v1",
|
|
"id": new_id("aud"),
|
|
"ran_at": _iso(now),
|
|
"policy_version": rules.privacy_policy()["version"],
|
|
"results": [
|
|
{"action": "expire_memory", "count": expired},
|
|
{"action": "decay_topic_context", "count": decayed},
|
|
{"action": "purge_forgotten_content", "count": purged},
|
|
{"action": "report", "count": 1},
|
|
],
|
|
}
|
|
problems = contracts.validate_record(record, SCHEMAS)
|
|
if problems:
|
|
raise Invalid("retention audit failed contract validation", problems)
|
|
return _strip(store.put(RETENTION_FAMILY, record))
|
|
|
|
|
|
def forget_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> dict[str, Any]:
|
|
"""Mark a conversation forgotten: its memory entries move to forgotten, its events go to full redaction."""
|
|
from hux import events, memory
|
|
|
|
set_flag(store, conversation_id, "forgotten", True)
|
|
forgotten_ids = memory.forget_from_conversation(store, identity, conversation_id)
|
|
redacted = events.rewrite_full(store, conversation_id, "[forgotten conversation]", "conversation forgotten")
|
|
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted, "document_blanked": _blank_document(store, conversation_id)}
|
|
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
|
|
return {"conversation_id": conversation_id, "forgotten": True, **counts}
|
|
|
|
|
|
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
|
|
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
|
|
try:
|
|
from hux import organization
|
|
except ModuleNotFoundError:
|
|
return False
|
|
return organization.mark_forgotten(store, conversation_id)
|
|
|
|
|
|
# -- routes ---------------------------------------------------------------------
|
|
|
|
def _strip(record: dict[str, Any]) -> dict[str, Any]:
|
|
return {key: value for key, value in record.items() if key != "revision"}
|
|
|
|
|
|
def get_policy(request: Request) -> Response:
|
|
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
|
|
request.audit("privacy.policy", "policy")
|
|
stale = audit_stale(request.store)
|
|
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
|
|
|
|
|
|
def post_notice(request: Request) -> Response:
|
|
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
|
|
from hux import events
|
|
|
|
body = request.body if isinstance(request.body, dict) else {}
|
|
topic, conversation_id = body.get("topic"), body.get("conversation_id")
|
|
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
|
|
raise Invalid("topic and conversation_id are required")
|
|
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
|
|
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
|
|
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
|
|
problems = contracts.validate_record(notice, SCHEMAS)
|
|
if problems:
|
|
raise Invalid("notice failed contract validation", problems)
|
|
request.store.append(FAMILY, "notices", notice)
|
|
mark_topic(request.store, conversation_id, topic)
|
|
chosen = body.get("chosen")
|
|
if chosen == "disable_memory_here":
|
|
set_flag(request.store, conversation_id, "memory_disabled", True)
|
|
elif chosen == "forget_this_conversation":
|
|
forget_conversation(request.store, request.identity, conversation_id)
|
|
# F13a: a notice for a conversation this subject never had must not conjure
|
|
# an event ledger for it; the notice itself is still on record.
|
|
known = events.conversation_known(request.store, conversation_id)
|
|
if known:
|
|
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
|
|
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
|
|
return Response(201, notice)
|
|
|
|
|
|
def post_forget(request: Request) -> Response:
|
|
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
|
|
from hux import events
|
|
|
|
conversation_id = request.params["id"]
|
|
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
|
|
raise NotFound("conversation not found")
|
|
result = forget_conversation(request.store, request.identity, conversation_id)
|
|
request.audit("privacy.forget", conversation_id)
|
|
return Response(200, result)
|
|
|
|
|
|
def get_audit(request: Request) -> Response:
|
|
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
|
|
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
|
|
request.audit("privacy.audit", "retention")
|
|
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
|
|
|
|
|
|
PAGE_LIMIT = 200
|
|
|
|
|
|
def get_conversation_privacy(request: Request) -> Response:
|
|
"""``GET /hux/v1/conversations/{id}/privacy``: forget/disable state and topics, so the hook can stop proposing memory early."""
|
|
conversation_id = check_id(request.params["id"])
|
|
state = conversation_state(request.store, conversation_id)
|
|
mode = None
|
|
try:
|
|
from hux.organization import CONVERSATIONS
|
|
mode = request.store.get(CONVERSATIONS, conversation_id).get("mode")
|
|
except (ModuleNotFoundError, ImportError, NotFound):
|
|
mode = None
|
|
request.audit("privacy.conversation_state", conversation_id)
|
|
return Response(200, {
|
|
"conversation_id": conversation_id,
|
|
"forgotten": bool(state.get("forgotten")),
|
|
"memory_disabled": bool(state.get("memory_disabled")),
|
|
"topics": sorted(state.get("topics", {})) if isinstance(state.get("topics"), dict) else list(state.get("topics", [])),
|
|
"mode": mode,
|
|
"memory_writes_allowed": not (state.get("forgotten") or state.get("memory_disabled") or mode == "private"),
|
|
})
|
|
|
|
|
|
def register(router: Router) -> None:
|
|
"""Attach HUX-10 routes."""
|
|
router.add("GET", "/hux/v1/privacy/policy", "HUX-10", "privacy.policy", get_policy)
|
|
router.add("POST", "/hux/v1/privacy/notices", "HUX-10", "privacy.notice", post_notice)
|
|
router.add("POST", "/hux/v1/conversations/{id}/forget", "HUX-10", "privacy.forget", post_forget)
|
|
router.add("GET", "/hux/v1/privacy/audit", "HUX-10", "privacy.audit", get_audit)
|
|
router.add("GET", "/hux/v1/conversations/{id}/privacy", "HUX-10", "privacy.conversation_state", get_conversation_privacy)
|