jenkins 681b040885 hermes(hux): close Wave A review findings in events, memory, privacy and organization
F3 memory edits go through the same privacy shaping as proposals; F5 seq is
derived from the ledger tail so a crash between append and checkpoint never
duplicates; F7 transitions re-read under the lock and always write with the
loaded revision; F9 secret scrub on titles, passages, claims and notebooks
and forget blanks the conversation document; F13 no ghost conversations
from notices, idempotency under the lock, artifact titles searchable,
normalised paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00

316 lines
15 KiB
Python

"""HUX-01 activity events: the per-conversation append-only log and its readers.
``emit`` is the one write path every family uses. It runs the redaction
pipeline, allocates ``seq`` under the conversation lock, honours idempotency
keys and keeps a checkpoint document per conversation. Readers page by
``after_seq`` or follow an SSE stream whose ``id:`` is the seq, and every
served record passes serve-time redaction for the caller's surface.
"""
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
from typing import Any
from collections.abc import Iterator
from hux import contracts, redaction
from hux.errors import Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, new_id, now_iso
FAMILY = "events"
SEQ_FAMILY = "events_seq"
IDEM_FAMILY = "events_idem"
PAGE_MAX = 200
PAGE_DEFAULT = 100
STREAM_MAX_POLLS = 900
STREAM_POLL_SECONDS = 1.0
SCHEMAS = contracts.load_all()
KINDS = frozenset(contracts.load_schema("event.schema.json")["properties"]["kind"]["enum"])
USER_KINDS = frozenset({"message.user", "approval.resolved", "run.cancelled", "memory.forgotten", "suggestion.dismissed"})
_streams: dict[str, int] = {}
_streams_guard = threading.Lock()
def _seq_id(conversation_id: str) -> str:
return f"seq_{conversation_id}"
def is_private(store: TenantStore, conversation_id: str) -> bool:
"""True when the conversation document (HUX-03) says the mode is private (SO-28)."""
try:
return store.get("conversations", conversation_id).get("mode") == "private"
except NotFound:
return False
def _ledger_path(store: TenantStore, conversation_id: str) -> Path:
return store.root / FAMILY / f"{conversation_id}.jsonl"
def conversation_known(store: TenantStore, conversation_id: str) -> bool:
"""Ownership check (SO-18): the conversation document, its seq checkpoint or its event ledger exists in this subject's tree."""
return (
store.exists("conversations", conversation_id)
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
or _ledger_path(store, conversation_id).is_file()
)
def last_seq(store: TenantStore, conversation_id: str) -> int:
"""The seq of the newest complete ledger line, read from the file tail (0 when the ledger is empty or absent).
A crash between the ledger append and the checkpoint write leaves the
checkpoint behind the ledger; ``emit`` takes the larger of the two so a seq
is never handed out twice (F5). Only the last ``LINE_CAP_BYTES`` window is
read, and a torn trailing line is skipped.
"""
path = _ledger_path(store, conversation_id)
if not path.is_file():
return 0
with open(path, "rb") as handle:
handle.seek(0, 2)
size = handle.tell()
handle.seek(max(0, size - redaction.LINE_CAP_BYTES - 2))
tail = handle.read()
for line in reversed(tail.split(b"\n")):
if not line:
continue
try:
return int(json.loads(line).get("seq", 0))
except (json.JSONDecodeError, AttributeError, ValueError):
continue
return 0
def _actor(identity: Identity, kind: str) -> dict[str, str]:
if kind in USER_KINDS and identity.trust != "worker":
return {"type": "user", "id": identity.subject}
if identity.trust == "worker":
return {"type": "system", "id": "hux-worker"}
return {"type": "assistant", "id": "hermes"}
def _find_replay(store: TenantStore, conversation_id: str, key: str) -> dict[str, Any] | None:
for row in store.read(IDEM_FAMILY, conversation_id):
if row.get("idempotency_key") == key:
for event in store.read(FAMILY, conversation_id):
if event.get("id") == row.get("event_id"):
return event
return None
def build(identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None, evidence: list | None,
sensitivity: str, run_id: str | None, turn: int | None, correlation_id: str | None, idempotency_key: str | None) -> dict[str, Any]:
"""Run the redaction pipeline and shape an unsequenced ``hux.event.v1`` record."""
if kind not in KINDS:
raise Invalid(f"unknown event kind {kind!r}")
if sensitivity not in ("public", "personal", "sensitive", "restricted"):
raise Invalid("unknown sensitivity")
hits: list[str] = []
summary_clean = redaction.scrub_value(str(summary or "").strip(), hits)[: redaction.SUMMARY_MAX] or "[empty]"
detail_clean = redaction.scrub_value(redaction.filter_detail(kind, detail), hits)
detail_clean, truncated = redaction.cap_detail(detail_clean)
evidence_clean = redaction.scrub_value(redaction.filter_evidence(evidence), hits)
stamp = now_iso()
record: dict[str, Any] = {
"schema": "hux.event.v1",
"id": new_id("evt"),
"seq": 0,
"ts": stamp,
"conversation_id": conversation_id,
"kind": kind,
"summary": summary_clean,
"provenance": {"surface": identity.surface, "actor": _actor(identity, kind), "recorded_at": stamp, "conversation_id": conversation_id},
"sensitivity": sensitivity,
"redaction": redaction.derive_level(sensitivity, hits, truncated),
"turn": max(0, int(turn or 0)),
"identity": identity.record(),
}
if detail_clean:
record["detail"] = detail_clean
if evidence_clean:
record["evidence"] = evidence_clean
if run_id:
record["run_id"] = str(run_id)[:120]
record["provenance"]["run_id"] = record["run_id"]
if correlation_id:
record["correlation_id"] = str(correlation_id)[:120]
if idempotency_key:
record["idempotency_key"] = idempotency_key
return record
def emit_with_status(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
correlation_id: str | None = None, idempotency_key: str | None = None) -> tuple[dict[str, Any] | None, bool]:
"""Like ``emit`` but also says whether the record was an idempotent replay. None means private mode (nothing written)."""
if is_private(store, conversation_id):
return None, False
record = build(identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
with store.lock(f"{FAMILY}:{conversation_id}"):
if idempotency_key:
existing = _find_replay(store, conversation_id, idempotency_key)
if existing is not None:
return existing, True
seq_id = _seq_id(conversation_id)
checkpoint = store.get(SEQ_FAMILY, seq_id) if store.exists(SEQ_FAMILY, seq_id) else {"id": seq_id, "next_seq": 1, "last_event_id": ""}
record["seq"] = max(int(checkpoint["next_seq"]), last_seq(store, conversation_id) + 1)
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("event failed contract validation", problems)
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
if len(line) > redaction.LINE_CAP_BYTES:
raise TooLarge("event line exceeds 64 KiB")
store.append(FAMILY, conversation_id, record)
store.put(SEQ_FAMILY, {**checkpoint, "next_seq": record["seq"] + 1, "last_event_id": record["id"], "checkpointed_at": now_iso()})
if idempotency_key:
store.append(IDEM_FAMILY, conversation_id, {"idempotency_key": idempotency_key, "event_id": record["id"], "seq": record["seq"], "at": record["ts"]})
return record, False
def emit(store: TenantStore, identity: Identity, conversation_id: str, kind: str, summary: str, detail: dict | None = None,
evidence: list | None = None, sensitivity: str = "personal", run_id: str | None = None, turn: int | None = None,
correlation_id: str | None = None, idempotency_key: str | None = None) -> dict[str, Any] | None:
"""Append one event and return the stored record (None when the conversation is private)."""
record, _ = emit_with_status(store, identity, conversation_id, kind, summary, detail, evidence, sensitivity, run_id, turn, correlation_id, idempotency_key)
return record
def read_after(store: TenantStore, conversation_id: str, after_seq: int, limit: int) -> list[dict[str, Any]]:
"""Stored events with ``seq > after_seq``, oldest first, at most ``limit``."""
rows = [row for row in store.read(FAMILY, conversation_id) if int(row.get("seq", 0)) > after_seq]
return rows[:limit]
def rewrite_full(store: TenantStore, conversation_id: str, summary: str, reason: str, match=None) -> int:
"""Rewrite matching events of one conversation to ``redaction.level: full`` (SO-24, forget, decay). Returns the count."""
with store.lock(f"{FAMILY}:{conversation_id}"):
rows = store.read(FAMILY, conversation_id)
changed = 0
out: list[dict[str, Any]] = []
for row in rows:
if row.get("redaction", {}).get("level") != "full" and (match is None or match(row)):
row = redaction.full_redaction(row, summary, reason)
changed += 1
out.append(row)
if changed:
store.rewrite(FAMILY, conversation_id, out)
return changed
def redact_memory_references(store: TenantStore, memory_id: str) -> int:
"""Fully redact every event, in any conversation, that names a forgotten memory id (SO-24)."""
def references(row: dict[str, Any]) -> bool:
if row.get("detail", {}).get("memory_id") == memory_id:
return True
return any(ref.get("kind") == "memory" and ref.get("id") == memory_id for ref in row.get("evidence", []))
return sum(rewrite_full(store, name, "[forgotten memory]", "memory forgotten", references) for name in store.ledgers(FAMILY))
# -- routes ------------------------------------------------------------------
def _int_query(request: Request, name: str, default: int, floor: int = 0) -> int:
raw = request.query.get(name, "")
if raw == "":
return default
if not raw.lstrip("-").isdigit():
raise Invalid(f"{name} must be an integer")
return max(floor, int(raw))
def _require_conversation(request: Request) -> str:
conversation_id = request.params["id"]
if not conversation_known(request.store, conversation_id):
raise NotFound("conversation not found")
return conversation_id
def list_events(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/events?after_seq=&limit=``: one page, ``next`` is the last seq served."""
conversation_id = _require_conversation(request)
after_seq = _int_query(request, "after_seq", 0)
limit = min(_int_query(request, "limit", PAGE_DEFAULT, 1), PAGE_MAX)
rows = read_after(request.store, conversation_id, after_seq, limit)
items = [redaction.redact_record(row, request.identity.surface) for row in rows]
request.audit("events.list", conversation_id)
return page(items, items[-1]["seq"] if len(items) == limit else None)
def append_event(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/events``: append from the trusted hop; ids and seq are server-assigned (SO-15)."""
conversation_id = _require_conversation(request)
body = request.body if isinstance(request.body, dict) else None
if body is None:
raise Invalid("body must be an object")
if "id" in body or "seq" in body:
raise Invalid("id and seq are server-assigned")
if not isinstance(body.get("kind"), str) or not isinstance(body.get("summary"), str):
raise Invalid("kind and summary are required")
detail = body.get("detail") if isinstance(body.get("detail"), dict) else None
evidence = body.get("evidence") if isinstance(body.get("evidence"), list) else None
turn = body.get("turn") if isinstance(body.get("turn"), int) else None
record, replayed = emit_with_status(
request.store, request.identity, conversation_id, body["kind"], body["summary"], detail, evidence,
body.get("sensitivity", "personal"), body.get("run_id"), turn, body.get("correlation_id"), request.idempotency_key() or None,
)
if record is None:
request.audit("events.append", conversation_id, "allow", "private_mode")
return Response(204)
request.audit("events.append", f"{conversation_id}/{record['id']}", "allow", "replayed" if replayed else "")
served = redaction.redact_record(record, request.identity.surface)
return Response(200 if replayed else 201, served, {"HUX-Replayed": "true"} if replayed else {})
def _sse(record: dict[str, Any]) -> bytes:
return f"id: {record['seq']}\nevent: {record['kind']}\ndata: {json.dumps(record, sort_keys=True)}\n\n".encode()
def stream_events(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/events/stream``: SSE replay from ``Last-Event-ID`` (or ``after_seq``), then a bounded live poll.
``max_polls`` and ``poll_ms`` (query) bound the live phase so a client, or a
test, decides how long to wait; the server caps them at the 15 minute idle
limit (SO-17). A second stream for the same (subject, conversation) closes
the first.
"""
conversation_id = _require_conversation(request)
last_id = request.header("Last-Event-ID")
cursor = int(last_id) if last_id.isdigit() else _int_query(request, "after_seq", 0)
max_polls = min(_int_query(request, "max_polls", STREAM_MAX_POLLS), STREAM_MAX_POLLS)
poll_seconds = min(_int_query(request, "poll_ms", int(STREAM_POLL_SECONDS * 1000)), 60000) / 1000
store, surface = request.store, request.identity.surface
key = f"{request.identity.subject}:{conversation_id}"
with _streams_guard:
token = _streams[key] = _streams.get(key, 0) + 1
request.audit("events.stream", conversation_id)
def generate() -> Iterator[bytes]:
position = cursor
yield b"retry: 2000\n\n"
polls = 0
while True:
for record in read_after(store, conversation_id, position, PAGE_MAX):
position = record["seq"]
yield _sse(redaction.redact_record(record, surface))
if polls >= max_polls or _streams.get(key) != token:
break
polls += 1
yield b": keepalive\n\n"
time.sleep(poll_seconds)
return Response(200, stream=generate)
def register(router: Router) -> None:
"""Attach HUX-01 routes."""
router.add("GET", "/hux/v1/conversations/{id}/events", "HUX-01", "events.list", list_events)
router.add("POST", "/hux/v1/conversations/{id}/events", "HUX-01", "events.append", append_event)
router.add("GET", "/hux/v1/conversations/{id}/events/stream", "HUX-01", "events.stream", stream_events)