hermes(hux): expose conversation privacy state for the agent hook

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
This commit is contained in:
jenkins 2026-08-24 00:54:34 -03:00
parent e1ef110c8a
commit 18b980d6fa
7 changed files with 67 additions and 12 deletions

View File

@ -29,7 +29,7 @@ CARD_ROUTES: dict[str, list[str]] = {
"HUX-07": [],
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
"HUX-09": [],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"],
"HUX-12": [],
}
# Cards whose routes are not shipped yet declare [] above until they land (F12).
@ -46,7 +46,7 @@ WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
("GET", "/hux/v1/runs/{id}/budget"),
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
("GET", "/hux/v1/privacy/policy"),
("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/conversations/{id}/privacy"),
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),

View File

@ -17,7 +17,7 @@ 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, new_id, now_iso
from hux.store import TenantStore, check_id, new_id, now_iso
FAMILY = "privacy"
RETENTION_FAMILY = "retention"
@ -265,9 +265,31 @@ def get_audit(request: Request) -> Response:
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)

View File

@ -181,11 +181,13 @@ def memory_gate(client: HuxClient, conversation_id: str) -> bool:
The service still enforces forget and disable state on the write itself.
"""
try:
client.get("/hux/v1/privacy/policy")
except HuxServiceError:
return False
try:
record = client.get(f"/hux/v1/conversations/{conversation_id}").body
state = client.get(f"/hux/v1/conversations/{conversation_id}/privacy").body
except HuxServiceError as error:
return error.status == 404
return not (isinstance(record, dict) and record.get("mode") == "private")
if error.status != 404:
return False
try:
client.get("/hux/v1/privacy/policy")
except HuxServiceError:
return False
return True
return bool(isinstance(state, dict) and state.get("memory_writes_allowed"))

View File

@ -63,7 +63,7 @@ tenant. Responses are the records below, wrapped as `{"items": [...],
| Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` |
| Research (HUX-08) | `POST /hux/v1/sources`, `GET /hux/v1/sources/{id}`, `POST /hux/v1/passages`, `GET/POST /hux/v1/messages/{id}/citations`, `POST /hux/v1/notebooks`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.v1` |
| Onboarding (HUX-09) | `GET /hux/v1/suggestions?context=`, `POST /hux/v1/suggestions/{id}/{dismiss,never,acted}` | `hux.suggestion.v1`, `hux.suggestion_state.v1` |
| Privacy (HUX-10) | `GET /hux/v1/privacy/policy` (+ `HUX-Audit-Stale` header), `POST /hux/v1/privacy/notices` (optional `chosen` control), `POST /hux/v1/conversations/{id}/forget`, `GET /hux/v1/privacy/audit` | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` |
| Privacy (HUX-10) | `GET /hux/v1/privacy/policy` (+ `HUX-Audit-Stale` header), `POST /hux/v1/privacy/notices` (optional `chosen` control), `POST /hux/v1/conversations/{id}/forget`, `GET /hux/v1/privacy/audit`, `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled / topics / mode / memory_writes_allowed — the hook reads this before proposing memory) | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` |
| Release (HUX-12) | `GET /hux/v1/releases` (operator surface, worker only) | `hux.release.v1` |
Multimodal (HUX-07) reuses artifacts for images/audio (`type` image/audio,

View File

@ -94,10 +94,13 @@ Suite after repairs: 377 tests, 99% line / 99% branch over `hermes-hux-foundatio
| Tests | `test_hermes_hux_policy_hook.py` (11), `test_hermes_hux_contract_hook.py` (10) — real service in-process, end-to-end approval → human decision → gate released once, canary never persisted, unreachable service fails closed for side effects |
| Codex needs | Agent container env `HUX_BASE_URL`, `HUX_TENANT_SLOT`, `HUX_SUBJECT`, `HUX_WORKER_KEY`; call order per `NOTES.md`: `before_tool` → execute only on `proceed``after_tool`; `record_spend` per turn; `on_stop` returning `None` means the stop is not done |
## Conversation privacy state — DONE
`GET /hux/v1/conversations/{id}/privacy` (HUX-10, worker-callable) returns forgotten / memory_disabled / topics / mode / `memory_writes_allowed`; `hux_hook.memory_gate` now reads it and fails closed. Tests: `test_hermes_hux_privacy_topics.py::test_conversation_privacy_state_route`, hook memory-gate test extended.
## Open items (not blockers for Codex integration)
- SO-46 hash-chained audit, SO-48 single-writer lock, SO-53 rate limits.
- Retention scheduler: `privacy.run_retention` is on-demand; Codex decides cron vs sidecar.
- `GET /hux/v1/conversations/{id}/privacy` (forgotten / memory_disabled state) would let the hook stop proposing memory earlier.
- Search over `message_text`; artifact sharing (`shared_readonly`).
- `testing/quality_contract.json` line-limit globs / managed modules for the two new package paths (Codex's file).

View File

@ -206,6 +206,8 @@ def test_memory_gate(live, tmp_path):
assert memory_gate(worker, private) is False
assert memory_gate(worker, "conv_notknown01") is True
assert memory_gate(worker, "bad id") is False
human.post(f"/hux/v1/conversations/{normal}/forget", {})
assert memory_gate(worker, normal) is False
assert memory_gate(HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1), normal) is False
off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects")
try:

View File

@ -206,3 +206,29 @@ def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch):
monkeypatch.setitem(sys.modules, "hux.organization", None)
monkeypatch.delattr(hux, "organization", raising=False)
assert privacy._blank_document(tenant(tmp_path), CONV) is False
def test_conversation_privacy_state_route(tmp_path):
router = router_for(tmp_path)
"""Hook request: forget/disable state is readable so the agent stops proposing memory early (SO-22, SO-28)."""
status, conv, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "state", "mode": "thoughtful"})
assert status == 201
status, state, _ = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")
assert status == 200 and state["memory_writes_allowed"] is True and state["mode"] == "thoughtful"
assert state["forgotten"] is False and state["memory_disabled"] is False and state["topics"] == []
from hux import privacy, store, identity as ident_mod
tenant = store.TenantStore(router.data_root, ident_mod.resolve(HEADERS, {}))
privacy.mark_topic(tenant, conv["id"], "health")
privacy.set_flag(tenant, conv["id"], "memory_disabled", True)
state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1]
assert state["topics"] == ["health"] and state["memory_writes_allowed"] is False
status, _, _ = call(router, "POST", f"/hux/v1/conversations/{conv['id']}/forget", body={})
assert status == 200
state = call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy")[1]
assert state["forgotten"] is True
status, private, _ = call(router, "POST", "/hux/v1/conversations", body={"title": "p", "mode": "private"})
assert call(router, "GET", f"/hux/v1/conversations/{private['id']}/privacy")[1]["memory_writes_allowed"] is False
assert call(router, "GET", "/hux/v1/conversations/conv_unknown00/privacy")[1]["mode"] is None
assert call(router, "GET", "/hux/v1/conversations/bad%20id/privacy")[0] in (400, 404)
other = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
assert call(router, "GET", f"/hux/v1/conversations/{conv['id']}/privacy", other)[1]["forgotten"] is False