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-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-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-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": [], "HUX-12": [],
} }
# Cards whose routes are not shipped yet declare [] above until they land (F12). # 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"), ("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
("GET", "/hux/v1/runs/{id}/budget"), ("GET", "/hux/v1/runs/{id}/budget"),
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"), ("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"), ("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/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"), ("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.errors import Invalid, NotFound
from hux.http import Request, Response, Router, page from hux.http import Request, Response, Router, page
from hux.identity import Identity 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" FAMILY = "privacy"
RETENTION_FAMILY = "retention" RETENTION_FAMILY = "retention"
@ -265,9 +265,31 @@ def get_audit(request: Request) -> Response:
PAGE_LIMIT = 200 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: def register(router: Router) -> None:
"""Attach HUX-10 routes.""" """Attach HUX-10 routes."""
router.add("GET", "/hux/v1/privacy/policy", "HUX-10", "privacy.policy", get_policy) 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/privacy/notices", "HUX-10", "privacy.notice", post_notice)
router.add("POST", "/hux/v1/conversations/{id}/forget", "HUX-10", "privacy.forget", post_forget) 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/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. The service still enforces forget and disable state on the write itself.
""" """
try: try:
client.get("/hux/v1/privacy/policy") state = client.get(f"/hux/v1/conversations/{conversation_id}/privacy").body
except HuxServiceError:
return False
try:
record = client.get(f"/hux/v1/conversations/{conversation_id}").body
except HuxServiceError as error: except HuxServiceError as error:
return error.status == 404 if error.status != 404:
return not (isinstance(record, dict) and record.get("mode") == "private") 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` | | 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` | | 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` | | 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` | | 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, 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 | | 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 | | 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) ## Open items (not blockers for Codex integration)
- SO-46 hash-chained audit, SO-48 single-writer lock, SO-53 rate limits. - 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. - 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`). - 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). - `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, private) is False
assert memory_gate(worker, "conv_notknown01") is True assert memory_gate(worker, "conv_notknown01") is True
assert memory_gate(worker, "bad id") is False 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 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") off_base, off_server = start(tmp_path / "off", "hux.foundation,hux.projects")
try: 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.setitem(sys.modules, "hux.organization", None)
monkeypatch.delattr(hux, "organization", raising=False) monkeypatch.delattr(hux, "organization", raising=False)
assert privacy._blank_document(tenant(tmp_path), CONV) is 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