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
316 lines
15 KiB
Python
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)
|