"""HUX-09 restrained contextual suggestions with server-owned suppression. Clients submit a bounded context signal; they cannot create suggestion text, change cooldowns, or claim that a suggestion was clicked. Private and sensitive conversations return an explicit no-store result before any suggestion state or idempotency record is written. """ from __future__ import annotations import hashlib import json from collections.abc import Callable from datetime import datetime, timedelta, timezone from typing import Any from hux import contracts from hux.errors import Conflict, Invalid, NotFound from hux.http import Request, Response, Router, page from hux.store import now_iso CARD = "HUX-09" FAMILY = "suggestion_states" MAX_STATES = 1000 CONTEXTS = ("first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle") CATALOG: dict[str, dict[str, Any]] = { "first_session": {"id": "sug_first_session", "kind": "tip", "title": "Choose how Hermes works", "body": "Pick Fast, Thoughtful, Research, Create, or Private/local for this conversation.", "action": {"type": "open_mode"}, "priority": 80}, "empty_project": {"id": "sug_empty_project", "kind": "project", "title": "Give this project a starting point", "body": "Add a goal or move a related conversation here when it is useful.", "action": {"type": "create_project"}, "priority": 50}, "after_artifact": {"id": "sug_after_artifact", "kind": "feature", "title": "Keep working on this artifact", "body": "Open the artifact workspace to compare versions or continue editing.", "action": {"type": "open_artifacts"}, "priority": 70}, "after_research": {"id": "sug_after_research", "kind": "workflow", "title": "Keep the research together", "body": "Save the sources, assumptions, and open questions as a reusable workflow.", "action": {"type": "start_workflow"}, "priority": 65}, "after_approval": {"id": "sug_after_approval", "kind": "tip", "title": "Review autonomy controls", "body": "You can adjust what Hermes asks before doing for this conversation.", "action": {"type": "none"}, "priority": 40}, "idle": {"id": "sug_idle_workflow", "kind": "workflow", "title": "Make repeated work reusable", "body": "If this repeats, Hermes can turn it into a workflow without running it now.", "action": {"type": "start_workflow"}, "priority": 20}, } SCHEMAS = contracts.load_all() clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) def _body(request: Request, allowed: set[str]) -> dict[str, Any]: if not isinstance(request.body, dict): raise Invalid("body must be a JSON object") extra = set(request.body) - allowed if extra: raise Invalid("unexpected suggestion fields", sorted(extra)) return request.body def _scope(request: Request) -> tuple[str, dict[str, Any]]: from hux import organization project_id, conversation_id = request.params["project_id"], request.params["id"] try: conversation = request.store.get(organization.CONVERSATIONS, conversation_id) except (Invalid, NotFound) as error: raise NotFound("project or conversation not found") from error if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id): raise NotFound("project or conversation not found") return project_id, conversation def _privacy_gate(request: Request, conversation: dict[str, Any], body: dict[str, Any]) -> str | None: from hux import privacy if conversation.get("mode") == "private": return "private_mode" state = privacy.conversation_state(request.store, conversation["id"]) if state.get("forgotten") or state.get("memory_disabled"): return "conversation_no_store" if state.get("topics"): return "sensitive_topic" if body.get("no_store") is True or body.get("sensitivity") in {"sensitive", "restricted"}: return "client_no_store" return None def _suggestion(context: str, surface: str) -> dict[str, Any]: base = CATALOG[context] record = { "schema": "hux.suggestion.v1", **base, "trigger": {"surface": surface, "context": context}, "suppression": {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True}, } problems = contracts.validate("suggestion.schema.json", record, SCHEMAS, "/$defs/suggestion") if problems: raise Invalid("suggestion failed contract validation", problems) return record def _state_id(suggestion_id: str, project_id: str, conversation_id: str) -> str: value = f"{suggestion_id}:{project_id}:{conversation_id}".encode() return f"sugs_{hashlib.sha256(value).hexdigest()[:28]}" def _public(state: dict[str, Any]) -> dict[str, Any]: result = {key: state[key] for key in ("schema", "owner", "suggestion_id", "shows", "never_again")} for key in ("last_shown_at", "dismissed_at", "acted_at"): if key in state: result[key] = state[key] problems = contracts.validate("suggestion.schema.json", result, SCHEMAS, "/$defs/state") if problems: raise Invalid("suggestion state failed contract validation", problems) return result def _eligible(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> str | None: if state is None: return None if state.get("never_again"): return "never_again" rules = suggestion["suppression"] if state.get("shows", 0) >= rules["max_shows"]: return "max_shows" last = state.get("last_shown_at") if last and now - datetime.fromisoformat(last.replace("Z", "+00:00")) < timedelta(seconds=rules["cooldown_seconds"]): return "cooldown" return None def _key(request: Request) -> str: key = request.idempotency_key() if not key: raise Invalid("Idempotency-Key is required") return key def _fingerprint(body: dict[str, Any], path: str) -> str: return hashlib.sha256(json.dumps({"body": body, "path": path}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None: for row in request.store.read(FAMILY, "idempotency"): if row.get("key") != key: continue if row.get("fingerprint") != fingerprint: raise Conflict("Idempotency-Key was already used for a different suggestion action") return row["result"] return None def evaluate(request: Request) -> Response: """Evaluate and atomically count one eligible suggestion display.""" body = _body(request, {"context", "no_store", "sensitivity"}) project_id, conversation = _scope(request) context = body.get("context") if context not in CONTEXTS: raise Invalid("unknown suggestion context") gated = _privacy_gate(request, conversation, body) if gated: request.audit("suggestions.evaluate", conversation["id"], reason=gated) return Response(200, {"suggestion": None, "reason": gated, "stored": False}, {"Cache-Control": "no-store"}) key, fingerprint = _key(request), _fingerprint(body, request.path) suggestion = _suggestion(context, request.identity.surface) state_id = _state_id(suggestion["id"], project_id, conversation["id"]) with request.store.lock(FAMILY): replayed = _replay(request, key, fingerprint) if replayed is not None: return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"}) state = request.store.get(FAMILY, state_id) if request.store.exists(FAMILY, state_id) else None reason = _eligible(suggestion, state, clock()) if reason: result = {"suggestion": None, "reason": reason, "stored": False} else: if state is None and request.store.count(FAMILY) >= MAX_STATES: result = {"suggestion": None, "reason": "state_limit", "stored": False} else: stamp = now_iso() record = { "id": state_id, "schema": "hux.suggestion_state.v1", "owner": request.identity.subject, "suggestion_id": suggestion["id"], "shows": (state or {}).get("shows", 0) + 1, "last_shown_at": stamp, "never_again": False, } stored = request.store.put(FAMILY, record, expected_revision=state["revision"] if state else 0) result = {"suggestion": suggestion, "state": _public(stored), "revision": stored["revision"], "stored": True} request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": now_iso()}) request.audit("suggestions.evaluate", suggestion["id"], reason=result.get("reason", "shown")) headers = {"Cache-Control": "no-store"} if result.get("revision"): headers["ETag"] = str(result["revision"]) return Response(200, result, headers) def decide(request: Request) -> Response: """Record only an explicit user click, guarded by If-Match and idempotency.""" body = _body(request, {"decision", "clicked"}) _, conversation = _scope(request) gated = _privacy_gate(request, conversation, body) if gated: raise NotFound("suggestion state not found") if body.get("clicked") is not True or body.get("decision") not in {"dismissed", "acted", "never_again"}: raise Invalid("an explicit clicked decision is required") key, fingerprint = _key(request), _fingerprint(body, request.path) expected = request.if_match() if expected is None: raise Invalid("If-Match is required") state_id = _state_id(request.params["suggestion_id"], request.params["project_id"], conversation["id"]) with request.store.lock(FAMILY): replayed = _replay(request, key, fingerprint) if replayed is not None: return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"}) if not request.store.exists(FAMILY, state_id): raise NotFound("suggestion state not found") current = request.store.get(FAMILY, state_id) if current["revision"] != expected: raise Conflict("suggestion state revision does not match If-Match") stamp, decision = now_iso(), body["decision"] updated = {**current} if decision in {"dismissed", "never_again"}: updated["dismissed_at"] = stamp if decision == "acted": updated["acted_at"] = stamp if decision == "never_again": updated["never_again"] = True stored = request.store.put(FAMILY, updated, expected_revision=expected) result = {"state": _public(stored), "revision": stored["revision"], "decision": decision} request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "result": result, "at": stamp}) request.audit("suggestions.decide", request.params["suggestion_id"], reason=decision) return Response(200, result, {"ETag": str(stored["revision"]), "Cache-Control": "no-store"}) def list_states(request: Request) -> Response: """Inspect suppression state for exactly one project/conversation scope.""" project_id, conversation = _scope(request) if _privacy_gate(request, conversation, {}): return Response(200, {"items": [], "next": None}, {"Cache-Control": "no-store"}) prefix = lambda row: row["id"] == _state_id(row["suggestion_id"], project_id, conversation["id"]) items = [_public(row) | {"revision": row["revision"]} for row in request.store.scan(FAMILY) if prefix(row)] request.audit("suggestions.list", conversation["id"]) response = page(items) response.headers["Cache-Control"] = "no-store" return response def register(router: Router) -> None: """Attach HUX-09 routes.""" base = "/hux/v1/projects/{project_id}/conversations/{id}/suggestions" router.add("POST", base + "/evaluate", CARD, "suggestions.evaluate", evaluate) router.add("POST", base + "/{suggestion_id}/decisions", CARD, "suggestions.decide", decide) router.add("GET", base + "/states", CARD, "suggestions.list", list_states)