Compare commits

...

18 Commits

Author SHA1 Message Date
jenkins
e1ef110c8a docs(hux): close the Wave A review in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
a75ca29934 hermes(hux): force restricted sensitivity on secret-bearing artifact content
User artifacts are never rewritten, but a secret pattern in a version marks
the artifact restricted and audits the reason (review a2-C, SO-12).

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
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
jenkins
6964a9d8c8 hermes(hux): close Wave A review findings in autonomy and the HTTP pipeline
F1 policy writes and allow grants are human-surface only; F2 worker trust is
confined to the hook allowlist and unexpected exceptions become audited 500
error records; F4 external side effects release only for the same run and
argument hash; F6 the gate honours budget exhaustion; F8 only the gateway
can vouch for an empty process registry and failed receipts can be
superseded; F11/F12 receipt revision and unshipped card routes.

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
jenkins
74c6549965 docs(hux): record the Wave A review outcome in the threat model 2026-08-24 00:48:20 -03:00
jenkins
aeef0f8484 hermes(hux): worker-side hook library for approvals, gates, budgets, stop receipts and events
Stdlib client the agent runtime calls around its tool loop; fails closed for
side effects, fails open for telemetry, never carries raw arguments or outputs.

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
jenkins
1b1a14e972 hermes(hux): contract 1.1.0 additive revision and Wave A consolidation
Adds receipt evidence kind, 422 unprocessable, optional revision on research
records, audit_stale on the privacy policy, per-route body caps (25 MiB
artifact uploads), promotion checks the project exists, memory rules skip
content-free statuses. Handoff ledger covers every Wave A card.

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
jenkins
b8fb72fbcc hermes(hux): HUX-01 redacted activity events, HUX-02 memory ledger, HUX-10 privacy behaviour
Per-conversation monotonic event ledger with idempotent emit, SSE replay from
Last-Event-ID, per-kind detail allowlists and secret scrubbing, surface-aware
serve-time redaction; memory as an append-only ledger with no-store,
supersede, forget and retrieval tombstones so 'do not remember' blocks both
persistence and retrieval; sensitive-topic scoping, notices, conversation
forget and a retention job that never touches audit.

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
jenkins
3ebee2cc15 hermes(hux): HUX-05 autonomy engine and minimal HUX-03 organization API
Scoped policies resolved through the single capability matrix, grants with
server-set expiry, approval queue with once/session/always/deny, pre-side-effect
gate that releases a once approval exactly once against the argument hash,
run budgets with exhaustion events, honest cancellation receipts; projects,
conversations, branch lineage and search over the indexed fields.

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
jenkins
9f0ffe031c hermes(hux): HUX-04 artifact workspace and HUX-08 research backends
Immutable content-addressed artifact versions with If-Match, lineage that must
resolve under the caller, unified diffs, promotion; sources/passages/citations
with server-side hashing and dedupe, citation integrity checks and revisioned
research notebooks. Cross-tenant access is 404 and audited.

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
jenkins
01abf89270 docs(hux): record draft PR #55 in the handoff ledger 2026-08-24 00:48:20 -03:00
jenkins
b9b7126ee1 docs(hux): start the hermes-next handoff ledger with HUX-11 2026-08-24 00:48:20 -03:00
jenkins
6a5e0d872b hermes(hux): HUX-11 foundation service core with threat and data models
Stdlib per-tenant service: trusted-header identity (router/relay/worker,
constant-time keys, slot pinned to the pod), fail-closed card flags with
capability negotiation, tenant-scoped store (atomic writes, revisions,
append-only ledgers, content-addressed blobs, manifest), audit outcome for
every request, and the /hux/v1 pipeline that maps errors to hux.error.v1.

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
jenkins
b4145861d2 hermes(hux): freeze hux.v1 contract 1.0.0 and move reference modules into the foundation package
HUX-11 contract freeze: identity/capabilities/manifest/error records, event turn
and idempotency and delegation/side-effect kinds, memory no-store/supersedes/
retrieval removal, revisions for optimistic concurrency, artifact access,
budget scope/spend/subagents and external side-effect gating, notebook notes and
dedupe keys. ADR-0001 records the wire and compatibility rules.

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
flux-bot
1a586d9523 chore(hermes): promote validated image release 2026-08-24 03:43:01 +00:00
jenkins
91eb4f92b7 perf(hermes): isolate rolling speech inference 2026-08-24 00:36:28 -03:00
flux-bot
c7aa7f462b chore(hermes): promote validated image release 2026-08-24 03:11:53 +00:00
jenkins
e9a6eba30b fix(hermes): stabilize chat canvas gradients 2026-08-24 00:01:52 -03:00
87 changed files with 12365 additions and 433 deletions

View File

@ -156,6 +156,7 @@ spec:
dockerfiles/hermes_jetson_tts_cues.py
PYTHONPATH=/tmp/hermes-voice-test-deps python3 -m pytest -q \
testing/tests/test_hermes_stt_streaming.py \
testing/tests/test_hermes_stt_rolling_model.py \
testing/tests/test_hermes_tts_language_routing.py \
testing/tests/test_hermes_voice_language_routing.py \
testing/tests/test_hermes_oci_promote.py \

View File

@ -17,6 +17,9 @@ ADD --checksum=sha256:aff26ae408abcba5fbf8813c21e62b0941638c5f6eebfb145be0c98392
ADD --checksum=sha256:9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794 --chmod=0444 \
https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt \
/opt/models/whisper/small.pt
ADD --checksum=sha256:65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9 --chmod=0444 \
https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt \
/opt/models/whisper/tiny.pt
RUN chmod 0555 /opt/models /opt/models/whisper
COPY dockerfiles/hermes-jetson-stt-server.py /opt/atlas/hermes-jetson-stt-server.py
@ -28,11 +31,12 @@ WORKDIR /opt/atlas
# Import the Xavier CUDA stack and confirm Whisper resolves the baked artifact.
# Full GPU warm-up is covered by the Kubernetes startup probe on titan-21.
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt')); print(torch.__version__)"
RUN python3 -c "import stat; from pathlib import Path; import torch, whisper; p=Path('/opt/models/whisper'); print(whisper.__file__, whisper.__version__, whisper.available_models()); assert {'large-v3-turbo','small','tiny'} <= set(whisper.available_models()); assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE((p/name).stat().st_mode)==0o444 for name in ('large-v3-turbo.pt','small.pt','tiny.pt')); print(torch.__version__)"
ENV HERMES_STT_HOST=0.0.0.0 \
HERMES_STT_PORT=9000 \
HERMES_STT_MODEL=small \
HERMES_STT_ROLLING_MODEL=tiny \
HERMES_STT_CACHE=/opt/models/whisper \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

View File

@ -0,0 +1,424 @@
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
subtree. Content lives in the content-addressed blob store; the document only
carries ``content_ref`` hashes, so a version can never be rewritten once it
has been appended (SO-30). Blobs are served inside a JSON envelope with
``nosniff`` and never as an executable response type (SO-32). Every
referenced id is resolved under the caller's own subtree (SO-33); a foreign
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
this increment: ``access.mode`` is always ``owner`` (SO-34).
"""
from __future__ import annotations
import base64
import hashlib
from typing import Any
from hux import diffs
from hux.contracts import load_all, validate_record
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54)
def _project_exists(store, project_id: str) -> bool:
"""Ask the organization family when it is present; otherwise only the id shape is known."""
try:
from hux.organization import project_exists
except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts
return True
return project_exists(store, project_id)
FAMILY = "artifacts"
MAX_VERSION_BYTES = 25 * 1024 * 1024
MAX_VERSIONS = 200
MAX_ARTIFACTS = 2000
PAGE_SIZE = 50
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
_SCHEMAS: dict[str, dict[str, Any]] = {}
def _schemas() -> dict[str, dict[str, Any]]:
if not _SCHEMAS:
_SCHEMAS.update(load_all())
return _SCHEMAS
# -- helpers shared with other lanes -------------------------------------------
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
try:
return store.exists(FAMILY, artifact_id)
except Invalid:
return False
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
titles: dict[str, str] = {}
for artifact_id in ids:
if artifact_exists(store, artifact_id):
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
return titles
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
"""Record an activity event through the events lane when it is present.
Imported lazily so module import order never matters; a missing events
module (earlier build) is not an error because the artifact write itself
is the source of truth.
"""
conversation_id = artifact.get("conversation_id")
if conversation_id is None:
return
try:
from hux.events import emit
except ModuleNotFoundError:
return
version = artifact["current_version"]
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
def replay(store: TenantStore, family: str, key: str) -> str | None:
"""Record id previously created under an Idempotency-Key, if any."""
if not key:
return None
for row in store.read(family, "idempotency"):
if row.get("key") == key:
return row["id"]
return None
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
"""Persist an Idempotency-Key to record id mapping."""
if key:
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
# -- record shaping ------------------------------------------------------------
def _actor(identity: Identity) -> dict[str, str]:
if identity.trust == "worker":
return {"type": "system", "id": "worker"}
return {"type": "user", "id": identity.subject}
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
value = body.get(key)
if value is None:
if required:
raise Invalid(f"{key} is required")
return None
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
return value
def secret_hits(data: bytes) -> list[str]:
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
from hux.redaction import scrub_text
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return []
return scrub_text(text)[1]
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
"""Decode the submitted content and return ``(bytes, mime)``."""
text, encoded = body.get("content"), body.get("content_base64")
if isinstance(text, str) and encoded is None:
data = text.encode("utf-8")
elif isinstance(encoded, str) and text is None:
try:
data = base64.b64decode(encoded, validate=True)
except (ValueError, TypeError) as error:
raise Invalid("content_base64 is not valid base64") from error
else:
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
if len(data) > MAX_VERSION_BYTES:
raise TooLarge("version exceeds 25 MiB")
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
return data, mime
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
data, mime = _content(body)
digest = hashlib.sha256(data).hexdigest()
claimed = body.get("hash")
if claimed is not None and claimed != f"sha256:{digest}":
raise Invalid("hash does not match the submitted content")
store.put_blob(digest, data)
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
def _optional_id(body: dict[str, Any], key: str) -> str | None:
value = body.get(key)
return None if value is None else check_id(value)
def _load_owned(request: Request) -> dict[str, Any]:
"""The artifact named in the path; unknown, malformed or foreign is 404."""
try:
artifact = request.store.get(FAMILY, request.params["id"])
except Invalid as error:
raise NotFound("artifact not found") from error
if artifact.get("owner") != request.identity.subject:
raise NotFound("artifact not found")
return artifact
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
if not isinstance(number, int) or isinstance(number, bool):
raise Invalid("version must be an integer")
for entry in artifact["versions"]:
if entry["version"] == number:
return entry
raise NotFound(f"version {number} not found")
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
lineage = body.get("lineage")
if lineage is None:
return None
if not isinstance(lineage, dict):
raise Invalid("lineage must be an object")
try:
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
except (Invalid, NotFound) as error:
raise NotFound("lineage artifact not found") from error
if parent.get("owner") != request.identity.subject:
raise NotFound("lineage artifact not found")
version = _version(parent, lineage.get("version"))
return {"artifact_id": parent["id"], "version": version["version"]}
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
"""Append an immutable version entry and persist the document.
The version number must be exactly ``current_version + 1``; any attempt to
write an existing number is a Conflict so history is never rewritten.
"""
if any(v["version"] == entry["version"] for v in artifact["versions"]):
raise Conflict(f"version {entry['version']} already exists")
if entry["version"] != artifact["current_version"] + 1:
raise Conflict("versions are appended in order")
if len(artifact["versions"]) >= MAX_VERSIONS:
raise TooLarge("artifact has reached 200 versions")
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
_check(updated)
return store.put(FAMILY, updated, expected_revision=expected_revision)
def _check(artifact: dict[str, Any]) -> None:
problems = validate_record(artifact, _schemas())
if problems:
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
# -- handlers ------------------------------------------------------------------
def create(request: Request) -> Response:
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
body = _body(request)
key = request.idempotency_key()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key)
if existing is not None:
request.audit("artifacts.create", existing, reason="idempotent_replay")
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
raise TooLarge("tenant has reached 2000 artifacts")
artifact_type = _string(body, "type", 40)
if artifact_type not in ARTIFACT_TYPES:
raise Invalid("unknown artifact type")
sensitivity = body.get("sensitivity", "personal")
if sensitivity not in SENSITIVITIES:
raise Invalid("unknown sensitivity")
hits = secret_hits(_content(body)[0])
if hits:
sensitivity = "restricted"
stamp = now_iso()
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
for field, limit in (("message_id", 120), ("note", 200)):
if _string(body, field, limit, required=False) is not None:
version[field] = body[field]
lineage = _lineage(request, body)
if lineage is not None:
version["lineage"] = lineage
artifact: dict[str, Any] = {
"schema": "hux.artifact.v1",
"id": new_id("art"),
"owner": request.identity.subject,
"type": artifact_type,
"title": _string(body, "title", 200),
"current_version": 1,
"versions": [version],
"sensitivity": sensitivity,
"created_at": stamp,
"updated_at": stamp,
"revision": 1,
"access": {"mode": "owner"},
}
for field in ("conversation_id", "project_id"):
if _optional_id(body, field) is not None:
artifact[field] = body[field]
if _string(body, "language", 40, required=False) is not None:
artifact["language"] = body["language"]
_check(artifact)
stored = request.store.put(FAMILY, artifact)
remember(request.store, FAMILY, key, stored["id"])
request.audit("artifacts.create", stored["id"], reason="secret_pattern_restricted" if hits else "")
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
return Response(201, stored, {"ETag": str(stored["revision"])})
def list_artifacts(request: Request) -> Response:
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
cursor = request.query.get("cursor", "0")
if not cursor.isdigit():
raise Invalid("cursor must be a non-negative integer")
items = [
artifact
for artifact in request.store.scan(FAMILY)
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
]
start = int(cursor)
window = items[start : start + PAGE_SIZE]
request.audit("artifacts.list", "artifacts")
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
def get(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
artifact = _load_owned(request)
request.audit("artifacts.get", artifact["id"])
return Response(200, artifact, {"ETag": str(artifact["revision"])})
def add_version(request: Request) -> Response:
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
body = _body(request)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required to append a version")
key = request.idempotency_key()
with request.store.lock(FAMILY):
artifact = _load_owned(request)
existing = replay(request.store, FAMILY, key)
if existing is not None and existing.split("@")[0] == artifact["id"]:
request.audit("artifacts.version", existing, reason="idempotent_replay")
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
if expected != artifact["revision"]:
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
number = artifact["current_version"] + 1
hits = secret_hits(_content(body)[0])
if hits:
artifact = {**artifact, "sensitivity": "restricted"}
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
if body.get("diff_from") is not None:
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
for field, limit in (("message_id", 120), ("note", 200)):
if _string(body, field, limit, required=False) is not None:
entry[field] = body[field]
lineage = _lineage(request, body)
if lineage is not None:
entry["lineage"] = lineage
stored = append_version(request.store, artifact, entry, expected)
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
request.audit("artifacts.version", f"{stored['id']}@{number}", reason="secret_pattern_restricted" if hits else "")
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
return Response(201, stored, {"ETag": str(stored["revision"])})
def _version_number(request: Request) -> int:
raw = request.params["n"]
if not raw.isdigit() or int(raw) < 1:
raise Invalid("version must be a positive integer")
return int(raw)
def get_version(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
artifact = _load_owned(request)
entry = _version(artifact, _version_number(request))
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
if text is None:
body["content_base64"] = base64.b64encode(data).decode("ascii")
else:
body["content"] = text
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
return Response(200, body, headers)
def diff(request: Request) -> Response:
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
artifact = _load_owned(request)
to_entry = _version(artifact, _version_number(request))
raw_from = request.query.get("from", "")
if raw_from and not raw_from.isdigit():
raise Invalid("from must be a version number")
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
from_entry = _version(artifact, from_number)
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
def promote(request: Request) -> Response:
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
body = _body(request)
project_id = check_id(body.get("project_id"))
if not _project_exists(request.store, project_id):
raise NotFound("project not found")
expected = request.if_match()
with request.store.lock(FAMILY):
artifact = _load_owned(request)
if expected is not None and expected != artifact["revision"]:
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
version = artifact["current_version"]
if body.get("version") is not None:
version = _version(artifact, body["version"])["version"]
stamp = now_iso()
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
_check(updated)
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
return Response(200, stored, {"ETag": str(stored["revision"])})
def register(router: Router) -> None:
"""Attach HUX-04 routes."""
card = "HUX-04"
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY)
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY)
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)

View File

@ -0,0 +1,40 @@
"""Auditable outcome for every read and mutation.
One JSONL ledger per tenant per day. The record shape is
``common.schema.json#/$defs/audit_outcome``; it never carries request bodies,
only the action name, the resource id and the decision.
"""
from __future__ import annotations
from hux.identity import Identity
from hux.store import TenantStore, now_iso
OUTCOMES = ("allow", "deny", "not_found", "conflict", "flag_off")
def record(store: TenantStore, identity: Identity, action: str, resource: str, outcome: str, reason: str = "") -> dict:
"""Append an audit outcome and return it."""
if outcome not in OUTCOMES:
raise ValueError(f"unknown outcome {outcome!r}")
entry = {
"at": now_iso(),
"identity": identity.record(),
"action": action,
"resource": resource[:200],
"outcome": outcome,
}
if reason:
entry["reason"] = reason[:200]
store.append("audit", now_iso()[:10], entry)
return entry
def recent(store: TenantStore, limit: int = 200) -> list[dict]:
"""Newest audit rows across day ledgers, newest last."""
rows: list[dict] = []
for name in reversed(store.ledgers("audit")):
rows = store.read("audit", name) + rows
if len(rows) >= limit:
break
return rows[-limit:]

View File

@ -0,0 +1,236 @@
"""HUX-05 run controls: budgets, the pre-side-effect gate and cancellation receipts.
The agent hook reports spend per run and asks the gate before every side
effect. The gate releases only against an approved, unexpired approval for the
same run, capability and argument hash; a ``once`` approval is consumed by its
first release (SO-36, SO-37). A stop is not done until a receipt says what
actually happened to the run and its side effects (SO-41).
"""
from __future__ import annotations
from typing import Any
from hux import policy, rules
from hux.errors import Invalid
from hux.http import Request, Response, Router
from hux.identity import Identity
from hux.store import TenantStore, check_id
BUDGETS = "budgets"
RECEIPTS = "receipts"
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
LIMIT_OF = {
"tokens": "tokens_per_run", "tool_calls": "tool_calls_per_run", "wall_clock_seconds": "wall_clock_seconds",
"delegations": "delegations_per_run", "spend_units": "spend_units", "subagents": "subagents_per_run",
}
def run_id_from(request: Request) -> str:
"""The run id in the path; the router already bounded its alphabet."""
run_id = request.params["id"]
if len(run_id) > 120:
raise Invalid("run id too long")
return run_id
# -- budgets -------------------------------------------------------------------
def exhausted(spent: dict[str, int], limits: dict[str, Any]) -> list[str]:
"""Limit names whose spend has reached them; a zero limit is exhausted immediately."""
return [LIMIT_OF[k] for k in SPEND_KEYS if LIMIT_OF[k] in limits and spent.get(k, 0) >= limits[LIMIT_OF[k]]]
def budget_state(store: TenantStore, identity: Identity, run_id: str, conversation_id: str | None = None) -> dict[str, Any]:
"""Current ``hux.budget_state.v1`` for a run measured against the effective policy."""
doc_id = f"bud_{policy.run_key(run_id)}"
stored = store.get(BUDGETS, doc_id) if store.exists(BUDGETS, doc_id) else {"spent": {}, "_conversation_id": None}
conversation_id = conversation_id or stored.get("_conversation_id")
level, scope_id = ("conversation", conversation_id) if conversation_id else ("global", None)
limits = {k: v for k, v in policy.effective_policy(store, identity, level, scope_id)["budgets"].items() if k != "scope"}
spent = {k: int(stored["spent"].get(k, 0)) for k in SPEND_KEYS}
return policy.checked({
"schema": "hux.budget_state.v1", "run_id": run_id[:120], "spent": spent, "limits": limits,
"exhausted": exhausted(spent, limits), "_conversation_id": conversation_id, "_id": doc_id,
})
def get_budget(request: Request) -> Response:
"""``GET /hux/v1/runs/{id}/budget``: spend so far and what is exhausted."""
state = budget_state(request.store, request.identity, run_id_from(request))
request.audit("budgets.read", state["_id"])
return Response(200, policy.public(state))
def post_budget(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/budget``: add spend increments; emits budget.exhausted on the crossing."""
body = policy.body_dict(request)
run_id = run_id_from(request)
conversation_id = body.get("conversation_id")
if conversation_id is not None:
conversation_id = check_id(conversation_id)
increments = {}
for key in SPEND_KEYS:
value = body.get(key, 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise Invalid(f"{key} must be a non-negative integer")
increments[key] = value
with request.store.lock(BUDGETS):
before = budget_state(request.store, request.identity, run_id, conversation_id)
known = before["exhausted"] if request.store.exists(BUDGETS, before["_id"]) else []
spent = {k: before["spent"][k] + increments[k] for k in SPEND_KEYS}
doc = {"id": before["_id"], "run_id": run_id, "spent": spent, "_conversation_id": before["_conversation_id"]}
request.store.put(BUDGETS, doc)
after = budget_state(request.store, request.identity, run_id, conversation_id)
request.audit("budgets.write", after["_id"])
newly = [k for k in after["exhausted"] if k not in known]
if newly:
policy.emit(request.store, request.identity, after["_conversation_id"], "budget.exhausted",
f"Budget exhausted: {', '.join(newly)}", run_id=run_id)
return Response(200, policy.public(after))
# -- gate ----------------------------------------------------------------------
def hashes_of(approval: dict[str, Any]) -> set[str]:
"""Argument hashes the approval was requested for (tool_call evidence with a hash)."""
return {e["hash"] for e in approval["request"].get("evidence", []) if e.get("kind") == "tool_call" and e.get("hash")}
def run_conversation(store: TenantStore, run_id: str) -> str | None:
"""The conversation a run belongs to, from its budget document or an approval it raised; never the body (F4)."""
doc_id = f"bud_{policy.run_key(run_id)}"
if store.exists(BUDGETS, doc_id) and store.get(BUDGETS, doc_id).get("_conversation_id"):
return store.get(BUDGETS, doc_id)["_conversation_id"]
for record in store.scan(policy.APPROVALS):
if record["run_id"] == run_id:
return record["conversation_id"]
return None
def matching_approval(store: TenantStore, run_id: str, capability: str, argument_hash: str, external: bool, conversation_id: str | None) -> tuple[dict[str, Any] | None, str, str | None]:
"""The approval that releases this side effect, or why none does, plus the conversation the gate settled on.
External effects release only against an approval for the same run and
the same argument hash, whatever the choice (F4, SO-39). Non-external
session/always approvals stay reusable inside their conversation, which
is the run's own conversation when the run is known and otherwise the
approval's. A ``once`` approval names exactly one hash (SO-36, SO-37).
"""
reason = "no approval for this run and capability"
for record in store.scan(policy.APPROVALS):
record = policy.refresh(store, record)
if record["capability"] != capability:
continue
same_run = record["run_id"] == run_id
choice = record.get("decision", {}).get("choice")
reusable = choice in ("session", "always") and not external and not record["request"]["external"]
same_conv = record["conversation_id"] == (conversation_id or record["conversation_id"])
if not (same_run or (reusable and same_conv)):
continue
if record["status"] != "approved":
reason = f"approval {record['id']} is {record['status']}"
continue
if policy.parse(record["expires_at"]) <= policy.now():
reason = f"approval {record['id']} has expired"
continue
if external and not record["request"]["external"]:
reason = f"approval {record['id']} was not requested as external"
continue
if (external or choice == "once") and argument_hash not in hashes_of(record):
reason = f"approval {record['id']} was for different arguments"
continue
if choice == "once" and record.get("_consumed_at"):
reason = f"approval {record['id']} was already consumed"
continue
return record, "released", conversation_id or record["conversation_id"]
return None, reason, conversation_id
def gate(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/gate``: may this side effect proceed right now?"""
body = policy.body_dict(request)
run_id = run_id_from(request)
capability = body.get("capability")
argument_hash = body.get("argument_hash")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
if not isinstance(argument_hash, str) or not argument_hash.startswith("sha256:"):
raise Invalid("argument_hash must be sha256:<hex>")
external = bool(body.get("external", False))
conversation_id = run_conversation(request.store, run_id)
state = budget_state(request.store, request.identity, run_id, conversation_id)
if state["exhausted"]: # F6: an exhausted run releases nothing, whatever was approved
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="budget_exhausted")
policy.emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=run_id)
return Response(200, {"proceed": False, "reason": "budget_exhausted", "exhausted": state["exhausted"]})
with request.store.lock(policy.APPROVALS):
record, reason, conversation_id = matching_approval(request.store, run_id, capability, argument_hash, external, conversation_id)
if record is not None and record["decision"]["choice"] == "once":
request.store.put(policy.APPROVALS, {**record, "_consumed_at": policy.iso(policy.now())})
if record is None:
request.audit("gate.check", f"{run_id}:{capability}", outcome="deny", reason="policy_violation")
policy.emit(request.store, request.identity, conversation_id, "side_effect.blocked", f"{capability} blocked: {reason}"[:280], run_id=run_id)
return Response(200, {"proceed": False, "reason": reason})
request.audit("gate.check", record["id"])
policy.emit(request.store, request.identity, conversation_id, "side_effect.released", f"{capability} released by approval {record['id']}",
run_id=run_id, evidence=[{"kind": "approval", "id": record["id"]}])
return Response(200, {"proceed": True, "approval_id": record["id"], "reason": reason})
# -- stop ----------------------------------------------------------------------
def stop_outcome(request: Request, body: dict[str, Any]) -> tuple[str, str]:
"""(outcome, reason) for a stop; only the gateway (worker trust) may vouch for an empty process registry (F8, SO-41)."""
if body.get("already_complete"):
return "already_complete", "already_complete"
if body.get("process_registry_empty") is not True:
return "failed_to_cancel", "process_registry_not_empty"
if request.identity.trust != "worker":
return "failed_to_cancel", "registry_state_not_from_gateway"
return "cancelled", "cancelled"
def stop(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it.
A ``failed_to_cancel`` receipt is the one non-terminal outcome: a later
stop that really cancels or finds the run complete supersedes it with a
revision bump; an identical repeat still replays (F8).
"""
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
outcome, reason = stop_outcome(request, body)
with request.store.lock(RECEIPTS):
existing = request.store.get(RECEIPTS, receipt_id) if request.store.exists(RECEIPTS, receipt_id) else None
if existing is not None and (existing["outcome"] != "failed_to_cancel" or outcome == "failed_to_cancel"):
request.audit("runs.stop", receipt_id, reason="replayed")
return Response(200, policy.public(existing))
stamp = policy.iso(policy.now())
record: dict[str, Any] = {
"schema": "hux.cancel_receipt.v1", "id": receipt_id, "run_id": run_id, "requested_by": policy.actor_for(request.identity),
"requested_at": existing["requested_at"] if existing else stamp, "acknowledged_at": stamp, "outcome": outcome, "side_effects": side_effects,
}
if outcome != "failed_to_cancel":
record["completed_at"] = stamp
conversation_id = body.get("conversation_id", existing.get("conversation_id") if existing else None)
if conversation_id is not None:
record["conversation_id"] = check_id(conversation_id)
stored = request.store.put(RECEIPTS, policy.checked(record), expected_revision=existing["revision"] if existing else None)
request.audit("runs.stop", receipt_id, reason=reason if existing is None else f"superseded:{reason}")
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome} ({reason})",
run_id=run_id, evidence=[{"kind": "run", "id": receipt_id}])
return Response(201, policy.public(stored))
def register_routes(router: Router) -> None:
"""Attach the run-scoped HUX-05 routes; called by ``hux.policy.register``."""
router.add("GET", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.read", get_budget)
router.add("POST", "/hux/v1/runs/{id}/budget", policy.CARD, "budgets.write", post_budget)
router.add("POST", "/hux/v1/runs/{id}/gate", policy.CARD, "gate.check", gate)
router.add("POST", "/hux/v1/runs/{id}/stop", policy.CARD, "runs.stop", stop)

View File

@ -10,13 +10,18 @@ loudly rather than silently passing.
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux"
CONTRACT_DIR = Path(
os.environ.get("HUX_CONTRACT_DIR")
or Path(__file__).resolve().parents[3] / "services" / "hermes" / "contracts" / "hux"
)
SCHEMA_FILES = (
"common.schema.json",
"identity.schema.json",
"event.schema.json",
"memory.schema.json",
"project.schema.json",

View File

@ -0,0 +1,50 @@
"""Version-to-version comparison for artifacts (HUX-04).
Text artifacts get a unified diff from ``difflib``; anything that is not
valid UTF-8, or whose artifact type is binary, is described by sizes and
hashes only so the UI never tries to render bytes as text.
"""
from __future__ import annotations
import difflib
import hashlib
from typing import Any
TEXT_TYPES = frozenset({"markdown", "code", "html", "svg", "json", "csv"})
def is_text_type(artifact_type: str) -> bool:
"""True for artifact types whose content is served as UTF-8 text."""
return artifact_type in TEXT_TYPES
def decode_text(data: bytes) -> str | None:
"""UTF-8 decode, or None when the bytes are not text."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return None
def unified(artifact_type: str, older: bytes, newer: bytes, from_n: int, to_n: int) -> dict[str, Any]:
"""Diff record ``{"from", "to", "unified"|"binary"}`` for two versions."""
record: dict[str, Any] = {"from": from_n, "to": to_n}
old_text = decode_text(older) if is_text_type(artifact_type) else None
new_text = decode_text(newer) if is_text_type(artifact_type) else None
if old_text is None or new_text is None:
record["binary"] = {
"from_bytes": len(older),
"to_bytes": len(newer),
"from_hash": "sha256:" + hashlib.sha256(older).hexdigest(),
"to_hash": "sha256:" + hashlib.sha256(newer).hexdigest(),
}
return record
lines = difflib.unified_diff(
old_text.splitlines(keepends=True),
new_text.splitlines(keepends=True),
fromfile=f"v{from_n}",
tofile=f"v{to_n}",
)
record["unified"] = "".join(lines)
return record

View File

@ -0,0 +1,82 @@
"""Error types that map one-to-one onto ``hux.error.v1`` records."""
from __future__ import annotations
class HuxError(Exception):
"""Base class; ``status`` and ``code`` follow identity.schema.json#/$defs/error."""
status = 500
code = "invalid"
def __init__(self, message: str, details: list[str] | None = None) -> None:
super().__init__(message)
self.message = message
self.details = list(details or [])
def record(self) -> dict:
"""Serialise as a ``hux.error.v1`` record."""
body = {"schema": "hux.error.v1", "status": self.status, "code": self.code, "message": self.message[:280]}
if self.details:
body["details"] = [d[:280] for d in self.details[:32]]
return body
class Unauthorized(HuxError):
"""Identity headers missing, malformed or not trusted."""
status, code = 401, "unauthorized"
class Forbidden(HuxError):
"""Identity is valid but does not own the resource."""
status, code = 403, "forbidden"
class NotFound(HuxError):
"""Resource does not exist for this tenant."""
status, code = 404, "not_found"
class FlagOff(HuxError):
"""Card (or one of its dependencies) is disabled; indistinguishable from not found on purpose."""
status, code = 404, "flag_off"
class Conflict(HuxError):
"""If-Match revision mismatch or duplicate create."""
status, code = 409, "conflict"
class Invalid(HuxError):
"""Body failed contract validation."""
status, code = 400, "invalid"
class Unprocessable(HuxError):
"""Well-formed body that violates a rule (hash mismatch, policy violation)."""
status, code = 422, "unprocessable"
class TooLarge(HuxError):
"""Body, record or family exceeds its bound."""
status, code = 413, "too_large"
class ApprovalRequired(HuxError):
"""An action needs an approval record before it may proceed."""
status, code = 403, "approval_required"
class BudgetExhausted(HuxError):
"""A run budget has been spent."""
status, code = 429, "budget_exhausted"

View File

@ -0,0 +1,315 @@
"""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)

View File

@ -0,0 +1,97 @@
"""Per-card feature flags and the capabilities record clients negotiate with.
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
when it and every card it depends on are enabled, so a half-configured
deployment fails closed. Route ownership per card is declared here so the
capabilities record can tell a client exactly what it may call, and the
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
reach at all.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from hux.errors import FlagOff
from hux.identity import Identity
from hux.rules import flag_enabled, flag_registry
CONTRACT_VERSION = "1.1.0"
CARD_ROUTES: dict[str, list[str]] = {
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest"],
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
"HUX-06": [],
"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-12": [],
}
# Cards whose routes are not shipped yet declare [] above until they land (F12).
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
# Everything else is 403 before the flag check. These are the agent-hook
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
# the privacy policy, memory retrieval and proposals, research inputs and
# artifact writes. The conversation read is there so the hook can honour
# private mode (SO-28) before proposing a memory.
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"), ("GET", "/hux/v1/releases"),
("POST", "/hux/v1/approvals"),
("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/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"),
})
def worker_may_call(method: str, template: str) -> bool:
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
return (method, template) in WORKER_ROUTES
class Flags:
"""Snapshot of which cards are on for this process."""
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
self._environ = dict(os.environ if environ is None else environ)
self._registry = flag_registry()
def enabled(self, card: str) -> bool:
"""True when the card and its whole dependency chain are on."""
entry = self._registry.get(card)
return bool(entry) and flag_enabled(entry["flag"], self._environ)
def require(self, card: str) -> None:
"""Raise FlagOff unless the card is enabled."""
if not self.enabled(card):
raise FlagOff(f"{card} is not enabled")
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
"""Serialise ``hux.capabilities.v1`` for one caller."""
cards = [
{"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])}
for card, entry in sorted(self._registry.items())
]
server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v}
return {
"schema": "hux.capabilities.v1",
"contract_version": CONTRACT_VERSION,
"identity": identity.record(),
"cards": cards,
"server": server,
}
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
"""Commit and image digest the pod was started with, when the operator set them."""
environ = os.environ if environ is None else environ
return {"commit": environ.get("HUX_BUILD_COMMIT", ""), "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}

View File

@ -0,0 +1,24 @@
"""HUX-11 routes: capability negotiation and the data manifest."""
from __future__ import annotations
from hux.flags import CONTRACT_VERSION
from hux.http import Request, Response, Router
def capabilities(request: Request) -> Response:
"""``GET /hux/v1/capabilities``: what this tenant may call."""
request.audit("foundation.capabilities", "capabilities")
return Response(200, request.flags.capabilities(request.identity, request.flags_build))
def manifest(request: Request) -> Response:
"""``GET /hux/v1/manifest``: the on-disk layout version for rollback readers."""
request.audit("foundation.manifest", "manifest")
return Response(200, request.store.manifest(CONTRACT_VERSION))
def register(router: Router) -> None:
"""Attach HUX-11 routes."""
router.add("GET", "/hux/v1/capabilities", "HUX-11", "foundation.capabilities", capabilities)
router.add("GET", "/hux/v1/manifest", "HUX-11", "foundation.manifest", manifest)

View File

@ -0,0 +1,222 @@
"""Minimal HTTP layer for the per-tenant HUX service.
Stdlib only. A ``Router`` maps method + path template to a handler; family
modules register their routes with it. Every request resolves identity from
trusted headers, checks the owning card's flag, and writes an audit outcome.
Errors always leave as ``hux.error.v1``.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from collections.abc import Mapping
from urllib.parse import parse_qs, urlsplit
from hux import audit
from hux.errors import Forbidden, HuxError, Invalid, NotFound, TooLarge
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
from hux.identity import Identity, resolve
from hux.store import TenantStore
MAX_BODY_BYTES = 1024 * 1024
Handler = Callable[["Request"], "Response"]
@dataclass
class Request:
"""Everything a handler needs; no raw socket access."""
method: str
path: str
params: dict[str, str]
query: dict[str, str]
headers: Mapping[str, str]
body: Any
identity: Identity
store: TenantStore
flags: Flags
flags_build: dict[str, str] = field(default_factory=dict)
def if_match(self) -> int | None:
"""Parsed If-Match revision, or None when absent."""
raw = self.header("If-Match")
if raw == "":
return None
if not raw.isdigit():
raise Invalid("If-Match must be a revision integer")
return int(raw)
def idempotency_key(self) -> str:
"""Client idempotency key, validated against the contract pattern."""
raw = self.header("Idempotency-Key")
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
raise Invalid("malformed Idempotency-Key")
return raw
def header(self, name: str) -> str:
"""Case-insensitive header lookup."""
for key, value in self.headers.items():
if key.lower() == name.lower():
return value.strip()
return ""
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
"""Write an audit outcome for this request."""
audit.record(self.store, self.identity, action, resource, outcome, reason)
@dataclass
class Response:
"""JSON (or SSE) response."""
status: int = 200
body: Any = None
headers: dict[str, str] = field(default_factory=dict)
stream: Callable[[], Any] | None = None
@dataclass
class Route:
"""One registered handler."""
method: str
template: str
card: str
action: str
handler: Handler
max_body: int = MAX_BODY_BYTES
pattern: re.Pattern = field(init=False)
def __post_init__(self) -> None:
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
self.pattern = re.compile(f"^{regex}$")
class Router:
"""Route table plus the request pipeline."""
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
self.data_root = Path(data_root)
self.environ = environ
self.flags = Flags(environ)
self.build = build_from_environ(environ)
self.routes: list[Route] = []
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
self.routes.append(Route(method, template, card, action, handler, max_body))
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
"""Return (route, params, path_known)."""
known = False
for route in self.routes:
found = route.pattern.match(path)
if found:
known = True
if route.method == method:
return route, found.groupdict(), True
return None, {}, known
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
"""Run the full pipeline and never raise."""
parts = urlsplit(raw_path)
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
try:
identity = resolve(headers, self.environ)
store = TenantStore(self.data_root, identity)
except HuxError as error:
return Response(error.status, error.record())
route, params, known = self.match(method, parts.path)
if route is None:
error = Invalid("method not allowed") if known else NotFound("no such route")
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
return Response(405 if known else 404, error.record())
try:
# SO-08: the worker allowlist is checked before the flag so a
# worker cannot even learn which cards are on.
if identity.trust == "worker" and not worker_may_call(method, route.template):
raise Forbidden("route is not available to worker trust")
self.flags.require(route.card)
payload = self._decode(body, route.max_body)
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
response = route.handler(request)
except HuxError as error:
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
audit.record(store, identity, route.action, parts.path, outcome, error.message)
return Response(error.status, error.record())
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
error = HuxError("internal error")
audit.record(store, identity, route.action, parts.path, "deny", error.message)
return Response(error.status, error.record())
return response
@staticmethod
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
if not body:
return None
if len(body) > max_body:
raise TooLarge(f"body exceeds {max_body} bytes")
try:
return json.loads(body)
except json.JSONDecodeError as error:
raise Invalid(f"body is not JSON: {error.msg}") from error
def page(items: list[Any], next_cursor: Any = None) -> Response:
"""Standard list envelope."""
return Response(200, {"items": items, "next": next_cursor})
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
class HuxHandler(BaseHTTPRequestHandler):
server_version = "hux-foundation/1.0"
def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102 - quiet by design; audit ledger is the log
return
def _run(self) -> None:
if self.path == "/healthz":
self._send(Response(200, {"status": "ok", "contract_version": CONTRACT_VERSION}))
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
def _send(self, response: Response) -> None:
if response.stream is not None:
self.send_response(response.status)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
for chunk in response.stream():
self.wfile.write(chunk)
self.wfile.flush()
return
data = json.dumps(response.body, sort_keys=True).encode()
self.send_response(response.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(data)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
return HuxHandler
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
"""Create (but do not start) the server; callers call serve_forever()."""
server = ThreadingHTTPServer((host, port), make_handler(router))
server.daemon_threads = True
return server

View File

@ -0,0 +1,80 @@
"""Caller identity resolved from the trusted hop's headers.
The chat router, the Telegram relay and the Worker are the only callers. Each
asserts identity in headers; the request body is never trusted for identity.
Relay and worker callers must also present their shared key, compared in
constant time against the value the pod was started with.
"""
from __future__ import annotations
import hmac
import os
import re
from dataclasses import dataclass
from collections.abc import Mapping
from hux.errors import Unauthorized
HEADER_SLOT = "X-Hermes-Tenant-Identity"
HEADER_SUBJECT = "X-Hux-Subject"
HEADER_SURFACE = "X-Hux-Surface"
HEADER_TRUST = "X-Hux-Trust"
HEADER_KEY = "X-Hux-Relay-Key"
SLOT_RE = re.compile(r"^slot-[0-9]{1,3}$")
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
SURFACES = ("chat", "worker", "telegram", "voice", "api")
TRUSTS = ("router", "relay", "worker")
KEY_ENV = {"relay": "HUX_RELAY_KEY", "worker": "HUX_WORKER_KEY"}
@dataclass(frozen=True)
class Identity:
"""Who is calling, on which surface, asserted by which trusted hop."""
tenant_slot: str
subject: str
surface: str
trust: str
def record(self) -> dict[str, str]:
"""Serialise as ``common.identity``."""
return {"tenant_slot": self.tenant_slot, "subject": self.subject, "surface": self.surface, "trust": self.trust}
def _header(headers: Mapping[str, str], name: str) -> str:
for key, value in headers.items():
if key.lower() == name.lower():
return value.strip()
return ""
def resolve(headers: Mapping[str, str], environ: Mapping[str, str] | None = None) -> Identity:
"""Build an Identity from request headers or raise Unauthorized.
``trust`` defaults to ``router``; relay and worker trust require the
matching shared key to be configured and presented.
"""
environ = os.environ if environ is None else environ
slot = _header(headers, HEADER_SLOT)
subject = _header(headers, HEADER_SUBJECT)
surface = _header(headers, HEADER_SURFACE) or "chat"
trust = _header(headers, HEADER_TRUST) or "router"
if not SLOT_RE.match(slot):
raise Unauthorized("missing or malformed tenant slot")
own_slot = environ.get("HUX_TENANT_SLOT", "")
if own_slot and slot != own_slot:
raise Unauthorized("tenant slot does not belong to this pod")
if not SUBJECT_RE.match(subject):
raise Unauthorized("missing or malformed subject")
if surface not in SURFACES or trust not in TRUSTS:
raise Unauthorized("unknown surface or trust")
if trust in KEY_ENV:
expected = environ.get(KEY_ENV[trust], "")
presented = _header(headers, HEADER_KEY)
if not expected or not presented or not hmac.compare_digest(expected, presented):
raise Unauthorized(f"{trust} key missing or wrong")
if trust == "router" and surface == "worker":
raise Unauthorized("worker surface needs worker trust")
return Identity(slot, subject, surface, trust)

View File

@ -0,0 +1,459 @@
"""HUX-02 memory control: an append-only ledger with explicit user consent.
Every entry is a ``hux.memory.v1`` snapshot. The newest snapshot per id is
kept as a revisioned document (so If-Match works) and every snapshot is also
appended to ``ledger.jsonl``. Status moves only along
``rules.MEMORY_TRANSITIONS``; ``no_store`` and ``forgotten`` write a
content-free line plus a tombstone before anything is returned (SO-22), and
``retrieve`` consults the tombstones before the documents (SO-23).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, redaction, rules
from hux.errors import Conflict, Forbidden, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, new_id, now_iso
FAMILY = "memory"
IDEM_FAMILY = "memory_idem"
LEDGER = "ledger"
TOMBSTONES = "tombstones"
DEFAULT_CONVERSATION = "conv_memory"
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
ACTIONS = ("approve", "reject", "forget", "edit", "remove_retrieval", "restore_retrieval")
KINDS = ("preference", "fact", "instruction", "context")
DEFAULT_DECAY_DAYS = 180
SCHEMAS = contracts.load_all()
def _parse(stamp: str) -> datetime:
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def _emit(store: TenantStore, identity: Identity, record: dict[str, Any], kind: str, summary: str) -> None:
from hux import events
detail = {"memory_id": record["id"], "kind": record["kind"], "sensitivity": record["sensitivity"], "topic": record.get("topic", "general")}
conversation_id = record["provenance"].get("conversation_id", DEFAULT_CONVERSATION)
events.emit(store, identity, conversation_id, kind, summary, detail, [{"kind": "memory", "id": record["id"]}], sensitivity=record["sensitivity"])
def tombstoned(store: TenantStore) -> set[str]:
"""Ids that must never surface again, whatever the documents say."""
return {row["memory_id"] for row in store.read(FAMILY, TOMBSTONES)}
def _tombstone(store: TenantStore, memory_id: str, reason: str) -> None:
store.append(FAMILY, TOMBSTONES, {"memory_id": memory_id, "at": now_iso(), "reason": reason, "purged": False})
def effective_expiry(record: dict[str, Any]) -> datetime | None:
"""When the entry stops being active: ``expires_at``, or decay from the last approval (or creation)."""
ttl = record["ttl"]
if ttl["policy"] == "expires_at":
return _parse(ttl["expires_at"])
if ttl["policy"] == "decay":
approvals = [row["at"] for row in record["audit"] if row["action"] == "approved"]
return _parse(approvals[-1] if approvals else record["created_at"]) + timedelta(days=ttl["decay_days"])
return None
def _persist(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
"""Validate against contract and rules, then write document + ledger line under the family lock."""
problems = rules.memory_policy_violations(record) + contracts.validate_record({**record, "revision": record.get("revision") or 1}, SCHEMAS)
if problems:
raise Invalid("memory entry violates policy", problems)
with store.lock(FAMILY):
stored = store.put(FAMILY, record, expected)
store.append(FAMILY, LEDGER, stored)
return stored
def _transition(store: TenantStore, identity: Identity, record: dict[str, Any], target: str, action: str, actor: dict[str, str],
note: str = "", expected: int | None = None, **changes: Any) -> dict[str, Any]:
"""Move an entry to ``target`` from its *current* stored state (F7).
The caller's ``record`` may be stale: the entry is re-read under the family
lock, ``expected`` (If-Match) is checked against that copy, and the write is
always conditional on the loaded revision so a stale unconditional write can
never resurrect a forgotten entry (SO-22, SO-44).
"""
with store.lock(FAMILY):
record = _current(store, record, expected)
if not rules.transition_allowed(rules.MEMORY_TRANSITIONS, record["status"], target):
raise Conflict(f"{record['status']} -> {target} is not a legal memory transition")
stamp = now_iso()
updated = {**record, **changes, "status": target, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": action, "actor": actor, **({"note": note[:200]} if note else {})}]}
if target in {"forgotten", "rejected", "expired"}:
updated["content"], updated["retrievable"] = "", False
if target == "active":
updated["retrievable"] = changes.get("retrievable", True)
stored = _persist(store, updated, record["revision"])
if target == "forgotten":
_tombstone(store, stored["id"], action)
return stored
def _current(store: TenantStore, record: dict[str, Any], expected: int | None) -> dict[str, Any]:
"""Fresh copy of ``record`` from the store; Conflict when If-Match no longer matches it."""
current = store.get(FAMILY, record["id"])
if expected is not None and expected != current["revision"]:
raise Conflict(f"revision {expected} does not match current revision {current['revision']}", [str(current["revision"])])
return current
def load(store: TenantStore, memory_id: str, now: datetime | None = None) -> dict[str, Any]:
"""Newest snapshot with lazy expiry (SO-27): an entry past its expiry is written as expired before it is served."""
record = store.get(FAMILY, memory_id)
if record["status"] == "active":
expiry = effective_expiry(record)
if expiry is not None and expiry <= (now or datetime.now(timezone.utc)):
record = _transition(store, None, record, "expired", "expired", {"type": "system", "id": "retention"}, expected=record["revision"])
return record
def expire_due(store: TenantStore, now: datetime) -> int:
"""Retention pass: move every active entry past its expiry to expired. Returns the count."""
before = {row["id"]: row["status"] for row in store.scan(FAMILY)}
return sum(1 for memory_id, status in before.items() if status == "active" and load(store, memory_id, now)["status"] == "expired")
def purge_forgotten(store: TenantStore) -> int:
"""Retention pass (SO-47): blank ``content`` on every ledger snapshot of tombstoned ids, then mark them purged."""
with store.lock(FAMILY):
stones = store.read(FAMILY, TOMBSTONES)
pending = {row["memory_id"] for row in stones if not row.get("purged")}
if not pending:
return 0
rows = store.read(FAMILY, LEDGER)
store.rewrite(FAMILY, LEDGER, [{**row, "content": ""} if row.get("id") in pending else row for row in rows])
store.rewrite(FAMILY, TOMBSTONES, [{**row, "purged": True} for row in stones])
return len(pending)
def forget_from_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> list[str]:
"""Forget every entry sourced from ``conversation_id`` (used by the privacy forget); returns the ids."""
from hux import events
forgotten: list[str] = []
for row in list(store.scan(FAMILY)):
if row["provenance"].get("conversation_id") != conversation_id:
continue
if row["status"] in {"proposed"}:
row = _transition(store, identity, row, "rejected", "rejected", {"type": "system", "id": "forget"}, "conversation forgotten")
if row["status"] in {"active", "expired"}:
row = _transition(store, identity, row, "forgotten", "forgotten", {"type": "system", "id": "forget"}, "conversation forgotten")
if row["status"] not in {"forgotten", "no_store"}:
_tombstone(store, row["id"], "conversation forgotten")
forgotten.append(row["id"])
events.redact_memory_references(store, row["id"])
return forgotten
def retrieve(store: TenantStore, query_terms: list[str], scope: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""Agent read hook (SO-23): tombstones first, then only active + retrievable entries in scope; never no_store, forgotten or expired."""
from hux import privacy
stones = tombstoned(store)
blocked = privacy.blocked_conversations(store)
if scope and scope.get("level") == "conversation" and scope.get("scope_id") in blocked:
return []
terms = [term.lower() for term in query_terms if term]
hits: list[dict[str, Any]] = []
for row in store.scan(FAMILY):
if row["id"] in stones or row["provenance"].get("conversation_id") in blocked:
continue
record = load(store, row["id"])
if record["status"] != "active" or not record["retrievable"]:
continue
if scope and record["scope"]["level"] != "global" and record["scope"] != scope:
continue
text = record["content"].lower()
if terms and not any(term in text for term in terms):
continue
hits.append(record)
return sorted(hits, key=lambda r: r["updated_at"], reverse=True)
# -- proposals ------------------------------------------------------------------
def _human(identity: Identity) -> bool:
return identity.surface in HUMAN_SURFACES and identity.trust != "worker"
def _actor(request: Request) -> dict[str, str]:
if _human(request.identity) and request.body.get("proposed_by", "assistant") == "user":
return {"type": "user", "id": request.identity.subject}
return {"type": "assistant", "id": "hermes"}
def _classify(store: TenantStore, body: dict[str, Any], content: str, conversation_id: str | None) -> dict[str, Any]:
"""Scrub ``content`` and decide topic, sensitivity floor and the privacy gate; shared by create and edit (F3)."""
from hux import privacy
hits: list[str] = []
content = redaction.scrub_value(content.strip(), hits)[:2000]
detected = privacy.detect_topics(content) + (["credentials"] if hits else [])
topic = body.get("topic") if body.get("topic") in rules.PRIVACY_TOPICS or body.get("topic") == "general" else None
topic = topic if topic and topic != "general" else (detected[0] if detected else "general")
sensitivity = body.get("sensitivity", "personal")
if sensitivity not in privacy.SENSITIVITY_RANK:
raise Invalid("unknown sensitivity")
floor = privacy.topic_sensitivity(detected + ([topic] if topic != "general" else []))
if privacy.SENSITIVITY_RANK[floor] > privacy.SENSITIVITY_RANK[sensitivity]:
sensitivity = floor
allowed, why = privacy.memory_write_allowed(store, conversation_id, {"sensitivity": sensitivity, "topic": topic})
if why == "private_mode":
raise Forbidden("memory writes are refused in private mode")
return {"content": content, "topic": topic, "sensitivity": sensitivity, "allowed": allowed, "why": why}
def _decline(entry: dict[str, Any], why: str, actor: dict[str, str], stamp: str) -> dict[str, Any]:
"""Content-free ``no_store`` decision record.
The topic moves into the note because rules.memory_policy_violations reads a
deny topic on any non-rejected status as a write, and a sensitive entry must
say approval_mode=ask.
"""
topic = entry.pop("topic")
entry.update(status="no_store", approval_mode="ask" if entry["sensitivity"] == "sensitive" else "no_store", content="", retrievable=False,
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
return entry
def _shape(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Build the entry from a proposal body and decide its approval mode from policy; returns (entry, gate_reason)."""
if "id" in body or "revision" in body or "status" in body:
raise Invalid("id, revision and status are server-assigned")
content = body.get("content")
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
raise Invalid("content must be a non-empty string of at most 2000 chars")
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
verdict = _classify(request.store, body, content, conversation_id)
topic, sensitivity = verdict["topic"], verdict["sensitivity"]
scope = body.get("scope") if isinstance(body.get("scope"), dict) else {"level": "global"}
source = body.get("source") if isinstance(body.get("source"), dict) else {"kind": "message", "id": "unspecified"}
ttl = body.get("ttl") if isinstance(body.get("ttl"), dict) else None
if ttl is None or (sensitivity == "sensitive" and ttl.get("policy") == "never"):
days = rules.PRIVACY_TOPICS[topic]["decay_days"] if topic in rules.PRIVACY_TOPICS else DEFAULT_DECAY_DAYS
ttl = {"policy": "decay", "decay_days": days}
actor = _actor(request)
stamp = now_iso()
provenance = {"surface": request.identity.surface, "actor": actor, "recorded_at": stamp}
if conversation_id:
provenance["conversation_id"] = conversation_id
if isinstance(body.get("run_id"), str):
provenance["run_id"] = body["run_id"][:120]
entry: dict[str, Any] = {
"schema": "hux.memory.v1", "id": new_id("mem"), "owner": request.identity.subject, "scope": scope,
"kind": body.get("kind") if body.get("kind") in KINDS else "fact", "content": verdict["content"], "status": "proposed",
"approval_mode": "ask", "sensitivity": sensitivity, "topic": topic, "ttl": ttl, "source": source,
"provenance": provenance, "created_at": stamp, "updated_at": stamp,
"audit": [{"at": stamp, "action": "proposed", "actor": actor}], "reason": str(body.get("reason") or "Proposed to be remembered.")[:280],
"retrievable": False, "identity": request.identity.record(),
}
if isinstance(body.get("supersedes"), str):
entry["supersedes"] = body["supersedes"]
references_forgotten = entry.get("supersedes") in tombstoned(request.store) or source.get("id") in tombstoned(request.store)
wants = body.get("approval_mode", "ask" if actor["type"] == "assistant" else "automatic")
why = verdict["why"]
if not verdict["allowed"] or wants == "no_store":
why = why if not verdict["allowed"] else "declined"
_decline(entry, why, actor, stamp)
elif sensitivity == "sensitive" or wants == "ask" or references_forgotten or actor["type"] != "user":
entry["approval_mode"] = "ask"
else:
entry.update(status="active", approval_mode="automatic", retrievable=True)
entry["audit"].append({"at": stamp, "action": "approved", "actor": actor, "note": "automatic"})
return entry, why
def _replay(request: Request, key: str) -> Response | None:
for row in request.store.read(IDEM_FAMILY, "keys"):
if row["idempotency_key"] == key:
request.audit("memory.propose", row["memory_id"], "allow", "replayed")
return Response(200, load(request.store, row["memory_id"]), {"HUX-Replayed": "true"})
return None
def post_memory(request: Request) -> Response:
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202).
The Idempotency-Key lookup, the write and the key mapping all happen under
the family lock so concurrent retries with one key yield one record (F13b).
"""
body = request.body if isinstance(request.body, dict) else None
if body is None:
raise Invalid("body must be an object")
key = request.idempotency_key()
with request.store.lock(FAMILY):
replayed = _replay(request, key) if key else None
if replayed is not None:
return replayed
entry, why = _shape(request, body)
try:
stored = _persist(request.store, entry, None)
except Invalid:
request.store.append(FAMILY, TOMBSTONES, {"memory_id": entry["id"], "at": now_iso(), "reason": "policy_violation", "purged": True})
_emit(request.store, request.identity, entry, "memory.suppressed", "Memory proposal suppressed by policy")
raise
if key:
request.store.append(IDEM_FAMILY, "keys", {"idempotency_key": key, "memory_id": stored["id"], "at": stored["created_at"]})
if stored["status"] == "no_store":
_tombstone(request.store, stored["id"], why)
_emit(request.store, request.identity, stored, "memory.suppressed", f"Not remembered ({why})")
request.audit("memory.propose", stored["id"], "allow", "no_store")
return Response(202, stored)
kind = "memory.committed" if stored["status"] == "active" else "memory.proposed"
_emit(request.store, request.identity, stored, kind, "Remembered" if stored["status"] == "active" else "Proposed to remember")
request.audit("memory.propose", stored["id"])
return Response(201, stored)
def list_memory(request: Request) -> Response:
"""``GET /hux/v1/memory?status=&scope=``: owner-scoped list; ``scope`` is ``level`` or ``level:scope_id``."""
status, scope = request.query.get("status"), request.query.get("scope")
level, _, scope_id = (scope or "").partition(":")
items = []
for row in list(request.store.scan(FAMILY)):
record = load(request.store, row["id"])
if status and record["status"] != status:
continue
if level and (record["scope"]["level"] != level or (scope_id and record["scope"].get("scope_id") != scope_id)):
continue
items.append(record)
request.audit("memory.list", "memory")
return page(sorted(items, key=lambda r: r["updated_at"], reverse=True), None)
def get_memory(request: Request) -> Response:
"""``GET /hux/v1/memory/{id}``."""
memory_id = request.params["id"]
try:
record = load(request.store, memory_id)
except (NotFound, Invalid) as error:
raise NotFound("memory entry not found") from error
request.audit("memory.get", memory_id)
return Response(200, record, {"ETag": str(record["revision"])})
def _edit(request: Request, record: dict[str, Any], actor: dict[str, str], expected: int | None) -> dict[str, Any]:
"""Supersede ``record`` with edited content under the same privacy shaping as a proposal (F3).
A deny verdict (deny topic, restricted, memory disabled, conversation
forgotten) yields a content-free ``no_store`` record and leaves the old
entry untouched; a sensitive floor makes the replacement ``proposed`` with
``approval_mode: ask``. Private mode is refused outright.
"""
body = request.body if isinstance(request.body, dict) else {}
content = body.get("content")
if not isinstance(content, str) or not content.strip() or len(content) > 2000:
raise Invalid("edit needs new content of at most 2000 chars")
conversation_id = record["provenance"].get("conversation_id")
verdict = _classify(request.store, {**body, "sensitivity": record["sensitivity"]}, content, conversation_id)
stamp = now_iso()
fresh = {
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": verdict["content"],
"topic": verdict["topic"], "sensitivity": verdict["sensitivity"], "status": "active", "approval_mode": "automatic", "retrievable": True,
"created_at": stamp, "updated_at": stamp, "audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"edit of {record['id']}"}],
}
if not verdict["allowed"]:
stored = _persist(request.store, _decline(fresh, verdict["why"], actor, stamp), None)
_tombstone(request.store, stored["id"], verdict["why"])
return stored
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
from hux import events
events.redact_memory_references(request.store, old["id"])
fresh["supersedes"] = old["id"]
fresh["audit"][0]["note"] = f"supersedes {old['id']}"
if fresh["sensitivity"] == "sensitive":
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
else:
fresh["audit"].append({"at": stamp, "action": "approved", "actor": actor})
return _persist(request.store, fresh, None)
def _set_retrievable(store: TenantStore, record: dict[str, Any], retrievable: bool, actor: dict[str, str], expected: int | None) -> dict[str, Any]:
"""Flip ``retrievable`` on an active entry, re-reading it under the lock so a stale copy cannot overwrite a forget (F7)."""
with store.lock(FAMILY):
record = _current(store, record, expected)
if record["status"] != "active":
raise Conflict("retrieval can only change on active entries")
stamp = now_iso()
entry = {"at": stamp, "action": "approved" if retrievable else "retrieval_removed", "actor": actor, "note": "retrieval restored" if retrievable else ""}
return _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], entry]}, record["revision"])
def act_memory(request: Request) -> Response:
"""``POST /hux/v1/memory/{id}/{action}``: approve, reject, forget, edit, remove_retrieval, restore_retrieval; every move emits a memory.* event."""
memory_id, action = request.params["id"], request.params["action"]
if action not in ACTIONS:
raise NotFound("unknown memory action")
if not _human(request.identity):
raise Forbidden("memory decisions come from a human surface")
try:
record = load(request.store, memory_id)
except (NotFound, Invalid) as error:
raise NotFound("memory entry not found") from error
expected = request.if_match()
if expected is not None and expected != record["revision"]:
raise Conflict(f"revision {expected} does not match current revision {record['revision']}", [str(record["revision"])])
actor = {"type": "user", "id": request.identity.subject}
store, identity = request.store, request.identity
if action == "approve":
stored = _transition(store, identity, record, "active", "approved", actor, expected=expected)
_emit(store, identity, stored, "memory.committed", "Memory approved")
elif action == "reject":
stored = _transition(store, identity, record, "rejected", "rejected", actor, expected=expected)
_emit(store, identity, stored, "memory.suppressed", "Memory rejected")
elif action == "forget":
stored = _transition(store, identity, record, "forgotten", "forgotten", actor, expected=expected)
from hux import events
events.redact_memory_references(store, stored["id"])
_emit(store, identity, stored, "memory.forgotten", "Memory forgotten")
elif action == "edit":
stored = _edit(request, record, actor, expected)
if stored["status"] == "no_store":
_emit(store, identity, stored, "memory.suppressed", f"Edit not remembered ({stored['audit'][0]['note'].split(';')[0]})")
request.audit("memory.edit", memory_id, "allow", "no_store")
return Response(202, stored)
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
else:
retrievable = action == "restore_retrieval"
stored = _set_retrievable(store, record, retrievable, actor, expected)
_emit(store, identity, stored, "memory.retrieval_removed" if not retrievable else "memory.committed", "Retrieval removed" if not retrievable else "Retrieval restored")
request.audit(f"memory.{action}", memory_id, "allow", "" if expected is not None else "unconditional_write")
return Response(200, stored, {"ETag": str(stored["revision"])})
def export_memory(request: Request) -> Response:
"""``GET /hux/v1/memory/export``: active, retrievable entries only, each stamped ``exported`` (SO-26)."""
stones = tombstoned(request.store)
items = []
for row in list(request.store.scan(FAMILY)):
record = load(request.store, row["id"])
if record["id"] in stones or record["status"] != "active" or not record["retrievable"]:
continue
stamp = now_iso()
items.append(_persist(request.store, {**record, "audit": [*record["audit"], {"at": stamp, "action": "exported", "actor": {"type": "user", "id": request.identity.subject}}]}, record["revision"]))
request.store.append(FAMILY, "exports", {"at": now_iso(), "ids": [item["id"] for item in items]})
request.audit("memory.export", "memory")
response = page(items, None)
response.headers["Content-Disposition"] = 'attachment; filename="hux-memory-export.json"'
return response
def register(router: Router) -> None:
"""Attach HUX-02 routes."""
router.add("GET", "/hux/v1/memory/export", "HUX-02", "memory.export", export_memory)
router.add("POST", "/hux/v1/memory", "HUX-02", "memory.propose", post_memory)
router.add("GET", "/hux/v1/memory", "HUX-02", "memory.list", list_memory)
router.add("GET", "/hux/v1/memory/{id}", "HUX-02", "memory.get", get_memory)
router.add("POST", "/hux/v1/memory/{id}/{action}", "HUX-02", "memory.act", act_memory)

View File

@ -0,0 +1,323 @@
"""HUX-03 organisation: projects, conversations, branch lineage and search.
Projects own conversations; conversations carry tags, pins, a mode and an
optional branch pointer to the conversation they forked from. Search covers
only the ``project.schema.json#/$defs/search_index`` fields this increment
indexes (title, tags, project_name, and artifact titles when the artifacts
lane is present); message text is not indexed here and the response says so.
"""
from __future__ import annotations
import re
from typing import Any
from hux import contracts, redaction
from hux.errors import Conflict, Invalid, NotFound
from hux.http import Request, Response, Router, page
from hux.store import TenantStore, check_id, new_id, now_iso
CARD = "HUX-03"
PROJECTS = "projects"
CONVERSATIONS = "conversations"
MAX_PROJECTS = 200
MAX_CONVERSATIONS = 2000
PROJECT_FIELDS = ("name", "description", "tags", "pinned", "archived", "default_mode")
CONVERSATION_FIELDS = ("title", "tags", "pinned", "archived", "mode", "project_id")
INDEXED = ("title", "tags", "project_name", "artifact_titles")
NOT_INDEXED = ("message_text",)
SCHEMAS = contracts.load_all()
TOKEN_RE = re.compile(r"[a-z0-9]+")
def project_exists(store: TenantStore, project_id: str) -> bool:
"""True when this tenant owns a project with that id (helper for other lanes)."""
return isinstance(project_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", project_id)) and store.exists(PROJECTS, project_id)
def conversation_exists(store: TenantStore, conversation_id: str) -> bool:
"""True when this tenant owns a conversation with that id (helper for other lanes)."""
return isinstance(conversation_id, str) and bool(re.match(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$", conversation_id)) and store.exists(CONVERSATIONS, conversation_id)
def project_of(store: TenantStore, conversation_id: str | None) -> str | None:
"""The project a conversation belongs to, or None when unknown or unfiled."""
if not conversation_id or not conversation_exists(store, conversation_id):
return None
return store.get(CONVERSATIONS, conversation_id).get("project_id")
def checked(record: dict[str, Any]) -> dict[str, Any]:
"""Raise Invalid unless ``record`` satisfies its contract."""
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("record fails contract", problems)
return record
def body_dict(request: Request) -> dict[str, Any]:
"""The JSON object body or Invalid."""
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def pick(body: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Only the client-settable fields, secret-scrubbed (F9); ids, owner, timestamps and revision are server-set."""
return redaction.scrub_value({k: body[k] for k in fields if k in body}, [])
def replay(store: TenantStore, family: str, key: str) -> dict[str, Any] | None:
"""The record an Idempotency-Key already created in this family, if any."""
for row in store.read(family, "idempotency"):
if row["key"] == key:
return store.get(family, row["id"])
return None
def create(request: Request, family: str, record: dict[str, Any], key: str, cap: int) -> tuple[dict[str, Any], int]:
"""Create under the family lock honouring Idempotency-Key and the family count cap."""
with request.store.lock(family):
if key:
existing = replay(request.store, family, key)
if existing is not None:
return existing, 200
if request.store.count(family) >= cap:
raise Conflict(f"{family} cap of {cap} reached")
stored = request.store.put(family, checked({**record, "revision": 1}))
if key:
request.store.append(family, "idempotency", {"key": key, "id": stored["id"]})
return stored, 201
def update(request: Request, family: str, fields: tuple[str, ...]) -> dict[str, Any]:
"""PATCH under If-Match; a missing If-Match is accepted but audited as unconditional (SO-44)."""
changes = pick(body_dict(request), fields)
expected = request.if_match()
with request.store.lock(family):
current = request.store.get(family, check_id(request.params["id"]))
if "project_id" in changes and changes["project_id"] is not None and not project_exists(request.store, changes["project_id"]):
raise NotFound("project not found")
record = {**current, **{k: v for k, v in changes.items() if v is not None}, "updated_at": now_iso()}
if changes.get("project_id", "") is None:
record.pop("project_id", None)
stored = request.store.put(family, checked(record), expected_revision=expected)
request.audit(f"{family}.update", stored["id"], reason="" if expected is not None else "unconditional_write")
return stored
def etag(record: dict[str, Any]) -> dict[str, str]:
"""Revision as ETag so clients can send it back in If-Match."""
return {"ETag": str(record["revision"])}
# -- projects ------------------------------------------------------------------
def create_project(request: Request) -> Response:
"""``POST /hux/v1/projects``."""
body = pick(body_dict(request), PROJECT_FIELDS)
stamp = now_iso()
record = {
"schema": "hux.project.v1", "id": new_id("prj"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **body, "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, PROJECTS, record, request.idempotency_key(), MAX_PROJECTS)
request.audit("projects.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def list_projects(request: Request) -> Response:
"""``GET /hux/v1/projects?archived=``: pinned first, then most recently updated."""
archived = request.query.get("archived")
items = [p for p in request.store.scan(PROJECTS) if archived is None or p["archived"] == (archived == "true")]
items.sort(key=lambda p: p["updated_at"], reverse=True)
items.sort(key=lambda p: not p["pinned"])
request.audit("projects.list", "projects")
return page(items)
def get_project(request: Request) -> Response:
"""``GET /hux/v1/projects/{id}``."""
record = request.store.get(PROJECTS, check_id(request.params["id"]))
request.audit("projects.read", record["id"])
return Response(200, record, etag(record))
def patch_project(request: Request) -> Response:
"""``PATCH /hux/v1/projects/{id}`` with If-Match."""
stored = update(request, PROJECTS, PROJECT_FIELDS)
return Response(200, stored, etag(stored))
# -- conversations -------------------------------------------------------------
def new_conversation(request: Request, body: dict[str, Any], extra: dict[str, Any] | None = None) -> Response:
"""Build, validate and store a conversation from client fields plus server-set extras."""
if body.get("project_id") is not None and not project_exists(request.store, body["project_id"]):
raise NotFound("project not found")
stamp = now_iso()
record = {
"schema": "hux.conversation.v1", "id": new_id("conv"), "owner": request.identity.subject,
"tags": [], "pinned": False, "archived": False, **{k: v for k, v in body.items() if v is not None},
**(extra or {}), "artifact_ids": [], "created_at": stamp, "updated_at": stamp,
}
stored, status = create(request, CONVERSATIONS, record, request.idempotency_key(), MAX_CONVERSATIONS)
request.audit("conversations.create", stored["id"], reason="replayed" if status == 200 else "")
return Response(status, stored, etag(stored))
def create_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations``."""
return new_conversation(request, pick(body_dict(request), CONVERSATION_FIELDS))
def list_conversations(request: Request) -> Response:
"""``GET /hux/v1/conversations?project_id=&tag=&pinned=&archived=``: newest activity first."""
q = request.query
flags = {k: q[k] == "true" for k in ("pinned", "archived") if k in q}
items = []
for index, record in enumerate(request.store.scan(CONVERSATIONS)):
if "project_id" in q and record.get("project_id") != q["project_id"]:
continue
if "tag" in q and q["tag"] not in record["tags"]:
continue
if any(record[k] != v for k, v in flags.items()):
continue
items.append((record.get("last_message_at", record["updated_at"]), index, record))
items.sort(key=lambda item: item[:2], reverse=True)
request.audit("conversations.list", "conversations")
return page([record for _, _, record in items])
def get_conversation(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}``."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
request.audit("conversations.read", record["id"])
return Response(200, record, etag(record))
def patch_conversation(request: Request) -> Response:
"""``PATCH /hux/v1/conversations/{id}`` with If-Match."""
stored = update(request, CONVERSATIONS, CONVERSATION_FIELDS)
return Response(200, stored, etag(stored))
def branch_conversation(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/branch``: fork at a message, keeping project, tags and mode."""
body = body_dict(request)
point = body.get("branch_point_message_id")
if not isinstance(point, str) or not 1 <= len(point) <= 120:
raise Invalid("branch_point_message_id required")
parent = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
fields = {"title": body.get("title") or f"{parent['title']} (branch)"[:200], "tags": list(parent["tags"]),
"project_id": parent.get("project_id"), "mode": parent.get("mode")}
return new_conversation(request, fields, {"branch": {"parent_conversation_id": parent["id"], "branch_point_message_id": point}})
def lineage(request: Request) -> Response:
"""``GET /hux/v1/conversations/{id}/lineage``: ancestors root-first plus direct children."""
record = request.store.get(CONVERSATIONS, check_id(request.params["id"]))
ancestors: list[dict[str, Any]] = []
cursor, seen = record, {record["id"]}
while "branch" in cursor and len(ancestors) < 64:
parent_id = cursor["branch"]["parent_conversation_id"]
if parent_id in seen or not request.store.exists(CONVERSATIONS, parent_id):
break
cursor = request.store.get(CONVERSATIONS, parent_id)
seen.add(parent_id)
ancestors.insert(0, cursor)
children = [c for c in request.store.scan(CONVERSATIONS) if c.get("branch", {}).get("parent_conversation_id") == record["id"]]
request.audit("conversations.lineage", record["id"])
return Response(200, {"conversation": record, "ancestors": ancestors, "children": children})
# -- search --------------------------------------------------------------------
def artifact_index(store: TenantStore) -> dict[str, list[str]]:
"""Conversation id -> titles of the artifacts filed under it (F13c): both the conversation's ``artifact_ids`` and artifacts that name the conversation."""
try:
from hux import artifacts
except ModuleNotFoundError:
return {}
index: dict[str, list[str]] = {}
for artifact in store.scan(artifacts.FAMILY):
if isinstance(artifact.get("conversation_id"), str) and isinstance(artifact.get("title"), str):
index.setdefault(artifact["conversation_id"], []).append(artifact["title"])
for conversation in store.scan(CONVERSATIONS):
ids = [i for i in conversation.get("artifact_ids", []) if isinstance(i, str)]
titles = artifacts.artifact_titles(store, ids).values() if ids else []
for title in titles:
index.setdefault(conversation["id"], []).append(title)
return index
def artifact_titles(store: TenantStore, conversation: dict[str, Any], index: dict[str, list[str]] | None = None) -> list[str]:
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
index = artifact_index(store) if index is None else index
return list(dict.fromkeys(index.get(conversation["id"], [])))
def mark_forgotten(store: TenantStore, conversation_id: str) -> bool:
"""Blank a forgotten conversation's title and tags and archive it (F9); False when there is no document."""
with store.lock(CONVERSATIONS):
if not conversation_exists(store, conversation_id):
return False
current = store.get(CONVERSATIONS, conversation_id)
record = {**current, "title": "[forgotten]", "tags": [], "archived": True, "updated_at": now_iso()}
store.put(CONVERSATIONS, checked(record), expected_revision=current["revision"])
return True
def tokens(text: str) -> list[str]:
"""Lowercased alphanumeric terms."""
return TOKEN_RE.findall(text.lower())
def score(record: dict[str, Any], terms: list[str], project_name: str, titles: list[str]) -> int:
"""Title hit beats tag hit beats project/artifact hit; every term must match somewhere."""
fields = {"title": tokens(record["title"]), "tags": [t for tag in record["tags"] for t in tokens(tag)],
"project_name": tokens(project_name), "artifact_titles": [t for title in titles for t in tokens(title)]}
weight = {"title": 4, "tags": 3, "project_name": 2, "artifact_titles": 1}
total = 0
for term in terms:
hit = sum(weight[f] * words.count(term) for f, words in fields.items())
if not hit:
return 0
total += hit
return total
def search(request: Request) -> Response:
"""``GET /hux/v1/search?q=&project_id=``: token match over the indexed fields, best first."""
terms = tokens(request.query.get("q", ""))
if not terms:
raise Invalid("q is required")
project_filter = request.query.get("project_id")
names = {p["id"]: p["name"] for p in request.store.scan(PROJECTS)}
index = artifact_index(request.store)
ranked = []
for record in request.store.scan(CONVERSATIONS):
if project_filter and record.get("project_id") != project_filter:
continue
points = score(record, terms, names.get(record.get("project_id", ""), ""), artifact_titles(request.store, record, index))
if points:
ranked.append((points, record))
ranked.sort(key=lambda pair: (-pair[0], pair[1]["updated_at"]))
request.audit("search.query", "conversations")
return Response(200, {"items": [r for _, r in ranked], "next": None, "scores": {r["id"]: s for s, r in ranked},
"indexed": list(INDEXED), "not_indexed": list(NOT_INDEXED)})
def register(router: Router) -> None:
"""Attach HUX-03 routes."""
router.add("POST", "/hux/v1/projects", CARD, "projects.create", create_project)
router.add("GET", "/hux/v1/projects", CARD, "projects.list", list_projects)
router.add("GET", "/hux/v1/projects/{id}", CARD, "projects.read", get_project)
router.add("PATCH", "/hux/v1/projects/{id}", CARD, "projects.update", patch_project)
router.add("POST", "/hux/v1/conversations", CARD, "conversations.create", create_conversation)
router.add("GET", "/hux/v1/conversations", CARD, "conversations.list", list_conversations)
router.add("GET", "/hux/v1/conversations/{id}", CARD, "conversations.read", get_conversation)
router.add("PATCH", "/hux/v1/conversations/{id}", CARD, "conversations.update", patch_conversation)
router.add("POST", "/hux/v1/conversations/{id}/branch", CARD, "conversations.branch", branch_conversation)
router.add("GET", "/hux/v1/conversations/{id}/lineage", CARD, "conversations.lineage", lineage)
router.add("GET", "/hux/v1/search", CARD, "search.query", search)

View File

@ -0,0 +1,374 @@
"""HUX-05 autonomy: policy documents per scope and the approval queue.
A policy fixes the autonomy level, explicit grants and budgets for a scope
(global, project or conversation). ``rules.effective_decision`` is the only
resolver: an approval request resolves to allow, ask or deny against the most
specific policy, and every external side effect asks regardless. Budgets,
the pre-side-effect gate and cancellation receipts live in ``hux.budgets``;
this module registers their routes so the family stays one card.
"""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Any
from hux import contracts, rules
from hux.errors import BudgetExhausted, Conflict, Forbidden, Invalid
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id
CARD = "HUX-05"
FAMILY = "policy"
APPROVALS = "approvals"
SCOPES = ("global", "project", "conversation")
APPROVAL_TTL = timedelta(hours=24)
ALWAYS_TTL = timedelta(days=30)
SESSION_TTL = timedelta(hours=24)
HUMAN_SURFACES = frozenset({"chat", "telegram", "voice"})
HUMAN_TRUSTS = frozenset({"router", "relay"})
DEFAULT_BUDGETS = {
"tokens_per_run": 200000, "tool_calls_per_run": 40, "wall_clock_seconds": 900,
"delegations_per_run": 4, "spend_units": 50, "subagents_per_run": 2,
}
SCHEMAS = contracts.load_all()
def clock() -> datetime:
"""Current UTC time; tests replace this to move approvals past expiry."""
return datetime.now(timezone.utc)
def now() -> datetime:
"""Indirection so monkeypatching ``clock`` reaches every module."""
return clock()
def iso(when: datetime) -> str:
"""RFC 3339 second-precision UTC string."""
return when.strftime("%Y-%m-%dT%H:%M:%SZ")
def parse(stamp: str) -> datetime:
"""Inverse of ``iso``."""
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def run_key(run_id: str) -> str:
"""Stable hex handle for a free-form run id so it can name a document."""
return hashlib.sha256(run_id.encode()).hexdigest()[:32]
def is_human(identity: Identity) -> bool:
"""True when a person is behind the call: router/relay trust on a chat, telegram or voice surface."""
return identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES
def require_human(identity: Identity, what: str) -> None:
"""Forbidden unless a human actor is behind the call (F1: policy writes and allow grants)."""
if not is_human(identity):
raise Forbidden(f"{what} only from a human surface")
def actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if is_human(identity):
return {"type": "user", "id": identity.subject}
return {"type": "system", "id": identity.surface}
def provenance(identity: Identity) -> dict[str, Any]:
"""Server-set provenance; nothing here comes from the body."""
return {"surface": identity.surface, "actor": actor_for(identity), "recorded_at": iso(now())}
def public(record: dict[str, Any], revisioned: bool = False) -> dict[str, Any]:
"""Strip store-internal fields before a record leaves the service."""
return {k: v for k, v in record.items() if not k.startswith("_") and (revisioned or k != "revision")}
def emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, **extra: Any) -> None:
"""Record an activity event through the events lane when it is present."""
if not conversation_id:
return
try:
from hux import events
except ModuleNotFoundError:
return
events.emit(store, identity, conversation_id, kind, summary, **extra)
def checked(record: dict[str, Any]) -> dict[str, Any]:
"""Raise Invalid unless ``record`` satisfies its contract; internal fields are ignored."""
if record["schema"] == "hux.policy.v1":
candidate = public({"revision": 1, **record}, revisioned=True)
else:
candidate = public(record)
problems = contracts.validate_record(candidate, SCHEMAS)
if problems:
raise Invalid("record fails contract", problems)
return record
def body_dict(request: Request) -> dict[str, Any]:
"""The JSON object body or Invalid."""
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
# -- policy documents --------------------------------------------------------
def policy_id(level: str, scope_id: str | None) -> str:
"""Document id for a scope; global has exactly one."""
if level not in SCOPES:
raise Invalid("scope must be global, project or conversation")
if level == "global":
return "pol_global"
if scope_id is None or len(scope_id) > 60:
raise Invalid("scope_id required and at most 60 characters for project and conversation scopes")
return f"pol_{level}.{check_id(scope_id)}"
def default_policy(identity: Identity) -> dict[str, Any]:
"""The ``safe`` policy every tenant starts with."""
return {
"schema": "hux.policy.v1", "id": "pol_global", "owner": identity.subject, "scope": {"level": "global"},
"autonomy": "safe", "grants": [], "budgets": dict(DEFAULT_BUDGETS),
"provenance": provenance(identity), "updated_at": iso(now()),
}
def global_policy(store: TenantStore, identity: Identity) -> dict[str, Any]:
"""Read the global policy, creating the default on first touch."""
with store.lock(FAMILY):
if store.exists(FAMILY, "pol_global"):
return store.get(FAMILY, "pol_global")
return store.put(FAMILY, checked({**default_policy(identity), "revision": 1}))
def effective_policy(store: TenantStore, identity: Identity, level: str, scope_id: str | None) -> dict[str, Any]:
"""Most specific policy along conversation -> project -> global."""
chain: list[tuple[str, str | None]] = [(level, scope_id)]
if level == "conversation":
from hux import organization # lazy: organization never imports policy, but keep import order free
parent = organization.project_of(store, scope_id)
if parent:
chain.append(("project", parent))
for lvl, sid in chain:
if lvl != "global" and store.exists(FAMILY, policy_id(lvl, sid)):
return store.get(FAMILY, policy_id(lvl, sid))
return global_policy(store, identity)
def normalise_grants(grants: Any, identity: Identity) -> list[dict[str, Any]]:
"""Validate grants and pin every one to a server-set expiry of at most 30 days (SO-38)."""
if not isinstance(grants, list):
raise Invalid("grants must be a list")
ceiling = now() + ALWAYS_TTL
out = []
for grant in grants:
if not isinstance(grant, dict) or grant.get("capability") not in rules.CAPABILITIES:
raise Invalid("grant needs a known capability")
if grant.get("decision") not in ("allow", "ask", "deny"):
raise Invalid("grant decision must be allow, ask or deny")
if grant["decision"] == "allow":
require_human(identity, "allow grants")
expires = grant.get("expires_at")
if not isinstance(expires, str) or parse(expires) > ceiling:
expires = iso(ceiling)
out.append({"capability": grant["capability"], "decision": grant["decision"], "expires_at": expires, "granted_by": actor_for(identity)})
return out
def get_policy(request: Request) -> Response:
"""``GET /hux/v1/policy?scope=&scope_id=``: the effective policy for a scope."""
level = request.query.get("scope", "global")
scope_id = request.query.get("scope_id")
policy_id(level, scope_id)
record = effective_policy(request.store, request.identity, level, scope_id)
request.audit("policy.read", record["id"])
return Response(200, public(record, revisioned=True), {"ETag": str(record["revision"])})
def put_policy(request: Request) -> Response:
"""``PUT /hux/v1/policy``: replace the policy for the scope named in the body; humans only (F1)."""
require_human(request.identity, "policy writes")
body = body_dict(request)
scope = body.get("scope") or {}
if not isinstance(scope, dict):
raise Invalid("scope must be an object")
record_id = policy_id(scope.get("level", ""), scope.get("scope_id"))
if body.get("autonomy") not in ("ask_first", "safe", "autonomous"):
raise Invalid("autonomy must be ask_first, safe or autonomous")
if body["autonomy"] == "autonomous":
require_human(request.identity, "autonomy escalation")
budgets = body.get("budgets", dict(DEFAULT_BUDGETS))
record = {
"schema": "hux.policy.v1", "id": record_id, "owner": request.identity.subject, "scope": scope,
"autonomy": body["autonomy"], "grants": normalise_grants(body.get("grants", []), request.identity),
"budgets": budgets, "provenance": provenance(request.identity), "updated_at": iso(now()),
}
expected = request.if_match()
with request.store.lock(FAMILY):
exists = request.store.exists(FAMILY, record_id)
stored = request.store.put(FAMILY, checked(record), expected_revision=expected)
reason = "unconditional_write" if exists and expected is None else ""
request.audit("policy.write", record_id, reason=reason)
return Response(200, public(stored, revisioned=True), {"ETag": str(stored["revision"])})
def add_grant(store: TenantStore, identity: Identity, level: str, scope_id: str | None, capability: str, ttl: timedelta) -> None:
"""Append an allow grant to a scope's policy after a session/always decision; the decider must be human."""
require_human(identity, "allow grants")
record_id = policy_id(level, scope_id)
with store.lock(FAMILY):
if store.exists(FAMILY, record_id):
record = store.get(FAMILY, record_id)
else:
scope = {"level": level, **({"scope_id": scope_id} if scope_id else {})}
record = {**global_policy(store, identity), "id": record_id, "scope": scope}
record.pop("revision")
expected = record.pop("revision", None)
grants = [g for g in record["grants"] if g["capability"] != capability]
grants.append({"capability": capability, "decision": "allow", "expires_at": iso(now() + min(ttl, ALWAYS_TTL)), "granted_by": actor_for(identity)})
record = {**record, "grants": grants[-64:], "provenance": provenance(identity), "updated_at": iso(now())}
store.put(FAMILY, checked(record), expected_revision=expected)
# -- approvals -----------------------------------------------------------------
def refresh(store: TenantStore, record: dict[str, Any]) -> dict[str, Any]:
"""Expire a pending approval whose window has closed (SO-42); terminal records never change."""
if record["status"] == "pending" and parse(record["expires_at"]) <= now():
with store.lock(APPROVALS):
record = store.put(APPROVALS, {**record, "status": "expired"})
return record
def load_approval(store: TenantStore, approval_id: str) -> dict[str, Any]:
"""Read one approval for this tenant; unknown ids are 404 whoever owns them."""
return refresh(store, store.get(APPROVALS, check_id(approval_id)))
def replay(store: TenantStore, key: str) -> dict[str, Any] | None:
"""The approval an Idempotency-Key already created, if any."""
for row in store.read(APPROVALS, "idempotency"):
if row["key"] == key:
return load_approval(store, row["id"])
return None
def resolve_request(policy: dict[str, Any], capability: str, external: bool) -> str:
"""Effective decision for a request; external side effects never auto-allow (SO-39)."""
decision = rules.effective_decision(policy, capability, now())
if external and decision == "allow":
return "ask"
return decision
def create_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals``: the agent hook asks before a gated action."""
body = body_dict(request)
key = request.idempotency_key()
if key:
existing = replay(request.store, key)
if existing is not None:
request.audit("approvals.create", existing["id"], reason="replayed")
return Response(200, public(existing))
conversation_id = check_id(body.get("conversation_id"))
capability = body.get("capability")
if capability not in rules.CAPABILITIES:
raise Invalid("unknown capability")
from hux import budgets # lazy: budgets imports this module
state = budgets.budget_state(request.store, request.identity, str(body.get("run_id", "")), conversation_id)
if state["exhausted"]:
emit(request.store, request.identity, conversation_id, "budget.exhausted", f"Budget exhausted: {', '.join(state['exhausted'])}", run_id=state["run_id"])
raise BudgetExhausted("run budget exhausted", state["exhausted"])
policy = effective_policy(request.store, request.identity, "conversation", conversation_id)
req = body.get("request") if isinstance(body.get("request"), dict) else {}
external = bool(req.get("external", False))
evidence = req.get("evidence") if isinstance(req.get("evidence"), list) else []
if sum(1 for e in evidence if isinstance(e, dict) and e.get("kind") == "tool_call") > 1:
raise Invalid("an approval names exactly one tool_call; ask once per side effect (SO-37)")
decision = resolve_request(policy, capability, external)
stamp = now()
record: dict[str, Any] = {
"schema": "hux.approval.v1", "id": new_id("apr"), "run_id": body.get("run_id"), "conversation_id": conversation_id,
"capability": capability, "request": {**req, "external": external},
"status": {"allow": "approved", "deny": "denied", "ask": "pending"}[decision],
"requested_at": iso(stamp), "expires_at": iso(stamp + APPROVAL_TTL),
}
if decision != "ask":
record["decision"] = {"choice": "once" if decision == "allow" else "deny", "by": {"type": "system", "id": "policy"}, "at": iso(stamp)}
if key:
record["idempotency_key"] = key
with request.store.lock(APPROVALS):
stored = request.store.put(APPROVALS, checked(record))
if key:
request.store.append(APPROVALS, "idempotency", {"key": key, "id": stored["id"]})
request.audit("approvals.create", stored["id"], reason=f"policy_{decision}")
kind = "approval.requested" if decision == "ask" else "approval.resolved"
emit(request.store, request.identity, conversation_id, kind, f"{capability}: {record['request'].get('summary', '')}"[:280],
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
return Response(201, public(stored))
def list_approvals(request: Request) -> Response:
"""``GET /hux/v1/approvals?status=``: the queue, oldest request first."""
wanted = request.query.get("status")
if wanted and wanted not in rules.APPROVAL_TRANSITIONS:
raise Invalid("unknown status")
items = [refresh(request.store, r) for r in request.store.scan(APPROVALS)]
items = [public(r) for r in items if not wanted or r["status"] == wanted]
items.sort(key=lambda r: (r["requested_at"], r["id"]))
request.audit("approvals.list", wanted or "all")
return page(items)
def decide_approval(request: Request) -> Response:
"""``POST /hux/v1/approvals/{id}``: a human answers once, session, always or deny (SO-35)."""
identity = request.identity
if identity.trust not in HUMAN_TRUSTS or identity.surface not in HUMAN_SURFACES:
raise Forbidden("approvals are decided only from a human surface")
choice = body_dict(request).get("choice")
if choice not in ("once", "session", "always", "deny"):
raise Invalid("choice must be once, session, always or deny")
target = "denied" if choice == "deny" else "approved"
with request.store.lock(APPROVALS):
record = load_approval(request.store, request.params["id"])
if not rules.transition_allowed(rules.APPROVAL_TRANSITIONS, record["status"], target):
raise Conflict(f"approval is {record['status']}; only pending approvals can be decided")
record = {**record, "status": target, "decision": {"choice": choice, "by": actor_for(identity), "at": iso(now())}}
stored = request.store.put(APPROVALS, checked(record))
if choice == "session":
add_grant(request.store, identity, "conversation", record["conversation_id"], record["capability"], SESSION_TTL)
elif choice == "always":
add_grant(request.store, identity, "global", None, record["capability"], ALWAYS_TTL)
request.audit("approvals.decide", stored["id"], reason=choice)
emit(request.store, identity, record["conversation_id"], "approval.resolved", f"{record['capability']} {target} ({choice})",
run_id=record["run_id"], evidence=[{"kind": "approval", "id": stored["id"]}])
return Response(200, public(stored))
def get_approval(request: Request) -> Response:
"""``GET /hux/v1/approvals/{id}``: one record."""
record = load_approval(request.store, request.params["id"])
request.audit("approvals.read", record["id"])
return Response(200, public(record))
def register(router: Router) -> None:
"""Attach HUX-05 routes, including the budget, gate and stop routes from ``hux.budgets``."""
from hux import budgets
router.add("GET", "/hux/v1/policy", CARD, "policy.read", get_policy)
router.add("PUT", "/hux/v1/policy", CARD, "policy.write", put_policy)
router.add("POST", "/hux/v1/approvals", CARD, "approvals.create", create_approval)
router.add("GET", "/hux/v1/approvals", CARD, "approvals.list", list_approvals)
router.add("GET", "/hux/v1/approvals/{id}", CARD, "approvals.read", get_approval)
router.add("POST", "/hux/v1/approvals/{id}", CARD, "approvals.decide", decide_approval)
budgets.register_routes(router)

View File

@ -0,0 +1,273 @@
"""HUX-10 privacy behaviour: topic detection, notices, conversation forget and the retention job.
The policy itself lives in ``rules.privacy_policy``; this module records what
was shown, scopes detected topics to the conversation they appeared in, gates
memory writes for other families (``memory_write_allowed``) and runs the
daily retention audit that expires memory, decays topic context and purges
forgotten content while never touching the audit ledgers (SO-47).
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
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
FAMILY = "privacy"
RETENTION_FAMILY = "retention"
TOPICS_DOC = "topics_conversations"
TOPICS_SCHEMA = "hux.conversation_topics.v1"
CONTROLS = ("forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss")
SENSITIVITY_RANK = {"public": 0, "personal": 1, "sensitive": 2, "restricted": 3}
AUDIT_STALE_HOURS = 48
SCHEMAS = contracts.load_all()
# Deliberately narrow: a false positive only asks the user before remembering,
# a false negative would store something it should not. Detection is a hint,
# never proof, so callers treat "uncertain" as sensitive (SO-21).
TOPIC_PATTERNS: dict[str, re.Pattern[str]] = {
"credentials": re.compile(r"(?i)\b(password|passphrase|api[ _-]?key|secret key|private key|token|2fa|otp|ssh key|login credentials?)\b"),
"biometric": re.compile(r"(?i)\b(fingerprint|face ?id|retina|iris scan|voice ?print|dna|genome|biometric)\b"),
"minors": re.compile(r"(?i)\b(my (son|daughter|kid|kids|child|children|toddler|baby)|(?:\d{1,2}|[a-z]+)[- ]year[- ]old (?:son|daughter|boy|girl|child)|school ?(?:pickup|run)|daycare|minor child)\b"),
"location": re.compile(r"(?i)\b(home address|my address|lives? at \d|gps|coordinates|\d{1,5} [A-Z][a-z]+ (?:street|st|avenue|ave|road|rd|lane|ln|drive|dr)\b|latitude|longitude|track(?:ing)? (?:my|his|her) (?:phone|location))")
,
"health": re.compile(r"(?i)\b(diagnos(?:is|ed)|prescription|medication|therapist|therapy|symptoms?|doctor|clinic|hospital|surgery|chronic|depression|anxiety|cancer|diabetes|pregnan(?:t|cy)|mental health|blood (?:pressure|test))\b"),
"finance": re.compile(r"(?i)\b(salary|income|mortgage|bank account|credit card|iban|routing number|debt|loan|tax return|net worth|investment|401k|savings account|bankrupt(?:cy)?)\b"),
"legal": re.compile(r"(?i)\b(lawsuit|attorney|lawyer|court (?:date|case|order)|subpoena|arrest(?:ed)?|custody|divorce filing|criminal (?:record|charge)|restraining order|litigation)\b"),
"relationships": re.compile(r"(?i)\b(my (?:wife|husband|partner|girlfriend|boyfriend|ex)|breakup|broke up|divorce|affair|dating|marriage counsel(?:l)?ing)\b"),
}
def detect_topics(text: str) -> list[str]:
"""Sensitive topics a piece of text looks like it touches, most restrictive first. Heuristic and uncertain by design."""
found = [topic for topic, pattern in TOPIC_PATTERNS.items() if pattern.search(text or "")]
return sorted(found, key=lambda topic: (-SENSITIVITY_RANK[rules.PRIVACY_TOPICS[topic]["sensitivity"]], topic))
def topic_sensitivity(topics: list[str]) -> str:
"""Highest sensitivity among detected topics; ``personal`` when nothing matched."""
ranked = sorted((rules.PRIVACY_TOPICS[t]["sensitivity"] for t in topics), key=SENSITIVITY_RANK.get, reverse=True)
return ranked[0] if ranked else "personal"
def _parse(stamp: str) -> datetime:
return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
def _iso(when: datetime) -> str:
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load_topics(store: TenantStore) -> dict[str, Any]:
"""The ``conversation_topics`` document: conversation id -> scoping state."""
if store.exists(FAMILY, TOPICS_DOC):
return store.get(FAMILY, TOPICS_DOC)
return {"id": TOPICS_DOC, "schema": TOPICS_SCHEMA, "items": {}}
def _save_topics(store: TenantStore, doc: dict[str, Any]) -> None:
store.put(FAMILY, doc)
def conversation_state(store: TenantStore, conversation_id: str) -> dict[str, Any]:
"""Scoping row for one conversation (empty when nothing was ever detected)."""
return load_topics(store)["items"].get(conversation_id, {})
def mark_topic(store: TenantStore, conversation_id: str, topic: str, now: str | None = None) -> dict[str, Any]:
"""Scope ``topic`` to ``conversation_id``; the first sighting fixes ``decay_at``."""
now = now or now_iso()
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now, "memory_disabled": False, "forgotten": False})
if topic not in row["topics"]:
row["topics"].append(topic)
decay = _parse(row["first_seen"]) + timedelta(days=min(rules.PRIVACY_TOPICS[t]["decay_days"] for t in row["topics"]))
row["decay_at"] = _iso(decay)
_save_topics(store, doc)
return row
def set_flag(store: TenantStore, conversation_id: str, flag: str, value: bool) -> None:
"""Set ``memory_disabled`` or ``forgotten`` for a conversation."""
with store.lock(FAMILY):
doc = load_topics(store)
row = doc["items"].setdefault(conversation_id, {"topics": [], "first_seen": now_iso(), "memory_disabled": False, "forgotten": False})
row[flag] = value
_save_topics(store, doc)
def memory_write_allowed(store: TenantStore, conversation_id: str | None, entry: dict[str, Any]) -> tuple[bool, str]:
"""Gate every memory write (SO-21, SO-23, SO-28). Returns (allowed, reason)."""
from hux import events
if conversation_id:
if events.is_private(store, conversation_id):
return False, "private_mode"
state = conversation_state(store, conversation_id)
if state.get("forgotten"):
return False, "conversation_forgotten"
if state.get("memory_disabled"):
return False, "memory_disabled"
if entry.get("sensitivity") == "restricted":
return False, "restricted"
topic = entry.get("topic", "general")
if topic in rules.PRIVACY_TOPICS and rules.PRIVACY_TOPICS[topic]["memory_write"] == "deny":
return False, f"topic_{topic}"
return True, "allowed"
def blocked_conversations(store: TenantStore) -> set[str]:
"""Conversations that contribute nothing to retrieval: forgotten or memory disabled (SO-23)."""
return {cid for cid, row in load_topics(store)["items"].items() if row.get("forgotten") or row.get("memory_disabled")}
def latest_audit(store: TenantStore) -> dict[str, Any] | None:
"""Newest retention audit record, or None when the job never ran."""
rows = sorted(store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"])
return rows[-1] if rows else None
def audit_stale(store: TenantStore, now: datetime | None = None) -> bool:
"""SO-27: no retention audit in the last 48 hours."""
last = latest_audit(store)
now = now or datetime.now(timezone.utc)
return last is None or now - _parse(last["ran_at"]) > timedelta(hours=AUDIT_STALE_HOURS)
# -- retention ----------------------------------------------------------------
def run_retention(store: TenantStore, now: datetime | None = None, identity: Identity | None = None) -> dict[str, Any]:
"""Expire memory, decay topic context, purge forgotten content, report; write and return ``hux.retention_audit.v1``."""
from hux import events, memory
now = now or datetime.now(timezone.utc)
expired = memory.expire_due(store, now)
decayed = 0
with store.lock(FAMILY):
doc = load_topics(store)
for conversation_id, row in doc["items"].items():
if row.get("decayed") or not row.get("topics") or _parse(row.get("decay_at", "9999-01-01T00:00:00Z")) > now:
continue
notice = rules.privacy_policy()["topics"]
text = next(t["notice"] for t in notice if t["topic"] == row["topics"][0])
decayed += events.rewrite_full(store, conversation_id, text, "topic context decayed", lambda r: r.get("sensitivity") in {"sensitive", "restricted"})
row["decayed"] = True
_save_topics(store, doc)
purged = memory.purge_forgotten(store)
record = {
"schema": "hux.retention_audit.v1",
"id": new_id("aud"),
"ran_at": _iso(now),
"policy_version": rules.privacy_policy()["version"],
"results": [
{"action": "expire_memory", "count": expired},
{"action": "decay_topic_context", "count": decayed},
{"action": "purge_forgotten_content", "count": purged},
{"action": "report", "count": 1},
],
}
problems = contracts.validate_record(record, SCHEMAS)
if problems:
raise Invalid("retention audit failed contract validation", problems)
return _strip(store.put(RETENTION_FAMILY, record))
def forget_conversation(store: TenantStore, identity: Identity, conversation_id: str) -> dict[str, Any]:
"""Mark a conversation forgotten: its memory entries move to forgotten, its events go to full redaction."""
from hux import events, memory
set_flag(store, conversation_id, "forgotten", True)
forgotten_ids = memory.forget_from_conversation(store, identity, conversation_id)
redacted = events.rewrite_full(store, conversation_id, "[forgotten conversation]", "conversation forgotten")
counts = {"memory_forgotten": len(forgotten_ids), "events_redacted": redacted, "document_blanked": _blank_document(store, conversation_id)}
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
return {"conversation_id": conversation_id, "forgotten": True, **counts}
def _blank_document(store: TenantStore, conversation_id: str) -> bool:
"""Blank the HUX-03 conversation document (title, tags, archived) when the organisation lane holds one (F9)."""
try:
from hux import organization
except ModuleNotFoundError:
return False
return organization.mark_forgotten(store, conversation_id)
# -- routes ---------------------------------------------------------------------
def _strip(record: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in record.items() if key != "revision"}
def get_policy(request: Request) -> Response:
"""``GET /hux/v1/privacy/policy``: the frozen policy; ``HUX-Audit-Stale`` says whether retention ran lately (SO-27)."""
request.audit("privacy.policy", "policy")
stale = audit_stale(request.store)
return Response(200, rules.privacy_policy(), {"HUX-Audit-Stale": "true" if stale else "false"})
def post_notice(request: Request) -> Response:
"""``POST /hux/v1/privacy/notices``: record a just-in-time notice as shown and scope the topic to the conversation."""
from hux import events
body = request.body if isinstance(request.body, dict) else {}
topic, conversation_id = body.get("topic"), body.get("conversation_id")
if topic not in rules.PRIVACY_TOPICS or not isinstance(conversation_id, str):
raise Invalid("topic and conversation_id are required")
controls = [c for c in body.get("controls", list(CONTROLS)) if c in CONTROLS] or list(CONTROLS)
text = next(t["notice"] for t in rules.privacy_policy()["topics"] if t["topic"] == topic)
notice = {"schema": "hux.privacy_notice.v1", "topic": topic, "conversation_id": conversation_id, "text": text, "controls": sorted(set(controls), key=CONTROLS.index), "shown_at": now_iso()}
problems = contracts.validate_record(notice, SCHEMAS)
if problems:
raise Invalid("notice failed contract validation", problems)
request.store.append(FAMILY, "notices", notice)
mark_topic(request.store, conversation_id, topic)
chosen = body.get("chosen")
if chosen == "disable_memory_here":
set_flag(request.store, conversation_id, "memory_disabled", True)
elif chosen == "forget_this_conversation":
forget_conversation(request.store, request.identity, conversation_id)
# F13a: a notice for a conversation this subject never had must not conjure
# an event ledger for it; the notice itself is still on record.
known = events.conversation_known(request.store, conversation_id)
if known:
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
request.audit("privacy.notice", conversation_id, "allow", "" if known else "unknown_conversation")
return Response(201, notice)
def post_forget(request: Request) -> Response:
"""``POST /hux/v1/conversations/{id}/forget``: user-initiated forget; unknown conversations are 404 (SO-18)."""
from hux import events
conversation_id = request.params["id"]
if not (events.conversation_known(request.store, conversation_id) or conversation_id in load_topics(request.store)["items"]):
raise NotFound("conversation not found")
result = forget_conversation(request.store, request.identity, conversation_id)
request.audit("privacy.forget", conversation_id)
return Response(200, result)
def get_audit(request: Request) -> Response:
"""``GET /hux/v1/privacy/audit``: retention audit records, newest first."""
rows = sorted(request.store.scan(RETENTION_FAMILY), key=lambda row: row["ran_at"], reverse=True)
request.audit("privacy.audit", "retention")
return page([_strip(row) for row in rows[:PAGE_LIMIT]], None)
PAGE_LIMIT = 200
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)

View File

@ -0,0 +1,229 @@
"""Redaction pipeline shared by every HUX family (SO-11..SO-14, SO-19).
Order matters and is fixed here: drop detail keys outside the per-kind
allowlist, scrub secret patterns from every remaining string, cap sizes,
then derive ``redaction.level``. Serve-time redaction (``redact_record``) is
applied again on the way out so a reader that ignores the level still never
sees more than the surface is allowed to.
"""
from __future__ import annotations
import json
import os
import re
from typing import Any
DETAIL_CAP_BYTES = 32 * 1024
LINE_CAP_BYTES = 64 * 1024
SUMMARY_MAX = 280
URI_SCHEMES = ("https://", "hux://", "artifact://")
WORKSPACE_PREFIX = "/opt/data/workspace"
HASH_KEYS = frozenset({"hash", "argument_hash"})
HUMAN_SURFACES = frozenset({"telegram", "voice"})
FULL_KEEP = frozenset({"schema", "id", "seq", "ts", "conversation_id", "kind", "provenance", "sensitivity", "redaction", "turn", "identity"})
# Each pattern is (class, regex). Classes end up in the placeholder so a
# reviewer can tell what kind of thing was removed without seeing it.
SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)")),
("bearer", re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{16,}")),
("jwt", re.compile(r"eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]+)?")),
("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
("github_token", re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}")),
("gitlab_token", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
("slack_token", re.compile(r"xox[abp]-[A-Za-z0-9-]{10,}")),
("vault_token", re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
("password", re.compile(r"(?i)(?:password|passwd|pwd|secret|token)\s*[=:]\s*\S+")),
("hex", re.compile(r"\b[0-9a-fA-F]{32,}\b")),
("base64", re.compile(r"\b(?=[A-Za-z0-9+/]*[0-9])(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])[A-Za-z0-9+/]{40,}={0,2}")),
)
# Detail keys each event kind may persist. Everything else is dropped before
# a byte reaches disk, never filtered on read only.
DETAIL_ALLOWLIST: dict[str, frozenset[str]] = {
"message.user": frozenset({"message_id", "chars", "has_attachments"}),
"message.assistant": frozenset({"message_id", "chars", "has_attachments"}),
"decision.route": frozenset({"requested", "resolved_target", "provider", "effort", "reason"}),
"decision.plan": frozenset({"steps"}),
"tool.call": frozenset({"tool", "capability", "argument_names", "argument_hash", "argument_bytes", "target_path"}),
"tool.result": frozenset({"tool", "ok", "duration_ms", "bytes", "exit_code"}),
"approval.requested": frozenset({"approval_id", "capability", "choice", "external"}),
"approval.resolved": frozenset({"approval_id", "capability", "choice", "external"}),
"side_effect.blocked": frozenset({"approval_id", "capability", "choice", "external"}),
"side_effect.released": frozenset({"approval_id", "capability", "choice", "external"}),
"memory.proposed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.committed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.forgotten": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.suppressed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"memory.retrieval_removed": frozenset({"memory_id", "kind", "sensitivity", "topic"}),
"artifact.created": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"artifact.version": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"artifact.promoted": frozenset({"artifact_id", "version", "type", "bytes", "hash"}),
"run.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.cancelled": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"run.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.started": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.completed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"delegation.failed": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"budget.exhausted": frozenset({"run_id", "outcome", "receipt_id", "spent", "limits", "child_run_id"}),
"privacy.notice": frozenset({"schema", "topic", "conversation_id", "text", "controls", "shown_at"}),
"citation.attached": frozenset({"citation_id", "message_id", "passage_ids", "verdict"}),
"mode.changed": frozenset({"mode", "previous_mode", "route_id"}),
"suggestion.shown": frozenset({"suggestion_id", "action"}),
"suggestion.dismissed": frozenset({"suggestion_id", "action"}),
"release.transition": frozenset({"release_id", "state", "previous_state"}),
}
def canaries(environ: dict[str, str] | None = None) -> list[str]:
"""Literal secrets the pod was started with (SO-07); every one is scrubbed wherever it appears."""
environ = os.environ if environ is None else environ
values = [environ.get(name, "") for name in ("HUX_RELAY_KEY", "HUX_WORKER_KEY")]
path = environ.get("HUX_CANARY_FILE", "")
if path and os.path.exists(path):
with open(path, encoding="utf-8", errors="replace") as handle:
for line in handle:
_, _, value = line.strip().partition("=")
values.append(value.strip().strip("'\""))
return [v for v in values if len(v) >= 8]
def scrub_text(text: str, environ: dict[str, str] | None = None) -> tuple[str, list[str]]:
"""Replace secret-looking substrings with ``[redacted:<class>]``; return the text and the classes hit."""
hits: list[str] = []
for canary in canaries(environ):
if canary in text:
text = text.replace(canary, "[redacted:canary]")
hits.append("canary")
for name, pattern in SECRET_PATTERNS:
text, count = pattern.subn(f"[redacted:{name}]", text)
if count:
hits.append(name)
return text, hits
def scrub_value(value: Any, hits: list[str], environ: dict[str, str] | None = None) -> Any:
"""Scrub every string inside a JSON-like value, collecting classes into ``hits``.
Content hashes (``hash``, ``argument_hash``) are digests by construction and
are kept verbatim so the hex rule does not eat them.
"""
if isinstance(value, str):
text, found = scrub_text(value, environ)
hits.extend(found)
return text
if isinstance(value, list):
return [scrub_value(item, hits, environ) for item in value]
if isinstance(value, dict):
return {
str(key): item if key in HASH_KEYS and isinstance(item, str) else scrub_value(item, hits, environ)
for key, item in value.items()
}
return value
def filter_detail(kind: str, detail: dict[str, Any] | None) -> dict[str, Any]:
"""Keep only the allowlisted keys for ``kind`` (SO-11) and apply the kind-specific shape rules."""
if not detail:
return {}
allowed = DETAIL_ALLOWLIST.get(kind, frozenset())
kept = {key: value for key, value in detail.items() if key in allowed}
if kind == "decision.plan" and "steps" in kept:
steps = kept["steps"] if isinstance(kept["steps"], list) else []
kept["steps"] = [str(step)[:200] for step in steps[:20]]
if kind == "tool.call":
# Raw arguments never persist: only their names, a hash and a byte count.
# F13d: normalise before the prefix check so ``workspace/../.env`` cannot
# pass as a workspace path; the stored value is the normalised one.
path = os.path.normpath(kept["target_path"]) if isinstance(kept.get("target_path"), str) else ""
if path.startswith(WORKSPACE_PREFIX + "/"):
kept["target_path"] = path
else:
kept.pop("target_path", None)
if "argument_names" in kept:
names = kept["argument_names"] if isinstance(kept["argument_names"], list) else []
kept["argument_names"] = sorted({str(name)[:80] for name in names})[:64]
return kept
def cap_detail(detail: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Replace detail over 32 KiB with a truncation marker (SO-13)."""
size = len(json.dumps(detail, sort_keys=True, separators=(",", ":")).encode())
if size > DETAIL_CAP_BYTES:
return {"truncated": True, "bytes": size}, True
return detail, False
def filter_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
"""Keep well-formed refs, drop URIs outside the allowlisted schemes (SO-19), cap at 64."""
kept: list[dict[str, Any]] = []
for ref in evidence or []:
if not isinstance(ref, dict) or not isinstance(ref.get("kind"), str) or not isinstance(ref.get("id"), str):
continue
clean = {"kind": ref["kind"], "id": ref["id"][:200]}
uri = ref.get("uri")
if isinstance(uri, str) and uri.startswith(URI_SCHEMES):
clean["uri"] = uri[:2000]
if isinstance(ref.get("hash"), str):
clean["hash"] = ref["hash"]
kept.append(clean)
return kept[:64]
def derive_level(sensitivity: str, hits: list[str], truncated: bool) -> dict[str, str]:
"""Redaction block for a new event from its sensitivity and what the pipeline did."""
reasons: list[str] = []
level = "none"
if sensitivity == "sensitive":
level, reasons = "partial", ["sensitive topic"]
if hits:
level = "partial" if level == "none" else level
reasons.append("scrubbed " + ",".join(sorted(set(hits)))[:120])
if truncated:
level = "partial" if level == "none" else level
reasons.append("detail truncated")
if sensitivity == "restricted":
level, reasons = "full", ["restricted content"]
block = {"level": level}
if reasons:
block["reason"] = "; ".join(reasons)[:200]
return block
def redact_record(record: dict[str, Any], surface: str) -> dict[str, Any]:
"""Serve-time redaction (SO-14) for any record that carries ``redaction``; strips ``_meta`` (SO-51).
``none`` is served as stored except that telegram and voice are lifted to
``partial``; ``partial`` drops ``detail``; ``full`` keeps only the
contract-required envelope with the summary replaced by a placeholder.
"""
served = {key: value for key, value in record.items() if key != "_meta"}
block = served.get("redaction")
if not isinstance(block, dict):
return served
level = block.get("level", "none")
if level == "none" and surface in HUMAN_SURFACES:
level = "partial"
served["redaction"] = {"level": "partial", "reason": f"{surface} surface never receives unredacted detail"}
if level == "partial":
served.pop("detail", None)
if level == "full":
served = {key: value for key, value in served.items() if key in FULL_KEEP}
if "summary" in record:
served["summary"] = "[redacted]"
if "content" in record:
served["content"] = ""
return served
def full_redaction(record: dict[str, Any], summary: str, reason: str) -> dict[str, Any]:
"""Rewrite a stored event to ``redaction.level: full`` in place (forget and topic decay)."""
kept = {key: value for key, value in record.items() if key in FULL_KEEP}
kept["summary"] = summary[:SUMMARY_MAX] or "[redacted]"
kept["redaction"] = {"level": "full", "reason": reason[:200]}
if "idempotency_key" in record:
kept["idempotency_key"] = record["idempotency_key"]
return kept

View File

@ -0,0 +1,384 @@
"""HUX-08 research model: sources, passages, citations and notebooks.
A source says where evidence came from, a passage is the exact excerpt, a
citation ties a claim in a message to passages with a support verdict, and a
notebook collects them per research question. The service never fetches a
``uri`` (SO-19, SO-29): URIs are stored metadata under an allowlisted scheme.
Every referenced id resolves under the caller's own subtree (SO-33); a foreign
or unknown id is ``404`` so ids cannot be probed. Duplicates are merged on a
server-computed ``dedupe_key`` and the original record is returned.
"""
from __future__ import annotations
import hashlib
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from hux.artifacts import remember, replay
from hux import redaction
from hux.contracts import load_all, validate_record
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.http import Request, Response, Router, page
from hux.identity import Identity
from hux.store import TenantStore, check_id, new_id, now_iso
FAMILY = "research"
SOURCES, PASSAGES, CITATIONS, NOTEBOOKS = "sources", "passages", "citations", "notebooks"
SCHEMES = frozenset({"http", "https", "artifact", "memory", "file"})
SOURCE_KINDS = ("web", "document", "artifact", "memory", "tool_output", "dataset")
CLASSIFICATIONS = ("primary", "secondary", "unknown")
SUPPORT = ("supports", "partially_supports", "contradicts", "unverified")
NOTEBOOK_TRANSITIONS = {"open": frozenset({"answered", "abandoned"}), "answered": frozenset(), "abandoned": frozenset()}
MAX_SOURCES, MAX_PASSAGES = 10000, 20000
_SCHEMAS: dict[str, dict[str, Any]] = {}
def _schemas() -> dict[str, dict[str, Any]]:
if not _SCHEMAS:
_SCHEMAS.update(load_all())
return _SCHEMAS
def _check(record: dict[str, Any]) -> None:
problems = validate_record(record, _schemas())
if problems:
raise Invalid(f"{record.get('schema')} record is not valid", problems)
def _body(request: Request) -> dict[str, Any]:
if not isinstance(request.body, dict):
raise Invalid("body must be a JSON object")
return request.body
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
value = body.get(key)
if value is None:
if required:
raise Invalid(f"{key} is required")
return None
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
return value
def _clean(value: Any) -> Any:
"""Secret-scrub free text before it is stored (F9); ids and enums never carry secrets so they skip this."""
return redaction.scrub_value(value, [])
def _sha(*parts: str) -> str:
return "sha256:" + hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
def _lookup(store: TenantStore, ledger: str, key: str) -> str | None:
for row in store.read(FAMILY, ledger):
if row.get("key") == key:
return row["id"]
return None
def _resolve(store: TenantStore, family: str, record_id: Any) -> dict[str, Any]:
"""Load a record under the caller's subtree; malformed or missing is 404 (SO-33).
Sources, passages and citations are immutable, so the store's ``revision``
bookkeeping is stripped before they are served; notebooks keep theirs.
"""
try:
record = store.get(family, check_id(record_id))
except (Invalid, NotFound) as error:
raise NotFound(f"{family[:-1]} not found") from error
return record if family == NOTEBOOKS else {k: v for k, v in record.items() if k != "revision"}
def _id_list(body: dict[str, Any], key: str, maximum: int = 32) -> list[str]:
values = body.get(key, [])
if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
raise Invalid(f"{key} must be a list of ids")
unique = list(dict.fromkeys(values))
if len(unique) > maximum:
raise TooLarge(f"{key} exceeds {maximum} ids")
return unique
def _emit(store: TenantStore, identity: Identity, conversation_id: str | None, kind: str, summary: str, detail: dict[str, Any], evidence: list[dict[str, Any]]) -> None:
if conversation_id is None:
return
try:
from hux.events import emit
except ModuleNotFoundError:
return
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence)
# -- sources ---------------------------------------------------------------------
def normalise_uri(uri: str) -> str:
"""Canonical form used for dedupe: lowercase scheme and host, no fragment, no trailing slash."""
parts = urlsplit(uri.strip())
if parts.scheme.lower() not in SCHEMES:
raise Invalid("uri scheme must be one of http, https, artifact, memory, file")
path = parts.path.rstrip("/") or ("/" if parts.netloc else "")
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
def create_source(request: Request) -> Response:
"""``POST /hux/v1/sources``: record where evidence came from; duplicates return the original."""
body = _body(request)
key = request.idempotency_key()
kind = _string(body, "kind", 40)
if kind not in SOURCE_KINDS:
raise Invalid("unknown source kind")
classification = body.get("classification", "unknown")
if classification not in CLASSIFICATIONS:
raise Invalid("unknown classification")
title = _clean(_string(body, "title", 300))
uri = _string(body, "uri", 2000, required=False)
dedupe = _sha("uri", normalise_uri(uri)) if uri is not None else _sha("title", kind, title.strip().lower())
stamp = now_iso()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "source_keys", dedupe)
if existing is not None:
request.audit("research.source_create", existing, reason="deduplicated")
return Response(200, _resolve(request.store, SOURCES, existing), {"HUX-Replayed": "true"})
if request.store.count(SOURCES) >= MAX_SOURCES:
raise TooLarge("tenant has reached 10000 sources")
record: dict[str, Any] = {
"schema": "hux.source.v1", "id": new_id("src"), "kind": kind, "title": title,
"classification": classification, "retrieved_at": stamp, "dedupe_key": dedupe,
"provenance": {"surface": request.identity.surface, "actor": _actor(request.identity), "recorded_at": stamp},
}
if uri is not None:
record["uri"] = uri
for field, limit in (("publisher", 200), ("published_at", 40), ("content_hash", 71)):
if _string(body, field, limit, required=False) is not None:
record[field] = body[field]
conversation_id = body.get("conversation_id")
if conversation_id is not None:
record["provenance"]["conversation_id"] = check_id(conversation_id)
_check(record)
stored = record | {}
request.store.put(SOURCES, record)
request.store.append(FAMILY, "source_keys", {"key": dedupe, "id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.source_create", stored["id"])
return Response(201, stored)
def _actor(identity: Identity) -> dict[str, str]:
if identity.trust == "worker":
return {"type": "system", "id": "worker"}
return {"type": "user", "id": identity.subject}
def get_source(request: Request) -> Response:
"""``GET /hux/v1/sources/{id}``: one source record."""
record = _resolve(request.store, SOURCES, request.params["id"])
request.audit("research.source_get", record["id"])
return Response(200, record)
# -- passages --------------------------------------------------------------------
def create_passage(request: Request) -> Response:
"""``POST /hux/v1/passages``: an exact excerpt of a source; hash and dedupe key are server-computed."""
body = _body(request)
key = request.idempotency_key()
source = _resolve(request.store, SOURCES, body.get("source_id"))
text = _clean(_string(body, "text", 4000))
text_hash = "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
dedupe = _sha("passage", source["id"], text_hash)
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "passage_keys", dedupe)
if existing is not None:
request.audit("research.passage_create", existing, reason="deduplicated")
return Response(200, _resolve(request.store, PASSAGES, existing), {"HUX-Replayed": "true"})
if request.store.count(PASSAGES) >= MAX_PASSAGES:
raise TooLarge("tenant has reached 20000 passages")
record: dict[str, Any] = {"schema": "hux.passage.v1", "id": new_id("psg"), "source_id": source["id"], "text": text, "hash": text_hash, "dedupe_key": dedupe}
if body.get("locator") is not None:
record["locator"] = body["locator"]
_check(record)
stored = record | {}
request.store.put(PASSAGES, record)
request.store.append(FAMILY, "passage_keys", {"key": dedupe, "id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.passage_create", stored["id"])
return Response(201, stored)
# -- citations -------------------------------------------------------------------
def _message_ledger(message_id: str) -> str:
return "msg_" + hashlib.sha256(message_id.encode("utf-8")).hexdigest()[:40]
def attach_citation(request: Request) -> Response:
"""``POST /hux/v1/messages/{id}/citations``: tie a claim to passages with a support verdict."""
body = _body(request)
key = request.idempotency_key()
message_id = request.params["id"]
if len(message_id) > 120:
raise Invalid("message id is too long")
claim = _clean(_string(body, "claim", 1000))
support = body.get("support", "unverified")
if support not in SUPPORT:
raise Invalid("unknown support verdict")
passage_ids = _id_list(body, "passage_ids")
if not passage_ids:
raise Invalid("at least one passage_id is required")
passages = [_resolve(request.store, PASSAGES, pid) for pid in passage_ids]
dedupe = _sha("citation", message_id, claim, *sorted(passage_ids))
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key) or _lookup(request.store, "citation_keys", dedupe)
if existing is not None:
request.audit("research.citation_attach", existing, reason="deduplicated")
return Response(200, _resolve(request.store, CITATIONS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {"schema": "hux.citation.v1", "id": new_id("cit"), "message_id": message_id, "claim": claim, "passage_ids": passage_ids, "support": support, "dedupe_key": dedupe}
if _string(body, "note", 500, required=False) is not None:
record["note"] = _clean(body["note"])
_check(record)
stored = record | {}
request.store.put(CITATIONS, record)
request.store.append(FAMILY, "citation_keys", {"key": dedupe, "id": stored["id"]})
request.store.append(FAMILY, _message_ledger(message_id), {"id": stored["id"]})
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.citation_attach", stored["id"])
evidence = [{"kind": "passage", "id": p["id"], "hash": p["hash"]} for p in passages]
conversation_id = body.get("conversation_id")
_emit(request.store, request.identity, None if conversation_id is None else check_id(conversation_id), "citation.attached", f"Citation attached ({support})", {"message_id": message_id, "citation_id": stored["id"], "support": support}, evidence)
return Response(201, stored)
def list_citations(request: Request) -> Response:
"""``GET /hux/v1/messages/{id}/citations``: citations with passages and sources embedded."""
items = []
for row in request.store.read(FAMILY, _message_ledger(request.params["id"])):
citation = _resolve(request.store, CITATIONS, row["id"])
passages = [_resolve(request.store, PASSAGES, pid) for pid in citation["passage_ids"]]
sources = {p["source_id"]: _resolve(request.store, SOURCES, p["source_id"]) for p in passages}
items.append({"citation": citation, "passages": passages, "sources": list(sources.values())})
request.audit("research.citation_list", request.params["id"])
return page(items)
def validate_citations(store: TenantStore, citation_ids: list[str]) -> list[str]:
"""Integrity problems for a set of citations: dangling passages, passage/source mismatch, contradictions without a note."""
problems: list[str] = []
for citation_id in citation_ids:
try:
citation = _resolve(store, CITATIONS, citation_id)
except NotFound:
problems.append(f"{citation_id}: citation does not resolve")
continue
if citation["support"] == "contradicts" and not citation.get("note"):
problems.append(f"{citation_id}: contradicts without a note")
for passage_id in citation["passage_ids"]:
try:
passage = _resolve(store, PASSAGES, passage_id)
except NotFound:
problems.append(f"{citation_id}: passage {passage_id} does not resolve")
continue
if not store.exists(SOURCES, passage["source_id"]):
problems.append(f"{citation_id}: passage {passage_id} names missing source {passage['source_id']}")
return problems
# -- notebooks -------------------------------------------------------------------
def _text_list(body: dict[str, Any], key: str, current: list[str]) -> list[str]:
values = body.get(key)
if values is None:
return current
if not isinstance(values, list) or not all(isinstance(v, str) and v.strip() for v in values):
raise Invalid(f"{key} must be a list of non-empty strings")
return _clean(values)
def create_notebook(request: Request) -> Response:
"""``POST /hux/v1/notebooks``: open a research question for a conversation."""
body = _body(request)
key = request.idempotency_key()
with request.store.lock(FAMILY):
existing = replay(request.store, FAMILY, key)
if existing is not None:
request.audit("research.notebook_create", existing, reason="idempotent_replay")
return Response(200, request.store.get(NOTEBOOKS, existing), {"HUX-Replayed": "true"})
record: dict[str, Any] = {
"schema": "hux.research_notebook.v1", "id": new_id("nb"), "conversation_id": check_id(body.get("conversation_id")),
"question": _clean(_string(body, "question", 1000)), "status": "open", "source_ids": [], "passage_ids": [], "citation_ids": [],
"assumptions": _text_list(body, "assumptions", []), "unresolved_questions": _text_list(body, "unresolved_questions", []),
"updated_at": now_iso(), "notes": [], "revision": 1,
}
_check(record)
stored = request.store.put(NOTEBOOKS, record)
remember(request.store, FAMILY, key, stored["id"])
request.audit("research.notebook_create", stored["id"])
return Response(201, stored, {"ETag": "1"})
def get_notebook(request: Request) -> Response:
"""``GET /hux/v1/notebooks/{id}``: one notebook."""
record = _resolve(request.store, NOTEBOOKS, request.params["id"])
request.audit("research.notebook_get", record["id"])
return Response(200, record, {"ETag": str(record["revision"])})
def _merged_ids(store: TenantStore, family: str, current: list[str], body: dict[str, Any], key: str) -> list[str]:
added = _id_list(body, key, maximum=500)
for record_id in added:
_resolve(store, family, record_id)
return list(dict.fromkeys([*current, *added]))
def patch_notebook(request: Request) -> Response:
"""``PATCH /hux/v1/notebooks/{id}``: add references and notes, replace assumptions, move status (If-Match required)."""
body = _body(request)
expected = request.if_match()
if expected is None:
raise Invalid("If-Match is required")
with request.store.lock(FAMILY):
notebook = _resolve(request.store, NOTEBOOKS, request.params["id"])
if expected != notebook["revision"]:
raise Conflict(f"revision {expected} does not match current revision {notebook['revision']}", [str(notebook["revision"])])
stamp = now_iso()
updated = {
**notebook,
"source_ids": _merged_ids(request.store, SOURCES, notebook["source_ids"], body, "add_source_ids"),
"passage_ids": _merged_ids(request.store, PASSAGES, notebook["passage_ids"], body, "add_passage_ids"),
"citation_ids": _merged_ids(request.store, CITATIONS, notebook["citation_ids"], body, "add_citation_ids"),
"assumptions": _text_list(body, "assumptions", notebook["assumptions"]),
"unresolved_questions": _text_list(body, "unresolved_questions", notebook["unresolved_questions"]),
"updated_at": stamp,
}
notes = body.get("add_notes", [])
if not isinstance(notes, list) or not all(isinstance(n, dict) for n in notes):
raise Invalid("add_notes must be a list of objects")
for note in notes:
entry = {"at": stamp, "text": _clean(_string(note, "text", 2000))}
if note.get("source_id") is not None:
entry["source_id"] = _resolve(request.store, SOURCES, note["source_id"])["id"]
updated["notes"] = [*updated["notes"], entry]
status = body.get("status")
if status is not None and status != notebook["status"]:
if status not in NOTEBOOK_TRANSITIONS or status not in NOTEBOOK_TRANSITIONS[notebook["status"]]:
raise Conflict(f"notebook cannot move from {notebook['status']} to {status}")
updated["status"] = status
_check(updated)
stored = request.store.put(NOTEBOOKS, updated, expected_revision=expected)
request.audit("research.notebook_patch", stored["id"])
return Response(200, stored, {"ETag": str(stored["revision"])})
def register(router: Router) -> None:
"""Attach HUX-08 routes."""
card = "HUX-08"
router.add("POST", "/hux/v1/sources", card, "research.source_create", create_source)
router.add("GET", "/hux/v1/sources/{id}", card, "research.source_get", get_source)
router.add("POST", "/hux/v1/passages", card, "research.passage_create", create_passage)
router.add("POST", "/hux/v1/messages/{id}/citations", card, "research.citation_attach", attach_citation)
router.add("GET", "/hux/v1/messages/{id}/citations", card, "research.citation_list", list_citations)
router.add("POST", "/hux/v1/notebooks", card, "research.notebook_create", create_notebook)
router.add("GET", "/hux/v1/notebooks/{id}", card, "research.notebook_get", get_notebook)
router.add("PATCH", "/hux/v1/notebooks/{id}", card, "research.notebook_patch", patch_notebook)

View File

@ -13,7 +13,7 @@ import os
from datetime import datetime, timedelta, timezone
from typing import Any
from hux_contracts import load_flags
from hux.contracts import load_flags
EFFORT_ORDER = ("low", "medium", "high", "xhigh")
@ -44,11 +44,12 @@ RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = {
CAPABILITIES = (
"read_files", "write_files", "shell", "network", "web_search", "send_message",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy",
"memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", "external_side_effect",
)
_READ_ONLY = frozenset({"read_files", "web_search", "spend_tokens"})
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
_EXTERNAL = frozenset({"network", "deploy"})
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
MODE_CATALOG: dict[str, dict[str, Any]] = {
"fast": {
@ -123,11 +124,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
topic = entry.get("topic", "general")
if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}:
problems.append("restricted content may not be remembered")
if sensitivity == "sensitive" and entry.get("approval_mode") != "ask":
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
problems.append("sensitive memory requires approval_mode=ask")
if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never":
if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never":
problems.append("sensitive memory must expire or decay")
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected":
if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored:
problems.append(f"topic {topic} may not be written to memory")
ttl = entry.get("ttl", {})
if ttl.get("policy") == "expires_at" and "expires_at" not in ttl:
@ -140,12 +142,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]:
def default_capability_matrix() -> dict[str, dict[str, str]]:
"""Autonomy level -> capability -> allow|ask|deny. Deploy always asks."""
"""Autonomy level -> capability -> allow|ask|deny. Deploy and external side effects always ask."""
matrix: dict[str, dict[str, str]] = {}
for level in ("ask_first", "safe", "autonomous"):
row: dict[str, str] = {}
for capability in CAPABILITIES:
if capability == "deploy":
if capability in _ALWAYS_ASK:
row[capability] = "ask"
elif capability in _READ_ONLY:
row[capability] = "allow"
@ -171,7 +173,7 @@ def effective_decision(policy: dict[str, Any], capability: str, now: datetime |
if grant["decision"] == "deny":
return "deny"
decision = grant["decision"]
if capability == "deploy" and decision == "allow":
if capability in _ALWAYS_ASK and decision == "allow":
return "ask"
return decision

View File

@ -0,0 +1,48 @@
"""Process entry point: ``python -m hux.server``.
Environment: ``HUX_DATA_ROOT`` (tenant PVC path, default /opt/data),
``HUX_BIND`` (default 127.0.0.1), ``HUX_PORT`` (default 8790), ``HUX_FLAGS``,
``HUX_RELAY_KEY``, ``HUX_WORKER_KEY``, ``HUX_BUILD_COMMIT``, ``HUX_IMAGE_DIGEST``.
"""
from __future__ import annotations
import importlib
import os
from pathlib import Path
from hux.http import Router, serve
FAMILIES = ("foundation", "events", "memory", "privacy", "organization", "artifacts", "research", "policy")
def build_router(data_root: Path, environ: dict[str, str] | None = None) -> Router:
"""Create a router with every family module that is present registered."""
router = Router(data_root, environ)
for name in FAMILIES:
try:
module = importlib.import_module(f"hux.{name}")
except ModuleNotFoundError:
continue
module.register(router)
return router
def bind_address(env: dict[str, str]) -> str:
"""Loopback only: the router is in the same pod, nothing else may reach the service."""
bind = env.get("HUX_BIND", "127.0.0.1")
if bind not in {"127.0.0.1", "::1", "localhost"}:
raise SystemExit(f"refusing to bind {bind}: hux-foundation is loopback-only")
return bind
def main() -> None:
"""Run the server until killed."""
env = dict(os.environ)
router = build_router(Path(env.get("HUX_DATA_ROOT", "/opt/data")), env)
server = serve(router, bind_address(env), int(env.get("HUX_PORT", "8790")))
server.serve_forever()
if __name__ == "__main__": # pragma: no cover
main()

View File

@ -0,0 +1,223 @@
"""Per-tenant durable storage on the tenant PVC.
Every path is derived from the caller's tenant slot and hashed subject, so
there is no shared cross-tenant store and no way to address another tenant's
data. Records are JSON documents with a ``revision`` for optimistic
concurrency; ledgers are append-only JSONL. Writes are atomic (temp file +
fsync + rename) and guarded by one lock per family per tenant.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from collections.abc import Iterator
from hux.errors import Conflict, Invalid, NotFound, TooLarge
from hux.identity import Identity
ID_RE = re.compile(r"^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$")
LAYOUT_VERSION = 1
MAX_RECORD_BYTES = 256 * 1024
MAX_FAMILY_RECORDS = 20000
def now_iso() -> str:
"""RFC 3339 UTC timestamp with second precision and a Z suffix."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def new_id(prefix: str) -> str:
"""Opaque, prefix-typed identifier matching common.schema.json#/$defs/id."""
return f"{prefix}_{os.urandom(8).hex()}{int(time.time() * 1000) % 100000:05d}"
def check_id(value: Any) -> str:
"""Return ``value`` when it is a well-formed record id, else raise Invalid."""
if not isinstance(value, str) or not ID_RE.match(value):
raise Invalid("malformed id")
return value
def _atomic_write(path: Path, data: bytes) -> None:
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
with open(tmp, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
class TenantStore:
"""Files for exactly one (tenant slot, subject) pair."""
_locks: dict[str, threading.RLock] = {}
_locks_guard = threading.Lock()
def __init__(self, root: Path, identity: Identity) -> None:
self.identity = identity
self.root = Path(root) / "hux" / f"v{LAYOUT_VERSION}" / identity.tenant_slot / identity.subject
self.root.mkdir(parents=True, exist_ok=True)
# -- locking -----------------------------------------------------------
def lock(self, family: str) -> threading.RLock:
"""One re-entrant lock per family per tenant directory."""
key = f"{self.root}:{family}"
with self._locks_guard:
return self._locks.setdefault(key, threading.RLock())
# -- documents ---------------------------------------------------------
def _doc_path(self, family: str, record_id: str) -> Path:
directory = self.root / family
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{check_id(record_id)}.json"
def get(self, family: str, record_id: str) -> dict[str, Any]:
"""Read one document or raise NotFound."""
path = self._doc_path(family, record_id)
if not path.exists():
raise NotFound(f"{family} {record_id} not found")
return json.loads(path.read_bytes())
def exists(self, family: str, record_id: str) -> bool:
"""True when the document is present."""
return self._doc_path(family, record_id).exists()
def put(self, family: str, record: dict[str, Any], expected_revision: int | None = None) -> dict[str, Any]:
"""Create or replace a document, bumping ``revision``.
``expected_revision`` implements If-Match: it must equal the stored
revision (or be None for a create of a new id) or Conflict is raised.
"""
record_id = check_id(record.get("id"))
with self.lock(family):
path = self._doc_path(family, record_id)
current = json.loads(path.read_bytes()) if path.exists() else None
if current is None:
if expected_revision not in (None, 0):
raise Conflict("record does not exist yet")
if self.count(family) >= MAX_FAMILY_RECORDS:
raise TooLarge(f"{family} is full")
revision = 1
else:
if expected_revision is not None and expected_revision != current.get("revision"):
raise Conflict(f"revision {expected_revision} does not match current revision {current.get('revision')}")
revision = int(current.get("revision", 0)) + 1
stored = {**record, "revision": revision}
data = json.dumps(stored, sort_keys=True, separators=(",", ":")).encode()
if len(data) > MAX_RECORD_BYTES:
raise TooLarge("record exceeds size bound")
_atomic_write(path, data)
return stored
def delete(self, family: str, record_id: str) -> None:
"""Remove a document; missing is not an error."""
path = self._doc_path(family, record_id)
with self.lock(family):
if path.exists():
path.unlink()
def count(self, family: str) -> int:
"""Number of documents in a family."""
directory = self.root / family
return sum(1 for p in directory.glob("*.json")) if directory.exists() else 0
def scan(self, family: str) -> Iterator[dict[str, Any]]:
"""Yield every document in a family, oldest file first."""
directory = self.root / family
if not directory.exists():
return
for path in sorted(directory.glob("*.json"), key=lambda p: (p.stat().st_mtime_ns, p.name)):
yield json.loads(path.read_bytes())
# -- ledgers -----------------------------------------------------------
def _ledger_path(self, family: str, name: str) -> Path:
directory = self.root / family
directory.mkdir(parents=True, exist_ok=True)
if not re.match(r"^[A-Za-z0-9._-]{1,120}$", name):
raise Invalid("malformed ledger name")
return directory / f"{name}.jsonl"
def append(self, family: str, name: str, record: dict[str, Any]) -> None:
"""Append one JSON line with fsync."""
line = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + b"\n"
if len(line) > MAX_RECORD_BYTES:
raise TooLarge("ledger record exceeds size bound")
with self.lock(family):
path = self._ledger_path(family, name)
with open(path, "ab") as handle:
handle.write(line)
handle.flush()
os.fsync(handle.fileno())
def read(self, family: str, name: str) -> list[dict[str, Any]]:
"""Read a whole ledger; a torn trailing line from a crash is dropped."""
path = self._ledger_path(family, name)
if not path.exists():
return []
rows: list[dict[str, Any]] = []
for line in path.read_bytes().split(b"\n"):
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
break
return rows
def ledgers(self, family: str) -> list[str]:
"""Names of the ledgers in a family."""
directory = self.root / family
return sorted(p.stem for p in directory.glob("*.jsonl")) if directory.exists() else []
def rewrite(self, family: str, name: str, rows: list[dict[str, Any]]) -> None:
"""Replace a ledger atomically (used by retention purges)."""
data = b"".join(json.dumps(r, sort_keys=True, separators=(",", ":")).encode() + b"\n" for r in rows)
with self.lock(family):
_atomic_write(self._ledger_path(family, name), data)
# -- blobs -------------------------------------------------------------
def put_blob(self, digest: str, data: bytes) -> Path:
"""Store content-addressed bytes; the caller has already verified the digest."""
if not re.match(r"^[0-9a-f]{64}$", digest):
raise Invalid("malformed digest")
directory = self.root / "blobs" / digest[:2]
directory.mkdir(parents=True, exist_ok=True)
path = directory / digest
if not path.exists():
_atomic_write(path, data)
return path
def get_blob(self, digest: str) -> bytes:
"""Read content-addressed bytes or raise NotFound."""
if not re.match(r"^[0-9a-f]{64}$", digest):
raise Invalid("malformed digest")
path = self.root / "blobs" / digest[:2] / digest
if not path.exists():
raise NotFound("blob not found")
return path.read_bytes()
# -- manifest ----------------------------------------------------------
def manifest(self, contract_version: str) -> dict[str, Any]:
"""Read or create the ``hux.manifest.v1`` for this tenant directory."""
path = self.root / "MANIFEST.json"
with self.lock("manifest"):
if path.exists():
return json.loads(path.read_bytes())
stamp = now_iso()
record = {
"schema": "hux.manifest.v1",
"contract_version": contract_version,
"data_layout_version": LAYOUT_VERSION,
"min_reader_contract_version": "1.0.0",
"created_at": stamp,
"updated_at": stamp,
}
_atomic_write(path, json.dumps(record, sort_keys=True).encode())
return record

View File

@ -25,6 +25,7 @@ import whisper
HOST = os.getenv("HERMES_STT_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_STT_PORT", "9000"))
MODEL_NAME = os.getenv("HERMES_STT_MODEL", "small")
ROLLING_MODEL_NAME = os.getenv("HERMES_STT_ROLLING_MODEL", "tiny")
CACHE_DIR = Path(os.getenv("HERMES_STT_CACHE", "/cache/whisper"))
MAX_AUDIO_BYTES = 30 * 1024 * 1024
STREAM_SAMPLE_RATE = 16_000
@ -68,6 +69,8 @@ class InferenceGate:
self._last_final_wait_ms = 0.0
self._last_final_decode_ms = 0.0
self._last_background_decode_ms = 0.0
self._last_rolling_decode_ms = 0.0
self._last_speculative_decode_ms = 0.0
self._background_skips = 0
def run_final(self, callback):
@ -87,14 +90,14 @@ class InferenceGate:
return callback()
finally:
with self._condition:
self._last_final_decode_ms = (
time.monotonic() - decode_started
) * 1000
self._last_final_decode_ms = (time.monotonic() - decode_started) * 1000
self._busy = False
self._condition.notify_all()
def try_background(self, callback):
def try_background(self, callback, *, kind: str):
"""Run one useful predecode only when no final is active or waiting."""
if kind not in {"rolling", "speculative"}:
raise ValueError("unknown background inference kind")
with self._condition:
if self._busy or self._final_waiters:
self._background_skips += 1
@ -105,9 +108,12 @@ class InferenceGate:
return callback()
finally:
with self._condition:
self._last_background_decode_ms = (
time.monotonic() - decode_started
) * 1000
elapsed_ms = (time.monotonic() - decode_started) * 1000
self._last_background_decode_ms = elapsed_ms
if kind == "rolling":
self._last_rolling_decode_ms = elapsed_ms
else:
self._last_speculative_decode_ms = elapsed_ms
self._busy = False
self._condition.notify_all()
@ -119,8 +125,10 @@ class InferenceGate:
"final_waiters": self._final_waiters,
"last_final_wait_ms": round(self._last_final_wait_ms, 1),
"last_final_decode_ms": round(self._last_final_decode_ms, 1),
"last_background_decode_ms": round(
self._last_background_decode_ms, 1
"last_background_decode_ms": round(self._last_background_decode_ms, 1),
"last_rolling_decode_ms": round(self._last_rolling_decode_ms, 1),
"last_speculative_decode_ms": round(
self._last_speculative_decode_ms, 1
),
"background_skips": self._background_skips,
}
@ -128,6 +136,7 @@ class InferenceGate:
INFERENCE_GATE = InferenceGate()
STARTUP_WARMUP_MS: float | None = None
STARTUP_ROLLING_WARMUP_MS: float | None = None
def _repetitive_token(token: str) -> bool:
@ -264,20 +273,19 @@ def _warm_model(model: object) -> float:
return (time.monotonic() - started) * 1000
def _transcribe_pcm_rolling(
model: object, pcm: bytes, language: str
) -> dict | None:
def _transcribe_pcm_rolling(model: object, pcm: bytes, language: str) -> dict | None:
"""Decode a rolling window only when the single GPU worker is immediately free.
Partial work is disposable. It must never form an inference backlog ahead of
a final utterance or another user's request, so a busy model means this
snapshot is skipped and a later audio frame may try again.
speech epoch is skipped rather than retried.
"""
temp_path = ""
try:
temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language)
lambda: _decode_path(model, temp_path, language),
kind="rolling",
)
finally:
if temp_path:
@ -295,7 +303,8 @@ def _transcribe_pcm_speculative(
try:
temp_path = _write_pcm_wav(pcm)
return INFERENCE_GATE.try_background(
lambda: _decode_path(model, temp_path, language)
lambda: _decode_path(model, temp_path, language),
kind="speculative",
)
finally:
if temp_path:
@ -462,9 +471,15 @@ def _pcm_rms(pcm: bytes) -> float:
class StreamingTranscription:
"""Own one turn's bounded PCM, VAD state and speculative Whisper work."""
def __init__(self, connection: WebSocketConnection, model: object) -> None:
def __init__(
self,
connection: WebSocketConnection,
model: object,
rolling_model: object | None = None,
) -> None:
self.connection = connection
self.model = model
self.rolling_model = rolling_model if rolling_model is not None else model
self.turn_id = ""
self.language = "auto"
self._pcm = bytearray()
@ -489,6 +504,7 @@ class StreamingTranscription:
self._rolling_previous = ""
self._rolling_previous_window_start = 0
self._rolling_revision = 0
self._rolling_attempted = False
def _response(self, message_type: str, **values: object) -> dict:
return {"type": message_type, "turn_id": self.turn_id, **values}
@ -543,7 +559,7 @@ class StreamingTranscription:
start_ms = start * 1000 // (STREAM_SAMPLE_RATE * STREAM_SAMPLE_WIDTH)
return pcm, start_ms
def _reset_rolling_history_locked(self) -> None:
def _reset_rolling_history_locked(self, *, rearm: bool) -> None:
"""Discard provisional agreement after the client's speech epoch changes."""
self._rolling_previous = ""
self._rolling_previous_window_start = 0
@ -551,10 +567,10 @@ class StreamingTranscription:
self._rolling_speech_bytes = 0
self._rolling_last_speech_bytes = 0
self._rolling_last_started_at = 0.0
if rearm:
self._rolling_attempted = False
def _begin_rolling_locked(
self, now: float
) -> tuple[bytes, int, int, int] | None:
def _begin_rolling_locked(self, now: float) -> tuple[bytes, int, int, int] | None:
"""Reserve one due rolling decode without ever queuing a second one."""
total_bytes = len(self._pcm)
minimum_bytes = _pcm_bytes_for_ms(ROLLING_MIN_AUDIO_MS)
@ -564,17 +580,20 @@ class StreamingTranscription:
)
if (
self._rolling_inflight
or self._rolling_attempted
or self._rolling_speech_bytes < minimum_bytes
or self._rolling_speech_bytes - self._rolling_last_speech_bytes
< required_new_bytes
or (
self._rolling_last_started_at
and (now - self._rolling_last_started_at) * 1000
< ROLLING_INTERVAL_MS
and (now - self._rolling_last_started_at) * 1000 < ROLLING_INTERVAL_MS
)
):
return None
pcm, window_start_ms = self._rolling_snapshot_locked()
# Reserve the epoch before spawning. A busy gate, decode error or stale
# result must not create a retry loop that can collide with the final.
self._rolling_attempted = True
self._rolling_inflight = True
self._rolling_last_started_at = now
self._rolling_last_speech_bytes = self._rolling_speech_bytes
@ -592,7 +611,11 @@ class StreamingTranscription:
def worker() -> None:
payload: dict | None = None
try:
payload = _transcribe_pcm_rolling(self.model, pcm, self.language)
payload = _transcribe_pcm_rolling(
self.rolling_model,
pcm,
self.language,
)
except Exception as exc:
# Partial inference is best-effort. The canonical commit path
# remains authoritative and reports its own actionable errors.
@ -670,7 +693,7 @@ class StreamingTranscription:
self._epoch += 1
self._cached_key = ""
self._cached_payload = None
self._reset_rolling_history_locked()
self._reset_rolling_history_locked(rearm=True)
self._heard_speech = True
self._at_eos = False
self._silence_bytes = 0
@ -687,7 +710,7 @@ class StreamingTranscription:
)
if self._silence_bytes >= required and not self._at_eos:
self._at_eos = True
self._reset_rolling_history_locked()
self._reset_rolling_history_locked(rearm=False)
auto_speculate = True
if auto_speculate:
self.speculate()
@ -705,7 +728,7 @@ class StreamingTranscription:
self._client_active = True
self._cached_key = ""
self._cached_payload = None
self._reset_rolling_history_locked()
self._reset_rolling_history_locked(rearm=True)
def speculate(self) -> None:
"""Decode a stable snapshot once; only an unchanged turn may consume it."""
@ -846,11 +869,19 @@ class SpeechHandler(BaseHTTPRequestHandler):
{
"ok": True,
"model": MODEL_NAME,
"rolling_model": ROLLING_MODEL_NAME,
"device": "cuda" if torch.cuda.is_available() else "cpu",
"inference": INFERENCE_GATE.snapshot(),
"startup": {
"warmed": STARTUP_WARMUP_MS is not None,
"warmed": (
STARTUP_WARMUP_MS is not None
and STARTUP_ROLLING_WARMUP_MS is not None
),
"warmup_ms": round(STARTUP_WARMUP_MS or 0.0, 1),
"rolling_warmup_ms": round(
STARTUP_ROLLING_WARMUP_MS or 0.0,
1,
),
},
"streaming": {
"enabled": True,
@ -909,6 +940,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
session = StreamingTranscription(
websocket,
self.server.model, # type: ignore[attr-defined]
self.server.rolling_model, # type: ignore[attr-defined]
)
close_code = 1000
close_reason = ""
@ -1018,16 +1050,32 @@ class SpeechHandler(BaseHTTPRequestHandler):
def main() -> None:
"""Warm Whisper once, then serve concurrent clients through one GPU lock."""
global STARTUP_WARMUP_MS
global STARTUP_ROLLING_WARMUP_MS, STARTUP_WARMUP_MS
CACHE_DIR.mkdir(parents=True, exist_ok=True)
if not torch.cuda.is_available():
raise RuntimeError("CUDA is required for the Atlas Whisper service")
print(f"[stt] loading Whisper {MODEL_NAME} into CUDA", flush=True)
print(f"[stt] loading Whisper {MODEL_NAME} for final CUDA", flush=True)
model = whisper.load_model(MODEL_NAME, device="cuda", download_root=str(CACHE_DIR))
print(f"[stt] loading Whisper {ROLLING_MODEL_NAME} for rolling CUDA", flush=True)
rolling_model = whisper.load_model(
ROLLING_MODEL_NAME,
device="cuda",
download_root=str(CACHE_DIR),
)
STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)
print(
"[stt] rolling CUDA warm-up completed in " f"{STARTUP_ROLLING_WARMUP_MS:.1f}ms",
flush=True,
)
# Warm the authoritative model last so the first real final never inherits
# lazy CUDA work from either model's startup path.
STARTUP_WARMUP_MS = _warm_model(model)
print(f"[stt] CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True)
print(
f"[stt] final CUDA warm-up completed in {STARTUP_WARMUP_MS:.1f}ms", flush=True
)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
server.model = model # type: ignore[attr-defined]
server.rolling_model = rolling_model # type: ignore[attr-defined]
print(f"[stt] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)

View File

@ -638,12 +638,16 @@
if(Number.isFinite(revision)&&revision>partialRevision){
partialRevision=revision;
const stable=String(payload.stable_transcript||'').trim();
if(stable&&active&&captureTurnId===turnId&&state==='listening'){
const preview=stable.length>72?stable.slice(0,69)+'…':stable;
label.textContent='Listening · '+preview;
const provisional=String(payload.transcript||'').trim();
const visible=stable||provisional;
if(visible&&active&&captureTurnId===turnId&&state==='listening'){
const preview=visible.length>72?visible.slice(0,69)+'…':visible;
label.textContent='Listening · '+preview+(stable?'':' · provisional');
if(stable!==lastPreflightText){
lastPreflightText=stable;
scheduleVoicePreflight(turnId,revision,stable);
if(stable){
lastPreflightText=stable;
scheduleVoicePreflight(turnId,revision,stable);
}
}
}
}

View File

@ -78,13 +78,22 @@
background-color: transparent;
}
:root.dark .messages {
:root.dark .messages-shell {
background-image:
linear-gradient(var(--hermes-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--hermes-grid) 1px, transparent 1px),
radial-gradient(circle at 76% 22%, rgba(76, 164, 205, 0.055), transparent 30rem),
radial-gradient(circle at 22% 78%, var(--hermes-violet-muted), transparent 35rem);
background-size: 32px 32px, 32px 32px, auto, auto;
background-repeat: repeat, repeat, no-repeat, no-repeat;
}
/* The transcript is an accelerated scroll surface. Painting gradients on it
produces stale compositor tiles on Chromium as the scroll offset changes,
which looks like a dark rectangle moving along the right edge. Keep the
scroll surface transparent and paint the texture on its stationary shell. */
:root.dark .messages {
background-image: none;
}
:root.dark .rail {

View File

@ -0,0 +1,83 @@
# hux_hook: wiring notes for the agent runtime patch
`hux_hook` is a stdlib-only library the Hermes agent process imports. HUX
(`hermes-hux-foundation`, loopback `127.0.0.1:8790` in the same pod) is the
source of truth; the agent never decides an approval, never reads the tenant
ledger directly and never persists raw tool arguments or output.
## Environment the agent process needs
| Variable | Purpose |
| --- | --- |
| `HUX_BASE_URL` | default `http://127.0.0.1:8790`; the service is loopback-only |
| `HUX_TENANT_SLOT` | `slot-N`, same value the service was started with (`HUX_TENANT_SLOT` on the service side) |
| `HUX_SUBJECT` | `usr_<hash>` of the slot owner; the router derives it, the pod env carries it |
| `HUX_WORKER_KEY` | shared key mounted read-only under `/runtime-access`; sent as `X-Hux-Relay-Key` |
| `HUX_SURFACE` / `HUX_TRUST` | default `worker` / `worker`; the agent hook is never a human surface (SO-35) |
| `HUX_TIMEOUT_SECONDS` | default 5 |
Headers the client sends (exactly `hux/identity.py`): `X-Hermes-Tenant-Identity`,
`X-Hux-Subject`, `X-Hux-Surface`, `X-Hux-Trust`, `X-Hux-Relay-Key`, plus
`Idempotency-Key` on creates and `If-Match` on revisioned PUTs.
## Construction
```python
from hux_hook import HuxClient
client = HuxClient(os.environ.get("HUX_BASE_URL", "http://127.0.0.1:8790"),
{"tenant_slot": os.environ["HUX_TENANT_SLOT"], "subject": os.environ["HUX_SUBJECT"],
"surface": "worker", "trust": "worker"},
key=os.environ.get("HUX_WORKER_KEY"), timeout=float(os.environ.get("HUX_TIMEOUT_SECONDS", "5")))
```
One client per process. `client.capabilities()` is cached; call
`client.forget_capabilities()` on SIGHUP or when the WebUI reports a flag change.
## Where each call goes in the tool loop
1. Run start: `emit(client, conversation_id, "run.started", "...", run_id=run_id)`.
2. Immediately before executing any tool that is not read-only:
`d = before_tool(client, run_id, conversation_id, tool_name, arguments, capability, external, risk)`.
- `d.proceed is True`: execute now, with exactly the `arguments` object that was hashed.
Re-serialising or "normalising" arguments after the gate breaks SO-37.
- `d.reason == "approval_required"`: do not execute. Surface `d.approval_id` to the UI
(chat posts `POST /hux/v1/approvals/{id}` with `once|session|always|deny` from a human surface),
park the turn, and call `before_tool` again with the same arguments after the decision.
The idempotent replay reaches the gate; a `once` approval releases exactly once.
- Any other reason (`approval_denied`, `budget_exhausted`, `hux_unavailable`, `autonomy_off`,
`service_error:*`, a gate reason): refuse the tool and tell the model why. Never fall back to
the upstream gateway approval prompt while `hux.autonomy` is on.
Capability mapping is the runtime's job: `read_files`, `write_files`, `shell`, `network`,
`web_search`, `send_message`, `memory_write`, `artifact_write`, `spend_tokens`, `delegate`,
`deploy`, `external_side_effect` (see `hux/rules.py`). `external=True` for anything that
leaves the tenant (messages, network, deploy).
3. After every tool: `after_tool(client, run_id, conversation_id, tool_name, ok, bytes_out, turn,
argument_hash=canonical_argument_hash(tool_name, arguments), duration_ms=..., exit_code=...)`
then `record_spend(client, run_id, conversation_id, tool_calls=1, tokens=<delta>)`.
Both are best-effort: they return `None` on failure and never raise.
4. Delegation: `record_spend(..., delegations=1)` / `subagents=1` when spawning; the child run
uses its own `run_id` and the same `conversation_id`.
5. Memory: before proposing a memory write call `memory_gate(client, conversation_id)`;
`False` means do not even propose. The write itself still goes through `POST /hux/v1/memory`,
which enforces forget, disable and topic rules server-side.
6. Stop: when the user cancels, kill the tool processes, then
`receipt = on_stop(client, run_id, conversation_id, process_registry_empty=<real registry check>,
side_effects=[{"description": ..., "reverted": bool}, ...])`. `None` means no receipt exists and
the stop is NOT done: retry, and never report "cancelled" to the user without a receipt (SO-41).
`process_registry_empty` must come from the gateway's process registry, not from a timer.
7. Run end: `emit(..., "run.completed" | "run.failed", ...)`.
## What must never happen
- Raw arguments, file contents, command output, prompts or secrets in any `summary`, `detail`
or `evidence`. The library only ever sends tool name, capability, hash, byte counts, status.
- Deciding an approval with the worker identity (the service answers 403; do not retry as `chat`).
- Executing a tool after `proceed=False` for any reason, including HUX being unreachable.
- Logging `HuxServiceError` bodies with request payloads: exceptions carry status/code/message only.
## Routes used
`GET /hux/v1/capabilities`, `POST /hux/v1/approvals`, `POST /hux/v1/runs/{id}/gate`,
`POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop`,
`POST /hux/v1/conversations/{id}/events`, `GET /hux/v1/privacy/policy`,
`GET /hux/v1/conversations/{id}`.

View File

@ -0,0 +1,24 @@
"""Agent-side hook library for the per-tenant HUX service.
Stdlib only. The Hermes agent process (tenant pod or Worker) calls these
functions around its tool loop so that HUX, not the agent, decides approvals,
gates, budgets, stop receipts and what lands on the activity timeline.
Side effects fail closed when the service is unreachable; telemetry fails open.
"""
from hux_hook.client import HuxClient, HuxServiceError, HuxUnavailable
from hux_hook.hooks import (
Decision,
after_tool,
before_tool,
canonical_argument_hash,
emit,
memory_gate,
on_stop,
record_spend,
)
__all__ = [
"Decision", "HuxClient", "HuxServiceError", "HuxUnavailable", "after_tool", "before_tool",
"canonical_argument_hash", "emit", "memory_gate", "on_stop", "record_spend",
]

View File

@ -0,0 +1,178 @@
"""Thin urllib client for the HUX service on the pod loopback.
The client owns three things: the identity headers ``hux.identity`` expects,
the mapping of ``hux.error.v1`` bodies to ``HuxServiceError``, and the
per-process capabilities cache. It never logs or embeds request bodies in
exceptions (SO-07, SO-11): an error carries status, code and the service's
own message only.
"""
from __future__ import annotations
import http.client
import json
import threading
import urllib.error
import urllib.request
from collections.abc import Mapping
from typing import Any
HEADER_SLOT = "X-Hermes-Tenant-Identity"
HEADER_SUBJECT = "X-Hux-Subject"
HEADER_SURFACE = "X-Hux-Surface"
HEADER_TRUST = "X-Hux-Trust"
HEADER_KEY = "X-Hux-Relay-Key"
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
MESSAGE_MAX = 280
class HuxServiceError(Exception):
"""The service answered with a ``hux.error.v1`` body (or a non-JSON failure)."""
def __init__(self, status: int, code: str, message: str) -> None:
super().__init__(f"{status} {code}: {message}")
self.status = int(status)
self.code = str(code)
self.message = str(message)[:MESSAGE_MAX]
class HuxUnavailable(HuxServiceError):
"""The service could not be reached at all; callers decide open or closed."""
def __init__(self, message: str = "hux service unreachable") -> None:
super().__init__(0, "unavailable", message)
class HuxResponse:
"""Status, parsed JSON body and headers of one answer."""
def __init__(self, status: int, body: Any, headers: Mapping[str, str]) -> None:
self.status = status
self.body = body
self.headers = {k.lower(): v for k, v in headers.items()}
def header(self, name: str) -> str:
"""Case-insensitive header lookup; empty when absent."""
return self.headers.get(name.lower(), "")
def _error_from(status: int, body: Any) -> HuxServiceError:
if isinstance(body, dict) and body.get("schema") == "hux.error.v1":
return HuxServiceError(int(body.get("status", status)), str(body.get("code", "invalid")), str(body.get("message", "")))
return HuxServiceError(status, "invalid", f"unexpected response {status}")
class HuxClient:
"""One tenant identity talking to one HUX base URL.
``identity`` carries ``tenant_slot``, ``subject``, ``surface`` and
``trust``; ``key`` is the relay or worker shared key when ``trust`` needs
one. The agent hook normally runs as ``surface=worker, trust=worker``.
"""
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None,
key: str | None = None, timeout: float = 5) -> None:
identity = dict(identity or {})
self.base_url = base_url.rstrip("/")
self.identity = {
"tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")),
"surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")),
}
self._key = key
self.timeout = timeout
self._capabilities: dict[str, Any] | None = None
self._guard = threading.Lock()
# -- transport -------------------------------------------------------------
def headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
"""Identity headers exactly as ``hux.identity.resolve`` reads them, plus ``extra``."""
out = {
HEADER_SLOT: self.identity["tenant_slot"], HEADER_SUBJECT: self.identity["subject"],
HEADER_SURFACE: self.identity["surface"], HEADER_TRUST: self.identity["trust"],
"Accept": "application/json",
}
if self._key:
out[HEADER_KEY] = self._key
for name, value in (extra or {}).items():
if value:
out[name] = str(value)
return out
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None,
if_match: int | str | None = None, query: Mapping[str, str] | None = None) -> HuxResponse:
"""Send one request; raise ``HuxServiceError`` on 4xx/5xx and ``HuxUnavailable`` on transport failure."""
extra: dict[str, str] = {}
if idempotency_key:
extra["Idempotency-Key"] = idempotency_key
if if_match is not None:
extra["If-Match"] = str(if_match)
data = None
if body is not None:
data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
extra["Content-Type"] = "application/json"
url = self.base_url + path
if query:
url += "?" + "&".join(f"{k}={urllib.request.quote(str(v), safe='')}" for k, v in query.items())
req = urllib.request.Request(url, data=data, method=method, headers=self.headers(extra))
try:
with urllib.request.urlopen(req, timeout=self.timeout) as raw: # noqa: S310 - loopback only
response = HuxResponse(raw.status, _decode(raw.read()), dict(raw.headers.items()))
except urllib.error.HTTPError as error:
payload = _decode(error.read())
raise _error_from(error.code, payload) from None
except (urllib.error.URLError, OSError, TimeoutError) as error:
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
except http.client.InvalidURL:
# http.client refuses control characters and spaces in the path; treat it as our own bad request.
raise HuxServiceError(400, "invalid", "malformed request path") from None
except http.client.HTTPException as error:
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
if response.status >= 400:
raise _error_from(response.status, response.body)
return response
def get(self, path: str, query: Mapping[str, str] | None = None) -> HuxResponse:
"""``GET path``."""
return self.request("GET", path, query=query)
def post(self, path: str, body: Any, idempotency_key: str | None = None) -> HuxResponse:
"""``POST path`` with an optional ``Idempotency-Key``."""
return self.request("POST", path, body, idempotency_key=idempotency_key)
def put(self, path: str, body: Any, if_match: int | str | None = None) -> HuxResponse:
"""``PUT path`` with an optional ``If-Match`` revision."""
return self.request("PUT", path, body, if_match=if_match)
# -- capabilities ------------------------------------------------------------
def capabilities(self, refresh: bool = False) -> dict[str, Any]:
"""Which cards are on, cached per process; unreachable or off means every card reads as off."""
with self._guard:
if self._capabilities is not None and not refresh:
return self._capabilities
try:
body = self.get("/hux/v1/capabilities").body
except HuxServiceError as error:
return {"reachable": error.code != "unavailable", "cards": {}, "contract_version": ""}
cards = {c["card"]: bool(c.get("enabled")) for c in body.get("cards", []) if isinstance(c, dict) and "card" in c}
self._capabilities = {"reachable": True, "cards": cards, "contract_version": str(body.get("contract_version", ""))}
return self._capabilities
def card_enabled(self, card: str) -> bool:
"""True only when the service answered and reports ``card`` on."""
return bool(self.capabilities()["cards"].get(card))
def forget_capabilities(self) -> None:
"""Drop the cache so the next call re-reads flags (used after a reload signal)."""
with self._guard:
self._capabilities = None
def _decode(raw: bytes) -> Any:
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return None

View File

@ -0,0 +1,191 @@
"""Pure hook functions the agent calls around its tool loop.
Every function takes a ``HuxClient`` and never touches the agent's own state.
``before_tool`` is the only call whose answer the agent must obey: it asks for
an approval, then asks the gate, and fails closed on any doubt (SO-37, SO-39,
SO-40). ``after_tool``, ``record_spend`` and ``emit`` are telemetry: they never
raise into the loop and never carry raw arguments or tool output (SO-11).
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any
from hux_hook.client import HuxClient, HuxServiceError
CARD_EVENTS = "HUX-01"
CARD_AUTONOMY = "HUX-05"
CARD_PRIVACY = "HUX-10"
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
RISKS = ("low", "medium", "high")
PATH_SEGMENT = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
@dataclass(frozen=True)
class Decision:
"""What the agent may do next; ``proceed`` is False unless the gate released the call."""
proceed: bool
approval_id: str | None
reason: str
def canonical_json(value: Any) -> bytes:
"""Canonical JSON: sorted keys, no whitespace, ASCII escapes; key order and formatting cannot change it."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
def canonical_argument_hash(tool_name: str, arguments: Any) -> str:
"""``sha256:<hex>`` over the canonical JSON of ``{"tool": name, "arguments": ...}``; the gate compares this exact value."""
return "sha256:" + hashlib.sha256(canonical_json({"tool": tool_name, "arguments": arguments})).hexdigest()
def call_ref(tool_name: str, argument_hash: str) -> str:
"""Evidence id for one tool call: name plus a hash prefix, never the arguments."""
return f"{KEY_CHARS.sub('-', tool_name)[:40]}:{argument_hash[7:23]}"
def idempotency_key(run_id: str, kind: str, token: str) -> str:
"""A key that satisfies the contract pattern whatever the run id looks like."""
key = KEY_CHARS.sub("-", f"{run_id}:{kind}:{token}")
return key[:120] if len(key) >= 8 else (key + ".pad").ljust(8, "0")
def _safe_summary(tool_name: str, capability: str, argument_hash: str, size: int) -> str:
return f"{tool_name} ({capability}) arguments {argument_hash[:23]} {size} bytes"[:280]
def before_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, arguments: Any, capability: str,
external: bool = False, risk: str = "medium", turn: int | None = None) -> Decision:
"""Request (or replay) the approval for this exact call, then ask the gate. Fails closed.
A pending approval returns ``proceed=False, reason="approval_required"`` with
the approval id so the UI can prompt; the agent calls again with the same
arguments once the human has decided and the idempotent replay reaches the gate.
"""
if not PATH_SEGMENT.match(str(run_id)):
return Decision(False, None, "invalid_run_id")
if not client.card_enabled(CARD_AUTONOMY):
return Decision(False, None, "hux_unavailable" if not client.capabilities()["reachable"] else "autonomy_off")
argument_hash = canonical_argument_hash(tool_name, arguments)
size = len(canonical_json(arguments))
body = {
"run_id": run_id, "conversation_id": conversation_id, "capability": capability,
"request": {
"summary": _safe_summary(tool_name, capability, argument_hash, size),
"risk": risk if risk in RISKS else "high", "external": bool(external),
"evidence": [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}],
},
}
try:
approval = client.post("/hux/v1/approvals", body, idempotency_key(run_id, "approval", argument_hash[7:39])).body
except HuxServiceError as error:
return Decision(False, None, _failure_reason(error))
approval_id = approval.get("id") if isinstance(approval, dict) else None
status = approval.get("status") if isinstance(approval, dict) else None
if status == "pending":
return Decision(False, approval_id, "approval_required")
if status != "approved":
return Decision(False, approval_id, f"approval_{status or 'invalid'}")
gate_body = {"capability": capability, "argument_hash": argument_hash, "external": bool(external), "conversation_id": conversation_id}
try:
verdict = client.post(f"/hux/v1/runs/{run_id}/gate", gate_body).body
except HuxServiceError as error:
return Decision(False, approval_id, _failure_reason(error))
if not isinstance(verdict, dict) or verdict.get("proceed") is not True:
reason = verdict.get("reason", "gate_blocked") if isinstance(verdict, dict) else "gate_invalid"
return Decision(False, approval_id, str(reason)[:280])
return Decision(True, str(verdict.get("approval_id") or approval_id), "released")
def _failure_reason(error: HuxServiceError) -> str:
if error.code in {"unavailable", "budget_exhausted", "flag_off", "approval_required"}:
return "hux_unavailable" if error.code == "unavailable" else error.code
return f"service_error:{error.code}"
def emit(client: HuxClient, conversation_id: str, kind: str, summary: str, detail: dict[str, Any] | None = None,
evidence: list[dict[str, Any]] | 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:
"""Best-effort activity event; returns the stored record, or None when it could not be recorded."""
body: dict[str, Any] = {"kind": kind, "summary": str(summary)[:280], "sensitivity": sensitivity}
if detail:
body["detail"] = detail
if evidence:
body["evidence"] = evidence
if run_id:
body["run_id"] = run_id
if turn is not None:
body["turn"] = int(turn)
if correlation_id:
body["correlation_id"] = correlation_id
try:
response = client.post(f"/hux/v1/conversations/{conversation_id}/events", body, idempotency_key)
except (HuxServiceError, TypeError, ValueError):
return None
return response.body if isinstance(response.body, dict) else None
def after_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, ok: bool, bytes_out: int,
turn: int | None = None, argument_hash: str | None = None, duration_ms: int | None = None,
exit_code: int | None = None) -> dict[str, Any] | None:
"""Emit ``tool.result`` with status and sizes only; the output itself never leaves the agent."""
detail: dict[str, Any] = {"tool": tool_name, "ok": bool(ok), "bytes": max(0, int(bytes_out))}
if duration_ms is not None:
detail["duration_ms"] = max(0, int(duration_ms))
if exit_code is not None:
detail["exit_code"] = int(exit_code)
evidence = [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}] if argument_hash else None
summary = f"{tool_name} {'succeeded' if ok else 'failed'} ({detail['bytes']} bytes)"
key = idempotency_key(run_id, "result", f"{turn or 0}:{(argument_hash or 'none')[7:23]}")
return emit(client, conversation_id, "tool.result", summary, detail, evidence, run_id=run_id, turn=turn, idempotency_key=key)
def record_spend(client: HuxClient, run_id: str, conversation_id: str | None = None, **increments: int) -> dict[str, Any] | None:
"""Add spend to the run budget; returns the new ``hux.budget_state.v1`` or None. Never raises."""
body: dict[str, Any] = {k: max(0, int(v)) for k, v in increments.items() if k in SPEND_KEYS}
if conversation_id:
body["conversation_id"] = conversation_id
try:
response = client.post(f"/hux/v1/runs/{run_id}/budget", body)
except (HuxServiceError, TypeError, ValueError):
return None
return response.body if isinstance(response.body, dict) else None
def on_stop(client: HuxClient, run_id: str, conversation_id: str | None, process_registry_empty: bool,
side_effects: list[dict[str, Any]] | None = None, already_complete: bool = False) -> dict[str, Any] | None:
"""Write the cancellation receipt (SO-41). None means no receipt exists and the stop is not done."""
body: dict[str, Any] = {"process_registry_empty": bool(process_registry_empty), "side_effects": list(side_effects or [])}
if already_complete:
body["already_complete"] = True
if conversation_id:
body["conversation_id"] = conversation_id
try:
response = client.post(f"/hux/v1/runs/{run_id}/stop", body)
except HuxServiceError:
return None
return response.body if isinstance(response.body, dict) else None
def memory_gate(client: HuxClient, conversation_id: str) -> bool:
"""May the agent propose a memory write from this conversation? Conservative False when in doubt.
Requires the privacy card to answer (SO-27 header is passed through as a
hint only); a conversation whose mode is ``private`` never writes (SO-28).
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
except HuxServiceError as error:
return error.status == 404
return not (isinstance(record, dict) and record.get("mode") == "private")

View File

@ -10,9 +10,12 @@ Schemas live in `services/hermes/contracts/hux/` as JSON Schema 2020-12 with
one worked example per record under `examples/`. The rules a schema cannot
express (state machines, capability matrix, mode catalog, privacy defaults,
suggestion gating, flag dependencies) live in
`services/hermes/scripts/hux_policy.py`; `hux_contracts.py` validates records
without external packages. `testing/tests/test_hermes_hux_contracts.py` keeps
schemas, examples, rules and the live Switchyard catalog in agreement.
`dockerfiles/hermes-hux-foundation/hux/rules.py`; `hux/contracts.py`
validates records without external packages.
`testing/tests/test_hermes_hux_contract_schemas.py` keeps schemas, examples,
rules and the live Switchyard catalog in agreement. The contract is frozen at
`1.1.0` (see `docs/hux/ADR-0001-hux-v1-contract-freeze.md` for identity
headers, `If-Match`, idempotency and compatibility rules).
## Where the backend lives
@ -51,15 +54,16 @@ tenant. Responses are the records below, wrapped as `{"items": [...],
| Area | Routes | Record |
|---|---|---|
| Events (HUX-01) | `GET /hux/v1/conversations/{id}/events?after_seq=N` (JSON), `GET .../events/stream` (SSE, `id:` = seq) | `hux.event.v1` |
| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `POST /hux/v1/memory/{id}/{approve,reject,forget}`, `GET /hux/v1/memory/export` | `hux.memory.v1` |
| Projects (HUX-03) | `GET/POST/PATCH /hux/v1/projects`, `GET/PATCH /hux/v1/conversations`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/search?q=` | `hux.project.v1`, `hux.conversation.v1` |
| Foundation (HUX-11) | `GET /hux/v1/capabilities`, `GET /hux/v1/manifest` | `hux.capabilities.v1`, `hux.manifest.v1`, errors as `hux.error.v1` |
| Events (HUX-01) | `GET/POST /hux/v1/conversations/{id}/events?after_seq=N&limit=` (JSON, ≤200), `GET .../events/stream` (SSE, `id:` = seq, resumes from `Last-Event-ID`) | `hux.event.v1` |
| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `GET /hux/v1/memory/{id}`, `POST /hux/v1/memory/{id}/{approve,reject,forget,edit,remove_retrieval,restore_retrieval}`, `GET /hux/v1/memory/export` | `hux.memory.v1` |
| Projects (HUX-03) | `GET/POST /hux/v1/projects`, `GET/PATCH /hux/v1/projects/{id}`, `GET/POST /hux/v1/conversations`, `GET/PATCH /hux/v1/conversations/{id}`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/conversations/{id}/lineage`, `GET /hux/v1/search?q=&project_id=` (message_text not indexed yet; response says `indexed`/`not_indexed`) | `hux.project.v1`, `hux.conversation.v1` |
| Artifacts (HUX-04) | `GET/POST /hux/v1/artifacts`, `POST /hux/v1/artifacts/{id}/versions`, `GET .../versions/{n}/diff?from=`, `POST .../promote` | `hux.artifact.v1` |
| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET /hux/v1/approvals`, `POST /hux/v1/approvals/{id}` (`once|session|always|deny`, the gateway's own choices), `POST /hux/v1/runs/{id}/stop` returns the receipt | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1` |
| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET/POST /hux/v1/approvals` (agent hook creates with `request.evidence[{kind: tool_call, id, hash}]`), `GET/POST /hux/v1/approvals/{id}` (human decision `once|session|always|deny`; worker/api trust gets 403), `POST /hux/v1/runs/{id}/gate` (`{capability, argument_hash, external}` → `{proceed, approval_id?, reason}`), `GET/POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop` returns the receipt (repeat returns the same one) | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1`, `hux.budget_state.v1` |
| Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` |
| Research (HUX-08) | `GET /hux/v1/messages/{id}/citations`, `GET /hux/v1/sources/{id}`, `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` |
| Privacy (HUX-10) | `GET /hux/v1/privacy/policy`, `POST /hux/v1/conversations/{id}/forget` | `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` | `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

@ -0,0 +1,91 @@
# ADR-0001: freeze the hux.v1 contract at 1.0.0
Status: accepted, 2026-08-24. Owners: Claude (backend), Codex (integration).
## Decision
`services/hermes/contracts/hux/` is frozen as contract version `1.0.0`
(`VERSION`, `flags.json.contract_version`). It is the only shape any HUX
surface (Chat, Worker, Telegram, voice) codes against. The Python reference
implementation lives in `dockerfiles/hermes-hux-foundation/hux/`:
`contracts.py` validates records, `rules.py` holds the governance rules the
schemas cannot express.
Records carry their own version in `schema` (`hux.event.v1`). Wire fixtures
under `services/hermes/contracts/hux/examples/` are validated by
`testing/tests/test_hermes_hux_contract_schemas.py` and are the exchange
format with Codex: a change to a fixture is a change to the contract.
## What 1.0.0 covers
| Requirement | Where |
|---|---|
| Tenant and surface identity | `common.identity` (tenant_slot, hashed subject, surface, trust: router/relay/worker) — carried on every stored record that a caller can create |
| Capability negotiation, default-off flags | `identity.capabilities` (`GET /hux/v1/capabilities`), `flags.json`; a card is enabled only when its dependency chain is enabled (`rules.flag_enabled`) |
| Redacted activity events | `event`: `seq` (monotonic per conversation), `turn`, `correlation_id`, `idempotency_key`, replay by `after_seq`, kinds for decisions, delegation, failure/completion, cancellation, budget and side-effect gates; `detail` is allowlisted per kind and scrubbed |
| Memory | `memory`: provenance, `source`, `reason`, `sensitivity`, `topic`, `ttl`, `approval_mode` incl. `no_store`, `status` incl. `no_store`, edit via `supersedes`, delete via `forgotten`, retrieval removal via `retrievable=false`, revisioned |
| Projects, lineage, artifacts, versions, diffs, authorization | `project`, `conversation.branch`, `artifact.versions[]` immutable with `content_ref` hash, `diff_from`, `lineage`, `promotion`, `access`, `revision` |
| Autonomy | `permission.policy` (levels, grants, budgets: tokens/tool calls/wall clock/spend/subagents/delegations/scope), `approval` with `request.external`, `cancellation_receipt`, `budget_state` |
| Research | `citation.source/passage/citation/notebook` with classification, support verdict, notes, assumptions, unresolved questions, `dedupe_key` |
| Additive migrations, rollback readers | `identity.manifest` (`data_layout_version`, `min_reader_contract_version`); rules in "Compatibility" below |
## Wire conventions
- Identity is asserted by the trusted hop, never by the client body: the
chat router sets `X-Hermes-Tenant-Identity: slot-N`, `X-Hux-Subject:
usr_<hash>`, `X-Hux-Surface: chat`; the Telegram relay adds the relay key
and `trust: relay`; the Worker uses `trust: worker`. Requests without a
complete, well-formed identity are `401 unauthorized`. The service must
only be reachable from those hops (NetworkPolicy is Codex's to enforce).
- Mutations of revisioned records send `If-Match: <revision>`; a mismatch is
`409 conflict` with `hux.error.v1`. Creates accept `Idempotency-Key`; a
repeat returns the original record with `200` instead of a duplicate.
- Lists return `{"items": [...], "next": <cursor|null>}`. Event pages use
`after_seq`; the SSE stream sets `id:` to `seq` so `Last-Event-ID`
resumes exactly.
- Errors are always `hux.error.v1`. A disabled card answers `404 flag_off`
so an old client and a flag-off server look identical.
- Every read and mutation writes a `common.audit_outcome`.
## Compatibility rules
1. Within `1.x`: only add optional fields, enum values or new record kinds.
Never rename, remove, change a type, or make an optional field required.
2. Readers ignore unknown fields and unknown enum values they do not need;
a reader that needs a new enum value must check `contract_version`.
3. On-disk data is append-only per family; `data_layout_version` bumps only
for additive layout changes, and `min_reader_contract_version` says the
oldest service that can still read it. Rolling the service back to any
version ≥ `min_reader_contract_version` leaves data readable.
4. Anything outside 1–3 is `hux.v2`, served beside v1 until every surface
has moved.
## Revision 1.1.0 (2026-08-24, additive)
Raised after the three Wave A lanes reported. Adds `receipt` to
`evidence_ref.kind`, `unprocessable` (422) to error codes, optional
`revision` on source/passage/citation, optional `audit_stale` on the privacy
policy, and per-route body caps (artifact uploads up to 25 MiB content). The
argument hash a `once` approval is released against travels as
`request.evidence[] = {kind: "tool_call", id, hash}`; the agent hook must
send it or the gate can never release. Memory rules skip content-free
statuses (`no_store`, `forgotten`, `rejected`).
Review follow-up (same revision, still additive): `cancellation_receipt`
gains optional `revision`. Stored documents in every revisioned family carry
the store's `revision`, and a `failed_to_cancel` receipt is the one
non-terminal outcome: a later stop that the gateway (worker trust) confirms
supersedes it with a revision bump; an identical repeat replays. Only a
human actor (router/relay trust on chat, telegram or voice) may write a
policy or hold an `allow` grant; `trust: worker` reaches only the routes in
`hux.flags.WORKER_ROUTES` (SO-08). External side effects release only
against an approval for the same run and argument hash. Cards HUX-06,
HUX-09 and HUX-12 declare no routes until they ship.
## Consequences
- Codex codes UI against the fixtures, not against the running service.
- Claude's implementation cards (HUX-01/02/03/04/05/08/10/11) may add
optional fields but must ship the fixture change in the same commit.
- If Codex needs a shared-interface change, it lands as a `1.(x+1).0`
revision of the schemas and fixtures here, never as an informal edit.

353
docs/hux/DATA-MODEL.md Normal file
View File

@ -0,0 +1,353 @@
# HUX foundation: on-PVC data model
`hux-foundation` is a Python stdlib service that runs inside every tenant pod
and owns the records defined in `services/hermes/contracts/hux/`. It has no
database and no storage shared between tenants. Everything it keeps lives on
the tenant's own `home` PVC (mounted at `/opt/data`, beside `webui/`,
`workspace/`, `home/` and the Telegram media roots `cache/images` and
`workspace`). The Go router in `services/hermes/router/` fronts it: the router
resolves Keycloak identity to a tenant slot and forwards the
`common.schema.json#/$defs/identity` tuple (`tenant_slot`, `subject`,
`surface`, `trust`) as headers, and enforces `hux.foundation` before any
request reaches this service. The service trusts nothing else about the
caller.
The conventions below are lifted from what already works in this repo:
`hermes_model_routing._atomic_write` (temp file + `os.replace`),
`cli_lane_records.atomic_json` (journal first, act second), and
`execution_pool_store` (idempotent add by digest, terminal rows only are ever
garbage-collected). SQLite is deliberately not used: the WebUI already owns
`state.db` on this volume and a second writer with its own WAL is one more
thing to recover; JSON and JSONL are greppable during an incident.
## 1. Directory layout
Tenant root is `HUX_DATA_ROOT`, default `/opt/data/hux`. The layout version is
a directory (`v1`), so a v2 layout can be built beside v1 and swapped by
`MANIFEST.json`, never by rewriting v1 in place.
```
/opt/data/hux/
MANIFEST.json hux.manifest.v1 (identity.schema.json): contract and layout versions
v1/
.lock advisory lock file (flock) proving single writer per tenant
users/<usr_hash>/ one subtree per hashed subject; nothing lives above it
profile.json {schema:"hux.user_profile.v1", memory_enabled, default_mode, revision}
events/
<conv_id>/
events.jsonl append-only hux.event.v1, one per line, ordered by seq
seq.json {next_seq, last_event_id, bytes, checkpointed_at}
idempotency.jsonl {idempotency_key, event_id, seq, at}; last 10k keys kept
memory/
ledger.jsonl append-only hux.memory.v1 snapshots; a status change appends a full record
tombstones.jsonl {memory_id, at, reason, purged:bool}; written on forget and on decay
index.json retrieval index (section 3), rebuilt from the ledger on demand
exports/<ts>.json GET /memory/export snapshots, audited, pruned after 7 days
projects/
<prj_id>.json hux.project.v1 + revision
index.json {items:[{id,name,tags,pinned,archived,updated_at}], revision}
conversations/
<conv_id>.json hux.conversation.v1 + revision
index.json {items:[{id,project_id,title,tags,pinned,archived,mode,branch,last_message_at}]}
search/
<conv_id>.json per-conversation term postings for message_text and artifact_titles
artifacts/
<art_id>.json hux.artifact.v1 + revision (versions[] is the version list)
index.json {items:[{id,type,title,conversation_id,project_id,current_version}]}
blobs/sha256/<aa>/<hash> content-addressed, immutable, 0400, fan-out on first two hex chars
blobs/refs.json {hash: [ "<art_id>@<version>", ...]} for safe purge
research/
sources/<src_id>.json hux.source.v1
passages/<psg_id>.json hux.passage.v1 (text + locator; hash is the dedupe key)
citations/<msg_id>.jsonl hux.citation.v1 per message, append-only
notebooks/<nb_id>.json hux.research_notebook.v1 + revision
index.json {by_conversation:{conv_id:[nb_id]}, by_message:{msg_id:count}, passage_hashes:{hash:psg_id}}
policy/
global.json hux.policy.v1 for scope level global
project/<prj_id>.json hux.policy.v1 per project
conversation/<conv_id>.json hux.policy.v1 per conversation
approvals/<apr_id>.json hux.approval.v1; terminal records never change again
approvals/pending.json {items:[apr_id], revision}; the queue the UI polls
receipts/<rcpt_id>.json hux.cancel_receipt.v1
receipts/by_run.json {run_id: rcpt_id}
suggestions/
state.json {schema:"hux.suggestion_states.v1", items:{sug_id: hux.suggestion_state.v1}, revision}
privacy/
notices.jsonl hux.privacy_notice.v1 as shown, append-only
conversation_topics.json {conv_id:{topic, first_seen, decay_at, memory_disabled:bool}}
forgotten.jsonl {conv_id, requested_at, purged_at, counts}
audit/
outcomes/<YYYY-MM-DD>.jsonl one hux.audit_outcome.v1 per read or mutation (section 6)
retention/<YYYY-MM-DD>.json hux.retention_audit.v1 per run
catalog/
suggestions.json hux.suggestion.v1 catalog shipped with the image (read-only copy)
privacy_policy.json hux.privacy_policy.v1 as served, with the policy version
```
The pod is single-tenant, so the `users/` level is not multi-tenancy; it is
the guarantee that every path contains the hashed subject the router asserted
and that a second subject on the same pod (operator break-glass, future
household sharing) can never see another's tree without a distinct path.
Record ids are minted by the service: `<prefix>_<ts36><6 random base32>`, e.g.
`evt_m0k3xq9a2bcd7f`, so they sort by creation time and satisfy the
`common.schema.json` id pattern. `hux.audit_outcome.v1` and
`hux.user_profile.v1` are foundation-internal records; they follow the same
provenance and versioning rules but are not contracts the UI codes against.
## 2. Write semantics
**Atomic document write.** Serialise with `json.dumps(sort_keys=True,
ensure_ascii=False)` plus a trailing newline, write to
`.<name>.<pid>.<counter>.tmp` in the same directory, `flush()`, `os.fsync(fd)`,
`os.replace(tmp, final)`, then `os.fsync(dir_fd)` so the rename itself is
durable. Temp files that survive a crash are deleted on open. Blobs are
written the same way under their hash; an existing blob is never rewritten
(compare size and hash; on mismatch refuse and audit).
**Append.** JSONL families open with `O_APPEND`, write the whole line in one
`os.write`, and `fsync` the file before the HTTP response is sent. The
controlling checkpoint (`seq.json`, `index.json`) is written atomically after
the append; a checkpoint may lag the log, never lead it.
**Crash recovery.** On first access to a family the store validates the log:
each line must parse and, for events, `seq` must equal the previous `seq + 1`.
A trailing partial line (no newline, or JSON error on the last line only) is
truncated to the last good newline; a bad line anywhere else is a hard error
(the family is marked read-only, an audit outcome is written, `/healthz`
reports `degraded`). The checkpoint is then reconciled from the log:
`next_seq` = last good `seq + 1`, indexes rebuilt if `checkpointed_at` is
older than the log mtime.
**Idempotency.** Every mutating request may carry `Idempotency-Key`
(`common.schema.json#/$defs/idempotency_key`, `^[A-Za-z0-9._:-]{8,120}$`). For
events the key is stored in `idempotency.jsonl`; a replay returns the original
event (same `id`, same `seq`) with `HTTP 200` and `HUX-Replayed: true`. For
documents the key is stored in the record's `_meta.idempotency_keys` (last 16)
and a replay returns the current record without bumping `revision`. Event
`id` is unique per tenant; an append whose `id` already exists in the last
checkpoint window is treated as a replay, not a duplicate.
**Seq allocation.** The HTTP server is a single process,
`ThreadingHTTPServer`. Locks are per path, held in a process-wide
`dict[str, threading.RLock]` guarded by one `threading.Lock`. The unit of
locking is the family directory for JSONL (`events/<conv>`, `memory/`) and the
document path for JSON. Seq is allocated inside the conversation lock:
read `next_seq` from memory (loaded from `seq.json` once per open), assign,
append, fsync, write `seq.json`, release. Since one process owns the PVC,
`v1/.lock` is taken with `fcntl.flock(LOCK_EX|LOCK_NB)` at start-up so an
accidental second replica fails fast instead of interleaving appends.
**Optimistic concurrency.** Every JSON document carries an integer
`revision` (starts at 1) beside the contract fields; it is served as the
`ETag`. `PATCH`/`PUT` require `If-Match: <revision>`; a mismatch returns
`409` with a `hux.error.v1` body (`code: conflict`, the current revision in
`details`) as `common.schema.json#/$defs/revision` specifies. A missing `If-Match` is accepted only when the
request carries an `Idempotency-Key`, and then the write is last-writer-wins
with the outcome audited as `why: "unconditional_write"`. Indexes carry their
own `revision` and are rewritten under the family lock after the document.
**Caps.** Rejected with `hux.error.v1` `too_large` (413, size) or `conflict`
(409, count):
- event line 64 KiB, `detail` 32 KiB; 50 000 events per conversation, then
the conversation is `archived` and further appends need a branch
- memory content 2 000 chars (schema), 5 000 live entries, ledger 64 MiB
before compaction (section 4)
- conversation and project documents 256 KiB; 2 000 conversations, 200
projects, `artifact_ids` 500 (schema)
- artifact blob 25 MiB (`tenantMediaLimit` is 50 MiB; half leaves room for
the Telegram path), 200 versions per artifact, 2 000 artifacts, blob store
2 GiB per tenant
- passage text 4 000 chars, 10 000 sources, 50 000 passages
- audit outcomes are never capped by count; they rotate daily and age out
## 3. Indexes
Nothing is indexed that a scan cannot rebuild; every `index.json` is a cache
of its family and carries `built_from` (log bytes or document count) so a
stale index is detected and rebuilt rather than trusted.
**Conversation search** (`GET /hux/v1/search?q=`) covers exactly
`project.schema.json#/$defs/search_index`: `title`, `tags`, `project_name`
come from `conversations/index.json` joined with `projects/index.json` in
memory (a few thousand rows, scanned per query); `message_text` and
`artifact_titles` come from `conversations/search/<conv_id>.json`, a per
conversation bag of lowercased, punctuation-stripped terms with positions,
updated when a `message.*` event or `artifact.*` event is appended. Query
terms are ANDed; ranking is title hit > tag hit > term frequency > recency.
Events with `sensitivity: restricted` or `redaction.level: full` never enter
the search bag, so a search result can never leak what the timeline hides.
**Memory retrieval** (`memory/index.json`) is a term index over `content` of
`active` entries only, keyed by `scope.level`/`scope_id` and `topic`, plus
`expires` sorted by the effective expiry (`expires_at` or `created_at +
decay_days`) for the retention job. Entries are added on `approved`
(automatic or user), removed on `rejected`, `expired`, `forgotten`. Retrieval
reads the index, then loads the newest ledger snapshot for each id and drops
any whose status is no longer `active`, so a lagging index fails safe.
**Tombstones remove from retrieval before content is gone.** `forget` is two
writes under the memory lock: append a ledger snapshot with
`status: forgotten`, `content: ""`, audit `forgotten`; then append
`tombstones.jsonl` `{memory_id, at, reason, purged:false}`. The index write
follows. Both the retrieval path and the export path consult the tombstone
set (loaded once, appended in memory) before returning anything, which is
what makes "do not remember" hold even if the index rebuild is interrupted.
The earlier ledger lines that still contain content are what the purge job
in section 4 rewrites. The same tombstone file records `disable_memory_here`
per conversation via `privacy/conversation_topics.json`, which retrieval also
checks: a conversation with `memory_disabled` contributes no entries and
receives none.
**Research** indexes by hash: `passage_hashes` dedupes passages across
sources, `by_message` lets `GET /messages/{id}/citations` open one JSONL
without listing a directory, `by_conversation` backs the notebook drawer.
Approvals index only the pending queue; terminal approvals are found by id.
## 4. Retention
One thread runs the retention job every `retention_audit.interval_days`
(1 day) at a jittered hour, and on demand via `POST /hux/v1/admin/retention`
(worker surface only). Each run writes `audit/retention/<date>.json` as
`hux.retention_audit.v1` with the counts the schema names, so a day without
the record is itself a finding.
- `expire_memory`: entries whose effective expiry has passed get a new ledger
snapshot `status: expired` and leave the index. Decay means the entry
expires `decay_days` after `created_at` unless a later ledger snapshot
carries a newer `updated_at` from an `approved` audit action, which resets
the clock once.
- `decay_topic_context`: `privacy/conversation_topics.json` rows past
`decay_at` (`PRIVACY_TOPICS[topic].decay_days` after `first_seen`) cause
the conversation's events with that `sensitivity` to be rewritten with
`redaction.level: full` and `detail` removed; `summary` is replaced by the
topic notice text. Seq, ids and provenance are kept, so the timeline stays
contiguous.
- `purge_forgotten_content`: for every tombstone with `purged:false`, rewrite
`ledger.jsonl` (temp + replace, under the memory lock) replacing `content`
with `""` on every snapshot of that id, keeping `audit[]`; then set
`purged:true`. Forgotten conversations (`POST /conversations/{id}/forget`)
are handled the same way: `events.jsonl` is rewritten with `detail`
dropped and `redaction.level: full`, `search/<conv>.json` is deleted,
artifacts owned only by that conversation lose their blobs (via
`blobs/refs.json`), and `privacy/forgotten.jsonl` records the counts. The
conversation document stays with `archived: true` so branches still
resolve their parent.
- `report`: bounds check. Per-family bounds for a home cluster (10 Gi PVC
shared with the WebUI): events 1 GiB total and 180 days for archived
conversations, memory ledger compacted when over 64 MiB (rewrite keeping
only the newest snapshot per id plus every snapshot of ids with a
tombstone), blobs 2 GiB with unreferenced blobs deleted 7 days after their
last ref disappears, research 512 MiB and sources unreferenced by any
notebook or citation for 90 days deleted, audit outcomes 90 days,
retention audits 400 days, memory exports 7 days, idempotency keys 10 000
per conversation. Terminal approvals and receipts are kept 180 days.
Nothing in `audit/` is ever removed by a forget or purge; only age.
Private mode conversations (`retention: ephemeral`) are not written to
`events/` at all; the service returns `204` to appends and the router's
session memory is the only copy.
## 5. Migrations
`MANIFEST.json`:
```json
{"schema":"hux.manifest.v1","contract_version":"1.0.0","data_layout_version":1,
"min_reader_contract_version":"1.0.0","created_at":"...","updated_at":"..."}
```
`contract_version` is `services/hermes/contracts/hux/VERSION`; the service
that last opened the tree writes its own version there and bumps
`updated_at`. `data_layout_version` names the `v<N>/` directory in use.
Rules:
- Additive only within a layout. A release may add optional fields to a
record, add a new file name, add a new directory, add an enum value that a
reader can ignore. It may not rename or remove a field, change a field's
type, change the meaning of an existing enum value, change the id pattern,
change `seq` semantics, or move a family to a different path.
- Every record keeps its contract `schema` value (`hux.event.v1`). Internal
bookkeeping is under `_meta` (`revision`, `idempotency_keys`, `built_from`),
which is stripped before a record is served and which readers must ignore.
- Readers ignore unknown fields and never fail a family because one record
has an unknown optional key. A reader from the previous release therefore
reads records from the next one; a rollback of the service leaves every
file readable because the older code sees the same required fields.
- A record whose `schema` is a version the running code does not know is
skipped on list and returned `409 hux.schema_unknown` on direct fetch, and
logged once per family per process.
- The service refuses to open a tree whose `data_layout_version` is
greater than the one it was built for, or whose
`min_reader_contract_version` is above its own `VERSION`, and it never
bumps either on its own; a v2 layout is a separate migration tool that
builds `v2/` beside `v1/` and flips the manifest last. A release that only
adds optional fields leaves `min_reader_contract_version` alone, which is
exactly what lets the previous release read after a rollback.
- Index files are never migrated; they are deleted and rebuilt.
## 6. Authorization
The router asserts the identity tuple: `tenant_slot` (`^slot-[0-9]{1,3}$`),
`subject` (`usr_<hash>`), `surface` and `trust` (`router|relay|worker`); the
service checks the slot equals its own `HERMES_TENANT_SLOT` and refuses
otherwise. Every filesystem path is then built by `store.path_for(user,
family, *ids)`, where `user` must match `^usr_[0-9a-f]{16,64}$` and each id
must match the `common.schema.json` id pattern
`^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$` (dates in `audit/` match
`^\d{4}-\d{2}-\d{2}$`, versions `^\d+$`, hashes `^[0-9a-f]{64}$`). The
pattern admits `.` but not `/`, so `..` alone is impossible in an id; the
resolved path is still checked with `os.path.commonpath` against the user's
subtree, the same belt-and-braces the router applies in
`normalizeTenantMediaPath`. No caller-supplied string is ever joined into a
path without going through `path_for`.
Ownership: records with an `owner` field must equal the asserted user on
read and write; records without one (events, approvals, receipts, research)
are owned through their `conversation_id`, whose document is loaded and
checked first. A lookup that fails ownership returns `404`, not `403`, so
ids cannot be probed.
Every read and mutation appends one line to `audit/outcomes/<date>.jsonl`
shaped as `common.schema.json#/$defs/audit_outcome`, also for denials. The
line is the contract object plus an `_meta` envelope the store owns:
```json
{"at":"...","identity":{"tenant_slot":"slot-3","subject":"usr_...","surface":"chat","trust":"router"},
"action":"memory.forget","resource":"memory/mem_...","outcome":"allow","reason":"owner_match",
"_meta":{"schema":"hux.audit_outcome.v1","id":"aud_...","request_id":"...","idempotency_key":"...",
"revision_before":4,"revision_after":5,"build":{"commit":"...","image_digest":"sha256:..."}}}
```
`action` is `<family>.<op>`; `resource` is the family-relative record path;
`outcome` is `allow|deny|not_found|conflict|flag_off`; `reason` is a short
fixed vocabulary (`owner_match`, `owner_mismatch`, `invalid_id`,
`revision_conflict`, `cap_exceeded`, `policy_violation`,
`unconditional_write`, `replayed`, `family_readonly`). Audit lines never
contain record content.
## 7. Module map
All modules live in the `hux` package under `dockerfiles/hermes-hux-foundation/`
(where `contracts.py` and `rules.py`, the former `hux_contracts.py` and
`hux_policy.py`, already sit), stdlib only, each at most 500 lines, and each
tested in `testing/tests/test_hermes_hux_foundation_*.py`.
| Module | Responsibility |
|---|---|
| `identity.py` | Parse and validate router headers (slot, `usr_` hash, request id); id and hash regexes from `common.schema.json`; id minting; ownership check helpers. |
| `flags.py` | Wrap `rules.flag_enabled` with per-request evaluation of `HUX_FLAGS`; map each route to its card flag; 404 when the chain is off. |
| `store.py` | `path_for`, `MANIFEST.json`, `.lock`, per-path lock registry, atomic document write, fsync'd append, tmp-file cleanup, crash validation and truncation, revision/`If-Match`, idempotency-key storage, size caps. No record semantics. |
| `events.py` | Per-conversation JSONL log: seq allocation, `seq.json`, `after_seq` reads, SSE cursor, search-bag updates, redaction on read, ephemeral-mode short circuit. |
| `memory.py` | Ledger append, state machine via `rules.MEMORY_TRANSITIONS`, `memory_policy_violations`, tombstones, retrieval index, export snapshots, compaction. |
| `privacy.py` | Topic detection hooks, `conversation_topics.json`, notices log, `forget` for a conversation, retention job (`expire_memory`, `decay_topic_context`, `purge_forgotten_content`, `report`) and `hux.retention_audit.v1`. |
| `artifacts.py` | Artifact documents, version list, content-addressed blobs and `refs.json`, diff between versions, promotion, lineage, blob GC. |
| `research.py` | Sources, passages (hash dedupe), per-message citation logs, notebooks, research index. |
| `policy.py` | Policy documents per scope, `effective_decision`, approvals queue and terminal transitions, cancellation receipts and `by_run.json`. |
| `organization.py` | Projects and conversations, indexes, branch lineage, search over the `search_index` fields, suggestion state and `suggestion_allowed` gating. |
| `audit.py` | `hux.audit_outcome.v1` writer with daily rotation, the `why` vocabulary, structured logging of degraded families, age-based pruning. |
| `http.py` | `ThreadingHTTPServer` on the tenant loopback port, route table for `/hux/v1`, JSON and SSE responses, `{"items":[],"next":cursor}` list wrapping, error mapping (`404/409/412/413`), `/healthz`. |
Dependency direction is one way: `http` -> family modules -> `store`, with
`identity`, `flags` and `audit` used by everyone and importing only `store`
and `rules`/`contracts`. Family modules never touch the filesystem directly.

103
docs/hux/HANDOFF.md Normal file
View File

@ -0,0 +1,103 @@
# hermes-next: Claude backend handoff ledger
Branch `feature/hermes-next-hux` (origin, pushed after every card); draft PR #55 https://scm.bstein.dev/atlas/titan-iac/pulls/55 (opened through the SCM broker, never merged by Claude). Board
`hermes-next` on the operator Hermes Kanban; cards HUX-01..HUX-12 are parked
(`blocked`) on purpose so the CLI lane runner never auto-claims them — the
Claude coordinator and the Codex root session are the only implementers.
Contract: `hux.v1` 1.0.0 (`services/hermes/contracts/hux/VERSION`,
`docs/hux/ADR-0001-hux-v1-contract-freeze.md`). Threat model:
`docs/hux/THREAT-MODEL.md` (SO-01..SO-54). Data model: `docs/hux/DATA-MODEL.md`.
Run everything with the CI interpreter:
`python -m pytest testing/tests/test_hermes_hux_*.py -q` and
`python -m coverage run --branch --source=dockerfiles/hermes-hux-foundation -m pytest testing/tests/test_hermes_hux_*.py`.
## Codex integration requirements common to every card
- Package `dockerfiles/hermes-hux-foundation/hux/` runs as `python -m hux.server`
inside each `hermes-chat-tenant` pod (and the Worker) on `127.0.0.1:8790`.
Image, manifest, NetworkPolicy and Flux wiring are Codex's (SO-01, SO-02,
SO-45: separate uid, `/opt/data/hux` mode 0700).
- Env: `HUX_DATA_ROOT` (tenant PVC, default `/opt/data`), `HUX_TENANT_SLOT`
(pin, SO-03), `HUX_FLAGS` (comma list, default empty = everything off),
`HUX_RELAY_KEY` (Telegram relay), `HUX_WORKER_KEY` (Worker),
`HUX_BUILD_COMMIT`, `HUX_IMAGE_DIGEST`, `HUX_CONTRACT_DIR` (schemas path in
the image; default resolves relative to the repo layout).
- Router → service headers (SO-04): `X-Hermes-Tenant-Identity: slot-N`,
`X-Hux-Subject: usr_<hash>`, `X-Hux-Surface: chat|telegram|voice|api|worker`,
optional `X-Hux-Trust: relay|worker` + `X-Hux-Relay-Key`. The router must
strip any inbound `X-Hux-*` before setting its own.
- First client call: `GET /hux/v1/capabilities` → which cards/routes are on.
Disabled or unknown → `404` (`flag_off` vs `not_found` only in the body).
- Mutations: `If-Match: <revision>` on revisioned records (409 on mismatch);
`Idempotency-Key` on creates. Errors are `hux.error.v1`.
- Fixtures Codex codes against: `services/hermes/contracts/hux/examples/*.json`
(validated in CI by `test_hermes_hux_contract_schemas.py`).
## HUX-11 shared foundation — DONE (backend)
| | |
|---|---|
| Commits | `47a9fd83` (contract freeze 1.0.0), `7cf9a714` (service core) |
| Files | `services/hermes/contracts/hux/{common,identity}.schema.json` + examples, `VERSION`; `dockerfiles/hermes-hux-foundation/hux/{__init__,errors,identity,flags,store,audit,http,foundation,server,contracts,rules}.py`; `docs/hux/ADR-0001-*.md`, `THREAT-MODEL.md`, `DATA-MODEL.md` |
| Tests | `testing/tests/test_hermes_hux_contract_schemas.py` (61), `test_hermes_hux_contract_foundation.py` (30): identity header rejection matrix, relay/worker keys, slot pinning, fail-closed flag chains, capabilities record validity, tenant-scoped paths + traversal rejection, revisions/conflicts, torn-ledger recovery, blobs, manifest, concurrent writers, audit rows, flag-off == not-found, 401 never touches storage, real HTTP server JSON + SSE, loopback-only bind, all errors validate, ≤500 LOC guard |
| Coverage | 99% line / 98% branch over the package |
| Contract | 1.0.0 |
| Flag | `hux.foundation` (root of every dependency chain) |
| Risks | Rate limiting (SO-53) and hash-chained audit (SO-46) not yet implemented — tracked for the Wave A review; relay callers still carry `X-Hux-Subject` (deviates from SO-06; the slot→owner mapping lives in the router, so the header is redundant but harmless and lets the service pin the subject) |
| Codex needs | Wire the service into the tenant pod + Worker, NetworkPolicy, router header contract above, `HUX_FLAGS=hux.foundation` for the first canary; `testing/quality_contract.json` may add `dockerfiles/hermes-hux-foundation/**/*.py` to `line_limit_globs` and the two moved modules to `managed_modules` (Claude does not edit that file) |
## Wave A backends — DONE, awaiting adversarial review and Codex integration
Full suite: `testing/tests/test_hermes_hux_*.py` = 325 tests, 99% line / 99% branch over `dockerfiles/hermes-hux-foundation/hux/` (every family module 99–100%). Every module ≤ 500 LOC (guarded by tests). Contract revised additively to 1.1.0 (ADR-0001).
| Card | Commit | Files | Tests | Flag | Codex needs |
|---|---|---|---|---|---|
| HUX-01 activity timeline | `1cb6f07c` | `hux/events.py`, `hux/redaction.py` | `test_hermes_hux_contract_events.py` (34): ordering, idempotency, replay/reconnect, redaction by surface, cancellation receipts, cross-tenant | `hux.activity_timeline` | Router forwards `Last-Event-ID`; telegram/voice surfaces get partial redaction; agent hook posts events with `Idempotency-Key`; `HUX_CANARY_FILE=/opt/data/.env` so secrets are scrubbed |
| HUX-02 memory center | `1cb6f07c` | `hux/memory.py` | `test_hermes_hux_memory_ledger.py` (20), `test_hermes_hux_memory_retrieval.py` (7): no-store, suggest-only, edit/supersede, forget, retrieval removal, export, If-Match | `hux.memory_control` (needs `hux.privacy`) | Agent memory tool must call `hux.memory.retrieve` semantics (tombstones first) instead of the upstream memory file; UI per-message controls call `/memory/{id}/{action}` with `If-Match` |
| HUX-10 privacy | `1cb6f07c` | `hux/privacy.py` | `test_hermes_hux_privacy_topics.py` (19), `test_hermes_hux_privacy_retention.py` (6) | `hux.privacy` | Schedule `run_retention` daily (no thread inside the service by design); surface `HUX-Audit-Stale`; show notices and post `chosen` |
| HUX-04 artifacts | `ada74ce0` (+ upload caps, project check in the 1.1.0 batch) | `hux/artifacts.py`, `hux/diffs.py` | `test_hermes_hux_artifact_versions.py`, `test_hermes_hux_artifact_auth.py` (immutable versions, concurrency, lineage forgery 404, caps 413, diffs, promotion) | `hux.artifacts` (needs `hux.projects`) | Version content served with `nosniff` + attachment; uploads ≤ 25 MiB content via base64; sharing intentionally not implemented (`access.mode` always `owner`) |
| HUX-08 research | `ada74ce0` | `hux/research.py` | `test_hermes_hux_research_citations.py`, `test_hermes_hux_research_notebook.py` (dedupe, integrity, notebook state machine) | `hux.research` | Service never fetches URIs (SO-19): the web tool records sources/passages after its own fetch; citation strip = `GET /messages/{id}/citations` (passages + sources embedded) |
| HUX-05 autonomy | `b3de70ba` | `hux/policy.py`, `hux/budgets.py` | `test_hermes_hux_policy_matrix.py` (22), `_approvals.py` (22), `_receipts.py` (13) | `hux.autonomy` (needs `hux.activity_timeline`) | Agent hook: `POST /approvals` before any external side effect, `POST /runs/{id}/gate` immediately before executing with the canonical argument hash, `POST /runs/{id}/budget` per turn, `POST /runs/{id}/stop` with `process_registry_empty` from the real process registry; decisions only from human surfaces (worker trust → 403) |
| HUX-03 organization API | `b3de70ba` | `hux/organization.py` | `test_hermes_hux_contract_organization.py` (14) | `hux.projects` | Codex owns the UI + migration of upstream WebUI projects; search over `message_text` is a later increment |
Known gaps carried to the Wave A review: rate limiting (SO-53), hash-chained audit (SO-46), retention scheduler ownership (Codex cron vs service thread), approval expiry applied lazily on read, linear scans for idempotency/dedupe (fine at documented caps).
## Wave A adversarial review — CLOSED
A fresh reviewer attacked HEAD `124206b7` (tenant isolation, privacy, autonomy, rollback) and reported 13 findings; the repro harness lives outside the repo. Every finding is closed by a regression test that cites it:
| # | Severity | Finding | Fix commit |
|---|---|---|---|
| F1 | critical | worker/api trust could `PUT /policy` and self-approve | `dd80e4bd` — policy writes and allow grants are human-surface only |
| F2 | high | worker trust could read every tenant record (SO-08) | `dd80e4bd` — `flags.WORKER_ROUTES` allowlist enforced in `Router.dispatch`; unexpected exceptions become audited 500 records |
| F3 | high | memory `edit` skipped topic/sensitivity/private-mode gates | `4417475d` — one `_classify` path for proposals and edits |
| F4 | high | a `session` approval for an external effect released unrelated later effects | `dd80e4bd` — external effects match only the same run + argument hash; gate conversation from the run, not the body |
| F5 | high | seq duplicated after a crash between append and checkpoint | `4417475d` — seq = max(checkpoint, ledger tail + 1) under the lock |
| F6 | medium | gate ignored budget exhaustion | `dd80e4bd` |
| F7 | medium | stale unconditional write could resurrect a forgotten memory | `4417475d` — re-read under lock, always write with the loaded revision |
| F8 | medium | any caller could assert `process_registry_empty`; failed receipts were sticky | `dd80e4bd` — gateway (worker trust) only; failed receipts supersedable |
| F9 | medium | secrets stored verbatim in titles/passages/claims/notebooks; forget left the title | `4417475d` — scrub applied; forget blanks the document. Artifact bodies stay verbatim (user-owned) but are forced `restricted` and audited (`this commit`) |
| F10 | medium | SO-46/48/53 claimed but absent | threat model amended (`5acf640d`): tracked as open, not claimed |
| F11 | low | stored receipts carry `revision` the schema forbade | `dd80e4bd` — optional `revision` on the receipt |
| F12 | low | capabilities advertised routes without handlers | `dd80e4bd` — HUX-06/09/12 routes empty until shipped |
| F13 | low | misc (healthz version, ghost conversations from notices, idempotency outside the lock, artifact titles not searchable, unnormalised paths, multi-hash once approvals) | `dd80e4bd` + `4417475d` |
Suite after repairs: 377 tests, 99% line / 99% branch over `hermes-hux-foundation` and `hermes-worker-hux`. Full repo gate: same 13 pre-existing, unrelated failures as `main`.
## Worker hook library — DONE
| | |
|---|---|
| Commit | `532add3a` |
| Files | `dockerfiles/hermes-worker-hux/hux_hook/{__init__,client,hooks}.py`, `NOTES.md` (wiring guide for the runtime patch, which is Codex's file) |
| 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 |
## 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).

269
docs/hux/THREAT-MODEL.md Normal file
View File

@ -0,0 +1,269 @@
# hux-foundation threat model
Scope: the per-tenant `hux-foundation` service (Python, stdlib
`ThreadingHTTPServer`) that runs as a sidecar in each `hermes-chat-tenant-N`
pod, keeps HUX records on that pod's `home` PVC under `/opt/data/hux/v1/`
(layout in `DATA-MODEL.md`), and is reached only through the chat router,
the Worker, and the Telegram relay. It implements the frozen 1.0.0 contracts
in `services/hermes/contracts/hux/` (ADR-0001) with the rules in
`dockerfiles/hermes-hux-foundation/hux/rules.py`. Where a NetworkPolicy or
pod setting is needed it is written as a requirement for Codex, not a
manifest. Every mitigation cites a contract field, a code obligation, or an
SO id from the checklist at the end.
Assets, most important first:
1. Tenant isolation: one Keycloak subject, one pod, one PVC
(`chat-statefulset.yaml`, `ai.bstein.dev/isolation`). Everything rests on it.
2. Secrets that transit the pod: the relay key at
`/runtime-access/chat-relay-key`, provider keys in `/opt/data/.env`, and
the OAuth headers the router already strips (`main.go` Director).
3. User content: memory, artifacts, sources, events. Sensitive and restricted
topics have hard rules in `PRIVACY_TOPICS` and `memory_policy_violations`.
4. Honesty of the record: `seq`, approval decisions, cancel receipts, budget
state, release evidence. If these can be forged the UI lies to the user.
Attackers, in the order we expect to meet them:
- A: another tenant's Hermes process or a tool it ran. Shell, Python,
Playwright, and a same-namespace address.
- B: this tenant's own agent gone wrong (prompt injection via web or files).
Tenant privileges, plus tool arguments and logs full of things the user
never meant to keep.
- C: the tenant's browser session, possibly a stolen `hermes_chat_session`
cookie, driving `/hux/v1` through the router.
- D: a linked Telegram peer, or a leaked relay key.
- E: operator mistakes: a flag on without its chain, an old binary reading
new records, a purge with a bad glob.
## 1. Tenant and surface identity
The router is the only identity authority. `slotFor` maps subject to slot,
the Director strips `Authorization`, `Cookie`, `X-Auth-Request-*` and
`X-Forwarded-*`, and sets `X-Hermes-Tenant-Identity: slot-N`. Nothing in the
pod ever sees a raw subject. ADR-0001 adds `X-Hux-Subject: usr_<hash>`
(`identityHash("keycloak", subject)`) and `X-Hux-Surface`. The service
turns those into `common.identity` (`tenant_slot`, `subject`, `surface`,
`trust`) and stamps it on every record a caller can create.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| Pod-network client sends `X-Hermes-Tenant-Identity: slot-2` straight to slot 2's hux port | S | A | Bind `127.0.0.1` only; the WebUI sidecar is the sole in-pod caller and forwards the router's headers verbatim. No cross-pod path exists to spoof. (SO-01) |
| Someone reaches 8787/8642/8788 from a pod that is not the router | S | A | `hermes-chat-tenant-isolation` already admits those ports only from `app: hermes-chat-router`. Requirement: no hux port is ever added to that ingress list; the sandbox pods keep their single-source ingress. (SO-02) |
| Browser sends `X-Hux-Subject`, router forwards it | S | C | Router deletes inbound `X-Hux-*` and `X-Hermes-Tenant-Identity` before setting its own, exactly as it deletes `Authorization`. Service refuses a slot header that differs from its own ordinal (`HERMES_TENANT_SLOT`). (SO-03) |
| Two header vocabularies (ADR: `X-Hux-Subject`; DATA-MODEL §6: `X-Hermes-User`) and one hop honours the wrong one | S, E | E | One header set, defined once in `identity.py`, with the other names rejected as `401 unauthorized`. (SO-04) |
| Telegram relay carries the shared relay key and no subject | S, E | D | `Authorization: Bearer <relay_key>` compared with `hmac.compare_digest` (as `telegram_media_server.py` does); `X-Hux-Surface: telegram`, `trust: relay`. The owner is the slot's single assignment. A relay request that names `X-Hux-Subject` is `401`. (SO-05, SO-06) |
| Relay key ends up in a record or log | I | B | Key lives only under `/runtime-access` (read-only mount, not the PVC). It, and every value in `/opt/data/.env`, is a literal canary in the scrub of section 2. (SO-07) |
| Worker reads a tenant's user data through `trust: worker` | E, I | E | `trust: worker` may call `GET /hux/v1/releases`, `GET /hux/v1/capabilities` and `POST /hux/v1/admin/retention` only; any other route is `403 forbidden`. No worker bypass port. (SO-08) |
| Slot handed to a new subject; old subtree leaks | I | E | Paths are `users/<usr_hash>/...`; reads join on `identity.subject`. A different hash sees an empty tree, never another's. (SO-09) |
| Body claims `provenance.surface: worker` or `actor.type: user` | R, T | B, C | Server overwrites `identity`, `provenance.surface`, `provenance.actor` (when `type: user`) and `recorded_at` from the trusted hop and its own clock. (SO-10) |
NetworkPolicy requirements for Codex: router-only ingress to every listening
port in the tenant pod; no egress from hux-foundation (it fetches nothing);
sandbox pods gain no route to the tenant's loopback services.
## 2. Activity events
`detail` is schema-open ("kind-specific payload"). Tool arguments carry file
paths, shell lines with tokens, URLs with query strings, pasted secrets.
The pipeline, in order, before any byte is written: drop keys outside the
per-kind allowlist; scrub every remaining string; cap sizes; raise
`redaction.level` if anything fired; then and only then allocate `seq`.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| `tool.call` detail persists raw arguments | I | B | Allowlist below; unknown keys dropped before write, never filtered on read only. (SO-11) |
| Secret inside an allowed string (`summary`, `detail.target_path`) | I | B | Pattern scrub: `sk-[A-Za-z0-9]{20,}`, `ghp_|gho_|glpat-`, `AKIA[0-9A-Z]{16}`, `xox[abp]-`, `Bearer [A-Za-z0-9._-]{16,}`, `-----BEGIN [A-Z ]*PRIVATE KEY`, JWT `eyJ[A-Za-z0-9_-]{20,}\.`, hex/base64 runs ≥ 40 chars, plus the literal canaries from SO-07. Match becomes `[redacted:<class>]`, `redaction.level` ≥ `partial`, `redaction.reason` set. (SO-12) |
| Oversized detail fills the PVC or an SSE buffer | D | B | `detail` > 32 KiB (DATA-MODEL cap) is replaced by `{"truncated": true, "bytes": N}`; line cap 64 KiB; `evidence` ≤ 64 and `summary` ≤ 280 per schema. (SO-13) |
| Reader ignores `redaction.level` | I | C, D | Enforced at serve time too: `none` as stored; `partial` strips `detail`; `full` serves `kind`, `seq`, `ts`, `sensitivity`, `redaction` only. `surface: telegram` and `voice` never receive `none`. (SO-14) |
| Client supplies `seq` or `id` | T | B, C | Server-assigned under the conversation lock; a body carrying either is `400 invalid`. (SO-15) |
| Replay through `Idempotency-Key` returns someone else's event | I | C | Idempotency keys are scoped per conversation file; a replay is served only after the ownership check. (SO-16) |
| Reconnect storm, `after_seq=0` on a long log | D | C | Pages ≤ 200; `after_seq` before the retention window resumes from the oldest kept event; one SSE stream per (subject, conversation), a second open closes the first; idle timeout 15 min. (SO-17) |
| `GET /conversations/{guess}/events` | I | C | Ids are time-prefixed plus 30 random bits, so ownership is the real gate: `conversation_id` document loaded first, mismatch is `404 not_found`, not `403`. (SO-18) |
| `evidence[].uri` of `file:///opt/data/.env` expanded by a UI | I | B | Service never dereferences `evidence_ref.uri`; stored URIs must be `https://`, `hux://` or `artifact://`, others dropped. (SO-19) |
| Restricted event leaks via search | I | C | `sensitivity: restricted` or `redaction.level: full` events never enter `search/<conv>.json`. (SO-20) |
Detail allowlist (anything else is dropped):
- `message.*`: `message_id`, `chars`, `has_attachments`
- `decision.route`: `requested`, `resolved_target`, `provider`, `effort`, `reason`
- `decision.plan`: `steps` (≤ 20 strings ≤ 200 chars)
- `tool.call`: `tool`, `capability`, `argument_names`, `argument_hash`,
`target_path` (kept only under `/opt/data/workspace`, else dropped)
- `tool.result`: `tool`, `ok`, `duration_ms`, `bytes`, `exit_code`
- `approval.*`, `side_effect.*`: `approval_id`, `capability`, `choice`, `external`
- `memory.*`: `memory_id`, `kind`, `sensitivity`, `topic`
- `artifact.*`: `artifact_id`, `version`, `type`, `bytes`, `hash`
- `run.*`, `delegation.*`, `budget.exhausted`: `run_id`, `outcome`,
`receipt_id`, `spent`, `limits`, `child_run_id`
- `privacy.notice`: the `hux.privacy_notice.v1` fields only
- `citation.attached`, `mode.changed`, `suggestion.*`, `release.transition`:
ids and enum fields of their own schemas only
## 3. Memory
Defaults: `approval_mode: ask` for anything above `personal`; `no_store` is
a first-class status (`memory.status`, `memory.approval_mode`) so "do not
remember" is recorded as a decision rather than as absence. A proposal
unanswered for 7 days expires.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| Restricted content written because the topic detector missed it | I | B | `memory_policy_violations` on every write and transition; any problem is `422`/`invalid` with a `memory.suppressed` event. The service never lowers `sensitivity`; its own scrub may raise it. (SO-21) |
| "Do not remember" blocks the write but the entry surfaces later | I | B, C | `no_store` writes a content-free ledger line and a tombstone; `forget` writes `status: forgotten`, `content: ""`, `retrievable: false`, tombstone. Retrieval, `/search`, `/export` and the agent read hook check the tombstone set before the index and return only `status: active` with `retrievable: true`. (SO-22, SO-23) |
| Forgotten content lingers in earlier events or ledger lines | I | B | `memory.*` events carry ids only. `forget` re-redacts events referencing the id to `full` and queues `purge_forgotten_content` to rewrite older ledger snapshots. (SO-24) |
| Correction resurrects forgotten text | T | B | An entry whose `supersedes` or `source` points at a forgotten id needs `approval_mode: ask` answered by a `user` actor. (SO-25) |
| Export leaks another owner or forgotten entries | I | C | Export is owner-scoped, excludes `forgotten`, `rejected`, `expired`, `no_store` and `retrievable: false`, appends `exported` to `audit[]`, is served as an attachment, and the snapshot file is pruned after 7 days. (SO-26) |
| TTL never enforced because the audit job died | I | E | Expiry checked lazily at read (an expired entry is never served as active) and eagerly by the retention job; a missing `hux.retention_audit.v1` older than 48 h makes `/privacy/policy` report `audit_stale: true`. (SO-27) |
| Private mode writes memory | I | B | `MODE_CATALOG["private"].memory.write == False`: `POST /memory` is refused while the conversation's mode is `private`, and its events are not written at all (`204`). (SO-28) |
| `disable_memory_here` ignored on the read side | I | B | `privacy/conversation_topics.json` `memory_disabled` is checked by retrieval: that conversation contributes and receives nothing. (SO-23) |
## 4. Artifacts and research
Sources are records about where evidence came from. The service stores and
serves them; it never fetches a URI.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| SSRF via `source.uri` or `evidence_ref.uri` | S, I | B | No outbound HTTP client in the service; a test asserts `urllib.request`, `http.client` and `socket.create_connection` are absent from the module graph. (SO-29) |
| Wrong hash on upload; UI dedupes on it | T | B | `sha256` recomputed server-side; mismatch with `content_ref.hash` is `422`; blobs stored at `blobs/sha256/<aa>/<hash>`, mode 0400, never rewritten. (SO-30) |
| Oversized artifact | D | B, C | 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blob store per tenant (DATA-MODEL caps); over limit is `413 too_large` or `409 conflict`. (SO-31) |
| HTML/SVG served executable into the chat origin | I, E | B | Blobs served as `application/octet-stream`, `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`. `mime` is metadata for the UI's sandboxed preview, never a response header. (SO-32) |
| Lineage or promotion names a record the caller does not own | T, I | B, C | `lineage.artifact_id`, `promotion.project_id`, `passage.source_id`, `citation.passage_ids`, notebook id lists all resolved under the caller's subtree at write time; unknown or foreign is `422`. There is no cross-tenant store, and the check stays explicit so a future shared cache cannot widen it. (SO-33) |
| `access.mode: shared_readonly` used as a cross-tenant channel | I | C | Share tokens are minted by the owner, read-only, expire at `share_expires_at` ≤ 7 days, and resolve only inside the same pod; the router does not route them to another slot. (SO-34) |
| Passage `text` used to smuggle secrets into the citation strip | I | B | Passage text passes the SO-12 scrub; 4 000-char cap is the schema's. (SO-12) |
## 5. Autonomy
The service is the policy store and approval queue; the gateway executes
tools. The gate is only as strong as the gateway's call to
`effective_decision` and its presentation of the approval id at execution.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| Agent decides its own approval | S, E | B | `POST /approvals/{id}` needs `identity.surface` in `{chat, telegram, voice}` and `trust` in `{router, relay}`; the stored `decision.by` is the asserted user, not the body's actor. (SO-35) |
| `once` reused for two side effects | T | B | Approvals are terminal after one transition (`APPROVAL_TRANSITIONS`); consume marks `_meta.consumed_at` and a second consume of `once` is `409 conflict`. (SO-36) |
| TOCTOU: approved "write notes.md", executed "write ~/.ssh/authorized_keys" | T | B | `request.evidence` carries a `tool_call` ref whose `hash` is the sha256 of the canonical argument JSON; consume must present the same hash or it is `denied` with a `side_effect.blocked` event. (SO-37) |
| `always` grant never expires | E | C | `always` writes a grant with server-set `expires_at` ≤ 30 days and `granted_by`; the client cannot extend it. (SO-38) |
| `deploy`, `external_side_effect` or `network` slip through under `safe` | E | B, C | `effective_decision` is the only resolver; `_ALWAYS_ASK` ends `ask`, unexpired `deny` wins. `request.external: true` always requires an approval record. (SO-39) |
| Budget exhausted by delegation fan-out | D | B | `hux.budget_state.v1` is updated per `tool.result`/`delegation.*`; once `exhausted` the service refuses new approvals with `402`-style `budget_exhausted` and emits `budget.exhausted`. (SO-40) |
| Receipt says `cancelled` while a shell still runs | R | B | `outcome: cancelled` only after the gateway reports the run's process registry empty; else `failed_to_cancel`. `side_effects` must include every `tool.result` with `ok: true` after `requested_at`, each marked `reverted`. Stop is not done until the receipt exists. (SO-41) |
| Approval sits open forever | D | B | `expires_at` ≤ 24 h from `requested_at`; expiry is a terminal transition written by the retention thread. (SO-42) |
## 6. Storage
Layout per DATA-MODEL §1: `/opt/data/hux/v1/users/<usr_hash>/<family>/...`.
No table, index or cache is shared between tenants because no two tenants
share a PVC.
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| Path traversal via id | I, T | B, C | `store.path_for` validates the user hash and every id against the `common.schema.json` patterns (`/` impossible), then `os.path.commonpath` against the user subtree, as `normalizeTenantMediaPath` does. No caller string reaches a path any other way. (SO-43) |
| Lost update between chat and Telegram | T | C, D | `revision` served as `ETag`; PATCH/PUT need `If-Match`, mismatch `409 conflict`; writes are temp + `os.replace` + fsync under the family lock. Unconditional writes are audited `why: unconditional_write`. (SO-44) |
| Agent edits the audit log; it has write on the PVC | R, T | B | hux-foundation runs as its own uid (not 10000); `/opt/data/hux` is 0700 to that uid; the agent reaches records only through the API. Requirement for Codex: separate `runAsUser`, `readOnlyRootFilesystem`, `drop: [ALL]`. (SO-45) |
| Audit line removed or altered | R | B, E | `audit/outcomes/*.jsonl` lines carry `prev` = sha256 of the previous line; a break is reported in the next retention audit as `report`. Audit lines never contain record content. (SO-46) |
| Purge deletes the wrong thing | D | E | `purge_forgotten_content` touches only tombstoned ids and blobs absent from `refs.json` for 7 days, writes the dry-run count into the retention record before the delete pass, and never touches `audit/`. (SO-47) |
| Second replica interleaves appends | T | E | `v1/.lock` taken with `flock(LOCK_EX\|LOCK_NB)` at start-up; failure is fatal. (SO-48) |
| PVC full stops the WebUI too | D | B | Per-family bounds from DATA-MODEL §4; a write over cap is `413 too_large`, and total `/opt/data/hux` above 4 GiB puts `/healthz` at `degraded`. (SO-49) |
## 7. Feature flags
| Threat | STRIDE | Who | Mitigation |
|---|---|---|---|
| `hux.memory_control` on, `hux.privacy` off | E | E | `flag_enabled` walks `depends_on`; a route serves only when the chain is on; `/hux/v1` is `404 flag_off` without `hux.foundation`. `GET /capabilities` reports the resolved chain, not the raw env. (SO-50) |
| Old binary reads new records | D | E | Additive-only within 1.x; `_meta` stripped on serve; unknown `schema` skipped on list, `409` on fetch; `MANIFEST.json` `min_reader_layout` refuses a tree the binary cannot read. (SO-51) |
| Rollback leaves data the disabled UI cannot show | I | E | Disabling hides routes, not data; TTL, forget and purge keep running. (SO-52) |
## 8. Abuse and DoS bounds
Sized for 4-8 tenants, one human each, on a 10 Gi PVC shared with the WebUI.
- Bodies: 64 KiB JSON, 25 MiB blob, rejected before parsing.
- Rate: 30 writes/min and 300 reads/min per subject, `429 rate_limited`
with `Retry-After`. Relay and worker get the same limits per slot.
- Events: 50 000 per conversation then archived; 1 GiB total.
- Memory: 5 000 live entries; proposals expire in 7 days; ledger compacts at 64 MiB.
- Artifacts: 2 000, 200 versions each, 2 GiB blobs.
- Research: 10 000 sources, 50 000 passages, 512 MiB.
- Approvals: 50 pending per run; expiry ≤ 24 h.
- SSE: one per (subject, conversation); idle timeout 15 min; 8 per subject.
- Threads: `ThreadingHTTPServer` capped at 32 concurrent requests; beyond that `503`.
## Security obligations checklist
- SO-01 The service binds 127.0.0.1 only; any other bind address fails at startup.
- SO-02 Tenant pod ingress admits `app: hermes-chat-router` only; no hux port is added to any NetworkPolicy.
- SO-03 The router deletes inbound `X-Hux-*` and `X-Hermes-Tenant-Identity` before setting its own; the service rejects a slot header not equal to `HERMES_TENANT_SLOT`.
- SO-04 Exactly one identity header set is accepted (`X-Hermes-Tenant-Identity`, `X-Hux-Subject`, `X-Hux-Surface`); any other vocabulary is `401`.
- SO-05 Relay requests are checked with `hmac.compare_digest` against the relay key and carry `trust: relay`.
- SO-06 A relay request carrying `X-Hux-Subject` is `401`; the owner is derived from the slot.
- SO-07 The relay key and every `/opt/data/.env` value never appear in a record, event detail, audit line or log.
- SO-08 `trust: worker` may call only `/releases`, `/capabilities` and `/admin/retention`; everything else is `403`.
- SO-09 Every path contains the asserted `usr_` hash; a different subject on the same pod sees an empty tree.
- SO-10 `identity`, `provenance.surface`, user-type `provenance.actor` and `recorded_at` are server-set and client values ignored.
- SO-11 Event `detail` keys outside the per-kind allowlist are dropped before persistence.
- SO-12 Every stored string passes the secret scrub; a hit becomes `[redacted:<class>]` and raises `redaction.level` to at least `partial`.
- SO-13 `detail` over 32 KiB is replaced by a truncation marker; a line over 64 KiB is rejected.
- SO-14 `redaction.level` is enforced at serve time; telegram and voice never receive `none`.
- SO-15 `seq` and `id` are server-assigned; a body containing either is `400`.
- SO-16 Idempotent replays are served only after the ownership check passes.
- SO-17 Event pages are ≤ 200; one SSE stream per (subject, conversation); idle timeout 15 min.
- SO-18 A conversation the caller does not own returns `404`, never `403`.
- SO-19 The service never dereferences `evidence_ref.uri` or `source.uri`; stored URIs use an allowlisted scheme.
- SO-20 Restricted or fully redacted events never enter the search index.
- SO-21 `memory_policy_violations` runs on every memory write and transition; a violation is `422` plus a `memory.suppressed` event.
- SO-22 `no_store` and `forget` write a content-free ledger line and a tombstone before returning.
- SO-23 Retrieval, search, export and the agent read hook consult tombstones and `memory_disabled` first and return only `active` entries with `retrievable: true`.
- SO-24 `forget` re-redacts events referencing the memory id to `full` and queues the ledger rewrite.
- SO-25 An entry that `supersedes` or is sourced from a forgotten id requires a user-actor approval.
- SO-26 Export is owner-scoped, excludes non-active entries, appends `exported` to `audit[]`, and its snapshot is pruned after 7 days.
- SO-27 Expired entries are never served as active; a retention audit older than 48 h is reported as `audit_stale`.
- SO-28 In `private` mode `POST /memory` is refused and event appends return `204` without writing.
- SO-29 A test asserts no outbound HTTP or raw socket client is importable from the service's module graph.
- SO-30 Blob hashes are recomputed on upload; a mismatch is `422`; blobs are 0400 and never rewritten.
- SO-31 Artifact caps: 25 MiB per version, 200 versions, 2 000 artifacts, 2 GiB blobs.
- SO-32 Blobs are served as `application/octet-stream` attachments with `nosniff`.
- SO-33 Every referenced id (lineage, promotion, source, passage, citation, notebook) must resolve under the caller's subtree.
- SO-34 Share tokens are owner-minted, read-only, expire within 7 days, and resolve only on the owning pod.
- SO-35 Approvals are decided only from a human surface with `trust` router or relay; `decision.by` is the asserted user.
- SO-36 A `once` approval can be consumed exactly once.
- SO-37 Consume must present the argument hash recorded in `request.evidence` at request time.
- SO-38 `always` grants carry a server-set `expires_at` of at most 30 days.
- SO-39 `effective_decision` is the sole resolver; `deploy` and `external_side_effect` always resolve to `ask`; `request.external: true` always needs an approval record.
- SO-40 An exhausted `budget_state` blocks new approvals with `budget_exhausted` and emits `budget.exhausted`.
- SO-41 A receipt says `cancelled` only after the run's process registry is empty, and lists every successful side effect after `requested_at`.
- SO-42 Pending approvals expire within 24 h of `requested_at`.
- SO-43 All paths go through `path_for`: pattern-validated ids plus `commonpath` containment in the user subtree.
- SO-44 Revisioned writes require `If-Match`; mismatch is `409`; unconditional writes are audited.
- SO-45 The service runs as a distinct uid with `/opt/data/hux` mode 0700; the agent has no filesystem path to records.
- SO-46 Audit outcome lines are hash-chained and a broken chain is reported in the next retention audit.
- SO-47 Purge touches only tombstoned ids and unreferenced blobs older than 7 days, records a dry-run count first, and never touches `audit/`.
- SO-48 A second service process against the same tree fails at startup on `v1/.lock`.
- SO-49 Writes beyond family caps are `413`; total store above 4 GiB marks `/healthz` degraded.
- SO-50 A route serves only when its flag and every dependency flag are on; `/capabilities` reports the resolved chain.
- SO-51 Responses strip `_meta`, unknown `schema` values are skipped on list and `409` on fetch, and a tree above `min_reader_layout` is refused.
- SO-52 Disabling a flag hides routes; TTL, forget and purge continue.
- SO-53 Rate limits (30 writes/min, 300 reads/min per subject) return `429` with `Retry-After`.
- SO-54 Bodies over 64 KiB (JSON) or 25 MiB (blob) are rejected with `413` before parsing.
## Wave A review outcome (2026-08-24)
A fresh adversarial review of HEAD `124206b7` produced thirteen findings
(see `docs/hux/HANDOFF.md`, "Wave A review"). Corrections to the obligations
above as a result:
- SO-08 is amended: `trust: worker` is the agent hook, not only the operator
surface. It may call the hook allowlist in `hux/flags.py` (`WORKER_ROUTES`:
capabilities, manifest, releases, approvals create, run gate/budget/stop,
event emit, privacy policy read, memory proposal and retrieval, source/
passage/citation and artifact creation). Everything else is 403. Policy
writes and approval decisions are human-surface only (F1, F2).
- SO-29 is satisfied by a source test (no `urllib.request`, `requests`,
`httpx` or raw `socket` use in the service modules), not by a module-graph
assertion: `http.server` legitimately loads `http.client`.
- SO-46 (hash-chained audit), SO-48 (`v1/.lock` single-writer) and SO-53
(rate limits) are **not implemented** in this increment and are tracked as
open items, not claimed controls.
- On-disk documents carry a `revision` field the 1.0.0 fixtures did not have;
served bodies strip it where the record schema forbids it (F11).

View File

@ -320,7 +320,7 @@ spec:
requests: {cpu: 250m, memory: 256Mi}
limits: {cpu: "1", memory: 2Gi}
- name: webui
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:

View File

@ -0,0 +1 @@
1.1.0

View File

@ -5,65 +5,192 @@
"description": "Typed, versioned output (HUX-04). Content lives behind content_ref; versions are immutable and diffable; lineage records what an artifact was derived from; promotion copies a version into a project.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "type", "title", "current_version", "versions", "sensitivity", "created_at", "updated_at"],
"required": [
"schema",
"id",
"owner",
"type",
"title",
"current_version",
"versions",
"sensitivity",
"created_at",
"updated_at",
"revision",
"access"
],
"properties": {
"schema": {"const": "hux.artifact.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"type": {"type": "string", "enum": ["markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio"]},
"language": {"type": "string", "maxLength": 40},
"title": {"type": "string", "minLength": 1, "maxLength": 200},
"current_version": {"type": "integer", "minimum": 1},
"schema": {
"const": "hux.artifact.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"owner": {
"$ref": "common.schema.json#/$defs/user_ref"
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"project_id": {
"$ref": "common.schema.json#/$defs/id"
},
"type": {
"type": "string",
"enum": [
"markdown",
"code",
"html",
"svg",
"image",
"json",
"csv",
"document",
"audio"
]
},
"language": {
"type": "string",
"maxLength": 40
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"current_version": {
"type": "integer",
"minimum": 1
},
"versions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["version", "created_at", "created_by", "content_ref"],
"required": [
"version",
"created_at",
"created_by",
"content_ref"
],
"properties": {
"version": {"type": "integer", "minimum": 1},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"created_by": {"$ref": "common.schema.json#/$defs/actor"},
"message_id": {"type": "string", "maxLength": 120},
"version": {
"type": "integer",
"minimum": 1
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"created_by": {
"$ref": "common.schema.json#/$defs/actor"
},
"message_id": {
"type": "string",
"maxLength": 120
},
"content_ref": {
"type": "object",
"additionalProperties": false,
"required": ["hash", "bytes", "mime"],
"required": [
"hash",
"bytes",
"mime"
],
"properties": {
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
"bytes": {"type": "integer", "minimum": 0},
"mime": {"type": "string", "maxLength": 120}
"hash": {
"$ref": "common.schema.json#/$defs/sha256"
},
"bytes": {
"type": "integer",
"minimum": 0
},
"mime": {
"type": "string",
"maxLength": 120
}
}
},
"diff_from": {"type": "integer", "minimum": 1},
"diff_from": {
"type": "integer",
"minimum": 1
},
"lineage": {
"type": "object",
"additionalProperties": false,
"required": ["artifact_id", "version"],
"required": [
"artifact_id",
"version"
],
"properties": {
"artifact_id": {"$ref": "common.schema.json#/$defs/id"},
"version": {"type": "integer", "minimum": 1}
"artifact_id": {
"$ref": "common.schema.json#/$defs/id"
},
"version": {
"type": "integer",
"minimum": 1
}
}
},
"note": {"type": "string", "maxLength": 200}
"note": {
"type": "string",
"maxLength": 200
}
}
}
},
"promotion": {
"type": "object",
"additionalProperties": false,
"required": ["project_id", "version", "at"],
"required": [
"project_id",
"version",
"at"
],
"properties": {
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"version": {"type": "integer", "minimum": 1},
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
"project_id": {
"$ref": "common.schema.json#/$defs/id"
},
"version": {
"type": "integer",
"minimum": 1
},
"at": {
"$ref": "common.schema.json#/$defs/timestamp"
}
}
},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"sensitivity": {
"$ref": "common.schema.json#/$defs/sensitivity"
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
},
"access": {
"type": "object",
"description": "Owner-only unless a share token is minted; shares are read-only and expire.",
"additionalProperties": false,
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"owner",
"shared_readonly"
]
},
"share_expires_at": {
"$ref": "common.schema.json#/$defs/timestamp"
}
}
}
}
}

View File

@ -7,82 +7,329 @@
"source": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "kind", "title", "classification", "retrieved_at"],
"required": [
"schema",
"id",
"kind",
"title",
"classification",
"retrieved_at"
],
"properties": {
"schema": {"const": "hux.source.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"kind": {"type": "string", "enum": ["web", "document", "artifact", "memory", "tool_output", "dataset"]},
"uri": {"type": "string", "maxLength": 2000},
"title": {"type": "string", "minLength": 1, "maxLength": 300},
"publisher": {"type": "string", "maxLength": 200},
"published_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"retrieved_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"classification": {"type": "string", "enum": ["primary", "secondary", "unknown"]},
"content_hash": {"$ref": "common.schema.json#/$defs/sha256"},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"}
"schema": {
"const": "hux.source.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"kind": {
"type": "string",
"enum": [
"web",
"document",
"artifact",
"memory",
"tool_output",
"dataset"
]
},
"uri": {
"type": "string",
"maxLength": 2000
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 300
},
"publisher": {
"type": "string",
"maxLength": 200
},
"published_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"retrieved_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"classification": {
"type": "string",
"enum": [
"primary",
"secondary",
"unknown"
]
},
"content_hash": {
"$ref": "common.schema.json#/$defs/sha256"
},
"provenance": {
"$ref": "common.schema.json#/$defs/provenance"
},
"dedupe_key": {
"type": "string",
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"passage": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "source_id", "text", "hash"],
"required": [
"schema",
"id",
"source_id",
"text",
"hash"
],
"properties": {
"schema": {"const": "hux.passage.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"source_id": {"$ref": "common.schema.json#/$defs/id"},
"text": {"type": "string", "minLength": 1, "maxLength": 4000},
"hash": {"$ref": "common.schema.json#/$defs/sha256"},
"schema": {
"const": "hux.passage.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"source_id": {
"$ref": "common.schema.json#/$defs/id"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"hash": {
"$ref": "common.schema.json#/$defs/sha256"
},
"locator": {
"type": "object",
"additionalProperties": false,
"properties": {
"page": {"type": "integer", "minimum": 1},
"selector": {"type": "string", "maxLength": 500},
"line_start": {"type": "integer", "minimum": 1},
"line_end": {"type": "integer", "minimum": 1},
"char_start": {"type": "integer", "minimum": 0},
"char_end": {"type": "integer", "minimum": 0}
"page": {
"type": "integer",
"minimum": 1
},
"selector": {
"type": "string",
"maxLength": 500
},
"line_start": {
"type": "integer",
"minimum": 1
},
"line_end": {
"type": "integer",
"minimum": 1
},
"char_start": {
"type": "integer",
"minimum": 0
},
"char_end": {
"type": "integer",
"minimum": 0
}
}
},
"dedupe_key": {
"type": "string",
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"citation": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "message_id", "claim", "passage_ids", "support"],
"required": [
"schema",
"id",
"message_id",
"claim",
"passage_ids",
"support"
],
"properties": {
"schema": {"const": "hux.citation.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"message_id": {"type": "string", "minLength": 1, "maxLength": 120},
"claim": {"type": "string", "minLength": 1, "maxLength": 1000},
"passage_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"support": {"type": "string", "enum": ["supports", "partially_supports", "contradicts", "unverified"]},
"note": {"type": "string", "maxLength": 500}
"schema": {
"const": "hux.citation.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"message_id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"claim": {
"type": "string",
"minLength": 1,
"maxLength": 1000
},
"passage_ids": {
"type": "array",
"minItems": 1,
"maxItems": 32,
"uniqueItems": true,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"support": {
"type": "string",
"enum": [
"supports",
"partially_supports",
"contradicts",
"unverified"
]
},
"note": {
"type": "string",
"maxLength": 500
},
"dedupe_key": {
"type": "string",
"description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"notebook": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "conversation_id", "question", "status", "source_ids", "passage_ids", "citation_ids", "assumptions", "unresolved_questions", "updated_at"],
"required": [
"schema",
"id",
"conversation_id",
"question",
"status",
"source_ids",
"passage_ids",
"citation_ids",
"assumptions",
"unresolved_questions",
"updated_at",
"notes",
"revision"
],
"properties": {
"schema": {"const": "hux.research_notebook.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"question": {"type": "string", "minLength": 1, "maxLength": 1000},
"status": {"type": "string", "enum": ["open", "answered", "abandoned"]},
"source_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"passage_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"citation_ids": {"type": "array", "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
"unresolved_questions": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 500}},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"schema": {
"const": "hux.research_notebook.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"question": {
"type": "string",
"minLength": 1,
"maxLength": 1000
},
"status": {
"type": "string",
"enum": [
"open",
"answered",
"abandoned"
]
},
"source_ids": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"passage_ids": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"citation_ids": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"assumptions": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"unresolved_questions": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"notes": {
"type": "array",
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"at",
"text"
],
"properties": {
"at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"source_id": {
"$ref": "common.schema.json#/$defs/id"
}
}
}
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
}
},
"oneOf": [
{"$ref": "#/$defs/source"},
{"$ref": "#/$defs/passage"},
{"$ref": "#/$defs/citation"},
{"$ref": "#/$defs/notebook"}
{
"$ref": "#/$defs/source"
},
{
"$ref": "#/$defs/passage"
},
{
"$ref": "#/$defs/citation"
},
{
"$ref": "#/$defs/notebook"
}
]
}

View File

@ -25,98 +25,318 @@
},
"surface": {
"type": "string",
"enum": ["chat", "worker", "telegram", "voice", "api"]
"enum": [
"chat",
"worker",
"telegram",
"voice",
"api"
]
},
"provider": {
"type": "string",
"description": "Provider class, never a vendor model name. Switchyard resolves the class to a concrete target.",
"enum": ["codex", "claude", "local"]
"enum": [
"codex",
"claude",
"local"
]
},
"effort": {
"type": "string",
"enum": ["low", "medium", "high", "xhigh"]
"enum": [
"low",
"medium",
"high",
"xhigh"
]
},
"sensitivity": {
"type": "string",
"description": "public: safe to show anywhere; personal: user-owned but not sensitive; sensitive: health/finance/legal/relationships; restricted: credentials, minors, biometric, location traces.",
"enum": ["public", "personal", "sensitive", "restricted"]
"enum": [
"public",
"personal",
"sensitive",
"restricted"
]
},
"redaction": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"required": [
"level"
],
"properties": {
"level": {"type": "string", "enum": ["none", "partial", "full"]},
"reason": {"type": "string", "maxLength": 200}
"level": {
"type": "string",
"enum": [
"none",
"partial",
"full"
]
},
"reason": {
"type": "string",
"maxLength": 200
}
}
},
"actor": {
"type": "object",
"additionalProperties": false,
"required": ["type", "id"],
"required": [
"type",
"id"
],
"properties": {
"type": {"type": "string", "enum": ["user", "assistant", "tool", "system", "operator"]},
"id": {"type": "string", "minLength": 1, "maxLength": 120},
"display": {"type": "string", "maxLength": 120}
"type": {
"type": "string",
"enum": [
"user",
"assistant",
"tool",
"system",
"operator"
]
},
"id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"display": {
"type": "string",
"maxLength": 120
}
}
},
"route": {
"type": "object",
"description": "What was asked of Switchyard and what it resolved. requested is a friendly mode or a route id; resolved_target is the Switchyard target name.",
"additionalProperties": false,
"required": ["requested"],
"required": [
"requested"
],
"properties": {
"requested": {"type": "string", "minLength": 1, "maxLength": 120},
"resolved_target": {"type": "string", "maxLength": 120},
"provider": {"$ref": "#/$defs/provider"},
"effort": {"$ref": "#/$defs/effort"}
"requested": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"resolved_target": {
"type": "string",
"maxLength": 120
},
"provider": {
"$ref": "#/$defs/provider"
},
"effort": {
"$ref": "#/$defs/effort"
}
}
},
"build": {
"type": "object",
"additionalProperties": false,
"properties": {
"commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"image_digest": {"$ref": "#/$defs/sha256"}
"commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"image_digest": {
"$ref": "#/$defs/sha256"
}
}
},
"provenance": {
"type": "object",
"description": "Who produced a record, on which surface, under which session/run, through which route, from which build.",
"additionalProperties": false,
"required": ["surface", "actor", "recorded_at"],
"required": [
"surface",
"actor",
"recorded_at"
],
"properties": {
"surface": {"$ref": "#/$defs/surface"},
"actor": {"$ref": "#/$defs/actor"},
"recorded_at": {"$ref": "#/$defs/timestamp"},
"session_id": {"type": "string", "maxLength": 120},
"conversation_id": {"$ref": "#/$defs/id"},
"message_id": {"type": "string", "maxLength": 120},
"run_id": {"type": "string", "maxLength": 120},
"route": {"$ref": "#/$defs/route"},
"build": {"$ref": "#/$defs/build"}
"surface": {
"$ref": "#/$defs/surface"
},
"actor": {
"$ref": "#/$defs/actor"
},
"recorded_at": {
"$ref": "#/$defs/timestamp"
},
"session_id": {
"type": "string",
"maxLength": 120
},
"conversation_id": {
"$ref": "#/$defs/id"
},
"message_id": {
"type": "string",
"maxLength": 120
},
"run_id": {
"type": "string",
"maxLength": 120
},
"route": {
"$ref": "#/$defs/route"
},
"build": {
"$ref": "#/$defs/build"
}
}
},
"evidence_ref": {
"type": "object",
"description": "Pointer to the thing that justifies a record. Never inline the payload here; the UI expands it through the owning API.",
"additionalProperties": false,
"required": ["kind", "id"],
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"type": "string",
"enum": ["message", "tool_call", "tool_result", "artifact_version", "source", "passage", "memory", "approval", "run", "url", "file", "build", "flux", "pod"]
"enum": [
"message",
"tool_call",
"tool_result",
"artifact_version",
"source",
"passage",
"memory",
"approval",
"run",
"url",
"file",
"build",
"flux",
"pod",
"receipt"
]
},
"id": {"type": "string", "minLength": 1, "maxLength": 200},
"uri": {"type": "string", "maxLength": 2000},
"hash": {"$ref": "#/$defs/sha256"}
"id": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"uri": {
"type": "string",
"maxLength": 2000
},
"hash": {
"$ref": "#/$defs/sha256"
}
}
},
"tags": {
"type": "array",
"maxItems": 32,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"}
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
}
},
"tenant_slot": {
"type": "string",
"description": "Router-assigned tenant process slot.",
"pattern": "^slot-[0-9]{1,3}$"
},
"trust": {
"type": "string",
"description": "How the identity was asserted: router (browser via chat router), relay (Telegram relay key), worker (owner control plane).",
"enum": [
"router",
"relay",
"worker"
]
},
"identity": {
"type": "object",
"description": "Resolved caller identity. Every record path and authorization check derives from tenant_slot + subject.",
"additionalProperties": false,
"required": [
"tenant_slot",
"subject",
"surface",
"trust"
],
"properties": {
"tenant_slot": {
"$ref": "#/$defs/tenant_slot"
},
"subject": {
"$ref": "#/$defs/user_ref"
},
"surface": {
"$ref": "#/$defs/surface"
},
"trust": {
"$ref": "#/$defs/trust"
}
}
},
"revision": {
"type": "integer",
"description": "Optimistic concurrency counter. Mutations send If-Match: <revision>; a mismatch is 409.",
"minimum": 1
},
"idempotency_key": {
"type": "string",
"description": "Client-chosen key; a repeat with the same key returns the original record instead of creating a duplicate.",
"pattern": "^[A-Za-z0-9._:-]{8,120}$"
},
"contract_version": {
"type": "string",
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
},
"audit_outcome": {
"type": "object",
"description": "Result of an authorization decision, written for every read and mutation.",
"additionalProperties": false,
"required": [
"at",
"identity",
"action",
"resource",
"outcome"
],
"properties": {
"at": {
"$ref": "#/$defs/timestamp"
},
"identity": {
"$ref": "#/$defs/identity"
},
"action": {
"type": "string",
"pattern": "^[a-z_]+\\.[a-z_]+$"
},
"resource": {
"type": "string",
"maxLength": 200
},
"outcome": {
"type": "string",
"enum": [
"allow",
"deny",
"not_found",
"conflict",
"flag_off"
]
},
"reason": {
"type": "string",
"maxLength": 200
}
}
}
}
}

View File

@ -5,38 +5,120 @@
"description": "One entry in the per-conversation activity stream (HUX-01). seq is strictly monotonic per conversation; consumers resume with after_seq. Every other HUX record that changes state emits exactly one event.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "seq", "ts", "conversation_id", "kind", "summary", "provenance", "sensitivity", "redaction"],
"required": [
"schema",
"id",
"seq",
"ts",
"conversation_id",
"kind",
"summary",
"provenance",
"sensitivity",
"redaction",
"turn",
"identity"
],
"properties": {
"schema": {"const": "hux.event.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"seq": {"type": "integer", "minimum": 0},
"ts": {"$ref": "common.schema.json#/$defs/timestamp"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "maxLength": 120},
"parent_event_id": {"$ref": "common.schema.json#/$defs/id"},
"correlation_id": {"type": "string", "maxLength": 120},
"schema": {
"const": "hux.event.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"seq": {
"type": "integer",
"minimum": 0
},
"ts": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"run_id": {
"type": "string",
"maxLength": 120
},
"parent_event_id": {
"$ref": "common.schema.json#/$defs/id"
},
"correlation_id": {
"type": "string",
"maxLength": 120
},
"kind": {
"type": "string",
"enum": [
"message.user", "message.assistant",
"decision.route", "decision.plan",
"tool.call", "tool.result",
"approval.requested", "approval.resolved",
"memory.proposed", "memory.committed", "memory.forgotten",
"artifact.created", "artifact.version", "artifact.promoted",
"message.user",
"message.assistant",
"decision.route",
"decision.plan",
"tool.call",
"tool.result",
"approval.requested",
"approval.resolved",
"memory.proposed",
"memory.committed",
"memory.forgotten",
"artifact.created",
"artifact.version",
"artifact.promoted",
"citation.attached",
"mode.changed",
"run.started", "run.cancelled", "run.completed", "run.failed",
"run.started",
"run.cancelled",
"run.completed",
"run.failed",
"privacy.notice",
"suggestion.shown", "suggestion.dismissed",
"release.transition"
"suggestion.shown",
"suggestion.dismissed",
"release.transition",
"delegation.started",
"delegation.completed",
"delegation.failed",
"memory.suppressed",
"memory.retrieval_removed",
"budget.exhausted",
"side_effect.blocked",
"side_effect.released"
]
},
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
"detail": {"type": "object", "description": "Kind-specific payload. Redacted according to redaction.level before leaving the tenant."},
"evidence": {"type": "array", "maxItems": 64, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"redaction": {"$ref": "common.schema.json#/$defs/redaction"}
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 280
},
"detail": {
"type": "object",
"description": "Kind-specific payload restricted to the allowlisted keys for that kind. Raw tool arguments, logs, credentials and tokens are never stored here; the redaction pipeline drops unknown keys and scrubs secret patterns before the event is written."
},
"evidence": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "common.schema.json#/$defs/evidence_ref"
}
},
"provenance": {
"$ref": "common.schema.json#/$defs/provenance"
},
"sensitivity": {
"$ref": "common.schema.json#/$defs/sensitivity"
},
"redaction": {
"$ref": "common.schema.json#/$defs/redaction"
},
"turn": {
"type": "integer",
"description": "User-visible turn number within the conversation; several events share a turn.",
"minimum": 0
},
"idempotency_key": {
"$ref": "common.schema.json#/$defs/idempotency_key"
},
"identity": {
"$ref": "common.schema.json#/$defs/identity"
}
}
}

View File

@ -1 +1,30 @@
{"schema":"hux.approval.v1","id":"apr_0001aaaa","run_id":"run_9f","conversation_id":"conv_0001abcd","capability":"write_files","request":{"summary":"Write supplier-comparison.md to the workspace","risk":"low","evidence":[{"kind":"tool_call","id":"call-7"}]},"status":"approved","decision":{"choice":"session","by":{"type":"user","id":"usr_0123456789abcdef"},"at":"2026-08-23T10:00:03Z"},"requested_at":"2026-08-23T10:00:02Z","expires_at":"2026-08-23T10:10:02Z"}
{
"schema": "hux.approval.v1",
"id": "apr_0001aaaa",
"run_id": "run_9f",
"conversation_id": "conv_0001abcd",
"capability": "write_files",
"request": {
"summary": "Write supplier-comparison.md to the workspace",
"risk": "low",
"evidence": [
{
"kind": "tool_call",
"id": "call-7"
}
],
"external": false
},
"status": "approved",
"decision": {
"choice": "session",
"by": {
"type": "user",
"id": "usr_0123456789abcdef"
},
"at": "2026-08-23T10:00:03Z"
},
"requested_at": "2026-08-23T10:00:02Z",
"expires_at": "2026-08-23T10:10:02Z",
"idempotency_key": "run_9f:approval:call-7"
}

View File

@ -1 +1,53 @@
{"schema":"hux.artifact.v1","id":"art_0001aaaa","owner":"usr_0123456789abcdef","conversation_id":"conv_0001abcd","project_id":"prj_0001aaaa","type":"markdown","title":"Supplier comparison","current_version":2,"versions":[{"version":1,"created_at":"2026-08-22T10:00:00Z","created_by":{"type":"assistant","id":"hermes"},"message_id":"msg-20","content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2048,"mime":"text/markdown"}},{"version":2,"created_at":"2026-08-23T10:00:00Z","created_by":{"type":"user","id":"usr_0123456789abcdef"},"content_ref":{"hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","bytes":2100,"mime":"text/markdown"},"diff_from":1,"note":"added delivery lead times"}],"promotion":{"project_id":"prj_0001aaaa","version":2,"at":"2026-08-23T10:00:05Z"},"sensitivity":"personal","created_at":"2026-08-22T10:00:00Z","updated_at":"2026-08-23T10:00:05Z"}
{
"schema": "hux.artifact.v1",
"id": "art_0001aaaa",
"owner": "usr_0123456789abcdef",
"conversation_id": "conv_0001abcd",
"project_id": "prj_0001aaaa",
"type": "markdown",
"title": "Supplier comparison",
"current_version": 2,
"versions": [
{
"version": 1,
"created_at": "2026-08-22T10:00:00Z",
"created_by": {
"type": "assistant",
"id": "hermes"
},
"message_id": "msg-20",
"content_ref": {
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"bytes": 2048,
"mime": "text/markdown"
}
},
{
"version": 2,
"created_at": "2026-08-23T10:00:00Z",
"created_by": {
"type": "user",
"id": "usr_0123456789abcdef"
},
"content_ref": {
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"bytes": 2100,
"mime": "text/markdown"
},
"diff_from": 1,
"note": "added delivery lead times"
}
],
"promotion": {
"project_id": "prj_0001aaaa",
"version": 2,
"at": "2026-08-23T10:00:05Z"
},
"sensitivity": "personal",
"created_at": "2026-08-22T10:00:00Z",
"updated_at": "2026-08-23T10:00:05Z",
"revision": 2,
"access": {
"mode": "owner"
}
}

View File

@ -0,0 +1,20 @@
{
"schema": "hux.budget_state.v1",
"run_id": "run_9f",
"spent": {
"tokens": 120000,
"tool_calls": 12,
"wall_clock_seconds": 300,
"delegations": 1,
"spend_units": 10,
"subagents": 1
},
"limits": {
"tokens_per_run": 200000,
"tool_calls_per_run": 40,
"wall_clock_seconds": 900,
"spend_units": 50,
"subagents_per_run": 2
},
"exhausted": []
}

View File

@ -1 +1,24 @@
{"schema":"hux.cancel_receipt.v1","id":"rcpt_0001aaaa","run_id":"run_9f","requested_by":{"type":"user","id":"usr_0123456789abcdef"},"requested_at":"2026-08-23T10:00:04Z","acknowledged_at":"2026-08-23T10:00:04Z","completed_at":"2026-08-23T10:00:05Z","outcome":"cancelled","side_effects":[{"description":"Partial file supplier-comparison.md left in workspace","reverted":true,"evidence":{"kind":"file","id":"supplier-comparison.md"}}]}
{
"schema": "hux.cancel_receipt.v1",
"id": "rcpt_0001aaaa",
"run_id": "run_9f",
"requested_by": {
"type": "user",
"id": "usr_0123456789abcdef"
},
"requested_at": "2026-08-23T10:00:04Z",
"acknowledged_at": "2026-08-23T10:00:04Z",
"completed_at": "2026-08-23T10:00:05Z",
"outcome": "cancelled",
"side_effects": [
{
"description": "Partial file supplier-comparison.md left in workspace",
"reverted": true,
"evidence": {
"kind": "file",
"id": "supplier-comparison.md"
}
}
],
"conversation_id": "conv_0001abcd"
}

View File

@ -0,0 +1,33 @@
{
"schema": "hux.capabilities.v1",
"contract_version": "1.0.0",
"identity": {
"tenant_slot": "slot-3",
"subject": "usr_0123456789abcdef",
"surface": "chat",
"trust": "router"
},
"cards": [
{
"card": "HUX-11",
"flag": "hux.foundation",
"enabled": true,
"routes": [
"/hux/v1/capabilities"
]
},
{
"card": "HUX-01",
"flag": "hux.activity_timeline",
"enabled": false,
"routes": [
"/hux/v1/conversations/{id}/events",
"/hux/v1/conversations/{id}/events/stream"
]
}
],
"server": {
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
}
}

View File

@ -1 +1,11 @@
{"schema":"hux.citation.v1","id":"cit_0001aaaa","message_id":"msg-44","claim":"Base cabinets from Example Co take three to four weeks.","passage_ids":["psg_0001aaaa"],"support":"supports"}
{
"schema": "hux.citation.v1",
"id": "cit_0001aaaa",
"message_id": "msg-44",
"claim": "Base cabinets from Example Co take three to four weeks.",
"passage_ids": [
"psg_0001aaaa"
],
"support": "supports",
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
}

View File

@ -1 +1,24 @@
{"schema":"hux.conversation.v1","id":"conv_0001abcd","owner":"usr_0123456789abcdef","project_id":"prj_0001aaaa","title":"Compare cabinet suppliers","tags":["suppliers"],"pinned":false,"archived":false,"mode":"research","branch":{"parent_conversation_id":"conv_0000root","branch_point_message_id":"msg-12"},"artifact_ids":["art_0001aaaa"],"last_message_at":"2026-08-23T10:00:01Z","created_at":"2026-08-22T09:00:00Z","updated_at":"2026-08-23T10:00:01Z"}
{
"schema": "hux.conversation.v1",
"id": "conv_0001abcd",
"owner": "usr_0123456789abcdef",
"project_id": "prj_0001aaaa",
"title": "Compare cabinet suppliers",
"tags": [
"suppliers"
],
"pinned": false,
"archived": false,
"mode": "research",
"branch": {
"parent_conversation_id": "conv_0000root",
"branch_point_message_id": "msg-12"
},
"artifact_ids": [
"art_0001aaaa"
],
"last_message_at": "2026-08-23T10:00:01Z",
"created_at": "2026-08-22T09:00:00Z",
"updated_at": "2026-08-23T10:00:01Z",
"revision": 3
}

View File

@ -0,0 +1,9 @@
{
"schema": "hux.error.v1",
"status": 409,
"code": "conflict",
"message": "revision 2 does not match current revision 3",
"details": [
"If-Match: 2"
]
}

View File

@ -1 +1,52 @@
{"schema":"hux.event.v1","id":"evt_0001aaaa","seq":17,"ts":"2026-08-23T10:00:01Z","conversation_id":"conv_0001abcd","run_id":"run_9f","kind":"decision.route","summary":"Routed to a high-effort Claude target for a multi-step refactor","detail":{"candidates":3},"evidence":[{"kind":"run","id":"run_9f"}],"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"sensitivity":"personal","redaction":{"level":"none"}}
{
"schema": "hux.event.v1",
"id": "evt_0001aaaa",
"seq": 17,
"ts": "2026-08-23T10:00:01Z",
"conversation_id": "conv_0001abcd",
"run_id": "run_9f",
"kind": "decision.route",
"summary": "Routed to a high-effort Claude target for a multi-step refactor",
"detail": {
"candidates": 3
},
"evidence": [
{
"kind": "run",
"id": "run_9f"
}
],
"provenance": {
"surface": "chat",
"actor": {
"type": "assistant",
"id": "hermes"
},
"recorded_at": "2026-08-23T10:00:00Z",
"session_id": "sess-a1",
"conversation_id": "conv_0001abcd",
"run_id": "run_9f",
"route": {
"requested": "thoughtful",
"resolved_target": "claude_sonnet_high",
"provider": "claude",
"effort": "high"
},
"build": {
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
}
},
"sensitivity": "personal",
"redaction": {
"level": "none"
},
"turn": 5,
"identity": {
"tenant_slot": "slot-3",
"subject": "usr_0123456789abcdef",
"surface": "chat",
"trust": "router"
},
"idempotency_key": "run_9f:decision:17"
}

View File

@ -0,0 +1,8 @@
{
"schema": "hux.manifest.v1",
"contract_version": "1.0.0",
"data_layout_version": 1,
"min_reader_contract_version": "1.0.0",
"created_at": "2026-08-24T00:00:00Z",
"updated_at": "2026-08-24T00:00:00Z"
}

View File

@ -1 +1,65 @@
{"schema":"hux.memory.v1","id":"mem_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"project","scope_id":"prj_0001aaaa"},"kind":"preference","content":"Prefers terse answers with code first.","status":"proposed","approval_mode":"ask","sensitivity":"personal","topic":"general","ttl":{"policy":"decay","decay_days":180},"source":{"kind":"message","id":"msg-42"},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"created_at":"2026-08-23T10:00:00Z","updated_at":"2026-08-23T10:00:00Z","audit":[{"at":"2026-08-23T10:00:00Z","action":"proposed","actor":{"type":"assistant","id":"hermes"}}]}
{
"schema": "hux.memory.v1",
"id": "mem_0001aaaa",
"owner": "usr_0123456789abcdef",
"scope": {
"level": "project",
"scope_id": "prj_0001aaaa"
},
"kind": "preference",
"content": "Prefers terse answers with code first.",
"status": "proposed",
"approval_mode": "ask",
"sensitivity": "personal",
"topic": "general",
"ttl": {
"policy": "decay",
"decay_days": 180
},
"source": {
"kind": "message",
"id": "msg-42"
},
"provenance": {
"surface": "chat",
"actor": {
"type": "assistant",
"id": "hermes"
},
"recorded_at": "2026-08-23T10:00:00Z",
"session_id": "sess-a1",
"conversation_id": "conv_0001abcd",
"run_id": "run_9f",
"route": {
"requested": "thoughtful",
"resolved_target": "claude_sonnet_high",
"provider": "claude",
"effort": "high"
},
"build": {
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
}
},
"created_at": "2026-08-23T10:00:00Z",
"updated_at": "2026-08-23T10:00:00Z",
"audit": [
{
"at": "2026-08-23T10:00:00Z",
"action": "proposed",
"actor": {
"type": "assistant",
"id": "hermes"
}
}
],
"reason": "You asked for code-first answers twice in this project.",
"retrievable": false,
"revision": 1,
"identity": {
"tenant_slot": "slot-3",
"subject": "usr_0123456789abcdef",
"surface": "chat",
"trust": "router"
}
}

View File

@ -1 +1,31 @@
{"schema":"hux.research_notebook.v1","id":"nb_0001aaaa","conversation_id":"conv_0001abcd","question":"Which supplier delivers fastest under budget?","status":"open","source_ids":["src_0001aaaa"],"passage_ids":["psg_0001aaaa"],"citation_ids":["cit_0001aaaa"],"assumptions":["Budget cap is unchanged at 12k"],"unresolved_questions":["Does the lead time include installation?"],"updated_at":"2026-08-23T10:00:01Z"}
{
"schema": "hux.research_notebook.v1",
"id": "nb_0001aaaa",
"conversation_id": "conv_0001abcd",
"question": "Which supplier delivers fastest under budget?",
"status": "open",
"source_ids": [
"src_0001aaaa"
],
"passage_ids": [
"psg_0001aaaa"
],
"citation_ids": [
"cit_0001aaaa"
],
"assumptions": [
"Budget cap is unchanged at 12k"
],
"unresolved_questions": [
"Does the lead time include installation?"
],
"updated_at": "2026-08-23T10:00:01Z",
"notes": [
{
"at": "2026-08-23T10:00:00Z",
"text": "Lead times exclude installation per supplier FAQ.",
"source_id": "src_0001aaaa"
}
],
"revision": 4
}

View File

@ -1 +1,13 @@
{"schema":"hux.passage.v1","id":"psg_0001aaaa","source_id":"src_0001aaaa","text":"Standard base cabinets ship in 3-4 weeks.","hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","locator":{"selector":"#lead-times","char_start":120,"char_end":160}}
{
"schema": "hux.passage.v1",
"id": "psg_0001aaaa",
"source_id": "src_0001aaaa",
"text": "Standard base cabinets ship in 3-4 weeks.",
"hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"locator": {
"selector": "#lead-times",
"char_start": 120,
"char_end": 160
},
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
}

View File

@ -1 +1,56 @@
{"schema":"hux.policy.v1","id":"pol_0001aaaa","owner":"usr_0123456789abcdef","scope":{"level":"conversation","scope_id":"conv_0001abcd"},"autonomy":"safe","grants":[{"capability":"network","decision":"ask","expires_at":"2026-08-24T10:00:00Z","granted_by":{"type":"user","id":"usr_0123456789abcdef"}}],"budgets":{"tokens_per_run":200000,"tool_calls_per_run":40,"wall_clock_seconds":900},"provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}},"updated_at":"2026-08-23T10:00:00Z"}
{
"schema": "hux.policy.v1",
"id": "pol_0001aaaa",
"owner": "usr_0123456789abcdef",
"scope": {
"level": "conversation",
"scope_id": "conv_0001abcd"
},
"autonomy": "safe",
"grants": [
{
"capability": "network",
"decision": "ask",
"expires_at": "2026-08-24T10:00:00Z",
"granted_by": {
"type": "user",
"id": "usr_0123456789abcdef"
}
}
],
"budgets": {
"tokens_per_run": 200000,
"tool_calls_per_run": 40,
"wall_clock_seconds": 900,
"spend_units": 50,
"subagents_per_run": 2,
"scope": {
"conversations": [
"conv_0001abcd"
]
}
},
"provenance": {
"surface": "chat",
"actor": {
"type": "assistant",
"id": "hermes"
},
"recorded_at": "2026-08-23T10:00:00Z",
"session_id": "sess-a1",
"conversation_id": "conv_0001abcd",
"run_id": "run_9f",
"route": {
"requested": "thoughtful",
"resolved_target": "claude_sonnet_high",
"provider": "claude",
"effort": "high"
},
"build": {
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
}
},
"updated_at": "2026-08-23T10:00:00Z",
"revision": 1
}

View File

@ -1 +1,17 @@
{"schema":"hux.project.v1","id":"prj_0001aaaa","owner":"usr_0123456789abcdef","name":"Kitchen renovation","description":"Quotes, plans and supplier research.","tags":["home","2026"],"pinned":true,"archived":false,"default_mode":"research","created_at":"2026-08-20T09:00:00Z","updated_at":"2026-08-23T10:00:00Z"}
{
"schema": "hux.project.v1",
"id": "prj_0001aaaa",
"owner": "usr_0123456789abcdef",
"name": "Kitchen renovation",
"description": "Quotes, plans and supplier research.",
"tags": [
"home",
"2026"
],
"pinned": true,
"archived": false,
"default_mode": "research",
"created_at": "2026-08-20T09:00:00Z",
"updated_at": "2026-08-23T10:00:00Z",
"revision": 3
}

View File

@ -1 +1,33 @@
{"schema":"hux.source.v1","id":"src_0001aaaa","kind":"web","uri":"https://example.com/cabinets","title":"Cabinet pricing 2026","publisher":"Example Co","retrieved_at":"2026-08-23T09:59:00Z","classification":"primary","content_hash":"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881","provenance":{"surface":"chat","actor":{"type":"assistant","id":"hermes"},"recorded_at":"2026-08-23T10:00:00Z","session_id":"sess-a1","conversation_id":"conv_0001abcd","run_id":"run_9f","route":{"requested":"thoughtful","resolved_target":"claude_sonnet_high","provider":"claude","effort":"high"},"build":{"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"}}}
{
"schema": "hux.source.v1",
"id": "src_0001aaaa",
"kind": "web",
"uri": "https://example.com/cabinets",
"title": "Cabinet pricing 2026",
"publisher": "Example Co",
"retrieved_at": "2026-08-23T09:59:00Z",
"classification": "primary",
"content_hash": "sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"provenance": {
"surface": "chat",
"actor": {
"type": "assistant",
"id": "hermes"
},
"recorded_at": "2026-08-23T10:00:00Z",
"session_id": "sess-a1",
"conversation_id": "conv_0001abcd",
"run_id": "run_9f",
"route": {
"requested": "thoughtful",
"resolved_target": "claude_sonnet_high",
"provider": "claude",
"effort": "high"
},
"build": {
"commit": "d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06",
"image_digest": "sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"
}
},
"dedupe_key": "sha256:abababababababababababababababababababababababababababababababab"
}

View File

@ -2,17 +2,202 @@
"schema": "hux.flags.v1",
"env_var": "HUX_FLAGS",
"cards": [
{"card": "HUX-01", "flag": "hux.activity_timeline", "title": "Activity timeline", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["event.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; events keep accumulating on the tenant PVC and are not user-visible"},
{"card": "HUX-02", "flag": "hux.memory_control", "title": "Memory control", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["memory.schema.json"], "depends_on": ["HUX-11", "HUX-10"], "default": false, "rollback": "disable flag; ledger stays read-only, upstream memory tool unchanged"},
{"card": "HUX-03", "flag": "hux.projects", "title": "Projects and chats", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["project.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; upstream project list remains authoritative"},
{"card": "HUX-04", "flag": "hux.artifacts", "title": "Artifact workspace", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json"], "depends_on": ["HUX-11", "HUX-03"], "default": false, "rollback": "disable flag; artifacts remain downloadable files in the tenant workspace"},
{"card": "HUX-05", "flag": "hux.autonomy", "title": "Autonomy controls", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["permission.schema.json"], "depends_on": ["HUX-11", "HUX-01"], "default": false, "rollback": "disable flag; gateway approval mode reverts to the tenant default"},
{"card": "HUX-06", "flag": "hux.friendly_modes", "title": "Friendly modes", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["mode.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; routing-priority chips remain"},
{"card": "HUX-07", "flag": "hux.multimodal", "title": "Multimodal", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["artifact.schema.json", "event.schema.json"], "depends_on": ["HUX-04", "HUX-05"], "default": false, "rollback": "disable flag; upstream upload path remains"},
{"card": "HUX-08", "flag": "hux.research", "title": "Research and citations", "wave": "B", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["citation.schema.json"], "depends_on": ["HUX-11", "HUX-06"], "default": false, "rollback": "disable flag; prose citations remain"},
{"card": "HUX-09", "flag": "hux.onboarding", "title": "Onboarding", "wave": "C", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["suggestion.schema.json"], "depends_on": ["HUX-03", "HUX-06"], "default": false, "rollback": "disable flag; no suggestions shown"},
{"card": "HUX-10", "flag": "hux.privacy", "title": "Privacy behaviour", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["privacy.schema.json"], "depends_on": ["HUX-11"], "default": false, "rollback": "disable flag; tenant isolation remains the privacy model"},
{"card": "HUX-11", "flag": "hux.foundation", "title": "Shared foundation", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["common.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; /hux/v1 returns 404 from the router"},
{"card": "HUX-12", "flag": "hux.release_followthrough", "title": "Deployment follow-through", "wave": "A", "backend_owner": "claude", "frontend_owner": "codex", "contracts": ["release.schema.json"], "depends_on": [], "default": false, "rollback": "disable flag; release lane evidence archive remains the source of truth"}
]
{
"card": "HUX-01",
"flag": "hux.activity_timeline",
"title": "Activity timeline",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"event.schema.json"
],
"depends_on": [
"HUX-11"
],
"default": false,
"rollback": "disable flag; events keep accumulating on the tenant PVC and are not user-visible"
},
{
"card": "HUX-02",
"flag": "hux.memory_control",
"title": "Memory control",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"memory.schema.json"
],
"depends_on": [
"HUX-11",
"HUX-10"
],
"default": false,
"rollback": "disable flag; ledger stays read-only, upstream memory tool unchanged"
},
{
"card": "HUX-03",
"flag": "hux.projects",
"title": "Projects and chats",
"wave": "B",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"project.schema.json"
],
"depends_on": [
"HUX-11"
],
"default": false,
"rollback": "disable flag; upstream project list remains authoritative"
},
{
"card": "HUX-04",
"flag": "hux.artifacts",
"title": "Artifact workspace",
"wave": "B",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"artifact.schema.json"
],
"depends_on": [
"HUX-11",
"HUX-03"
],
"default": false,
"rollback": "disable flag; artifacts remain downloadable files in the tenant workspace"
},
{
"card": "HUX-05",
"flag": "hux.autonomy",
"title": "Autonomy controls",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"permission.schema.json"
],
"depends_on": [
"HUX-11",
"HUX-01"
],
"default": false,
"rollback": "disable flag; gateway approval mode reverts to the tenant default"
},
{
"card": "HUX-06",
"flag": "hux.friendly_modes",
"title": "Friendly modes",
"wave": "B",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"mode.schema.json"
],
"depends_on": [
"HUX-11"
],
"default": false,
"rollback": "disable flag; routing-priority chips remain"
},
{
"card": "HUX-07",
"flag": "hux.multimodal",
"title": "Multimodal",
"wave": "C",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"artifact.schema.json",
"event.schema.json"
],
"depends_on": [
"HUX-04",
"HUX-05"
],
"default": false,
"rollback": "disable flag; upstream upload path remains"
},
{
"card": "HUX-08",
"flag": "hux.research",
"title": "Research and citations",
"wave": "B",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"citation.schema.json"
],
"depends_on": [
"HUX-11",
"HUX-06"
],
"default": false,
"rollback": "disable flag; prose citations remain"
},
{
"card": "HUX-09",
"flag": "hux.onboarding",
"title": "Onboarding",
"wave": "C",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"suggestion.schema.json"
],
"depends_on": [
"HUX-03",
"HUX-06"
],
"default": false,
"rollback": "disable flag; no suggestions shown"
},
{
"card": "HUX-10",
"flag": "hux.privacy",
"title": "Privacy behaviour",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"privacy.schema.json"
],
"depends_on": [
"HUX-11"
],
"default": false,
"rollback": "disable flag; tenant isolation remains the privacy model"
},
{
"card": "HUX-11",
"flag": "hux.foundation",
"title": "Shared foundation",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"common.schema.json",
"identity.schema.json"
],
"depends_on": [],
"default": false,
"rollback": "disable flag; /hux/v1 returns 404 from the router"
},
{
"card": "HUX-12",
"flag": "hux.release_followthrough",
"title": "Deployment follow-through",
"wave": "A",
"backend_owner": "claude",
"frontend_owner": "codex",
"contracts": [
"release.schema.json"
],
"depends_on": [],
"default": false,
"rollback": "disable flag; release lane evidence archive remains the source of truth"
}
],
"contract_version": "1.1.0"
}

View File

@ -0,0 +1,158 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hermes.bstein.dev/contracts/hux/v1/identity.schema.json",
"title": "HUX identity, capability negotiation and data manifest",
"description": "HUX-11 foundation records. capabilities is the first call every client makes: it learns the contract version, which cards are on for this tenant (a card is on only when its whole dependency chain is on), and the server build. manifest is stored on the tenant PVC and tells a rolled-back reader whether it can read the data.",
"$defs": {
"capabilities": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"contract_version",
"identity",
"cards",
"server"
],
"properties": {
"schema": {
"const": "hux.capabilities.v1"
},
"contract_version": {
"$ref": "common.schema.json#/$defs/contract_version"
},
"identity": {
"$ref": "common.schema.json#/$defs/identity"
},
"cards": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"card",
"flag",
"enabled",
"routes"
],
"properties": {
"card": {
"type": "string",
"pattern": "^HUX-[0-9]{2}$"
},
"flag": {
"type": "string",
"pattern": "^hux\\.[a-z_]+$"
},
"enabled": {
"type": "boolean"
},
"routes": {
"type": "array",
"items": {
"type": "string",
"pattern": "^/hux/v1/"
}
}
}
}
},
"server": {
"$ref": "common.schema.json#/$defs/build"
}
}
},
"manifest": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"contract_version",
"data_layout_version",
"min_reader_contract_version",
"created_at",
"updated_at"
],
"properties": {
"schema": {
"const": "hux.manifest.v1"
},
"contract_version": {
"$ref": "common.schema.json#/$defs/contract_version"
},
"data_layout_version": {
"type": "integer",
"minimum": 1
},
"min_reader_contract_version": {
"$ref": "common.schema.json#/$defs/contract_version"
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
}
}
},
"error": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"status",
"code",
"message"
],
"properties": {
"schema": {
"const": "hux.error.v1"
},
"status": {
"type": "integer",
"minimum": 400,
"maximum": 599
},
"code": {
"type": "string",
"enum": [
"unauthorized",
"forbidden",
"not_found",
"conflict",
"flag_off",
"invalid",
"too_large",
"rate_limited",
"approval_required",
"budget_exhausted",
"unprocessable"
]
},
"message": {
"type": "string",
"maxLength": 280
},
"details": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 280
}
}
}
}
},
"oneOf": [
{
"$ref": "#/$defs/capabilities"
},
{
"$ref": "#/$defs/manifest"
},
{
"$ref": "#/$defs/error"
}
]
}

View File

@ -5,54 +5,201 @@
"description": "A single remembered item (HUX-02). Memory is a ledger: entries are appended, approved, expired or forgotten, never edited in place. Forgotten entries keep their audit trail but drop content.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "scope", "kind", "content", "status", "approval_mode", "sensitivity", "ttl", "source", "provenance", "created_at", "updated_at", "audit"],
"required": [
"schema",
"id",
"owner",
"scope",
"kind",
"content",
"status",
"approval_mode",
"sensitivity",
"ttl",
"source",
"provenance",
"created_at",
"updated_at",
"audit",
"reason",
"retrievable",
"revision"
],
"properties": {
"schema": {"const": "hux.memory.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"schema": {
"const": "hux.memory.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"owner": {
"$ref": "common.schema.json#/$defs/user_ref"
},
"scope": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"required": [
"level"
],
"properties": {
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
"level": {
"type": "string",
"enum": [
"global",
"project",
"conversation"
]
},
"scope_id": {
"$ref": "common.schema.json#/$defs/id"
}
}
},
"kind": {"type": "string", "enum": ["preference", "fact", "instruction", "context"]},
"content": {"type": "string", "maxLength": 2000},
"status": {"type": "string", "enum": ["proposed", "active", "rejected", "expired", "forgotten"]},
"approval_mode": {"type": "string", "enum": ["automatic", "ask"]},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"topic": {"type": "string", "enum": ["general", "health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
"kind": {
"type": "string",
"enum": [
"preference",
"fact",
"instruction",
"context"
]
},
"content": {
"type": "string",
"maxLength": 2000
},
"status": {
"type": "string",
"enum": [
"proposed",
"active",
"rejected",
"expired",
"forgotten",
"no_store"
]
},
"approval_mode": {
"type": "string",
"enum": [
"automatic",
"ask",
"no_store"
]
},
"sensitivity": {
"$ref": "common.schema.json#/$defs/sensitivity"
},
"topic": {
"type": "string",
"enum": [
"general",
"health",
"finance",
"legal",
"relationships",
"credentials",
"minors",
"location",
"biometric"
]
},
"ttl": {
"type": "object",
"additionalProperties": false,
"required": ["policy"],
"required": [
"policy"
],
"properties": {
"policy": {"type": "string", "enum": ["never", "expires_at", "decay"]},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"decay_days": {"type": "integer", "minimum": 1, "maximum": 3650}
"policy": {
"type": "string",
"enum": [
"never",
"expires_at",
"decay"
]
},
"expires_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"decay_days": {
"type": "integer",
"minimum": 1,
"maximum": 3650
}
}
},
"source": {"$ref": "common.schema.json#/$defs/evidence_ref"},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"source": {
"$ref": "common.schema.json#/$defs/evidence_ref"
},
"provenance": {
"$ref": "common.schema.json#/$defs/provenance"
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"audit": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["at", "action", "actor"],
"required": [
"at",
"action",
"actor"
],
"properties": {
"at": {"$ref": "common.schema.json#/$defs/timestamp"},
"action": {"type": "string", "enum": ["proposed", "approved", "rejected", "expired", "forgotten", "exported"]},
"actor": {"$ref": "common.schema.json#/$defs/actor"},
"note": {"type": "string", "maxLength": 200}
"at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"action": {
"type": "string",
"enum": [
"proposed",
"approved",
"rejected",
"expired",
"forgotten",
"exported",
"no_store",
"superseded",
"retrieval_removed",
"edited"
]
},
"actor": {
"$ref": "common.schema.json#/$defs/actor"
},
"note": {
"type": "string",
"maxLength": 200
}
}
}
},
"reason": {
"type": "string",
"description": "Why the assistant proposed remembering this, in the user's terms.",
"minLength": 1,
"maxLength": 280
},
"supersedes": {
"$ref": "common.schema.json#/$defs/id",
"description": "An edit is a new entry that supersedes the old one; the old entry moves to forgotten."
},
"retrievable": {
"type": "boolean",
"description": "False removes the entry from retrieval without deleting audit. Forgotten, rejected, expired and no_store entries are never retrievable."
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
},
"identity": {
"$ref": "common.schema.json#/$defs/identity"
}
}
}

View File

@ -6,39 +6,105 @@
"$defs": {
"capability": {
"type": "string",
"enum": ["read_files", "write_files", "shell", "network", "web_search", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy"]
"enum": [
"read_files",
"write_files",
"shell",
"network",
"web_search",
"send_message",
"memory_write",
"artifact_write",
"spend_tokens",
"delegate",
"deploy",
"external_side_effect"
]
},
"decision": {
"type": "string",
"enum": [
"allow",
"ask",
"deny"
]
},
"decision": {"type": "string", "enum": ["allow", "ask", "deny"]},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"],
"required": [
"schema",
"id",
"owner",
"scope",
"autonomy",
"grants",
"budgets",
"provenance",
"updated_at",
"revision"
],
"properties": {
"schema": {"const": "hux.policy.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"schema": {
"const": "hux.policy.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"owner": {
"$ref": "common.schema.json#/$defs/user_ref"
},
"scope": {
"type": "object",
"additionalProperties": false,
"required": ["level"],
"required": [
"level"
],
"properties": {
"level": {"type": "string", "enum": ["global", "project", "conversation"]},
"scope_id": {"$ref": "common.schema.json#/$defs/id"}
"level": {
"type": "string",
"enum": [
"global",
"project",
"conversation"
]
},
"scope_id": {
"$ref": "common.schema.json#/$defs/id"
}
}
},
"autonomy": {"type": "string", "enum": ["ask_first", "safe", "autonomous"]},
"autonomy": {
"type": "string",
"enum": [
"ask_first",
"safe",
"autonomous"
]
},
"grants": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["capability", "decision"],
"required": [
"capability",
"decision"
],
"properties": {
"capability": {"$ref": "#/$defs/capability"},
"decision": {"$ref": "#/$defs/decision"},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"granted_by": {"$ref": "common.schema.json#/$defs/actor"}
"capability": {
"$ref": "#/$defs/capability"
},
"decision": {
"$ref": "#/$defs/decision"
},
"expires_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"granted_by": {
"$ref": "common.schema.json#/$defs/actor"
}
}
}
},
@ -46,85 +112,342 @@
"type": "object",
"additionalProperties": false,
"properties": {
"tokens_per_run": {"type": "integer", "minimum": 0},
"tool_calls_per_run": {"type": "integer", "minimum": 0},
"wall_clock_seconds": {"type": "integer", "minimum": 0},
"delegations_per_run": {"type": "integer", "minimum": 0}
"tokens_per_run": {
"type": "integer",
"minimum": 0
},
"tool_calls_per_run": {
"type": "integer",
"minimum": 0
},
"wall_clock_seconds": {
"type": "integer",
"minimum": 0
},
"delegations_per_run": {
"type": "integer",
"minimum": 0
},
"spend_units": {
"type": "integer",
"minimum": 0
},
"subagents_per_run": {
"type": "integer",
"minimum": 0
},
"scope": {
"type": "object",
"additionalProperties": false,
"properties": {
"conversations": {
"type": "array",
"maxItems": 64,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"paths": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"maxLength": 300
}
}
}
}
}
},
"provenance": {"$ref": "common.schema.json#/$defs/provenance"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"provenance": {
"$ref": "common.schema.json#/$defs/provenance"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"approval": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "run_id", "conversation_id", "capability", "request", "status", "requested_at", "expires_at"],
"required": [
"schema",
"id",
"run_id",
"conversation_id",
"capability",
"request",
"status",
"requested_at",
"expires_at"
],
"properties": {
"schema": {"const": "hux.approval.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"capability": {"$ref": "#/$defs/capability"},
"schema": {
"const": "hux.approval.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"run_id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"capability": {
"$ref": "#/$defs/capability"
},
"request": {
"type": "object",
"additionalProperties": false,
"required": ["summary", "risk"],
"required": [
"summary",
"risk",
"external"
],
"properties": {
"summary": {"type": "string", "minLength": 1, "maxLength": 280},
"detail": {"type": "string", "maxLength": 4000},
"risk": {"type": "string", "enum": ["low", "medium", "high"]},
"evidence": {"type": "array", "maxItems": 16, "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}}
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 280
},
"detail": {
"type": "string",
"maxLength": 4000
},
"risk": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"evidence": {
"type": "array",
"maxItems": 16,
"items": {
"$ref": "common.schema.json#/$defs/evidence_ref"
}
},
"external": {
"type": "boolean",
"description": "True when the action leaves the tenant (message, network call, deploy). External side effects always need an approval record regardless of autonomy level."
}
}
},
"status": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "cancelled"]},
"status": {
"type": "string",
"enum": [
"pending",
"approved",
"denied",
"expired",
"cancelled"
]
},
"decision": {
"type": "object",
"additionalProperties": false,
"required": ["choice", "by", "at"],
"required": [
"choice",
"by",
"at"
],
"properties": {
"choice": {"type": "string", "enum": ["once", "session", "always", "deny"]},
"by": {"$ref": "common.schema.json#/$defs/actor"},
"at": {"$ref": "common.schema.json#/$defs/timestamp"}
"choice": {
"type": "string",
"enum": [
"once",
"session",
"always",
"deny"
]
},
"by": {
"$ref": "common.schema.json#/$defs/actor"
},
"at": {
"$ref": "common.schema.json#/$defs/timestamp"
}
}
},
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"expires_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"requested_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"expires_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"idempotency_key": {
"$ref": "common.schema.json#/$defs/idempotency_key"
}
}
},
"cancellation_receipt": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "run_id", "requested_by", "requested_at", "outcome", "side_effects"],
"required": [
"schema",
"id",
"run_id",
"requested_by",
"requested_at",
"outcome",
"side_effects"
],
"properties": {
"schema": {"const": "hux.cancel_receipt.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"run_id": {"type": "string", "minLength": 1, "maxLength": 120},
"requested_by": {"$ref": "common.schema.json#/$defs/actor"},
"requested_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"acknowledged_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"completed_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"outcome": {"type": "string", "enum": ["cancelled", "already_complete", "failed_to_cancel"]},
"schema": {
"const": "hux.cancel_receipt.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"run_id": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"requested_by": {
"$ref": "common.schema.json#/$defs/actor"
},
"requested_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"acknowledged_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"completed_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"outcome": {
"type": "string",
"enum": [
"cancelled",
"already_complete",
"failed_to_cancel"
]
},
"side_effects": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["description", "reverted"],
"required": [
"description",
"reverted"
],
"properties": {
"description": {"type": "string", "minLength": 1, "maxLength": 280},
"reverted": {"type": "boolean"},
"evidence": {"$ref": "common.schema.json#/$defs/evidence_ref"}
"description": {
"type": "string",
"minLength": 1,
"maxLength": 280
},
"reverted": {
"type": "boolean"
},
"evidence": {
"$ref": "common.schema.json#/$defs/evidence_ref"
}
}
}
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"revision": {
"type": "integer",
"minimum": 1,
"description": "Store revision; bumps when a failed_to_cancel receipt is superseded by a later stop."
}
}
},
"budget_state": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"run_id",
"spent",
"limits",
"exhausted"
],
"properties": {
"schema": {
"const": "hux.budget_state.v1"
},
"run_id": {
"type": "string",
"maxLength": 120
},
"spent": {
"type": "object",
"additionalProperties": false,
"properties": {
"tokens": {
"type": "integer",
"minimum": 0
},
"tool_calls": {
"type": "integer",
"minimum": 0
},
"wall_clock_seconds": {
"type": "integer",
"minimum": 0
},
"delegations": {
"type": "integer",
"minimum": 0
},
"spend_units": {
"type": "integer",
"minimum": 0
},
"subagents": {
"type": "integer",
"minimum": 0
}
}
},
"limits": {
"$ref": "#/$defs/policy/properties/budgets"
},
"exhausted": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"enum": [
"tokens_per_run",
"tool_calls_per_run",
"wall_clock_seconds",
"delegations_per_run",
"spend_units",
"subagents_per_run"
]
}
}
}
}
},
"oneOf": [
{"$ref": "#/$defs/policy"},
{"$ref": "#/$defs/approval"},
{"$ref": "#/$defs/cancellation_receipt"}
{
"$ref": "#/$defs/policy"
},
{
"$ref": "#/$defs/approval"
},
{
"$ref": "#/$defs/cancellation_receipt"
},
{
"$ref": "#/$defs/budget_state"
}
]
}

View File

@ -4,47 +4,129 @@
"title": "HUX privacy policy, topic scoping and retention",
"description": "Sensitive-topic behaviour (HUX-10). The policy names each topic class, whether memory may be written from it, how long conversation-scoped context survives, and which just-in-time notice the surface must show. A retention audit record proves the rules ran.",
"$defs": {
"topic": {"type": "string", "enum": ["health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]},
"topic": {
"type": "string",
"enum": [
"health",
"finance",
"legal",
"relationships",
"credentials",
"minors",
"location",
"biometric"
]
},
"policy": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "version", "topics", "topic_scoping", "retention_audit"],
"required": [
"schema",
"version",
"topics",
"topic_scoping",
"retention_audit"
],
"properties": {
"schema": {"const": "hux.privacy_policy.v1"},
"version": {"type": "integer", "minimum": 1},
"schema": {
"const": "hux.privacy_policy.v1"
},
"version": {
"type": "integer",
"minimum": 1
},
"topics": {
"type": "array",
"minItems": 8,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["topic", "sensitivity", "memory_write", "decay_days", "notice"],
"required": [
"topic",
"sensitivity",
"memory_write",
"decay_days",
"notice"
],
"properties": {
"topic": {"$ref": "#/$defs/topic"},
"sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"},
"memory_write": {"type": "string", "enum": ["ask", "deny"]},
"decay_days": {"type": "integer", "minimum": 1, "maximum": 365},
"notice": {"type": "string", "minLength": 1, "maxLength": 280}
"topic": {
"$ref": "#/$defs/topic"
},
"sensitivity": {
"$ref": "common.schema.json#/$defs/sensitivity"
},
"memory_write": {
"type": "string",
"enum": [
"ask",
"deny"
]
},
"decay_days": {
"type": "integer",
"minimum": 1,
"maximum": 365
},
"notice": {
"type": "string",
"minLength": 1,
"maxLength": 280
}
}
}
},
"topic_scoping": {
"type": "object",
"additionalProperties": false,
"required": ["scope_to_conversation", "cross_surface_sharing"],
"required": [
"scope_to_conversation",
"cross_surface_sharing"
],
"properties": {
"scope_to_conversation": {"const": true},
"cross_surface_sharing": {"type": "string", "enum": ["never", "same_owner_only"]}
"scope_to_conversation": {
"const": true
},
"cross_surface_sharing": {
"type": "string",
"enum": [
"never",
"same_owner_only"
]
}
}
},
"retention_audit": {
"type": "object",
"additionalProperties": false,
"required": ["interval_days", "actions"],
"required": [
"interval_days",
"actions"
],
"properties": {
"interval_days": {"type": "integer", "minimum": 1, "maximum": 30},
"actions": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}}
"interval_days": {
"type": "integer",
"minimum": 1,
"maximum": 30
},
"actions": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"enum": [
"expire_memory",
"decay_topic_context",
"purge_forgotten_content",
"report"
]
}
}
}
},
"audit_stale": {
"type": "boolean",
"description": "True when the last retention audit is older than 48 hours (SO-27)."
}
}
},
@ -52,34 +134,95 @@
"type": "object",
"description": "Just-in-time notice a surface shows when a sensitive topic is detected. Emitted as event kind privacy.notice.",
"additionalProperties": false,
"required": ["schema", "topic", "conversation_id", "text", "controls", "shown_at"],
"required": [
"schema",
"topic",
"conversation_id",
"text",
"controls",
"shown_at"
],
"properties": {
"schema": {"const": "hux.privacy_notice.v1"},
"topic": {"$ref": "#/$defs/topic"},
"conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"text": {"type": "string", "minLength": 1, "maxLength": 280},
"controls": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss"]}},
"shown_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"schema": {
"const": "hux.privacy_notice.v1"
},
"topic": {
"$ref": "#/$defs/topic"
},
"conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 280
},
"controls": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"enum": [
"forget_this_conversation",
"switch_to_private",
"disable_memory_here",
"dismiss"
]
}
},
"shown_at": {
"$ref": "common.schema.json#/$defs/timestamp"
}
}
},
"audit": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "ran_at", "policy_version", "results"],
"required": [
"schema",
"id",
"ran_at",
"policy_version",
"results"
],
"properties": {
"schema": {"const": "hux.retention_audit.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"ran_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"policy_version": {"type": "integer", "minimum": 1},
"schema": {
"const": "hux.retention_audit.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"ran_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"policy_version": {
"type": "integer",
"minimum": 1
},
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["action", "count"],
"required": [
"action",
"count"
],
"properties": {
"action": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]},
"count": {"type": "integer", "minimum": 0}
"action": {
"type": "string",
"enum": [
"expire_memory",
"decay_topic_context",
"purge_forgotten_content",
"report"
]
},
"count": {
"type": "integer",
"minimum": 0
}
}
}
}
@ -87,8 +230,14 @@
}
},
"oneOf": [
{"$ref": "#/$defs/policy"},
{"$ref": "#/$defs/notice"},
{"$ref": "#/$defs/audit"}
{
"$ref": "#/$defs/policy"
},
{
"$ref": "#/$defs/notice"
},
{
"$ref": "#/$defs/audit"
}
]
}

View File

@ -7,63 +7,193 @@
"project": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "name", "tags", "pinned", "archived", "created_at", "updated_at"],
"required": [
"schema",
"id",
"owner",
"name",
"tags",
"pinned",
"archived",
"created_at",
"updated_at",
"revision"
],
"properties": {
"schema": {"const": "hux.project.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"description": {"type": "string", "maxLength": 2000},
"tags": {"$ref": "common.schema.json#/$defs/tags"},
"pinned": {"type": "boolean"},
"archived": {"type": "boolean"},
"default_mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
"memory_scope_id": {"$ref": "common.schema.json#/$defs/id"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"schema": {
"const": "hux.project.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"owner": {
"$ref": "common.schema.json#/$defs/user_ref"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": "string",
"maxLength": 2000
},
"tags": {
"$ref": "common.schema.json#/$defs/tags"
},
"pinned": {
"type": "boolean"
},
"archived": {
"type": "boolean"
},
"default_mode": {
"type": "string",
"enum": [
"fast",
"thoughtful",
"research",
"create",
"private"
]
},
"memory_scope_id": {
"$ref": "common.schema.json#/$defs/id"
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"conversation": {
"type": "object",
"additionalProperties": false,
"required": ["schema", "id", "owner", "title", "tags", "pinned", "archived", "artifact_ids", "created_at", "updated_at"],
"required": [
"schema",
"id",
"owner",
"title",
"tags",
"pinned",
"archived",
"artifact_ids",
"created_at",
"updated_at",
"revision"
],
"properties": {
"schema": {"const": "hux.conversation.v1"},
"id": {"$ref": "common.schema.json#/$defs/id"},
"owner": {"$ref": "common.schema.json#/$defs/user_ref"},
"project_id": {"$ref": "common.schema.json#/$defs/id"},
"title": {"type": "string", "minLength": 1, "maxLength": 200},
"tags": {"$ref": "common.schema.json#/$defs/tags"},
"pinned": {"type": "boolean"},
"archived": {"type": "boolean"},
"mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]},
"schema": {
"const": "hux.conversation.v1"
},
"id": {
"$ref": "common.schema.json#/$defs/id"
},
"owner": {
"$ref": "common.schema.json#/$defs/user_ref"
},
"project_id": {
"$ref": "common.schema.json#/$defs/id"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"tags": {
"$ref": "common.schema.json#/$defs/tags"
},
"pinned": {
"type": "boolean"
},
"archived": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"fast",
"thoughtful",
"research",
"create",
"private"
]
},
"branch": {
"type": "object",
"additionalProperties": false,
"required": ["parent_conversation_id", "branch_point_message_id"],
"required": [
"parent_conversation_id",
"branch_point_message_id"
],
"properties": {
"parent_conversation_id": {"$ref": "common.schema.json#/$defs/id"},
"branch_point_message_id": {"type": "string", "minLength": 1, "maxLength": 120}
"parent_conversation_id": {
"$ref": "common.schema.json#/$defs/id"
},
"branch_point_message_id": {
"type": "string",
"minLength": 1,
"maxLength": 120
}
}
},
"artifact_ids": {"type": "array", "maxItems": 500, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/id"}},
"last_message_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"created_at": {"$ref": "common.schema.json#/$defs/timestamp"},
"updated_at": {"$ref": "common.schema.json#/$defs/timestamp"}
"artifact_ids": {
"type": "array",
"maxItems": 500,
"uniqueItems": true,
"items": {
"$ref": "common.schema.json#/$defs/id"
}
},
"last_message_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"created_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"updated_at": {
"$ref": "common.schema.json#/$defs/timestamp"
},
"revision": {
"$ref": "common.schema.json#/$defs/revision"
}
}
},
"search_index": {
"type": "object",
"description": "Fields the tenant indexes; the UI may not assume anything else is searchable.",
"additionalProperties": false,
"required": ["conversation"],
"required": [
"conversation"
],
"properties": {
"conversation": {"type": "array", "items": {"type": "string", "enum": ["title", "tags", "message_text", "artifact_titles", "project_name"]}}
"conversation": {
"type": "array",
"items": {
"type": "string",
"enum": [
"title",
"tags",
"message_text",
"artifact_titles",
"project_name"
]
}
}
}
}
},
"oneOf": [
{"$ref": "#/$defs/project"},
{"$ref": "#/$defs/conversation"}
{
"$ref": "#/$defs/project"
},
{
"$ref": "#/$defs/conversation"
}
]
}

View File

@ -388,7 +388,7 @@ spec:
cpu: "2"
memory: 4Gi
- name: webui
image: registry.bstein.dev/bstein/hermes-webui:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:440d59b8fb77b95c6bef4e39f73fd08dd2ee4d044bf95725b31da1441c173438 # {"$imagepolicy": "hermes:hermes-webui-release"}
image: registry.bstein.dev/bstein/hermes-webui:git-e9a6eba30b5ec1b7cdd4aea816131e840d541353-build-16-release@sha256:2e241688a86ea6ef9cd5fc23bf75f97597552c015d1b68b98d471c8a2fe5c369 # {"$imagepolicy": "hermes:hermes-webui-release"}
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:

View File

@ -31,7 +31,7 @@ spec:
kubernetes.io/hostname: titan-21
containers:
- name: stt
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-d254931a14793a2e612bbea36642ee947c5355a9-build-15-release@sha256:6460847c21f41a3f2184bb4bac51ccb962bd8f79b5f597b60ff17e3e8859f5b2 # {"$imagepolicy": "hermes:hermes-stt-release"}
image: registry.bstein.dev/bstein/hermes-jetson-stt:git-af5ee4697bf064958e6ca363678321e606ce3e61-build-17-release@sha256:fa6a185a6e81f7b4690c7abff350a14fa731fbb455e5ff6c46dc253120cfbd21 # {"$imagepolicy": "hermes:hermes-stt-release"}
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 9000, protocol: TCP}

View File

@ -0,0 +1,146 @@
"""HUX-04 artifacts: authorization boundaries and tenant isolation.
Security obligations exercised: a second subject sees 404, never 403, for
every artifact route (SO-33, SO-34); malformed ids never reach a path (SO-43);
a forged owner field inside the tenant directory is still refused; every
denial leaves an audit outcome; the card serves nothing while its flag is off.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import artifacts, audit, contracts, errors, identity, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=OWNER):
payload = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, payload)
return response.status, response.body
@pytest.fixture
def artifact(router):
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Mine", "content": "secret\n", "conversation_id": "conv_0001abcd"})
assert status == 201
return record
def test_second_subject_gets_404_everywhere(router, artifact):
art = artifact["id"]
probes = [
("GET", f"/hux/v1/artifacts/{art}", None),
("GET", f"/hux/v1/artifacts/{art}/versions/1", None),
("GET", f"/hux/v1/artifacts/{art}/versions/1/diff", None),
("POST", f"/hux/v1/artifacts/{art}/versions", {"content": "x"}),
("POST", f"/hux/v1/artifacts/{art}/promote", {"project_id": "prj_0001aaaa"}),
]
for method, path, body in probes:
status, error = call(router, method, path, body, {**OTHER, "If-Match": "1"})
assert (status, error["code"]) == (404, "not_found"), path
assert contracts.validate_record(error, SCHEMAS) == []
status, body = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd", headers=OTHER)
assert status == 200 and body["items"] == []
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
assert [r["outcome"] for r in rows if r["action"].startswith("artifacts.")].count("not_found") == 5
def test_cross_tenant_lineage_forgery_is_404(router, artifact):
body = {"type": "markdown", "title": "Derived", "content": "copy", "lineage": {"artifact_id": artifact["id"], "version": 1}}
status, error = call(router, "POST", "/hux/v1/artifacts", body, OTHER)
assert (status, error["code"]) == (404, "not_found")
status, own = call(router, "POST", "/hux/v1/artifacts", {**body, "lineage": None}, OTHER)
assert status == 201
status, error = call(router, "POST", f"/hux/v1/artifacts/{own['id']}/versions", {"content": "v2", "lineage": {"artifact_id": artifact["id"], "version": 1}}, {**OTHER, "If-Match": "1"})
assert status == 404
assert call(router, "GET", f"/hux/v1/artifacts/{own['id']}", headers=OTHER)[1]["current_version"] == 1
def test_malformed_ids_never_reach_the_filesystem(router):
for path in ("/hux/v1/artifacts/..", "/hux/v1/artifacts/art_x", "/hux/v1/artifacts/ART_0000000000"):
status, error = call(router, "GET", path)
assert status == 404 and error["code"] == "not_found", path
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
assert not (tenant.root / "artifacts" / "...json").exists()
def test_forged_owner_inside_tenant_directory_is_refused(router, artifact):
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
forged = {**tenant.get("artifacts", artifact["id"]), "id": "art_forged000001", "owner": "usr_fedcba9876543210"}
tenant.put("artifacts", forged)
assert call(router, "GET", "/hux/v1/artifacts/art_forged000001")[0] == 404
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": "art_forged000001", "version": 1}})
assert status == 404
status, body = call(router, "GET", "/hux/v1/artifacts")
assert [a["id"] for a in body["items"]] == [artifact["id"]]
assert artifacts.artifact_exists(tenant, "art_forged000001")
def test_contract_validation_guards_every_write(router, artifact):
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
current = tenant.get("artifacts", artifact["id"])
with pytest.raises(errors.Invalid) as raised:
artifacts.append_version(tenant, current, {"version": 2, "created_at": "now", "created_by": {"type": "user", "id": "x"}, "content_ref": {"hash": "sha256:" + "a" * 64, "bytes": 1, "mime": "x"}}, 1)
assert raised.value.details and tenant.get("artifacts", artifact["id"])["current_version"] == 1
def test_flag_off_hides_the_card(tmp_path, artifact):
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
status, error = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
assert (status, error["code"]) == (404, "flag_off")
status, error = call(router, "GET", "/hux/v1/artifacts")
assert status == 404
def test_idempotency_replay_is_scoped_to_the_caller(router):
body = {"type": "code", "title": "t", "content": "c"}
status, mine = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-01"})
status, theirs = call(router, "POST", "/hux/v1/artifacts", body, {**OTHER, "Idempotency-Key": "shared-key-01"})
assert status == 201 and theirs["id"] != mine["id"] and theirs["owner"] == "usr_fedcba9876543210"
status, again = call(router, "POST", "/hux/v1/artifacts", body, {**OWNER, "Idempotency-Key": "shared-key-02"})
assert status == 201 and again["id"] != mine["id"]
status, error = call(router, "POST", f"/hux/v1/artifacts/{mine['id']}/versions", {"content": "v"}, {**OWNER, "If-Match": "1", "Idempotency-Key": "shared-key-02"})
assert status == 201
def test_audit_rows_name_family_verbs(router, artifact):
call(router, "GET", f"/hux/v1/artifacts/{artifact['id']}")
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
assert [r["action"] for r in rows] == ["artifacts.create", "artifacts.get"]
assert all(contracts.validate("common.schema.json", r, SCHEMAS, "/$defs/audit_outcome") == [] for r in rows)
def test_secret_bearing_content_is_kept_but_forced_restricted(router):
"""Review a2-C / SO-12: user artifacts are not rewritten, but a secret pattern forces restricted sensitivity and is audited."""
token = "ghp_" + "A" * 36
status, record = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "env", "content": f"TOKEN={token}", "sensitivity": "personal"})
assert status == 201 and record["sensitivity"] == "restricted"
status, version = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
assert status == 200 and token in version["content"]
clean = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "clean", "content": "x = 1"})[1]
assert clean["sensitivity"] == "personal"
status, bumped = call(router, "POST", f"/hux/v1/artifacts/{clean['id']}/versions", {"content": f"key: {token}"}, {**OWNER, "If-Match": "1"})
assert status == 201 and bumped["sensitivity"] == "restricted"
from hux import audit, store
reasons = [r.get("reason") for r in audit.recent(store.TenantStore(router.data_root, identity.resolve(OWNER, {}))) if r["action"].startswith("artifacts.")]
assert reasons.count("secret_pattern_restricted") == 2

View File

@ -0,0 +1,322 @@
"""HUX-04 artifacts: immutable versions, diffs, lineage, promotion and caps.
Security obligations exercised: server-side sha256 with mismatch rejection and
never-rewritten blobs (SO-30), per-version / per-artifact / per-tenant caps
(SO-31), nosniff attachment delivery (SO-32), lineage resolved under the
caller's subtree (SO-33), owner-only access (SO-34) and If-Match on every
revisioned write (SO-44).
"""
from __future__ import annotations
import base64
import hashlib
import json
import sys
import threading
import types
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import artifacts, contracts, diffs, errors, identity, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
def ident() -> identity.Identity:
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=None, raw=None):
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, payload)
return response.status, response.body, response.headers
def valid(record) -> bool:
return contracts.validate_record(record, SCHEMAS) == []
def make(router, text="# one\nline\n", **extra):
body = {"type": "markdown", "title": "Doc", "content": text, "mime": "text/markdown", "conversation_id": "conv_0001abcd", **extra}
status, record, _ = call(router, "POST", "/hux/v1/artifacts", body)
assert status == 201, record
return record
@pytest.fixture
def events(monkeypatch):
"""Fake events lane capturing emit() calls with the agreed signature."""
calls = []
module = types.ModuleType("hux.events")
module.emit = lambda store, identity, conversation_id, kind, summary, detail=None, evidence=None, sensitivity="personal", run_id=None, turn=None, correlation_id=None: calls.append((conversation_id, kind, summary, detail, evidence, sensitivity))
monkeypatch.setitem(sys.modules, "hux.events", module)
return calls
# --- create ------------------------------------------------------------------------
def test_create_computes_hash_and_serves_contract_valid_record(router, events):
record = make(router, language="markdown", message_id="msg-1", note="first")
assert valid(record)
digest = hashlib.sha256(b"# one\nline\n").hexdigest()
version = record["versions"][0]
assert version["content_ref"] == {"hash": f"sha256:{digest}", "bytes": 11, "mime": "text/markdown"}
assert version["created_by"] == {"type": "user", "id": "usr_0123456789abcdef"}
assert (version["message_id"], version["note"], record["language"]) == ("msg-1", "first", "markdown")
assert record["owner"] == "usr_0123456789abcdef" and record["access"] == {"mode": "owner"}
assert record["current_version"] == 1 and record["revision"] == 1
assert events[0][:2] == ("conv_0001abcd", "artifact.created") and events[0][5] == "personal"
assert events[0][4][0]["kind"] == "artifact_version"
assert store.TenantStore(router.data_root, ident()).get_blob(digest) == b"# one\nline\n"
def test_create_accepts_base64_and_verifies_claimed_hash(router):
data = bytes(range(256))
body = {"type": "image", "title": "Pixels", "content_base64": base64.b64encode(data).decode(), "mime": "image/png", "sensitivity": "sensitive", "project_id": "prj_0001aaaa"}
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + hashlib.sha256(data).hexdigest()})
assert status == 201 and valid(record) and record["versions"][0]["content_ref"]["bytes"] == 256
assert record["sensitivity"] == "sensitive" and record["project_id"] == "prj_0001aaaa"
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "hash": "sha256:" + "0" * 64})
assert (status, error["code"]) == (400, "invalid") and valid(error)
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content_base64": "not*base64"})
assert status == 400
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {**body, "content": "both"})
assert status == 400
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "audio", "title": "Beep", "content_base64": base64.b64encode(b"\x00\x01").decode()})
assert status == 201 and record["versions"][0]["content_ref"]["mime"] == "application/octet-stream"
def test_worker_trust_is_recorded_as_system_actor(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
status, record, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "gen.py", "content": "print(1)\n"}, headers)
assert status == 201 and record["versions"][0]["created_by"] == {"type": "system", "id": "worker"}
@pytest.mark.parametrize("bad", [
{"type": "binary"}, {"type": None}, {"sensitivity": "secret"}, {"title": ""}, {"title": "x" * 201}, {"content": 5},
{"conversation_id": "../etc"}, {"lineage": "art_x"}, {"note": "n" * 201}, {"mime": ""},
])
def test_create_rejects_bad_bodies(router, bad):
body = {"type": "markdown", "title": "Doc", "content": "x", **bad}
status, error, _ = call(router, "POST", "/hux/v1/artifacts", body)
assert status == 400 and valid(error)
status, error, _ = call(router, "POST", "/hux/v1/artifacts", raw=b"[]")
assert status == 400
def test_idempotency_key_replays_the_original(router):
body = {"type": "json", "title": "Data", "content": "{}"}
status, first, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
status, again, headers = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "create-0001"})
assert (status, headers.get("HUX-Replayed")) == (200, "true") and again["id"] == first["id"]
status, _, _ = call(router, "POST", "/hux/v1/artifacts", body, {"Idempotency-Key": "bad key"})
assert status == 400
assert store.TenantStore(router.data_root, ident()).count("artifacts") == 1
# --- list / get ----------------------------------------------------------------------
def test_list_filters_and_pages(router, monkeypatch):
monkeypatch.setattr(artifacts, "PAGE_SIZE", 2)
ids = [make(router)["id"] for _ in range(3)]
other = make(router, conversation_id="conv_0002abcd", project_id="prj_0001aaaa")["id"]
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd")
assert status == 200 and [a["id"] for a in body["items"]] == ids[:2] and body["next"] == "2"
status, body, _ = call(router, "GET", "/hux/v1/artifacts?conversation_id=conv_0001abcd&cursor=2")
assert [a["id"] for a in body["items"]] == ids[2:] and body["next"] is None
status, body, _ = call(router, "GET", "/hux/v1/artifacts?project_id=prj_0001aaaa")
assert [a["id"] for a in body["items"]] == [other] and all(valid(a) for a in body["items"])
assert call(router, "GET", "/hux/v1/artifacts?cursor=x")[0] == 400
status, record, headers = call(router, "GET", f"/hux/v1/artifacts/{other}")
assert status == 200 and record["id"] == other and headers["ETag"] == "1"
# --- versions --------------------------------------------------------------------------
def test_versions_append_immutably_with_if_match(router, events):
record = make(router)
path = f"/hux/v1/artifacts/{record['id']}/versions"
body = {"content": "# one\nline two\n", "mime": "text/markdown", "note": "edit", "message_id": "msg-2"}
assert call(router, "POST", path, body)[0] == 400
status, error, _ = call(router, "POST", path, body, {"If-Match": "7"})
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
status, updated, headers = call(router, "POST", path, body, {"If-Match": "1"})
assert status == 201 and valid(updated) and headers["ETag"] == "2"
entry = updated["versions"][1]
assert (entry["version"], entry["diff_from"], entry["note"], entry["message_id"]) == (2, 1, "edit", "msg-2")
assert updated["current_version"] == 2 and updated["versions"][0] == record["versions"][0]
assert events[-1][1] == "artifact.version" and events[-1][3]["version"] == 2
status, third, _ = call(router, "POST", path, {"content": "third", "diff_from": 1}, {"If-Match": "2"})
assert third["versions"][2]["diff_from"] == 1
assert call(router, "POST", path, {"content": "x", "diff_from": 9}, {"If-Match": "3"})[0] == 404
assert call(router, "POST", path, {"content": "x", "diff_from": "1"}, {"If-Match": "3"})[0] == 400
status, error, _ = call(router, "POST", path, raw=b"[1]", headers={"If-Match": "3"})
assert status == 400
def test_version_replay_and_old_blob_untouched(router):
record = make(router)
tenant = store.TenantStore(router.data_root, ident())
path = f"/hux/v1/artifacts/{record['id']}/versions"
status, second, _ = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
status, replay, headers = call(router, "POST", path, {"content": "v2"}, {"If-Match": "1", "Idempotency-Key": "ver-00001"})
assert (status, headers["HUX-Replayed"], replay["revision"]) == (200, "true", 2)
old_digest = record["versions"][0]["content_ref"]["hash"].split(":")[1]
tenant.put_blob(old_digest, b"tampered")
assert tenant.get_blob(old_digest) == b"# one\nline\n"
with pytest.raises(errors.Conflict):
artifacts.append_version(tenant, second, dict(second["versions"][1]), 2)
with pytest.raises(errors.Conflict):
artifacts.append_version(tenant, second, {**second["versions"][1], "version": 5}, 2)
assert tenant.get("artifacts", record["id"])["versions"] == second["versions"]
def test_concurrent_writers_never_lose_a_version(router):
record = make(router)
path = f"/hux/v1/artifacts/{record['id']}/versions"
statuses = []
def worker(n: int) -> None:
for _ in range(6):
_, current, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
status, _, _ = call(router, "POST", path, {"content": f"w{n}"}, {"If-Match": str(current["revision"])})
statuses.append(status)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(3)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
_, final, _ = call(router, "GET", f"/hux/v1/artifacts/{record['id']}")
assert statuses.count(201) == final["current_version"] - 1
assert [v["version"] for v in final["versions"]] == list(range(1, final["current_version"] + 1))
assert final["revision"] == final["current_version"]
def test_lineage_resolves_only_under_the_caller(router):
parent = make(router)
child = make(router, lineage={"artifact_id": parent["id"], "version": 1})
assert child["versions"][0]["lineage"] == {"artifact_id": parent["id"], "version": 1}
path = f"/hux/v1/artifacts/{child['id']}/versions"
status, updated, _ = call(router, "POST", path, {"content": "v2", "lineage": {"artifact_id": parent["id"], "version": 1}}, {"If-Match": "1"})
assert status == 201 and valid(updated) and updated["versions"][1]["lineage"]["artifact_id"] == parent["id"]
for bad in ({"artifact_id": parent["id"], "version": 9}, {"artifact_id": "art_missing0000", "version": 1}, {"artifact_id": "../x", "version": 1}):
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": bad})
assert (status, error["code"]) == (404, "not_found"), bad
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c", "lineage": {"artifact_id": parent["id"], "version": "1"}})
assert status == 400
def test_caps_are_413(router, monkeypatch):
record = make(router)
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 8)
status, error, _ = call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "123456789"}, {"If-Match": "1"})
assert (status, error["code"]) == (413, "too_large")
monkeypatch.setattr(artifacts, "MAX_VERSION_BYTES", 25 * 1024 * 1024)
monkeypatch.setattr(artifacts, "MAX_VERSIONS", 2)
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})[0] == 201
assert call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v3"}, {"If-Match": "2"})[0] == 413
monkeypatch.setattr(artifacts, "MAX_ARTIFACTS", 1)
status, error, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "t", "content": "c"})
assert (status, error["code"]) == (413, "too_large")
# --- content and diffs ------------------------------------------------------------------
def test_get_version_serves_text_or_base64_with_nosniff(router):
record = make(router)
status, body, headers = call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/1")
assert status == 200 and body["content"] == "# one\nline\n" and body["version"] == record["versions"][0]
assert headers["X-Content-Type-Options"] == "nosniff" and headers["Content-Disposition"] == "attachment"
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
assert base64.b64decode(body["content_base64"]) == b"\xff\xfe" and "content" not in body
status, body, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "code", "title": "bin", "content_base64": base64.b64encode(b"\xff\xfe").decode()})
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{body['id']}/versions/1")
assert "content_base64" in body
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/2")[0] == 404
assert call(router, "GET", f"/hux/v1/artifacts/{record['id']}/versions/0")[0] == 400
def test_diff_unified_for_text_and_hashes_for_binary(router):
record = make(router)
path = f"/hux/v1/artifacts/{record['id']}"
call(router, "POST", f"{path}/versions", {"content": "# one\nline two\n"}, {"If-Match": "1"})
call(router, "POST", f"{path}/versions", {"content": "# one\nline three\n"}, {"If-Match": "2"})
status, body, headers = call(router, "GET", f"{path}/versions/2/diff")
assert status == 200 and body["from"] == 1 and body["to"] == 2 and headers["X-Content-Type-Options"] == "nosniff"
assert "-line\n" in body["unified"] and "+line two\n" in body["unified"] and body["unified"].startswith("--- v1")
status, body, _ = call(router, "GET", f"{path}/versions/3/diff?from=1")
assert body["from"] == 1 and "+line three\n" in body["unified"]
status, body, _ = call(router, "GET", f"{path}/versions/1/diff")
assert body == {"from": 1, "to": 1, "unified": ""}
assert call(router, "GET", f"{path}/versions/3/diff?from=x")[0] == 400
assert call(router, "GET", f"{path}/versions/3/diff?from=9")[0] == 404
blob = base64.b64encode(b"\x00\x01").decode()
status, image, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "image", "title": "i", "content_base64": blob})
call(router, "POST", f"/hux/v1/artifacts/{image['id']}/versions", {"content_base64": base64.b64encode(b"\x00\x01\x02").decode()}, {"If-Match": "1"})
status, body, _ = call(router, "GET", f"/hux/v1/artifacts/{image['id']}/versions/2/diff")
assert body["binary"]["from_bytes"] == 2 and body["binary"]["to_bytes"] == 3 and "unified" not in body
assert body["binary"]["to_hash"] == "sha256:" + hashlib.sha256(b"\x00\x01\x02").hexdigest()
assert diffs.unified("code", b"\xff", b"ok", 1, 2)["binary"]["from_bytes"] == 1
# --- promotion ---------------------------------------------------------------------------
def test_promote_sets_promotion_and_emits(router, events):
record = make(router)
p1 = call(router, "POST", "/hux/v1/projects", {"name": "one"})[1]["id"]
p2 = call(router, "POST", "/hux/v1/projects", {"name": "two"})[1]["id"]
call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"})
path = f"/hux/v1/artifacts/{record['id']}/promote"
status, error, _ = call(router, "POST", path, {"project_id": p1}, {"If-Match": "1"})
assert status == 409
status, promoted, headers = call(router, "POST", path, {"project_id": p1}, {"If-Match": "2"})
assert status == 200 and valid(promoted) and headers["ETag"] == "3"
assert promoted["promotion"]["project_id"] == p1 and promoted["promotion"]["version"] == 2
assert promoted["project_id"] == p1
assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == p1
status, promoted, _ = call(router, "POST", path, {"project_id": p2, "version": 1})
assert status == 200 and promoted["promotion"]["version"] == 1
assert call(router, "POST", path, {"project_id": "nope"})[0] == 400
assert call(router, "POST", path, {"project_id": "prj_0009zzzz"})[0] == 404
assert call(router, "POST", path, {"project_id": p2, "version": 7})[0] == 404
assert call(router, "POST", path, raw=b"1")[0] == 400
from hux import audit
rows = [r for r in audit.recent(store.TenantStore(router.data_root, ident())) if r["action"] == "artifacts.promote" and r["outcome"] == "allow"]
assert [r.get("reason", "") for r in rows][-2:] == ["", "unconditional_write"]
# --- helpers for other lanes and hygiene ---------------------------------------------------
def test_helpers_and_event_fallbacks(router, monkeypatch):
record = make(router)
tenant = store.TenantStore(router.data_root, ident())
assert artifacts.artifact_exists(tenant, record["id"]) and not artifacts.artifact_exists(tenant, "bad id")
assert artifacts.artifact_titles(tenant, [record["id"], "art_missing0000"]) == {record["id"]: "Doc"}
monkeypatch.setitem(sys.modules, "hux.events", None)
artifacts.emit_event(tenant, ident(), record, "artifact.created", "s", {})
artifacts.emit_event(tenant, ident(), {**record, "conversation_id": None}, "artifact.created", "s", {})
status, plain, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "csv", "title": "no conv", "content": "a,b\n"})
assert status == 201 and "conversation_id" not in plain
def test_modules_stay_under_500_lines():
for name in ("artifacts.py", "diffs.py"):
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500

View File

@ -0,0 +1,428 @@
"""HUX-01 activity events: ordering, idempotency, replay, reconnect, redaction, cancellation.
Security obligations exercised: SO-10..SO-19 (server-set provenance, detail
allowlist, secret scrub, size caps, serve-time redaction, server-assigned seq,
idempotent replay after ownership, bounded pages and streams, 404 for foreign
conversations, evidence URI allowlist) and SO-28 (private mode writes nothing).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, errors, events, identity, redaction, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
def ident(**overrides) -> identity.Identity:
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
return identity.Identity(**{**base, **overrides})
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, headers=HEADERS, body=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, raw)
return response.status, response.body, response
def tenant(tmp_path, who=None) -> store.TenantStore:
return store.TenantStore(tmp_path, who or ident())
def valid(record) -> None:
assert contracts.validate_record(record, SCHEMAS) == [], record
# --- emit: ordering and idempotency (SO-15, SO-16) ---------------------------------
def test_emit_assigns_monotonic_seq_and_validates(tmp_path):
s = tenant(tmp_path)
first = events.emit(s, ident(), CONV, "message.user", "hello", {"message_id": "m1", "chars": 5, "junk": "x"}, turn=1)
second = events.emit(s, ident(), CONV, "message.assistant", "hi", run_id="run_9f", correlation_id="c1")
assert (first["seq"], second["seq"]) == (1, 2)
assert first["id"] != second["id"] and first["id"].startswith("evt_")
assert first["detail"] == {"message_id": "m1", "chars": 5}
assert second["run_id"] == "run_9f" and second["provenance"]["run_id"] == "run_9f"
assert first["provenance"]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
assert second["provenance"]["actor"]["type"] == "assistant"
assert first["identity"] == ident().record()
for record in (first, second):
valid(record)
checkpoint = s.get(events.SEQ_FAMILY, f"seq_{CONV}")
assert checkpoint["next_seq"] == 3 and checkpoint["last_event_id"] == second["id"]
other = events.emit(s, ident(), "conv_0002abcd", "run.started", "another conversation")
assert other["seq"] == 1
def test_emit_is_idempotent_on_key(tmp_path):
s = tenant(tmp_path)
one = events.emit(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
again = events.emit(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
assert again == one
assert len(s.read(events.FAMILY, CONV)) == 1
_, replayed = events.emit_with_status(s, ident(), CONV, "run.started", "start", idempotency_key="run_9f:start:1")
assert replayed is True
def test_emit_rejects_unknown_kind_and_sensitivity(tmp_path):
s = tenant(tmp_path)
with pytest.raises(errors.Invalid):
events.emit(s, ident(), CONV, "not.a.kind", "x")
with pytest.raises(errors.Invalid):
events.emit(s, ident(), CONV, "run.started", "x", sensitivity="secret")
with pytest.raises(errors.Invalid):
events.emit(s, ident(), "not a conversation id", "run.started", "x")
def test_emit_validates_against_contract_before_writing(tmp_path, monkeypatch):
s = tenant(tmp_path)
monkeypatch.setattr(events, "new_id", lambda prefix: "bad id")
with pytest.raises(errors.Invalid):
events.emit(s, ident(), CONV, "run.started", "x")
assert s.read(events.FAMILY, CONV) == []
def test_worker_emits_as_system_actor(tmp_path):
s = tenant(tmp_path)
record = events.emit(s, ident(surface="worker", trust="worker"), CONV, "message.user", "x")
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"}
def test_private_mode_writes_nothing(tmp_path):
s = tenant(tmp_path)
s.put("conversations", {"id": "conv_priv0001", "mode": "private"})
assert events.emit(s, ident(), "conv_priv0001", "message.user", "secret chat") is None
assert s.read(events.FAMILY, "conv_priv0001") == []
router = router_for(tmp_path)
status, body, _ = call(router, "POST", "/hux/v1/conversations/conv_priv0001/events", body={"kind": "message.user", "summary": "x"})
assert status == 204 and body is None
# --- redaction pipeline (SO-11, SO-12, SO-13, SO-19) ------------------------------
def test_tool_call_detail_keeps_only_hash_and_names(tmp_path):
s = tenant(tmp_path)
detail = {
"tool": "shell", "capability": "shell", "arguments": {"cmd": "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuv'"},
"argument_names": ["cmd", "cwd", "cmd"], "argument_hash": "a" * 64, "argument_bytes": 61,
"target_path": "/opt/data/.env", "stdout": "leak",
}
record = events.emit(s, ident(), CONV, "tool.call", "ran shell", detail)
assert record["detail"] == {"tool": "shell", "capability": "shell", "argument_names": ["cmd", "cwd"], "argument_hash": "a" * 64, "argument_bytes": 61}
assert "arguments" not in json.dumps(record) and "leak" not in json.dumps(record)
kept = events.emit(s, ident(), CONV, "tool.call", "wrote", {"tool": "write", "target_path": "/opt/data/workspace/notes.md"})
assert kept["detail"]["target_path"] == "/opt/data/workspace/notes.md"
plan = events.emit(s, ident(), CONV, "decision.plan", "plan", {"steps": ["s" * 300] * 25})
assert len(plan["detail"]["steps"]) == 20 and len(plan["detail"]["steps"][0]) == 200
assert events.emit(s, ident(), CONV, "decision.plan", "plan", {"steps": "nope"})["detail"] == {"steps": []}
assert events.emit(s, ident(), CONV, "tool.call", "x", {"tool": "t", "argument_names": "nope"})["detail"]["argument_names"] == []
@pytest.mark.parametrize("secret,klass", [
("Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123", "bearer"),
("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.abcdef", "jwt"),
("sk-abcdefghijklmnopqrstuvwxyz1234", "openai_key"),
("ghp_abcdefghijklmnopqrstuvwxyz1234", "github_token"),
("glpat-abcdefghijklmnopqrstuv", "gitlab_token"),
("AKIAABCDEFGHIJKLMNOP", "aws_key"),
("xoxb-1234567890-abcdef", "slack_token"),
("hvs.CAESIJabcdefghijklmnopqrstuvwxyz", "vault_token"),
("password=hunter2hunter2", "password"),
("0123456789abcdef0123456789abcdef", "hex"),
("-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----", "private_key"),
])
def test_secret_patterns_are_scrubbed(tmp_path, secret, klass):
s = tenant(tmp_path)
record = events.emit(s, ident(), CONV, "decision.route", f"used {secret} today", {"reason": secret})
text = json.dumps(record)
assert secret not in text and f"[redacted:{klass}]" in text
assert record["redaction"]["level"] == "partial" and klass in record["redaction"]["reason"]
def test_canaries_from_environment_and_file_are_scrubbed(tmp_path, monkeypatch):
canary_file = tmp_path / "env"
canary_file.write_text("OPENAI_KEY='filecanaryvalue'\nSHORT=x\n")
monkeypatch.setenv("HUX_RELAY_KEY", "relaykeycanary")
monkeypatch.setenv("HUX_CANARY_FILE", str(canary_file))
text, hits = redaction.scrub_text("relay relaykeycanary file filecanaryvalue")
assert text == "relay [redacted:canary] file [redacted:canary]" and hits == ["canary", "canary"]
assert redaction.canaries({}) == []
def test_hashes_survive_the_hex_rule(tmp_path):
s = tenant(tmp_path)
record = events.emit(s, ident(), CONV, "artifact.version", "v2", {"artifact_id": "art_0001aaaa", "hash": "sha256:" + "b" * 64}, [{"kind": "artifact_version", "id": "art_0001aaaa@2", "hash": "sha256:" + "b" * 64}])
assert record["detail"]["hash"] == "sha256:" + "b" * 64 and record["evidence"][0]["hash"] == "sha256:" + "b" * 64
assert record["redaction"] == {"level": "none"}
def test_oversized_detail_is_truncated_and_line_cap_enforced(tmp_path):
s = tenant(tmp_path)
record = events.emit(s, ident(), CONV, "decision.route", "big", {"reason": "r" * 40000})
assert record["detail"]["truncated"] is True and record["detail"]["bytes"] > redaction.DETAIL_CAP_BYTES
assert record["redaction"]["level"] == "partial" and "truncated" in record["redaction"]["reason"]
huge = [{"kind": "url", "id": "u", "uri": "https://x/" + "z" * 1990} for _ in range(64)]
with pytest.raises(errors.TooLarge):
events.emit(s, ident(), CONV, "decision.route", "too big", evidence=huge)
def test_evidence_uris_outside_allowlist_are_dropped(tmp_path):
s = tenant(tmp_path)
evidence = [
{"kind": "file", "id": "env", "uri": "file:///opt/data/.env"},
{"kind": "url", "id": "doc", "uri": "https://example.test/doc"},
{"kind": "artifact_version", "id": "art_0001aaaa@1", "uri": "artifact://art_0001aaaa/1"},
"garbage", {"kind": 5, "id": "x"},
]
record = events.emit(s, ident(), CONV, "tool.result", "read", evidence=evidence)
assert [ref.get("uri") for ref in record["evidence"]] == [None, "https://example.test/doc", "artifact://art_0001aaaa/1"]
assert len(redaction.filter_evidence([{"kind": "run", "id": "r"}] * 100)) == 64
def test_redaction_level_derivation():
assert redaction.derive_level("personal", [], False) == {"level": "none"}
assert redaction.derive_level("sensitive", [], False)["level"] == "partial"
assert redaction.derive_level("sensitive", ["hex"], True)["level"] == "partial"
assert redaction.derive_level("restricted", ["hex"], True) == {"level": "full", "reason": "restricted content"}
assert redaction.derive_level("public", [], True)["reason"] == "detail truncated"
def test_serve_time_redaction_by_surface(tmp_path):
s = tenant(tmp_path)
record = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"})
assert redaction.redact_record({**record, "_meta": {"x": 1}}, "chat") == record
for surface in ("telegram", "voice"):
served = redaction.redact_record(record, surface)
assert "detail" not in served and served["redaction"]["level"] == "partial"
valid(served)
partial = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"}, sensitivity="sensitive")
assert "detail" not in redaction.redact_record(partial, "chat")
full = events.emit(s, ident(), CONV, "decision.route", "routed", {"requested": "fast"}, ["x"], sensitivity="restricted", run_id="r")
served = redaction.redact_record(full, "chat")
assert served["summary"] == "[redacted]" and "detail" not in served and "evidence" not in served and "run_id" not in served
valid(served)
assert redaction.redact_record({"schema": "x", "content": "c"}, "chat") == {"schema": "x", "content": "c"}
memory_like = {"schema": "hux.memory.v1", "content": "secret", "redaction": {"level": "full"}}
assert redaction.redact_record(memory_like, "chat")["content"] == ""
def test_full_rewrite_keeps_timeline_contiguous(tmp_path):
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "one", {"message_id": "m1"}, idempotency_key="m1:00000001")
events.emit(s, ident(), CONV, "message.user", "two", sensitivity="sensitive")
assert events.rewrite_full(s, CONV, "", "forgotten") == 2
rows = s.read(events.FAMILY, CONV)
assert [r["seq"] for r in rows] == [1, 2] and all(r["redaction"]["level"] == "full" for r in rows)
assert rows[0]["summary"] == "[redacted]" and rows[0]["idempotency_key"] == "m1:00000001" and "detail" not in rows[0]
for row in rows:
valid(row)
assert events.rewrite_full(s, CONV, "", "again") == 0
def test_memory_reference_redaction_spans_conversations(tmp_path):
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "memory.committed", "kept", {"memory_id": "mem_0001aaaa"})
events.emit(s, ident(), "conv_0002abcd", "message.user", "plain", evidence=[{"kind": "memory", "id": "mem_0001aaaa"}])
events.emit(s, ident(), "conv_0002abcd", "message.user", "unrelated")
assert events.redact_memory_references(s, "mem_0001aaaa") == 2
assert s.read(events.FAMILY, "conv_0002abcd")[1]["redaction"]["level"] == "none"
# --- routes -------------------------------------------------------------------------
def test_list_pages_with_after_seq_and_limit(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
for n in range(5):
events.emit(s, ident(), CONV, "message.user", f"m{n}")
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=2")
assert status == 200 and [e["seq"] for e in body["items"]] == [1, 2] and body["next"] == 2
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=2&limit=2")
assert [e["seq"] for e in body["items"]] == [3, 4] and body["next"] == 4
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=4&limit=2")
assert [e["seq"] for e in body["items"]] == [5] and body["next"] is None
for item in body["items"]:
valid(item)
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=5000")
assert len(body["items"]) == 5
assert events.PAGE_MAX == 200
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=abc")[0] == 400
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events?limit=0&after_seq=-5")[1]["items"][0]["seq"] == 1
def test_foreign_conversation_is_404_not_403(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "mine")
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{CONV}/events", headers=OTHER)
assert (status, body["code"]) == (404, "not_found")
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0", headers=OTHER)[0] == 404
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers=OTHER, body={"kind": "run.started", "summary": "x"})[0] == 404
assert call(router, "GET", "/hux/v1/conversations/conv_nope0000/events")[0] == 404
rows = audit.recent(tenant(tmp_path, ident(subject="usr_fedcba9876543210")))
assert {r["outcome"] for r in rows} == {"not_found"}
def test_post_event_server_assigns_and_replays(tmp_path):
router = router_for(tmp_path)
tenant(tmp_path).put("conversations", {"id": CONV, "mode": "fast"})
body = {"kind": "run.cancelled", "summary": "stopped by user", "detail": {"run_id": "run_9f", "outcome": "cancelled", "receipt_id": "rcpt_0001aaaa", "raw": 1},
"evidence": [{"kind": "approval", "id": "apr_0001aaaa"}, {"kind": "run", "id": "run_9f"}], "run_id": "run_9f", "turn": 3,
"identity": {"tenant_slot": "slot-9"}, "provenance": {"surface": "worker"}}
headers = {**HEADERS, "Idempotency-Key": "run_9f:cancel:1"}
status, first, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers, body)
assert status == 201 and first["seq"] == 1 and first["identity"]["tenant_slot"] == "slot-3" and first["provenance"]["surface"] == "chat"
assert first["provenance"]["actor"]["type"] == "user" and first["detail"] == {"run_id": "run_9f", "outcome": "cancelled", "receipt_id": "rcpt_0001aaaa"}
assert [e["kind"] for e in first["evidence"]] == ["approval", "run"]
valid(first)
status, again, response = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", headers, body)
assert status == 200 and again == first and response.headers["HUX-Replayed"] == "true"
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "seq": 9})[0] == 400
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "id": "evt_0000"})[0] == 400
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"summary": "x"})[0] == 400
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, [1])[0] == 400
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/events", HEADERS, {"kind": "run.started", "summary": "x", "detail": "nope", "evidence": "nope", "turn": "3"})
assert status == 201 and body["turn"] == 0 and "detail" not in body
reasons = [r["reason"] for r in audit.recent(tenant(tmp_path)) if r["action"] == "events.append" and r["outcome"] == "allow" and r.get("reason")]
assert reasons == ["replayed"]
def _sse_records(chunks):
out = []
for chunk in chunks:
for line in chunk.decode().splitlines():
if line.startswith("data: "):
out.append(json.loads(line[6:]))
return out
def test_stream_replays_then_polls_and_resumes_from_last_event_id(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
for n in range(3):
events.emit(s, ident(), CONV, "message.user", f"m{n}")
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0")
chunks = list(response.stream())
assert status == 200 and chunks[0] == b"retry: 2000\n\n"
assert [r["seq"] for r in _sse_records(chunks)] == [1, 2, 3]
assert b"id: 3\nevent: message.user\n" in chunks[3]
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=1&poll_ms=0", {**HEADERS, "Last-Event-ID": "2"})
generator = response.stream()
assert next(generator) == b"retry: 2000\n\n"
assert _sse_records([next(generator)])[0]["seq"] == 3
assert next(generator) == b": keepalive\n\n"
events.emit(s, ident(), CONV, "message.assistant", "late")
assert _sse_records([next(generator)])[0]["seq"] == 4
assert list(generator) == []
status, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?after_seq=4&max_polls=0", {**HEADERS, "X-Hux-Surface": "voice"})
assert _sse_records(list(response.stream())) == []
def test_second_stream_closes_the_first(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "m")
_, _, first = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=5&poll_ms=0")
first_gen = first.stream()
next(first_gen)
next(first_gen)
_, _, second = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0")
list(second.stream())
assert list(first_gen) == []
def test_stream_redacts_for_voice_surface(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "decision.route", "r", {"requested": "fast"})
_, _, response = call(router, "GET", f"/hux/v1/conversations/{CONV}/events/stream?max_polls=0", {**HEADERS, "X-Hux-Surface": "voice"})
records = _sse_records(list(response.stream()))
assert "detail" not in records[0] and records[0]["redaction"]["level"] == "partial"
def test_flag_off_hides_event_routes(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
assert call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["code"] == "flag_off"
def test_f5_crash_between_append_and_checkpoint_never_duplicates_a_seq(tmp_path, monkeypatch):
"""F5 / SO-15: with the checkpoint left behind the ledger, the next emit takes max(checkpoint, tail + 1) so paging stays complete."""
router = router_for(tmp_path)
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "one")
original = store.TenantStore.put
def crash(self, family, record, expected_revision=None):
if family == events.SEQ_FAMILY:
raise RuntimeError("simulated crash after the ledger append")
return original(self, family, record, expected_revision)
monkeypatch.setattr(store.TenantStore, "put", crash)
with pytest.raises(RuntimeError):
events.emit(s, ident(), CONV, "message.user", "two")
monkeypatch.undo()
assert s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 2 and events.last_seq(s, CONV) == 2
third = events.emit(s, ident(), CONV, "message.user", "three")
assert third["seq"] == 3 and s.get(events.SEQ_FAMILY, events._seq_id(CONV))["next_seq"] == 4
items = call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["items"]
assert [(e["seq"], e["summary"]) for e in items] == [(1, "one"), (2, "two"), (3, "three")]
assert [e["seq"] for e in call(router, "GET", f"/hux/v1/conversations/{CONV}/events?after_seq=2")[1]["items"]] == [3]
def test_f5_last_seq_reads_only_the_tail_and_skips_torn_lines(tmp_path):
"""F5: a torn trailing line (crash mid-append) and a missing ledger are both handled."""
s = tenant(tmp_path)
assert events.last_seq(s, "conv_none0001") == 0
for index in range(3):
events.emit(s, ident(), CONV, "message.user", f"m{index}")
path = s.root / events.FAMILY / f"{CONV}.jsonl"
with open(path, "ab") as handle:
handle.write(b'{"schema":"hux.event.v1","seq":9')
assert events.last_seq(s, CONV) == 3
with open(path, "ab") as handle:
handle.write(b"\n[]\n\n")
assert events.last_seq(s, CONV) == 3
path.write_bytes(b"\n\n")
assert events.last_seq(s, CONV) == 0
def test_conversation_known_accepts_a_ledger_without_a_checkpoint(tmp_path):
"""F5 / SO-18: a conversation whose checkpoint write was lost is still this subject's conversation."""
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "one")
s.delete(events.SEQ_FAMILY, events._seq_id(CONV))
assert events.conversation_known(s, CONV) is True
assert events.conversation_known(s, "conv_other0001") is False
assert events.emit(s, ident(), CONV, "message.user", "two")["seq"] == 2
def test_f13d_target_path_is_normalised_before_the_workspace_check(tmp_path):
"""F13d / SO-11: ``workspace/../.env`` is not a workspace path; a dotted path inside the workspace is stored normalised."""
s = tenant(tmp_path)
escaped = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/../.env"})
assert "target_path" not in escaped["detail"]
inside = events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace/a/../notes.md"})
assert inside["detail"]["target_path"] == "/opt/data/workspace/notes.md"
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": 7})["detail"]
assert "target_path" not in events.emit(s, ident(), CONV, "tool.call", "ran", {"tool": "bash", "target_path": "/opt/data/workspace"})["detail"]
def test_events_sources_stay_under_500_lines():
for name in ("events.py", "redaction.py"):
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500, name

View File

@ -0,0 +1,436 @@
"""HUX-11 foundation: identity, flags, tenant store, audit and the HTTP pipeline.
Security obligations exercised here: identity comes only from trusted headers
(relay/worker keys compared in constant time), every request lands in a
tenant-scoped directory, disabled cards are indistinguishable from unknown
routes, every read and denial leaves an audit outcome, and revisions guard
concurrent writers.
"""
from __future__ import annotations
import json
import sys
import threading
from http.client import HTTPConnection
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, errors, flags, identity, store # noqa: E402
from hux.http import Router, Response, page, serve # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
def ident(**overrides) -> identity.Identity:
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
return identity.Identity(**{**base, **overrides})
# --- identity -------------------------------------------------------------------
def test_identity_from_router_headers():
who = identity.resolve(HEADERS, {})
assert who == ident()
assert contracts.validate("common.schema.json", who.record(), SCHEMAS, "/$defs/identity") == []
@pytest.mark.parametrize("bad", [
{"X-Hermes-Tenant-Identity": ""}, {"X-Hermes-Tenant-Identity": "slot-x"}, {"X-Hux-Subject": "brad@bstein.dev"},
{"X-Hux-Subject": ""}, {"X-Hux-Surface": "admin"}, {"X-Hux-Trust": "god"}, {"X-Hux-Surface": "worker"},
])
def test_identity_rejects_bad_headers(bad):
with pytest.raises(errors.Unauthorized):
identity.resolve({**HEADERS, **bad}, {})
def test_relay_and_worker_need_their_keys():
relay = {**HEADERS, "X-Hux-Trust": "relay", "X-Hux-Surface": "telegram"}
with pytest.raises(errors.Unauthorized):
identity.resolve(relay, {})
with pytest.raises(errors.Unauthorized):
identity.resolve({**relay, "X-Hux-Relay-Key": "nope"}, {"HUX_RELAY_KEY": "secret"})
assert identity.resolve({**relay, "X-Hux-Relay-Key": "secret"}, {"HUX_RELAY_KEY": "secret"}).trust == "relay"
worker = {**HEADERS, "X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
assert identity.resolve(worker, {"HUX_WORKER_KEY": "wk"}).surface == "worker"
assert identity.resolve(HEADERS).trust == "router"
def test_slot_must_match_the_pod_it_reaches():
with pytest.raises(errors.Unauthorized):
identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-4"})
assert identity.resolve(HEADERS, {"HUX_TENANT_SLOT": "slot-3"}).tenant_slot == "slot-3"
def test_server_refuses_non_loopback_bind():
from hux import server
assert server.bind_address({}) == "127.0.0.1"
with pytest.raises(SystemExit):
server.bind_address({"HUX_BIND": "0.0.0.0"})
# --- flags ---------------------------------------------------------------------
def test_flags_fail_closed_and_capabilities_validate():
off = flags.Flags({})
assert not off.enabled("HUX-11")
with pytest.raises(errors.FlagOff):
off.require("HUX-01")
partial = flags.Flags({"HUX_FLAGS": "hux.activity_timeline"})
assert not partial.enabled("HUX-01")
on = flags.Flags({"HUX_FLAGS": ALL_ON})
record = on.capabilities(ident(), {"commit": "d3cbeb06" * 5, "image_digest": "sha256:" + "4a" * 32, "junk": "x"})
assert contracts.validate_record(record, SCHEMAS) == []
assert all(card["enabled"] for card in record["cards"])
assert {card["card"] for card in record["cards"]} == {f"HUX-{n:02d}" for n in range(1, 13)}
assert not on.enabled("HUX-99")
assert flags.build_from_environ({"HUX_BUILD_COMMIT": "abc"}) == {"commit": "abc", "image_digest": ""}
assert "commit" in flags.build_from_environ()
def test_every_declared_route_belongs_to_exactly_one_card():
seen: dict[str, str] = {}
for card, routes in flags.CARD_ROUTES.items():
for route in routes:
assert route.startswith("/hux/v1/")
assert route not in seen, f"{route} owned by {seen[route]} and {card}"
seen[route] = card
# --- store ---------------------------------------------------------------------
def test_store_paths_are_tenant_scoped_and_ids_validated(tmp_path):
a = store.TenantStore(tmp_path, ident())
b = store.TenantStore(tmp_path, ident(subject="usr_fedcba9876543210"))
assert a.root != b.root and a.root.parent == b.root.parent
a.put("things", {"id": "thg_0001", "v": 1})
assert not b.exists("things", "thg_0001")
with pytest.raises(errors.Invalid):
a.get("things", "../../etc/passwd")
with pytest.raises(errors.Invalid):
a.put("things", {"id": "no-prefix"})
with pytest.raises(errors.Invalid):
a.append("ledger", "../x", {})
with pytest.raises(errors.NotFound):
a.get("things", "thg_9999")
assert store.ID_RE.match(store.new_id("evt"))
assert store.now_iso().endswith("Z")
def test_store_revisions_and_conflicts(tmp_path):
s = store.TenantStore(tmp_path, ident())
first = s.put("docs", {"id": "doc_0001", "n": 1})
assert first["revision"] == 1
second = s.put("docs", {"id": "doc_0001", "n": 2}, expected_revision=1)
assert second["revision"] == 2
with pytest.raises(errors.Conflict):
s.put("docs", {"id": "doc_0001", "n": 3}, expected_revision=1)
with pytest.raises(errors.Conflict):
s.put("docs", {"id": "doc_0002", "n": 1}, expected_revision=4)
assert s.put("docs", {"id": "doc_0003"}, expected_revision=0)["revision"] == 1
assert s.count("docs") == 2
assert [d["id"] for d in s.scan("docs")] == ["doc_0001", "doc_0003"]
assert list(s.scan("nothing")) == []
s.delete("docs", "doc_0003")
s.delete("docs", "doc_0003")
assert s.count("docs") == 1
def test_store_bounds(tmp_path, monkeypatch):
s = store.TenantStore(tmp_path, ident())
with pytest.raises(errors.TooLarge):
s.put("docs", {"id": "doc_0001", "blob": "x" * store.MAX_RECORD_BYTES})
with pytest.raises(errors.TooLarge):
s.append("ledger", "big", {"blob": "x" * store.MAX_RECORD_BYTES})
monkeypatch.setattr(store, "MAX_FAMILY_RECORDS", 1)
s.put("docs", {"id": "doc_0001"})
with pytest.raises(errors.TooLarge):
s.put("docs", {"id": "doc_0002"})
s.put("docs", {"id": "doc_0001", "again": True})
def test_store_ledgers_survive_torn_writes(tmp_path):
s = store.TenantStore(tmp_path, ident())
s.append("ledger", "conv_1", {"seq": 1})
s.append("ledger", "conv_1", {"seq": 2})
path = s.root / "ledger" / "conv_1.jsonl"
with open(path, "ab") as handle:
handle.write(b'{"seq": 3, "tru')
assert [r["seq"] for r in s.read("ledger", "conv_1")] == [1, 2]
assert s.read("ledger", "missing") == []
assert s.ledgers("ledger") == ["conv_1"] and s.ledgers("none") == []
s.rewrite("ledger", "conv_1", [{"seq": 9}])
assert s.read("ledger", "conv_1") == [{"seq": 9}]
def test_store_blobs_and_manifest(tmp_path):
s = store.TenantStore(tmp_path, ident())
digest = "ab" * 32
s.put_blob(digest, b"hello")
s.put_blob(digest, b"ignored")
assert s.get_blob(digest) == b"hello"
with pytest.raises(errors.NotFound):
s.get_blob("cd" * 32)
with pytest.raises(errors.Invalid):
s.put_blob("../x", b"")
with pytest.raises(errors.Invalid):
s.get_blob("zz")
manifest = s.manifest("1.0.0")
assert contracts.validate_record(manifest, SCHEMAS) == []
assert s.manifest("1.9.0") == manifest
def test_store_locks_serialise_concurrent_writers(tmp_path):
s = store.TenantStore(tmp_path, ident())
s.put("docs", {"id": "doc_0001", "n": 0})
def bump() -> None:
for _ in range(20):
with s.lock("docs"):
current = s.get("docs", "doc_0001")
s.put("docs", {**current, "n": current["n"] + 1}, expected_revision=current["revision"])
workers = [threading.Thread(target=bump) for _ in range(4)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
assert s.get("docs", "doc_0001")["n"] == 80
# --- audit ---------------------------------------------------------------------
def test_audit_rows_validate_and_never_carry_bodies(tmp_path):
s = store.TenantStore(tmp_path, ident())
row = audit.record(s, ident(), "memory.read", "mem_0001", "deny", "not owner")
assert contracts.validate("common.schema.json", row, SCHEMAS, "/$defs/audit_outcome") == []
with pytest.raises(ValueError):
audit.record(s, ident(), "x.y", "r", "maybe")
for _ in range(5):
audit.record(s, ident(), "memory.read", "mem_0002", "allow")
assert len(audit.recent(s, limit=3)) == 3
assert audit.recent(s)[0]["outcome"] == "deny"
# --- http pipeline ---------------------------------------------------------------
def _router(tmp_path, flags_value=ALL_ON, environ=None) -> Router:
env = {"HUX_FLAGS": flags_value, **(environ or {})}
return build_router(tmp_path, env)
def _call(router, method, path, headers=HEADERS, body=b"") -> tuple[int, dict]:
response = router.dispatch(method, path, headers, body)
return response.status, response.body
def test_capabilities_and_manifest_roundtrip(tmp_path):
router = _router(tmp_path, environ={"HUX_BUILD_COMMIT": "d3cbeb06" * 5})
status, body = _call(router, "GET", "/hux/v1/capabilities")
assert status == 200 and contracts.validate_record(body, SCHEMAS) == []
assert body["server"] == {"commit": "d3cbeb06" * 5}
status, body = _call(router, "GET", "/hux/v1/manifest")
assert status == 200 and body["schema"] == "hux.manifest.v1"
rows = audit.recent(store.TenantStore(tmp_path, ident()))
assert [r["action"] for r in rows] == ["foundation.capabilities", "foundation.manifest"]
def test_flag_off_and_unknown_route_look_the_same(tmp_path):
off = _router(tmp_path, flags_value="")
status, body = _call(off, "GET", "/hux/v1/capabilities")
assert (status, body["code"]) == (404, "flag_off")
status, body = _call(off, "GET", "/hux/v1/nothing")
assert (status, body["code"]) == (404, "not_found")
assert contracts.validate_record(body, SCHEMAS) == []
status, body = _call(off, "POST", "/hux/v1/capabilities")
assert status == 405
outcomes = [r["outcome"] for r in audit.recent(store.TenantStore(tmp_path, ident()))]
assert outcomes == ["flag_off", "not_found", "not_found"]
def test_unauthorized_requests_never_touch_storage(tmp_path):
router = _router(tmp_path)
status, body = _call(router, "GET", "/hux/v1/capabilities", headers={})
assert (status, body["code"]) == (401, "unauthorized")
assert not (tmp_path / "hux").exists()
def test_body_decoding_and_request_helpers(tmp_path):
router = _router(tmp_path)
captured = {}
def echo(request):
captured.update(body=request.body, if_match=request.if_match(), key=request.idempotency_key(), q=request.query)
return page([request.body], None)
router.add("POST", "/hux/v1/echo/{id}", "HUX-11", "test.echo", echo)
status, body = _call(router, "POST", "/hux/v1/echo/abc?x=1&x=2", {**HEADERS, "If-Match": "3", "Idempotency-Key": "run:1234567"}, b'{"a": 1}')
assert status == 200 and body == {"items": [{"a": 1}], "next": None}
assert captured == {"body": {"a": 1}, "if_match": 3, "key": "run:1234567", "q": {"x": "2"}}
assert _call(router, "POST", "/hux/v1/echo/abc", HEADERS, b"{oops")[1]["code"] == "invalid"
assert _call(router, "POST", "/hux/v1/echo/abc", HEADERS, b"x" * (1024 * 1024 + 1))[1]["code"] == "too_large"
assert _call(router, "POST", "/hux/v1/echo/abc", {**HEADERS, "If-Match": "abc"})[1]["code"] == "invalid"
assert _call(router, "POST", "/hux/v1/echo/abc", {**HEADERS, "Idempotency-Key": "!"})[1]["code"] == "invalid"
_, body = _call(router, "POST", "/hux/v1/echo/abc", HEADERS)
assert body["items"] == [None]
def test_handler_errors_are_audited_with_outcome(tmp_path):
router = _router(tmp_path)
def conflict(request):
raise errors.Conflict("stale")
def denied(request):
raise errors.Forbidden("not yours")
router.add("GET", "/hux/v1/c", "HUX-11", "test.conflict", conflict)
router.add("GET", "/hux/v1/d", "HUX-11", "test.denied", denied)
assert _call(router, "GET", "/hux/v1/c")[0] == 409
assert _call(router, "GET", "/hux/v1/d")[0] == 403
outcomes = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
assert outcomes == [("test.conflict", "conflict"), ("test.denied", "deny")]
def test_real_server_serves_json_and_sse(tmp_path):
router = _router(tmp_path)
def stream(request):
return Response(200, stream=lambda: (b"id: 1\ndata: {}\n\n", b"id: 2\ndata: {}\n\n"))
router.add("GET", "/hux/v1/s", "HUX-11", "test.stream", stream)
server = serve(router, "127.0.0.1", 0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
conn.request("GET", "/healthz")
assert json.loads(conn.getresponse().read())["status"] == "ok"
conn.request("GET", "/hux/v1/capabilities", headers=HEADERS)
reply = conn.getresponse()
assert reply.status == 200 and json.loads(reply.read())["schema"] == "hux.capabilities.v1"
conn.request("POST", "/hux/v1/capabilities", body=b"{}", headers={**HEADERS, "Content-Length": "2"})
assert conn.getresponse().status == 405
conn.request("GET", "/hux/v1/s", headers=HEADERS)
reply = conn.getresponse()
assert reply.getheader("Content-Type") == "text/event-stream"
assert reply.read() == b"id: 1\ndata: {}\n\nid: 2\ndata: {}\n\n"
finally:
server.shutdown()
server.server_close()
def test_all_errors_serialise_to_contract():
for cls in (errors.Unauthorized, errors.Forbidden, errors.NotFound, errors.FlagOff, errors.Conflict, errors.Invalid, errors.TooLarge, errors.ApprovalRequired, errors.BudgetExhausted):
record = cls("m" * 300, ["d" * 300] * 40).record()
assert contracts.validate_record(record, SCHEMAS) == [], cls
def test_foundation_sources_stay_under_500_lines():
for path in sorted(FOUNDATION.rglob("*.py")):
assert len(path.read_text().splitlines()) <= 500, path
def test_per_route_body_cap(tmp_path):
router = _router(tmp_path)
router.add("POST", "/hux/v1/big", "HUX-11", "test.big", lambda request: page([len(json.dumps(request.body))]), max_body=4 * 1024 * 1024)
payload = json.dumps({"blob": "x" * (2 * 1024 * 1024)}).encode()
assert _call(router, "POST", "/hux/v1/big", HEADERS, payload)[0] == 200
assert _call(router, "POST", "/hux/v1/echo", HEADERS, payload)[0] == 404
router.add("POST", "/hux/v1/small", "HUX-11", "test.small", lambda request: page([]))
assert _call(router, "POST", "/hux/v1/small", HEADERS, payload)[1]["code"] == "too_large"
def test_server_main_wires_environment(tmp_path, monkeypatch):
from hux import server
seen = {}
class Fake:
def serve_forever(self):
seen["served"] = True
monkeypatch.setattr(server, "serve", lambda router, host, port: seen.update(root=router.data_root, host=host, port=port) or Fake())
monkeypatch.setenv("HUX_DATA_ROOT", str(tmp_path))
monkeypatch.setenv("HUX_PORT", "8791")
server.main()
assert seen == {"root": tmp_path, "host": "127.0.0.1", "port": 8791, "served": True}
# --- F2: worker allowlist, internal errors, health -----------------------------------
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
def test_worker_trust_reaches_only_the_allowlisted_routes(tmp_path):
"""F2 (high) / SO-08: ``trust: worker`` gets 403 on every route outside ``flags.WORKER_ROUTES``, before the flag check."""
router = _router(tmp_path, environ={"HUX_WORKER_KEY": "wk"})
denied = [
("GET", "/hux/v1/memory/mem_0001aaaa"), ("GET", "/hux/v1/memory/export"), ("GET", "/hux/v1/conversations"),
("GET", "/hux/v1/conversations/conv_0001abcd/events"), ("GET", "/hux/v1/approvals"), ("PUT", "/hux/v1/policy"),
("GET", "/hux/v1/artifacts"), ("POST", "/hux/v1/conversations/conv_0001abcd/forget"), ("GET", "/hux/v1/privacy/audit"),
]
for method, path in denied:
status, body = _call(router, method, path, WORKER, b"{}" if method != "GET" else b"")
assert (status, body["code"]) == (403, "forbidden"), (method, path)
for method, path in [("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/runs/run_1/budget"), ("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/memory")]:
assert _call(router, method, path, WORKER)[0] == 200, (method, path)
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(WORKER, {"HUX_WORKER_KEY": "wk"})))
assert [r["outcome"] for r in rows if r["action"] == "memory.read" or r["action"].startswith("policy")][:1] == ["deny"]
assert all(r["outcome"] == "deny" for r in rows if r["action"] == "approvals.list")
off = _router(tmp_path, flags_value="", environ={"HUX_WORKER_KEY": "wk"})
assert _call(off, "GET", "/hux/v1/approvals", WORKER)[0] == 403, "the allowlist answers before the flag does"
assert _call(off, "GET", "/hux/v1/capabilities", HEADERS)[0] == 404, "humans still see flag-off as not found"
assert flags.worker_may_call("GET", "/hux/v1/releases") and not flags.worker_may_call("GET", "/hux/v1/policy")
assert all(any(t == route.template for _, t in flags.WORKER_ROUTES if route.method == _) or (route.method, route.template) not in flags.WORKER_ROUTES for route in router.routes)
def test_unshipped_cards_declare_no_routes():
"""F12 (low): HUX-06, HUX-09 and HUX-12 own no routes until they ship, so capabilities never advertises a 404."""
assert flags.CARD_ROUTES["HUX-06"] == flags.CARD_ROUTES["HUX-09"] == flags.CARD_ROUTES["HUX-12"] == []
def test_unexpected_handler_exceptions_become_a_500_error_record(tmp_path):
"""F2: a non-HuxError in a handler never propagates; the caller sees ``hux.error.v1`` 500 with no detail and the audit says deny."""
router = _router(tmp_path)
def boom(request):
raise RuntimeError("secret stack detail")
router.add("GET", "/hux/v1/boom", "HUX-11", "test.boom", boom)
status, body = _call(router, "GET", "/hux/v1/boom")
assert status == 500 and body == {"schema": "hux.error.v1", "status": 500, "code": "invalid", "message": "internal error"}
assert contracts.validate_record(body, SCHEMAS) == []
rows = [(r["action"], r["outcome"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, ident()))]
assert rows[-1] == ("test.boom", "deny", "internal error")
def test_healthz_reports_the_contract_version(tmp_path):
"""F2: /healthz carries ``flags.CONTRACT_VERSION`` rather than a hard-coded string."""
server = serve(_router(tmp_path), "127.0.0.1", 0)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
conn = HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
conn.request("GET", "/healthz")
assert json.loads(conn.getresponse().read()) == {"status": "ok", "contract_version": flags.CONTRACT_VERSION}
finally:
server.shutdown()
server.server_close()
def test_no_outbound_network_client_in_the_service(tmp_path):
"""F13 / SO-29: no hux module imports urllib.request, requests, httpx or socket; http.server is the only http.* import."""
import re
banned = re.compile(r"^\s*(?:import|from)\s+(urllib\.request|requests|httpx|socket|http\.client)\b", re.M)
for path in sorted((FOUNDATION / "hux").glob("*.py")):
assert banned.search(path.read_text()) is None, path
http_imports = re.findall(r"^\s*from\s+(http\.\w+)\s+import", path.read_text(), re.M)
assert set(http_imports) <= {"http.server"}, (path, http_imports)

View File

@ -0,0 +1,254 @@
"""HuxClient transport contract and the pure helpers of the agent hook.
Security obligations exercised: SO-04 (the client emits exactly the identity
header vocabulary ``hux.identity`` accepts, with the worker key), SO-07 (errors
carry status, code and message only; request bodies are never embedded),
SO-27 and SO-28 (memory writes need the privacy card and are refused in a
private conversation), SO-37 (the argument hash is stable across key order
and whitespace), SO-50 (capabilities are read from the resolved flag chain).
"""
from __future__ import annotations
import ast
import http.client
import io
import sys
import threading
import urllib.error
import urllib.request
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
for entry in (FOUNDATION, HOOK_ROOT):
if str(entry) not in sys.path:
sys.path.insert(0, str(entry))
from hux import contracts, identity # noqa: E402
from hux.http import serve # noqa: E402
from hux.server import build_router # noqa: E402
from hux_hook import HuxClient, HuxServiceError, HuxUnavailable, canonical_argument_hash, emit, memory_gate # noqa: E402
from hux_hook import client as client_mod # noqa: E402
from hux_hook import hooks # noqa: E402
SCHEMAS = contracts.load_all()
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
SUBJECT = "usr_0123456789abcdef"
WORKER = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
OTHER = {**HUMAN, "subject": "usr_fedcba9876543210"}
CANARY = "CANARY-9c1d-SECRET"
def start(tmp_path: Path, flags: str = ALL_ON):
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
server = serve(router, "127.0.0.1", 0)
threading.Thread(target=server.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{server.server_address[1]}", server
@pytest.fixture
def live(tmp_path):
base, server = start(tmp_path)
yield base, tmp_path
server.shutdown()
# --- headers and identity ---------------------------------------------------------
def test_headers_match_the_service_vocabulary():
"""SO-04: the exact header names identity.resolve reads, key only when present, extras only when non-empty."""
worker = HuxClient("http://127.0.0.1:1/", WORKER, key="wk")
sent = worker.headers({"Idempotency-Key": "run:approval:1", "If-Match": ""})
assert identity.resolve(sent, {"HUX_WORKER_KEY": "wk"}) == identity.Identity("slot-3", SUBJECT, "worker", "worker")
assert sent["Idempotency-Key"] == "run:approval:1" and "If-Match" not in sent
assert worker.base_url == "http://127.0.0.1:1"
plain = HuxClient(identity={"tenant_slot": "slot-3", "subject": SUBJECT}).headers()
assert client_mod.HEADER_KEY not in plain and plain[client_mod.HEADER_TRUST] == "worker"
assert HuxClient().identity == {"tenant_slot": "", "subject": "", "surface": "worker", "trust": "worker"}
def test_error_mapping_and_no_body_leak(live):
"""SO-07: a hux.error.v1 answer becomes HuxServiceError(status, code, message) and the body never appears in it."""
base, _ = live
human = HuxClient(base, HUMAN)
with pytest.raises(HuxServiceError) as bad:
human.post("/hux/v1/approvals", {"conversation_id": "conv_0001abcd", "capability": "nope", "secret": CANARY})
assert (bad.value.status, bad.value.code) == (400, "invalid") and CANARY not in str(bad.value)
with pytest.raises(HuxServiceError) as unauth:
HuxClient(base, WORKER, key="wrong").get("/hux/v1/capabilities")
assert unauth.value.code == "unauthorized"
with pytest.raises(HuxServiceError) as missing:
human.get("/hux/v1/no/such/route")
assert (missing.value.status, missing.value.code) == (404, "not_found")
assert HuxUnavailable().code == "unavailable" and HuxUnavailable().status == 0
assert HuxServiceError(500, "x", "m" * 400).message == "m" * 280
def test_transport_edge_cases(monkeypatch):
"""Non-JSON failures, a 4xx delivered without an exception and socket errors map to typed errors."""
client = HuxClient("http://127.0.0.1:1", WORKER, key="wk")
def raise_http(*args, **kwargs):
raise urllib.error.HTTPError("u", 502, "bad gateway", {}, io.BytesIO(b"<html>"))
monkeypatch.setattr(urllib.request, "urlopen", raise_http)
with pytest.raises(HuxServiceError) as html:
client.get("/hux/v1/capabilities")
assert (html.value.status, html.value.code) == (502, "invalid")
class Raw:
status = 418
headers = {"X-Test": "1"}
def read(self):
return b"{bad json"
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: Raw())
with pytest.raises(HuxServiceError) as teapot:
client.put("/hux/v1/policy", {"x": 1}, if_match=3)
assert teapot.value.status == 418
def raise_socket(*args, **kwargs):
raise TimeoutError("slow")
monkeypatch.setattr(urllib.request, "urlopen", raise_socket)
with pytest.raises(HuxUnavailable):
client.get("/hux/v1/capabilities", query={"a": "b c"})
def raise_half_closed(*args, **kwargs):
raise http.client.BadStatusLine("gone")
monkeypatch.setattr(urllib.request, "urlopen", raise_half_closed)
with pytest.raises(HuxUnavailable):
client.get("/hux/v1/capabilities")
def test_put_with_if_match_and_get_with_query(live):
"""Revisioned writes send If-Match; a stale revision is a conflict; queries reach the service."""
base, _ = live
human = HuxClient(base, HUMAN)
first = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"})
assert first.header("ETag") == "1" and first.header("Missing") == ""
second = human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"}, if_match=1)
assert second.body["revision"] == 2
with pytest.raises(HuxServiceError) as stale:
human.put("/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, if_match=1)
assert stale.value.code == "conflict"
assert human.get("/hux/v1/policy", {"scope": "global"}).body["autonomy"] == "autonomous"
# --- capabilities ---------------------------------------------------------------------
def test_capabilities_cached_per_process(live, monkeypatch):
"""SO-50: one capabilities read per process; refresh and forget re-read; failures are not cached."""
base, _ = live
worker = HuxClient(base, WORKER, key="wk")
calls = []
real_get = worker.get
monkeypatch.setattr(worker, "get", lambda path, query=None: calls.append(path) or real_get(path, query))
assert worker.card_enabled("HUX-05") and worker.card_enabled("HUX-01")
assert worker.capabilities()["contract_version"] == contracts.load_flags()["contract_version"]
assert len(calls) == 1
worker.capabilities(refresh=True)
worker.forget_capabilities()
worker.capabilities()
assert len(calls) == 3
monkeypatch.setattr(worker, "get", lambda path, query=None: (_ for _ in ()).throw(HuxServiceError(404, "flag_off", "off")))
worker.forget_capabilities()
assert worker.capabilities() == {"reachable": True, "cards": {}, "contract_version": ""}
dead = HuxClient("http://127.0.0.1:1", WORKER, key="wk", timeout=1)
assert dead.capabilities()["reachable"] is False and not dead.card_enabled("HUX-11")
# --- events ---------------------------------------------------------------------------
def test_emit_is_best_effort_and_redaction_safe(live):
"""SO-11: detail outside the allowlist is dropped by the service; failures return None and never raise."""
base, root = live
human = HuxClient(base, HUMAN)
conv = human.post("/hux/v1/conversations", {"title": "t"}).body["id"]
worker = HuxClient(base, WORKER, key="wk")
record = emit(worker, conv, "tool.call", "shell call", {"tool": "shell", "arguments": {"cmd": CANARY}, "argument_bytes": 7},
evidence=[{"kind": "run", "id": "run_1"}], run_id="run_1", turn=2, correlation_id="corr-1", idempotency_key="run_1:call:0001")
assert contracts.validate_record(record, SCHEMAS) == [] and record["detail"] == {"tool": "shell", "argument_bytes": 7}
assert record["provenance"]["actor"] == {"type": "system", "id": "hux-worker"} and record["correlation_id"] == "corr-1"
assert emit(worker, conv, "tool.call", "again", idempotency_key="run_1:call:0001")["id"] == record["id"]
assert emit(worker, conv, "not.a.kind", "x") is None
assert emit(worker, "conv_unknown0001", "tool.call", "x") is None
assert emit(worker, conv, "tool.call", "x", {"tool": object()}) is None
assert emit(HuxClient(base, OTHER), conv, "tool.call", "cross tenant") is None
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
assert emit(worker, private, "tool.call", "private mode writes nothing") is None
assert CANARY not in "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
# --- memory gate ------------------------------------------------------------------------
def test_memory_gate(live, tmp_path):
"""SO-27, SO-28: allowed only when the privacy card answers and the conversation is not private."""
base, _ = live
human = HuxClient(base, HUMAN)
worker = HuxClient(base, WORKER, key="wk")
normal = human.post("/hux/v1/conversations", {"title": "n", "mode": "thoughtful"}).body["id"]
private = human.post("/hux/v1/conversations", {"title": "p", "mode": "private"}).body["id"]
assert memory_gate(worker, normal) is True
assert memory_gate(worker, private) is False
assert memory_gate(worker, "conv_notknown01") is True
assert memory_gate(worker, "bad id") 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:
assert memory_gate(HuxClient(off_base, WORKER, key="wk"), normal) is False
finally:
off_server.shutdown()
# --- pure helpers -------------------------------------------------------------------------
def test_hash_is_canonical():
"""SO-37: key order, whitespace and nesting order of dict keys do not change the hash; values do."""
a = canonical_argument_hash("write", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}})
b = canonical_argument_hash("write", {"opts": {"a": [1, 2], "b": 1}, "path": "n.md"})
assert a == b and a.startswith("sha256:") and len(a) == 71
assert canonical_argument_hash("write", {"path": "n.md ", "opts": {"b": 1, "a": [1, 2]}}) != a
assert canonical_argument_hash("other", {"path": "n.md", "opts": {"b": 1, "a": [1, 2]}}) != a
assert canonical_argument_hash("w", "raw string") == canonical_argument_hash("w", "raw string")
assert hooks.canonical_json({"z": "é", "a": None}) == b'{"a":null,"z":"\\u00e9"}'
def test_key_and_ref_helpers():
"""Idempotency keys always satisfy the contract pattern; call refs never contain arguments."""
assert hooks.idempotency_key("r", "a", "") == "r:a:.pad"
assert hooks.idempotency_key("r", "a", "x") == "r:a:x.pad"
key = hooks.idempotency_key("run id/with spaces", "approval", "f" * 200)
assert len(key) == 120 and " " not in key and "/" not in key
assert hooks.call_ref("my tool/x", "sha256:" + "ab" * 32) == "my-tool-x:abababababababab"
assert hooks._failure_reason(HuxServiceError(404, "flag_off", "")) == "flag_off"
assert hooks._failure_reason(HuxServiceError(0, "unavailable", "")) == "hux_unavailable"
assert hooks._failure_reason(HuxServiceError(409, "conflict", "")) == "service_error:conflict"
def test_library_is_stdlib_only_small_and_documented():
"""Every module ≤ 500 lines, every public function and module documented, no third-party imports."""
for path in sorted((HOOK_ROOT / "hux_hook").glob("*.py")):
source = path.read_text()
assert len(source.splitlines()) <= 500, path
tree = ast.parse(source)
assert ast.get_docstring(tree), path
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.ClassDef) and not node.name.startswith("_"):
assert ast.get_docstring(node), f"{path.name}:{node.name}"
if isinstance(node, ast.Import | ast.ImportFrom):
root = (node.names[0].name if isinstance(node, ast.Import) else node.module or "").split(".")[0]
assert root in {"http", "json", "threading", "urllib", "collections", "typing", "hashlib", "re", "dataclasses", "hux_hook", "__future__"}, root

View File

@ -0,0 +1,254 @@
"""HUX-03 projects, conversations, branch lineage and search.
Security obligations exercised: SO-44 (If-Match on PATCH, 409 on mismatch,
unconditional writes audited) plus the storage rules every family shares:
ids and provenance are server-set, records the caller does not own are 404,
and every served record validates against ``project.schema.json``.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
import hux # noqa: E402
from hux import audit, contracts, identity, organization, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=HEADERS):
raw = b"" if body is None else json.dumps(body).encode()
response = router.dispatch(method, path, headers, raw)
return response.status, response.body, response.headers
def valid(record):
problems = contracts.validate_record(record, SCHEMAS)
assert problems == [], problems
return record
def make_project(router, **fields):
status, body, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen", **fields})
assert status == 201, body
return valid(body)
def make_conversation(router, **fields):
status, body, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", **fields})
assert status == 201, body
return valid(body)
def test_module_stays_under_the_line_budget():
assert len((FOUNDATION / "hux" / "organization.py").read_text().splitlines()) <= 500
# --- projects -------------------------------------------------------------------
def test_project_create_read_list_and_server_set_fields(router):
body = make_project(router, tags=["home"], pinned=True, default_mode="research", id="prj_evil", owner="usr_ffffffffffffffff", revision=9)
assert body["id"].startswith("prj_") and body["id"] != "prj_evil" and body["owner"] == HEADERS["X-Hux-Subject"] and body["revision"] == 1
assert body["created_at"] == body["updated_at"] and body["archived"] is False
status, got, headers = call(router, "GET", f"/hux/v1/projects/{body['id']}")
assert (status, got, headers["ETag"]) == (200, body, "1")
second = make_project(router, name="Garden")
status, listing, _ = call(router, "GET", "/hux/v1/projects")
assert [p["id"] for p in listing["items"]] == [body["id"], second["id"]], "pinned first"
assert call(router, "GET", "/hux/v1/projects?archived=true")[1]["items"] == []
assert call(router, "GET", "/hux/v1/projects?archived=false")[1]["items"] == listing["items"]
def test_project_patch_needs_if_match_and_audits_unconditional_writes(router, tmp_path):
project = make_project(router)
path = f"/hux/v1/projects/{project['id']}"
status, body, headers = call(router, "PATCH", path, {"name": "Kitchen v2", "archived": True, "owner": "usr_ffffffffffffffff"}, {**HEADERS, "If-Match": "1"})
assert status == 200 and valid(body)["name"] == "Kitchen v2" and body["archived"] is True and body["revision"] == 2 and headers["ETag"] == "2"
assert body["owner"] == project["owner"] and body["updated_at"] >= project["updated_at"]
status, error, _ = call(router, "PATCH", path, {"name": "stale"}, {**HEADERS, "If-Match": "1"})
assert (status, error["code"]) == (409, "conflict") and valid(error)
status, body, _ = call(router, "PATCH", path, {"description": "no If-Match"})
assert status == 200 and body["revision"] == 3
assert call(router, "PATCH", path, {"name": ""}, {**HEADERS, "If-Match": "3"})[0] == 400
assert call(router, "PATCH", path, {"default_mode": "turbo"}, {**HEADERS, "If-Match": "3"})[0] == 400
assert call(router, "PATCH", path, [], {**HEADERS, "If-Match": "3"})[0] == 400
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "projects.update"]
assert rows[0] == ("projects.update", "allow", "") and rows[1][1] == "conflict" and rows[2] == ("projects.update", "allow", "unconditional_write")
def test_project_idempotency_and_cap(router, monkeypatch):
key = {**HEADERS, "Idempotency-Key": "chat:project:0001"}
status, first, _ = call(router, "POST", "/hux/v1/projects", {"name": "Once"}, key)
status, again, _ = call(router, "POST", "/hux/v1/projects", {"name": "Twice"}, key)
assert (status, again) == (200, first)
status, other, _ = call(router, "POST", "/hux/v1/projects", {"name": "Other key"}, {**HEADERS, "Idempotency-Key": "chat:project:0002"})
assert status == 201 and other["id"] != first["id"]
assert call(router, "POST", "/hux/v1/projects", {"name": "x" * 121})[0] == 400
assert call(router, "POST", "/hux/v1/projects", None)[0] == 400
monkeypatch.setattr(organization, "MAX_PROJECTS", 1)
status, error, _ = call(router, "POST", "/hux/v1/projects", {"name": "Too many"})
assert (status, error["code"]) == (409, "conflict")
assert call(router, "POST", "/hux/v1/projects", {"name": "Replays still work"}, key)[0] == 200
def test_projects_are_invisible_to_a_second_subject(router):
project = make_project(router)
assert call(router, "GET", f"/hux/v1/projects/{project['id']}", headers=OTHER)[0] == 404
assert call(router, "PATCH", f"/hux/v1/projects/{project['id']}", {"name": "mine now"}, {**OTHER, "If-Match": "1"})[0] == 404
assert call(router, "GET", "/hux/v1/projects", headers=OTHER)[1]["items"] == []
assert call(router, "GET", "/hux/v1/projects/prj_missing0001")[0] == 404
assert call(router, "GET", "/hux/v1/projects/bad")[0] == 400
# --- conversations -------------------------------------------------------------
def test_conversation_create_list_filters_and_patch(router):
project = make_project(router)
first = make_conversation(router, project_id=project["id"], tags=["suppliers"], mode="research")
second = make_conversation(router, title="Loose", pinned=True)
assert first["project_id"] == project["id"] and first["artifact_ids"] == [] and second.get("project_id") is None
assert call(router, "POST", "/hux/v1/conversations", {"title": "Orphan", "project_id": "prj_missing0001"})[0] == 404
assert call(router, "POST", "/hux/v1/conversations", {"title": "", "tags": ["Bad Tag"]})[0] == 400
items = lambda query: [c["id"] for c in call(router, "GET", f"/hux/v1/conversations{query}")[1]["items"]] # noqa: E731
assert items("") == [second["id"], first["id"]]
assert items(f"?project_id={project['id']}") == [first["id"]]
assert items("?tag=suppliers") == [first["id"]] and items("?tag=nope") == []
assert items("?pinned=true") == [second["id"]] and items("?archived=true") == []
path = f"/hux/v1/conversations/{first['id']}"
status, body, _ = call(router, "PATCH", path, {"title": "Cabinet suppliers", "archived": True, "project_id": None}, {**HEADERS, "If-Match": "1"})
assert status == 200 and valid(body)["title"] == "Cabinet suppliers" and "project_id" not in body and body["revision"] == 2
assert items("?archived=true") == [first["id"]]
assert call(router, "PATCH", path, {"project_id": "prj_missing0001"}, {**HEADERS, "If-Match": "2"})[0] == 404
assert call(router, "PATCH", path, {"title": "stale"}, {**HEADERS, "If-Match": "1"})[0] == 409
status, body, headers = call(router, "GET", path)
assert status == 200 and headers["ETag"] == "2" and valid(body)
assert call(router, "GET", path, headers=OTHER)[0] == 404
assert call(router, "PATCH", path, {"title": "x"}, {**OTHER, "If-Match": "2"})[0] == 404
def test_helpers_for_other_lanes(router, tmp_path):
project = make_project(router)
conversation = make_conversation(router, project_id=project["id"])
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
theirs = store.TenantStore(tmp_path, identity.resolve(OTHER, {}))
assert organization.project_exists(mine, project["id"]) and not organization.project_exists(theirs, project["id"])
assert organization.conversation_exists(mine, conversation["id"]) and not organization.conversation_exists(theirs, conversation["id"])
assert not organization.project_exists(mine, "../escape") and not organization.conversation_exists(mine, None)
assert organization.project_of(mine, conversation["id"]) == project["id"]
assert organization.project_of(mine, None) is None and organization.project_of(mine, "conv_missing0001") is None
# --- branches and lineage ---------------------------------------------------------
def test_branch_copies_project_tags_and_mode_and_lineage_walks_both_ways(router):
project = make_project(router)
root = make_conversation(router, project_id=project["id"], tags=["a", "b"], mode="create")
status, child, _ = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-12"})
assert status == 201 and valid(child)["branch"] == {"parent_conversation_id": root["id"], "branch_point_message_id": "msg-12"}
assert child["project_id"] == project["id"] and child["tags"] == ["a", "b"] and child["mode"] == "create" and child["title"] == "Cabinets (branch)"
status, grandchild, _ = call(router, "POST", f"/hux/v1/conversations/{child['id']}/branch", {"branch_point_message_id": "msg-3", "title": "Deeper"})
assert grandchild["title"] == "Deeper"
sibling = call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "msg-1"})[1]
status, body, _ = call(router, "GET", f"/hux/v1/conversations/{grandchild['id']}/lineage")
assert status == 200 and body["conversation"] == grandchild
assert [a["id"] for a in body["ancestors"]] == [root["id"], child["id"]] and body["children"] == []
body = call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage")[1]
assert body["ancestors"] == [] and {c["id"] for c in body["children"]} == {child["id"], sibling["id"]}
for record in body["children"]:
valid(record)
assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {})[0] == 400
assert call(router, "POST", f"/hux/v1/conversations/{root['id']}/branch", {"branch_point_message_id": "m"}, OTHER)[0] == 404
assert call(router, "GET", f"/hux/v1/conversations/{root['id']}/lineage", headers=OTHER)[0] == 404
def test_lineage_survives_a_missing_or_cyclic_parent(router, tmp_path):
orphan = make_conversation(router)
mine = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
mine.put("conversations", {**orphan, "branch": {"parent_conversation_id": "conv_gone00000001", "branch_point_message_id": "m"}})
assert call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]["ancestors"] == []
mine.put("conversations", {**mine.get("conversations", orphan["id"]), "branch": {"parent_conversation_id": orphan["id"], "branch_point_message_id": "m"}})
body = call(router, "GET", f"/hux/v1/conversations/{orphan['id']}/lineage")[1]
assert body["ancestors"] == [] and [c["id"] for c in body["children"]] == [orphan["id"]]
# --- search -----------------------------------------------------------------------
def test_search_ranks_title_over_tag_over_project_and_says_what_is_indexed(router):
kitchen = make_project(router, name="Kitchen renovation")
by_title = make_conversation(router, title="Cabinet suppliers compared", project_id=kitchen["id"])
by_tag = make_conversation(router, title="Budget", tags=["cabinet"])
by_project = make_conversation(router, title="Flooring", project_id=kitchen["id"])
make_conversation(router, title="Unrelated")
status, body, _ = call(router, "GET", "/hux/v1/search?q=cabinet")
assert status == 200 and [c["id"] for c in body["items"]] == [by_title["id"], by_tag["id"]]
assert body["indexed"] == ["title", "tags", "project_name", "artifact_titles"] and body["not_indexed"] == ["message_text"]
assert body["scores"][by_title["id"]] > body["scores"][by_tag["id"]] and body["next"] is None
for record in body["items"]:
valid(record)
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"]] == [by_title["id"], by_project["id"]]
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=cabinet+kitchen")[1]["items"]] == [by_title["id"]], "terms are ANDed"
assert call(router, "GET", f"/hux/v1/search?q=kitchen&project_id={kitchen['id']}")[1]["items"] == call(router, "GET", "/hux/v1/search?q=kitchen")[1]["items"]
assert call(router, "GET", "/hux/v1/search?q=kitchen&project_id=prj_other0000001")[1]["items"] == []
assert call(router, "GET", "/hux/v1/search?q=%21%21")[0] == 400
assert call(router, "GET", "/hux/v1/search")[0] == 400
assert call(router, "GET", "/hux/v1/search?q=cabinet", headers=OTHER)[1]["items"] == []
def test_f13c_search_finds_conversations_by_artifact_title(router, monkeypatch):
"""F13c: an artifact filed under a conversation makes that conversation searchable by the artifact title; the lane may be absent."""
conversation = make_conversation(router, title="Plain")
other = make_conversation(router, title="Other")
status, artifact, _ = call(router, "POST", "/hux/v1/artifacts", {"type": "markdown", "title": "Supplier comparison sheet", "content": "x", "conversation_id": conversation["id"]})
assert status == 201, artifact
status, body, _ = call(router, "GET", "/hux/v1/search?q=supplier")
assert [c["id"] for c in body["items"]] == [conversation["id"]] and body["scores"][conversation["id"]] == 1
s = store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))
assert organization.artifact_titles(s, conversation) == ["Supplier comparison sheet"] and organization.artifact_titles(s, other) == []
# The conversation's own artifact_ids list is indexed as well, without duplicates.
s.put(organization.CONVERSATIONS, {**s.get(organization.CONVERSATIONS, other["id"]), "artifact_ids": [artifact["id"], "art_missing0001"]}, 1)
assert organization.artifact_titles(s, other) == ["Supplier comparison sheet"]
assert sorted(c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]) == sorted([conversation["id"], other["id"]])
monkeypatch.setitem(sys.modules, "hux.artifacts", None)
monkeypatch.delattr(hux, "artifacts", raising=False)
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
assert call(router, "GET", "/hux/v1/search?q=plain")[1]["items"] == [conversation]
def test_f9_titles_tags_names_and_descriptions_are_secret_scrubbed(router):
"""F9 / SO-07: a token pasted into a title, tag, project name or description is scrubbed before it is stored or indexed."""
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
project = make_project(router, name=f"proj {token}", description="password: hunter2")
assert token not in project["name"] and project["description"] == "[redacted:password]"
# A tag can only be [a-z0-9-], so a scrubbed tag fails the contract instead of being stored.
assert call(router, "POST", "/hux/v1/conversations", {"title": "t", "tags": [token]})[0] == 400
conversation = make_conversation(router, title=f"token {token}", tags=["ok"])
assert conversation["title"] == "token [redacted:github_token]" and conversation["tags"] == ["ok"]
status, patched, _ = call(router, "PATCH", f"/hux/v1/conversations/{conversation['id']}", {"title": f"again {token}"}, {**HEADERS, "If-Match": "1"})
assert status == 200 and token not in patched["title"]
assert call(router, "GET", f"/hux/v1/search?q={token}")[1]["items"] == []
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
assert leaked == []
def test_flag_off_hides_the_card(tmp_path):
off = build_router(tmp_path, {"HUX_FLAGS": ""})
status, body, _ = call(off, "GET", "/hux/v1/projects")
assert (status, body["code"]) == (404, "flag_off")

View File

@ -9,7 +9,6 @@ refuse the transitions that would let merged/built/deployed be confused.
from __future__ import annotations
import copy
import importlib.util
import json
import sys
import tomllib
@ -20,21 +19,13 @@ import pytest
import yaml
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "services" / "hermes" / "scripts"
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
CONTRACTS = ROOT / "services" / "hermes" / "contracts" / "hux"
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
contracts = _load("hux_contracts")
policy = _load("hux_policy")
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import contracts, rules as policy # noqa: E402
SCHEMAS = contracts.load_all()
EXAMPLES = {path.stem: json.loads(path.read_text()) for path in sorted((CONTRACTS / "examples").glob("*.json"))}
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=timezone.utc)
@ -166,6 +157,7 @@ def test_capability_matrix_shape():
for level, row in matrix.items():
assert set(row) == set(policy.CAPABILITIES)
assert row["deploy"] == "ask", level
assert row["external_side_effect"] == "ask", level
assert row["read_files"] == "allow"
assert matrix["ask_first"]["shell"] == "ask"
assert matrix["safe"]["network"] == "deny"

View File

@ -0,0 +1,313 @@
"""HUX-02 memory ledger: proposals, consent transitions, edits, If-Match and no-store.
Security obligations exercised: SO-10 (server-set owner and provenance),
SO-15 (server-assigned ids), SO-18 (foreign ids are 404), SO-21 (policy
violations suppressed with an event), SO-22 (no_store and forget write a
content-free line plus tombstone), SO-24 (forget re-redacts events), SO-25
(supersedes of a forgotten id needs a user approval), SO-26 (export scope and
audit), SO-28 (private mode refuses writes), SO-35-style human-only decisions,
SO-44 (If-Match conflicts, unconditional writes audited).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, events, identity, memory, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
USER = {"proposed_by": "user"}
def ident(**overrides) -> identity.Identity:
base = {"tenant_slot": "slot-3", "subject": "usr_0123456789abcdef", "surface": "chat", "trust": "router"}
return identity.Identity(**{**base, **overrides})
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, headers=HEADERS, body=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, raw)
return response.status, response.body, response
def tenant(tmp_path, who=None) -> store.TenantStore:
return store.TenantStore(tmp_path, who or ident())
def valid(record) -> None:
assert contracts.validate_record(record, SCHEMAS) == [], record
def propose(router, headers=HEADERS, **fields):
body = {"kind": "preference", "content": "Prefers terse answers with code first.", "reason": "asked twice", "conversation_id": CONV, **fields}
return call(router, "POST", "/hux/v1/memory", headers, body)
def event_kinds(tmp_path, conversation=CONV):
return [row["kind"] for row in tenant(tmp_path).read(events.FAMILY, conversation)]
# --- proposals ------------------------------------------------------------------
def test_user_proposal_with_personal_content_is_active_immediately(tmp_path):
router = router_for(tmp_path)
status, body, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"}, source={"kind": "message", "id": "msg-42"}, run_id="run_9f")
assert status == 201 and body["status"] == "active" and body["approval_mode"] == "automatic" and body["retrievable"] is True
assert body["owner"] == "usr_0123456789abcdef" and body["provenance"]["actor"]["type"] == "user" and body["provenance"]["run_id"] == "run_9f"
assert body["ttl"] == {"policy": "decay", "decay_days": 180} and body["topic"] == "general" and body["revision"] == 1
assert [row["action"] for row in body["audit"]] == ["proposed", "approved"]
valid(body)
assert event_kinds(tmp_path) == ["memory.committed"]
ledger = tenant(tmp_path).read(memory.FAMILY, memory.LEDGER)
assert [row["id"] for row in ledger] == [body["id"]]
def test_assistant_proposal_is_suggest_only(tmp_path):
router = router_for(tmp_path)
status, body, _ = propose(router)
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["retrievable"] is False
assert body["provenance"]["actor"] == {"type": "assistant", "id": "hermes"}
valid(body)
assert event_kinds(tmp_path) == ["memory.proposed"]
status, explicit, _ = propose(router, **USER, approval_mode="ask")
assert explicit["status"] == "proposed"
def test_body_supplied_identity_and_ids_are_rejected_or_ignored(tmp_path):
router = router_for(tmp_path)
assert propose(router, id="mem_evil0001")[0] == 400
assert propose(router, revision=7)[0] == 400
assert propose(router, status="active")[0] == 400
assert propose(router, content="")[0] == 400
assert propose(router, content="x" * 2001)[0] == 400
assert propose(router, sensitivity="ultra")[0] == 400
assert call(router, "POST", "/hux/v1/memory", HEADERS, [1])[0] == 400
status, body, _ = propose(router, **USER, owner="usr_fedcba9876543210", kind="weird", topic="not-a-topic", ttl="never", scope="bad", source="bad")
assert status == 201 and body["owner"] == "usr_0123456789abcdef" and body["kind"] == "fact" and body["scope"] == {"level": "global"}
assert body["source"] == {"kind": "message", "id": "unspecified"}
def test_sensitive_content_always_asks_and_decays(tmp_path):
router = router_for(tmp_path)
status, body, _ = propose(router, **USER, content="My therapist suggested a new medication for anxiety.", ttl={"policy": "never"})
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask"
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["ttl"] == {"policy": "decay", "decay_days": 30}
valid(body)
status, body, _ = propose(router, **USER, content="plain", sensitivity="sensitive")
assert body["approval_mode"] == "ask" and body["topic"] == "general"
def test_no_store_writes_tombstone_and_content_free_line(tmp_path):
router = router_for(tmp_path)
status, body, _ = propose(router, **USER, content="my password is hunter2hunter2 for the bank")
assert status == 202 and body["status"] == "no_store" and body["content"] == "" and body["retrievable"] is False and "topic" not in body
assert body["audit"][0]["action"] == "no_store" and "credentials" in body["audit"][0]["note"]
valid(body)
s = tenant(tmp_path)
assert "hunter2" not in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
assert body["id"] in memory.tombstoned(s)
assert event_kinds(tmp_path) == ["memory.suppressed"]
status, declined, _ = propose(router, **USER, approval_mode="no_store", content="plain thing")
assert status == 202 and declined["audit"][0]["note"].startswith("declined")
status, restricted, _ = propose(router, **USER, sensitivity="restricted", content="plain")
assert status == 202 and restricted["audit"][0]["note"].startswith("restricted")
status, sensitive_declined, _ = propose(router, **USER, approval_mode="no_store", content="my mortgage rate")
assert status == 202 and sensitive_declined["approval_mode"] == "ask"
assert memory.retrieve(s, ["password", "mortgage", "plain"]) == []
def test_private_mode_conversation_refuses_memory(tmp_path):
router = router_for(tmp_path)
tenant(tmp_path).put("conversations", {"id": "conv_priv0001", "mode": "private"})
status, body, _ = propose(router, **USER, conversation_id="conv_priv0001")
assert (status, body["code"]) == (403, "forbidden")
assert tenant(tmp_path).count(memory.FAMILY) == 0
def test_policy_violation_is_suppressed_with_event(tmp_path):
router = router_for(tmp_path)
status, body, _ = propose(router, **USER, ttl={"policy": "expires_at"})
assert status == 400 and any("expires_at" in d for d in body["details"])
assert event_kinds(tmp_path) == ["memory.suppressed"]
assert tenant(tmp_path).count(memory.FAMILY) == 0
def test_proposal_idempotency_key_replays(tmp_path):
router = router_for(tmp_path)
propose(router, {**HEADERS, "Idempotency-Key": "conv:mem:0000"}, **USER)
headers = {**HEADERS, "Idempotency-Key": "conv:mem:0001"}
status, first, _ = propose(router, headers, **USER)
status, again, response = propose(router, headers, **USER)
assert status == 200 and again == first and response.headers["HUX-Replayed"] == "true"
assert tenant(tmp_path).count(memory.FAMILY) == 2
# --- transitions -------------------------------------------------------------------
def test_approve_reject_and_illegal_transitions(tmp_path):
router = router_for(tmp_path)
_, proposed, _ = propose(router)
path = f"/hux/v1/memory/{proposed['id']}"
status, approved, response = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "1"})
assert status == 200 and approved["status"] == "active" and approved["retrievable"] is True and approved["revision"] == 2
assert response.headers["ETag"] == "2" and approved["audit"][-1]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
valid(approved)
status, body, _ = call(router, "POST", f"{path}/approve")
assert (status, body["code"]) == (409, "conflict")
_, other, _ = propose(router)
status, rejected, _ = call(router, "POST", f"/hux/v1/memory/{other['id']}/reject")
assert status == 200 and rejected["status"] == "rejected" and rejected["content"] == "" and rejected["retrievable"] is False
valid(rejected)
assert call(router, "POST", f"/hux/v1/memory/{other['id']}/forget")[0] == 409
assert event_kinds(tmp_path) == ["memory.proposed", "memory.committed", "memory.proposed", "memory.suppressed"]
def test_if_match_conflicts_and_unconditional_writes_are_audited(tmp_path):
router = router_for(tmp_path)
_, proposed, _ = propose(router)
path = f"/hux/v1/memory/{proposed['id']}"
status, body, _ = call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "5"})
assert (status, body["code"]) == (409, "conflict") and body["details"] == ["1"]
assert call(router, "POST", f"{path}/approve", {**HEADERS, "If-Match": "x"})[0] == 400
assert call(router, "POST", f"{path}/approve")[0] == 200
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(tenant(tmp_path)) if r["action"] in {"memory.act", "memory.approve"}]
assert rows[-3:] == [("memory.act", "conflict", "revision 5 does not match current revision 1"), ("memory.act", "deny", "If-Match must be a revision integer"), ("memory.approve", "allow", "unconditional_write")]
def test_forget_drops_content_tombstones_and_redacts_events(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER)
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.assistant", "used memory", evidence=[{"kind": "memory", "id": active["id"]}])
status, forgotten, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", {**HEADERS, "If-Match": "1"})
assert status == 200 and forgotten["status"] == "forgotten" and forgotten["content"] == "" and forgotten["retrievable"] is False
valid(forgotten)
assert active["id"] in memory.tombstoned(s)
rows = s.read(events.FAMILY, CONV)
assert [r["kind"] for r in rows] == ["memory.committed", "message.assistant", "memory.forgotten"]
assert rows[0]["redaction"]["level"] == "full" and rows[1]["redaction"]["level"] == "full"
assert call(router, "GET", f"/hux/v1/memory/{active['id']}")[1]["status"] == "forgotten"
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")[0] == 409
def test_edit_supersedes_and_forgets_the_old_entry(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER)
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", {**HEADERS, "If-Match": "1"}, {"content": "Prefers long answers now."})
assert status == 200 and fresh["id"] != active["id"] and fresh["supersedes"] == active["id"] and fresh["status"] == "active"
assert fresh["content"] == "Prefers long answers now." and [a["action"] for a in fresh["audit"]] == ["edited", "approved"]
valid(fresh)
old = tenant(tmp_path).get(memory.FAMILY, active["id"])
assert old["status"] == "forgotten" and old["content"] == "" and old["audit"][-1]["action"] == "superseded"
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS, {"content": ""})[0] == 400
assert call(router, "POST", f"/hux/v1/memory/{fresh['id']}/edit", HEADERS)[0] == 400
assert event_kinds(tmp_path)[-1] == "memory.committed"
def test_edit_of_sensitive_entry_goes_back_to_ask(tmp_path):
router = router_for(tmp_path)
_, proposed, _ = propose(router, **USER, content="my mortgage is with the bank")
_, active, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
status, fresh, _ = call(router, "POST", f"/hux/v1/memory/{active['id']}/edit", HEADERS, {"content": "refinanced the mortgage"})
assert status == 200 and fresh["status"] == "proposed" and fresh["approval_mode"] == "ask" and fresh["retrievable"] is False
valid(fresh)
def test_supersedes_of_forgotten_id_requires_ask(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER)
call(router, "POST", f"/hux/v1/memory/{active['id']}/forget")
status, body, _ = propose(router, **USER, supersedes=active["id"])
assert status == 201 and body["status"] == "proposed" and body["approval_mode"] == "ask" and body["supersedes"] == active["id"]
status, body, _ = propose(router, **USER, source={"kind": "memory", "id": active["id"]})
assert body["approval_mode"] == "ask"
def test_retrieval_removal_and_restore(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER)
path = f"/hux/v1/memory/{active['id']}"
status, removed, _ = call(router, "POST", f"{path}/remove_retrieval")
assert status == 200 and removed["status"] == "active" and removed["retrievable"] is False and removed["audit"][-1]["action"] == "retrieval_removed"
valid(removed)
assert memory.retrieve(tenant(tmp_path), []) == []
status, restored, _ = call(router, "POST", f"{path}/restore_retrieval", {**HEADERS, "If-Match": "2"})
assert status == 200 and restored["retrievable"] is True and restored["audit"][-1]["note"] == "retrieval restored"
assert [m["id"] for m in memory.retrieve(tenant(tmp_path), [])] == [active["id"]]
_, proposed, _ = propose(router)
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/remove_retrieval")[0] == 409
assert event_kinds(tmp_path)[:3] == ["memory.committed", "memory.retrieval_removed", "memory.committed"]
def test_decisions_need_a_human_surface(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
_, proposed, _ = propose(router)
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
status, body, _ = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", worker)
assert (status, body["code"]) == (403, "forbidden")
api = {**HEADERS, "X-Hux-Surface": "api"}
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve", api)[0] == 403
status, body, _ = propose(router, worker, proposed_by="user")
assert body["provenance"]["actor"]["type"] == "assistant"
# --- reads, ownership, export ------------------------------------------------------
def test_list_get_and_cross_tenant_denial(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER, scope={"level": "project", "scope_id": "prj_0001aaaa"})
_, proposed, _ = propose(router)
status, body, _ = call(router, "GET", "/hux/v1/memory")
assert status == 200 and {m["id"] for m in body["items"]} == {active["id"], proposed["id"]}
for item in body["items"]:
valid(item)
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?status=proposed")[1]["items"]] == [proposed["id"]]
assert [m["id"] for m in call(router, "GET", "/hux/v1/memory?scope=project:prj_0001aaaa")[1]["items"]] == [active["id"]]
assert call(router, "GET", "/hux/v1/memory?scope=project:prj_other000")[1]["items"] == []
assert call(router, "GET", "/hux/v1/memory?scope=global")[1]["items"][0]["id"] == proposed["id"]
status, got, response = call(router, "GET", f"/hux/v1/memory/{active['id']}")
assert status == 200 and got == active and response.headers["ETag"] == "1"
assert call(router, "GET", f"/hux/v1/memory/{active['id']}", OTHER)[0] == 404
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/forget", OTHER)[0] == 404
assert call(router, "GET", "/hux/v1/memory", OTHER)[1]["items"] == []
assert call(router, "GET", "/hux/v1/memory/not-an-id")[0] == 404
assert call(router, "GET", "/hux/v1/memory/mem_missing00")[0] == 404
assert call(router, "POST", f"/hux/v1/memory/{active['id']}/explode")[0] == 404
assert call(router, "POST", "/hux/v1/memory/mem_missing00/approve")[0] == 404
def test_export_is_active_only_and_audited(tmp_path):
router = router_for(tmp_path)
_, active, _ = propose(router, **USER)
_, hidden, _ = propose(router, **USER)
call(router, "POST", f"/hux/v1/memory/{hidden['id']}/remove_retrieval")
propose(router)
_, gone, _ = propose(router, **USER)
call(router, "POST", f"/hux/v1/memory/{gone['id']}/forget")
status, body, response = call(router, "GET", "/hux/v1/memory/export")
assert status == 200 and [m["id"] for m in body["items"]] == [active["id"]]
assert body["items"][0]["audit"][-1]["action"] == "exported" and body["items"][0]["revision"] == 2
assert response.headers["Content-Disposition"].startswith("attachment")
valid(body["items"][0])
assert tenant(tmp_path).read(memory.FAMILY, "exports")[0]["ids"] == [active["id"]]
assert call(router, "GET", "/hux/v1/memory/export", OTHER)[1]["items"] == []
def test_memory_module_stays_under_500_lines():
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500

View File

@ -0,0 +1,209 @@
"""HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b).
Security obligations exercised: SO-21 (deny topics never reach memory, even
through an edit), SO-22 (no_store and forget are content-free and final),
SO-23 (a forgotten entry never retrieves again), SO-28 (private mode refuses
memory writes), SO-44 (revision checks hold under concurrency).
"""
from __future__ import annotations
import json
import sys
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import contracts, identity, memory, privacy, store # noqa: E402
from hux.errors import Conflict # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
USER = {"proposed_by": "user", "approval_mode": "automatic"}
def ident() -> identity.Identity:
return identity.Identity(tenant_slot="slot-3", subject="usr_0123456789abcdef", surface="chat", trust="router")
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw)
return response.status, response.body
def tenant(tmp_path) -> store.TenantStore:
return store.TenantStore(tmp_path, ident())
def conversation(router) -> str:
status, body = call(router, "POST", "/hux/v1/conversations", {"title": "tea"})
assert status == 201
return body["id"]
def active(router, conv, content="I like tea") -> dict:
status, body = call(router, "POST", "/hux/v1/memory", {"content": content, "conversation_id": conv, **USER})
assert (status, body["status"]) == (201, "active"), body
return body
def valid(record) -> dict:
assert contracts.validate_record(record, SCHEMAS) == [], record
return record
# --- F3: edit goes through the same shaping as a proposal ---------------------------
def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original(tmp_path):
"""F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded."""
router = router_for(tmp_path)
conv = conversation(router)
original = active(router, conv)
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "my home address is 12 Main Street; my daughter is 6"}, {"If-Match": "1"})
assert status == 202 and valid(body)["status"] == "no_store" and body["content"] == "" and "topic" not in body
assert body["sensitivity"] == "restricted" and body["retrievable"] is False and "supersedes" not in body
assert body["audit"][0]["note"] == "restricted; topic=location" and "Main Street" not in json.dumps(body)
assert body["id"] in memory.tombstoned(tenant(tmp_path))
status, again = call(router, "GET", f"/hux/v1/memory/{original['id']}")
assert status == 200 and again["status"] == "active" and again["revision"] == 1 and again["content"] == "I like tea"
assert [hit["content"] for hit in memory.retrieve(tenant(tmp_path), ["address"])] == []
assert [hit["id"] for hit in memory.retrieve(tenant(tmp_path), ["tea"])] == [original["id"]]
kinds = [row["kind"] for row in tenant(tmp_path).read("events", conv)]
assert kinds[-1] == "memory.suppressed"
def test_f3_edit_with_sensitive_content_is_proposed_with_ask(tmp_path):
"""F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval."""
router = router_for(tmp_path)
conv = conversation(router)
original = active(router, conv)
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I take lithium for my diagnosis"})
assert status == 200 and valid(body)["status"] == "proposed" and body["approval_mode"] == "ask"
assert body["sensitivity"] == "sensitive" and body["topic"] == "health" and body["retrievable"] is False
assert body["supersedes"] == original["id"] and [row["action"] for row in body["audit"]] == ["edited"]
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "forgotten"
def test_f3_edit_scrubs_secrets_and_floors_credentials(tmp_path):
"""F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store."""
router = router_for(tmp_path)
conv = conversation(router)
original = active(router, conv)
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"})
assert status == 202 and body["status"] == "no_store" and body["sensitivity"] == "restricted"
leaked = [path for path in tmp_path.rglob("*.json*") if "ghp_ABCDEFGHIJ" in path.read_text()]
assert leaked == []
def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled(tmp_path):
"""F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store."""
router = router_for(tmp_path)
conv = conversation(router)
original = active(router, conv)
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", True)
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
assert status == 202 and body["status"] == "no_store" and body["audit"][0]["note"].startswith("memory_disabled")
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
privacy.set_flag(tenant(tmp_path), conv, "memory_disabled", False)
assert call(router, "PATCH", f"/hux/v1/conversations/{conv}", {"mode": "private"})[0] == 200
status, body = call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "I like coffee"})
assert status == 403 and body["code"] == "forbidden"
assert call(router, "GET", f"/hux/v1/memory/{original['id']}")[1]["status"] == "active"
def test_f3_edit_still_needs_content(tmp_path):
router = router_for(tmp_path)
original = active(router, conversation(router))
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": " "})[0] == 400
assert call(router, "POST", f"/hux/v1/memory/{original['id']}/edit", {"content": "x" * 2001})[0] == 400
# --- F7: a stale record cannot resurrect a forgotten entry ------------------------
def test_f7_stale_approve_after_forget_is_a_conflict(tmp_path, monkeypatch):
"""F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected."""
router = router_for(tmp_path)
conv = conversation(router)
status, proposed = call(router, "POST", "/hux/v1/memory", {"content": "I take lithium daily", "conversation_id": conv, "proposed_by": "user", "approval_mode": "ask"})
assert (status, proposed["status"]) == (201, "proposed")
stale = dict(proposed)
assert call(router, "POST", f"/hux/v1/memory/{proposed['id']}/reject")[1]["status"] == "rejected"
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
status, body = call(router, "POST", f"/hux/v1/memory/{proposed['id']}/approve")
assert status == 409 and body["code"] == "conflict"
monkeypatch.undo()
status, after = call(router, "GET", f"/hux/v1/memory/{proposed['id']}")
assert after["status"] == "rejected" and after["content"] == "" and after["revision"] == 2
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget(tmp_path, monkeypatch):
"""F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works."""
router = router_for(tmp_path)
conv = conversation(router)
entry = active(router, conv, "I take lithium daily")
stale = dict(entry)
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/forget")[1]["status"] == "forgotten"
monkeypatch.setattr(memory, "load", lambda store, memory_id, now=None: stale)
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/remove_retrieval")[0] == 409
monkeypatch.undo()
assert call(router, "POST", f"/hux/v1/memory/{entry['id']}/restore_retrieval")[0] == 409
assert call(router, "GET", f"/hux/v1/memory/{entry['id']}")[1]["status"] == "forgotten"
assert memory.retrieve(tenant(tmp_path), ["lithium"]) == []
def test_f7_transition_checks_if_match_against_the_fresh_copy(tmp_path):
"""F7 / SO-44: If-Match is compared with the stored revision, not the caller's stale snapshot."""
router = router_for(tmp_path)
entry = active(router, conversation(router))
s = tenant(tmp_path)
memory._set_retrievable(s, entry, False, {"type": "user", "id": "usr_0123456789abcdef"}, 1)
try:
memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"}, expected=1)
except Conflict as error:
assert "revision 1 does not match current revision 2" in str(error)
else:
raise AssertionError("stale If-Match must conflict")
assert memory.load(s, entry["id"])["status"] == "active"
stored = memory._transition(s, ident(), entry, "forgotten", "forgotten", {"type": "user", "id": "usr_0123456789abcdef"})
assert stored["status"] == "forgotten" and stored["revision"] == 3
# --- F13b: Idempotency-Key is checked under the lock ------------------------------
def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record(tmp_path):
"""F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays."""
router = router_for(tmp_path)
results: list[tuple[int, str]] = []
barrier = threading.Barrier(8)
def propose() -> None:
barrier.wait(5)
status, body = call(router, "POST", "/hux/v1/memory", {"content": "I like tea", **USER}, {"Idempotency-Key": "same-key-0001"})
results.append((status, body["id"]))
threads = [threading.Thread(target=propose) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(10)
assert sorted(status for status, _ in results) == [200] * 7 + [201]
assert len({memory_id for _, memory_id in results}) == 1
assert len(call(router, "GET", "/hux/v1/memory")[1]["items"]) == 1
assert len(tenant(tmp_path).read(memory.IDEM_FAMILY, "keys")) == 1
def test_memory_module_stays_under_500_lines():
assert len((FOUNDATION / "hux" / "memory.py").read_text().splitlines()) <= 500

View File

@ -0,0 +1,151 @@
"""HUX-02 retrieval hook: what the agent may read back from the memory ledger.
Security obligations exercised: SO-22 and SO-23 (tombstones and memory_disabled
consulted before the index; only active, retrievable entries are served; no_store,
forgotten, rejected and expired never surface), SO-27 (lazy expiry at read).
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import contracts, events, identity, memory, privacy, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
def ident() -> identity.Identity:
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, headers=HEADERS, body=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, raw)
return response.status, response.body
def tenant(tmp_path) -> store.TenantStore:
return store.TenantStore(tmp_path, ident())
def remember(router, content, conversation_id=CONV, **fields):
body = {"kind": "fact", "content": content, "reason": "user said so", "proposed_by": "user", "conversation_id": conversation_id, **fields}
status, record = call(router, "POST", "/hux/v1/memory", HEADERS, body)
assert status in (201, 202), record
return record
def ids(rows):
return [row["id"] for row in rows]
def test_do_not_remember_never_persists_nor_retrieves(tmp_path):
router = router_for(tmp_path)
kept = remember(router, "Likes espresso in the morning.")
declined = remember(router, "Do not remember: I take espresso with sugar.", approval_mode="no_store")
s = tenant(tmp_path)
assert declined["status"] == "no_store" and declined["id"] in memory.tombstoned(s)
assert "sugar" not in json.dumps(list(s.scan(memory.FAMILY))) and "sugar" not in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
assert ids(memory.retrieve(s, ["espresso"])) == [kept["id"]]
assert ids(memory.retrieve(s, ["sugar"])) == []
for record in memory.retrieve(s, []):
assert contracts.validate_record(record, SCHEMAS) == []
def test_tombstone_wins_even_when_the_document_says_active(tmp_path):
router = router_for(tmp_path)
kept = remember(router, "Team standup is at nine.")
s = tenant(tmp_path)
s.append(memory.FAMILY, memory.TOMBSTONES, {"memory_id": kept["id"], "at": "2026-08-23T00:00:00Z", "reason": "test", "purged": False})
assert memory.retrieve(s, ["standup"]) == []
assert call(router, "GET", "/hux/v1/memory/export")[1]["items"] == []
def test_only_active_and_retrievable_entries_surface(tmp_path):
router = router_for(tmp_path)
active = remember(router, "alpha fact")
proposed = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "beta fact", "reason": "r"})[1]
rejected = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "gamma fact", "reason": "r"})[1]
call(router, "POST", f"/hux/v1/memory/{rejected['id']}/reject")
forgotten = remember(router, "delta fact")
call(router, "POST", f"/hux/v1/memory/{forgotten['id']}/forget")
hidden = remember(router, "epsilon fact")
call(router, "POST", f"/hux/v1/memory/{hidden['id']}/remove_retrieval")
s = tenant(tmp_path)
assert ids(memory.retrieve(s, ["fact"])) == [active["id"]]
assert ids(memory.retrieve(s, [])) == [active["id"]]
assert proposed["status"] == "proposed"
def test_expired_entries_are_never_served_as_active(tmp_path):
router = router_for(tmp_path)
fresh = remember(router, "still valid", ttl={"policy": "expires_at", "expires_at": "2999-01-01T00:00:00Z"})
stale = remember(router, "already gone", ttl={"policy": "expires_at", "expires_at": "2020-01-01T00:00:00Z"})
forever = remember(router, "kept forever", ttl={"policy": "never"})
s = tenant(tmp_path)
assert ids(memory.retrieve(s, [])) == [forever["id"], fresh["id"]] or set(ids(memory.retrieve(s, []))) == {forever["id"], fresh["id"]}
expired = s.get(memory.FAMILY, stale["id"])
assert expired["status"] == "expired" and expired["content"] == "" and expired["audit"][-1]["actor"] == {"type": "system", "id": "retention"}
assert contracts.validate_record(expired, SCHEMAS) == []
status, body = call(router, "GET", f"/hux/v1/memory/{stale['id']}")
assert body["status"] == "expired"
assert memory.effective_expiry(forever) is None
decayed = remember(router, "decays", ttl={"policy": "decay", "decay_days": 1})
far = datetime.now(timezone.utc) + timedelta(days=2)
assert memory.load(s, decayed["id"], far)["status"] == "expired"
assert [m["status"] for m in call(router, "GET", "/hux/v1/memory?status=expired")[1]["items"]] == ["expired", "expired"]
def test_scope_and_query_filters(tmp_path):
router = router_for(tmp_path)
everywhere = remember(router, "global note about coffee")
project = remember(router, "project note about coffee", scope={"level": "project", "scope_id": "prj_0001aaaa"})
other = remember(router, "other project coffee", scope={"level": "project", "scope_id": "prj_0002aaaa"})
s = tenant(tmp_path)
assert set(ids(memory.retrieve(s, ["coffee"], {"level": "project", "scope_id": "prj_0001aaaa"}))) == {everywhere["id"], project["id"]}
assert set(ids(memory.retrieve(s, ["Coffee", ""], None))) == {everywhere["id"], project["id"], other["id"]}
assert ids(memory.retrieve(s, ["tea"])) == []
def test_blocked_conversations_contribute_and_receive_nothing(tmp_path):
router = router_for(tmp_path)
scoped = remember(router, "said in the disabled chat", "conv_0002abcd")
elsewhere = remember(router, "said elsewhere")
s = tenant(tmp_path)
privacy.set_flag(s, "conv_0002abcd", "memory_disabled", True)
assert ids(memory.retrieve(s, ["said"])) == [elsewhere["id"]]
assert memory.retrieve(s, ["said"], {"level": "conversation", "scope_id": "conv_0002abcd"}) == []
assert scoped["status"] == "active"
later = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "after disable", "reason": "r", "proposed_by": "user", "conversation_id": "conv_0002abcd"})
assert later[0] == 202 and later[1]["status"] == "no_store" and later[1]["audit"][0]["note"].startswith("memory_disabled")
def test_forgetting_a_conversation_removes_its_memory_from_retrieval(tmp_path):
router = router_for(tmp_path)
doomed = remember(router, "doomed detail", "conv_0003abcd")
pending = call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "pending detail", "reason": "r", "conversation_id": "conv_0003abcd"})[1]
survivor = remember(router, "survivor detail")
status, body = call(router, "POST", "/hux/v1/conversations/conv_0003abcd/forget")
assert status == 200 and body["memory_forgotten"] == 2 and body["forgotten"] is True
s = tenant(tmp_path)
assert all(r["redaction"]["level"] == "full" for r in s.read(events.FAMILY, "conv_0003abcd"))
assert ids(memory.retrieve(s, ["detail"])) == [survivor["id"]]
assert s.get(memory.FAMILY, doomed["id"])["status"] == "forgotten" and s.get(memory.FAMILY, pending["id"])["status"] == "rejected"
assert {doomed["id"], pending["id"]} <= memory.tombstoned(s)
assert call(router, "POST", "/hux/v1/memory", HEADERS, {"kind": "fact", "content": "late", "reason": "r", "proposed_by": "user", "conversation_id": "conv_0003abcd"})[1]["status"] == "no_store"

View File

@ -0,0 +1,359 @@
"""HUX-05 approval queue and the pre-side-effect gate.
Security obligations exercised: SO-35 (only a human surface with router or
relay trust decides; ``decision.by`` is the asserted user), SO-36 (``once``
is consumed exactly once), SO-37 (the gate must present the argument hash
recorded at request time), SO-39 (external side effects always need an
approval record), SO-40 (an exhausted budget blocks new approvals), SO-42
(pending approvals expire 24 h after ``requested_at``).
"""
from __future__ import annotations
import importlib
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
import hux # noqa: E402
from hux import audit, budgets, contracts, identity, policy, store # noqa: E402
from hux import events as hux_events # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
RELAY = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
CONV = "conv_0001abcd"
HASH = "sha256:" + "ab" * 32
OTHER_HASH = "sha256:" + "cd" * 32
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
@pytest.fixture
def frozen(monkeypatch):
state = {"now": T0}
monkeypatch.setattr(policy, "clock", lambda: state["now"])
return state
@pytest.fixture
def events(monkeypatch):
"""Stand-in for the events lane; records every emit call."""
seen: list[dict] = []
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
return seen
def call(router, method, path, body=None, headers=HEADERS):
raw = b"" if body is None else json.dumps(body).encode()
response = router.dispatch(method, path, headers, raw)
return response.status, response.body
def valid(record):
problems = contracts.validate_record(record, SCHEMAS)
assert problems == [], problems
return record
def request_body(capability="write_files", external=False, run_id="run_9f", **extra):
evidence = [{"kind": "tool_call", "id": "call-7", "hash": HASH}]
return {"run_id": run_id, "conversation_id": CONV, "capability": capability,
"request": {"summary": f"do {capability}", "risk": "low", "external": external, "evidence": evidence, **extra}}
def set_autonomy(router, level):
status, body = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": level})
assert status == 200, body
# --- creation resolves against the matrix ------------------------------------------
def test_safe_policy_auto_allows_reads_and_queues_mutations(router, frozen, events):
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
assert status == 201 and valid(body)["status"] == "approved"
assert body["decision"] == {"choice": "once", "by": {"type": "system", "id": "policy"}, "at": policy.iso(T0)}
status, body = call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)
assert status == 201 and valid(body)["status"] == "pending" and "decision" not in body
assert body["expires_at"] == policy.iso(T0 + timedelta(hours=24))
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network"), WORKER)
assert status == 201 and valid(body)["status"] == "denied" and body["decision"]["choice"] == "deny"
assert [e["kind"] for e in events] == ["approval.resolved", "approval.requested", "approval.resolved"]
assert events[0]["evidence"] == [{"kind": "approval", "id": events[0]["evidence"][0]["id"]}]
def test_external_side_effects_always_need_a_human_regardless_of_autonomy(router, events):
set_autonomy(router, "autonomous")
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message"), WORKER)
assert status == 201 and body["status"] == "approved"
status, body = call(router, "POST", "/hux/v1/approvals", request_body("send_message", external=True), WORKER)
assert status == 201 and body["status"] == "pending"
for always_ask in ("deploy", "external_side_effect"):
status, body = call(router, "POST", "/hux/v1/approvals", request_body(always_ask), WORKER)
assert body["status"] == "pending", always_ask
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "deny"}]})
status, body = call(router, "POST", "/hux/v1/approvals", request_body("network", external=True), WORKER)
assert body["status"] == "denied", "deny beats external ask"
def test_idempotency_key_returns_the_original(router):
key = {"Idempotency-Key": "run_9f:approval:call-7"}
status, first = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, **key})
assert status == 201 and first["idempotency_key"] == "run_9f:approval:call-7"
status, again = call(router, "POST", "/hux/v1/approvals", request_body("shell"), {**WORKER, **key})
assert (status, again) == (200, first)
status, other = call(router, "POST", "/hux/v1/approvals", request_body(), {**WORKER, "Idempotency-Key": "run_9f:approval:call-8"})
assert status == 201 and other["id"] != first["id"]
assert call(router, "GET", f"/hux/v1/approvals/{first['id']}")[1] == first
@pytest.mark.parametrize("body,status", [
(None, 400), ([], 400), ({"conversation_id": "nope", "capability": "shell"}, 400),
({"conversation_id": CONV, "capability": "teleport"}, 400),
({"conversation_id": CONV, "capability": "shell", "run_id": "r"}, 400),
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "", "risk": "low"}}, 400),
({"conversation_id": CONV, "capability": "shell", "run_id": "r", "request": {"summary": "x", "risk": "silly"}}, 400),
])
def test_create_rejects_bad_bodies(router, body, status):
got, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
assert got == status and valid(error)["code"] == "invalid"
# --- deciding -----------------------------------------------------------------------
def pending(router, capability="write_files", **kw):
status, body = call(router, "POST", "/hux/v1/approvals", request_body(capability, **kw), WORKER)
assert status == 201 and body["status"] == "pending", body
return body
def test_only_humans_on_router_or_relay_decide(router, tmp_path):
record = pending(router)
status, error = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, WORKER)
assert (status, error["code"]) == (403, "forbidden")
api = {**HEADERS, "X-Hux-Surface": "api"}
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, api)[0] == 403
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, RELAY)
assert status == 200 and valid(body)["status"] == "approved"
assert body["decision"]["by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
assert [(r["action"], r["outcome"]) for r in rows if r["action"] == "approvals.decide"] == [("approvals.decide", "deny")] * 2 + [("approvals.decide", "allow")]
def test_terminal_states_never_change_again(router, events):
record = pending(router)
path = f"/hux/v1/approvals/{record['id']}"
assert call(router, "POST", path, {"choice": "maybe"})[0] == 400
assert call(router, "POST", path, [])[0] == 400
status, body = call(router, "POST", path, {"choice": "deny"})
assert status == 200 and body["status"] == "denied" and body["decision"]["choice"] == "deny"
status, error = call(router, "POST", path, {"choice": "once"})
assert (status, error["code"]) == (409, "conflict")
assert call(router, "GET", path)[1] == body
assert events[-1]["kind"] == "approval.resolved" and "denied" in events[-1]["summary"]
def test_pending_approvals_expire_after_24h(router, frozen):
record = pending(router)
frozen["now"] = T0 + timedelta(hours=24)
status, body = call(router, "GET", f"/hux/v1/approvals/{record['id']}")
assert status == 200 and valid(body)["status"] == "expired"
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})[0] == 409
assert call(router, "GET", "/hux/v1/approvals?status=expired")[1]["items"] == [body]
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == []
def test_session_and_always_create_grants_at_their_scope(router, frozen):
record = pending(router, "shell")
status, body = call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
assert status == 200 and body["decision"]["choice"] == "session"
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
assert valid(conv_policy)["scope"] == {"level": "conversation", "scope_id": CONV}
assert conv_policy["grants"] == [{"capability": "shell", "decision": "allow", "expires_at": policy.iso(T0 + policy.SESSION_TTL), "granted_by": {"type": "user", "id": HEADERS["X-Hux-Subject"]}}]
status, body = call(router, "POST", "/hux/v1/approvals", request_body("shell"), WORKER)
assert body["status"] == "approved", "later shell requests in this conversation auto-approve"
assert call(router, "POST", "/hux/v1/approvals", request_body("write_files"), WORKER)[1]["status"] == "pending"
record = pending(router, "write_files")
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "always"})
global_policy = call(router, "GET", "/hux/v1/policy")[1]
assert [g["capability"] for g in global_policy["grants"]] == ["write_files"]
assert global_policy["grants"][0]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
frozen["now"] = T0 + timedelta(hours=25)
record = pending(router, "shell", run_id="run_other")
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
conv_policy = call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={CONV}")[1]
assert len(conv_policy["grants"]) == 1 and conv_policy["revision"] == 2, "a repeat grant replaces, never duplicates"
def test_list_filters_and_orders_by_request_time(router, frozen):
first = pending(router, "shell")
frozen["now"] = T0 + timedelta(seconds=5)
second = pending(router, "write_files")
status, body = call(router, "GET", "/hux/v1/approvals")
assert status == 200 and [i["id"] for i in body["items"]] == [first["id"], second["id"]] and body["next"] is None
assert call(router, "GET", "/hux/v1/approvals?status=pending")[1]["items"] == body["items"]
assert call(router, "GET", "/hux/v1/approvals?status=approved")[1]["items"] == []
assert call(router, "GET", "/hux/v1/approvals?status=bogus")[0] == 400
for item in body["items"]:
valid(item)
def test_second_subject_cannot_see_or_decide(router):
record = pending(router)
assert call(router, "GET", f"/hux/v1/approvals/{record['id']}", headers=OTHER)[0] == 404
assert call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"}, OTHER)[0] == 404
assert call(router, "GET", "/hux/v1/approvals", headers=OTHER)[1]["items"] == []
assert call(router, "GET", "/hux/v1/approvals/apr_doesnotexist")[0] == 404
assert call(router, "GET", "/hux/v1/approvals/..")[0] == 400
# --- budgets block new approvals -------------------------------------------------------
def test_exhausted_budget_blocks_new_approvals(router, events):
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
assert status == 200 and valid(body)["exhausted"] == ["tool_calls_per_run"]
status, error = call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)
assert (status, error["code"]) == (429, "budget_exhausted") and error["details"] == ["tool_calls_per_run"]
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
status, body = call(router, "POST", "/hux/v1/approvals", request_body("read_files", run_id="run_fresh"), WORKER)
assert status == 201, "another run keeps its own budget"
# --- gate --------------------------------------------------------------------------
def gate(router, run_id="run_9f", capability="write_files", argument_hash=HASH, external=False, **extra):
return call(router, "POST", f"/hux/v1/runs/{run_id}/gate", {"capability": capability, "argument_hash": argument_hash, "external": external, **extra}, WORKER)
def test_gate_blocks_before_approval_and_releases_once_exactly_once(router, events, tmp_path):
status, body = gate(router)
assert status == 200 and body == {"proceed": False, "reason": "no approval for this run and capability"}
record = pending(router)
assert gate(router)[1]["proceed"] is False and "pending" in gate(router)[1]["reason"]
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
status, wrong = gate(router, argument_hash=OTHER_HASH)
assert wrong["proceed"] is False and "different arguments" in wrong["reason"], "TOCTOU (SO-37)"
status, body = gate(router)
assert body == {"proceed": True, "approval_id": record["id"], "reason": "released"}
status, again = gate(router)
assert again["proceed"] is False and "already consumed" in again["reason"], "once is once (SO-36)"
kinds = [e["kind"] for e in events]
assert kinds.count("side_effect.released") == 1 and kinds.count("side_effect.blocked") == 4, "the first block predates any approval, so no conversation is known"
released = next(e for e in events if e["kind"] == "side_effect.released")
assert released["evidence"] == [{"kind": "approval", "id": record["id"]}] and released["run_id"] == "run_9f"
served = call(router, "GET", f"/hux/v1/approvals/{record['id']}")[1]
assert valid(served) and "_consumed_at" not in served
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
assert [r["outcome"] for r in rows if r["action"] == "gate.check"] == ["deny", "deny", "deny", "deny", "allow", "deny"]
def test_gate_session_is_reusable_within_the_conversation(router):
record = pending(router, "shell")
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
assert gate(router, capability="shell")[1]["proceed"] is True
assert gate(router, capability="shell", argument_hash=OTHER_HASH)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is True, "unknown run: the approval's conversation applies (F4)"
call(router, "POST", "/hux/v1/runs/run_elsewhere/budget", {"conversation_id": "conv_elsewhere01", "tokens": 1}, WORKER)
status, body = gate(router, run_id="run_elsewhere", capability="shell", conversation_id=CONV)
assert body["proceed"] is False, "the run's own conversation wins over the body's claim (F4)"
assert gate(router, capability="write_files")[1]["proceed"] is False
def test_gate_requires_external_approvals_for_external_effects_and_respects_expiry(router, frozen):
record = pending(router, "send_message")
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
status, body = gate(router, capability="send_message", external=True)
assert body["proceed"] is False and "not requested as external" in body["reason"]
record = pending(router, "send_message", external=True)
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
frozen["now"] = T0 + timedelta(hours=25)
status, body = gate(router, capability="send_message", external=True)
assert body["proceed"] is False and "expired" in body["reason"]
def test_gate_rejects_bad_bodies_and_other_tenants(router):
assert gate(router, capability="teleport")[0] == 400
assert gate(router, argument_hash="md5:zz")[0] == 400
assert gate(router, conversation_id="nope")[0] == 200, "the body's conversation_id is ignored, never validated (F4)"
assert call(router, "POST", "/hux/v1/runs/run_9f/gate", [], WORKER)[0] == 400
assert call(router, "POST", f"/hux/v1/runs/{'r' * 121}/gate", {"capability": "shell", "argument_hash": HASH}, WORKER)[0] == 400
record = pending(router)
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "once"})
status, body = call(router, "POST", "/hux/v1/runs/run_9f/gate", {"capability": "write_files", "argument_hash": HASH}, OTHER)
assert status == 200 and body["proceed"] is False
def test_events_module_absence_is_tolerated(router, monkeypatch):
monkeypatch.setitem(sys.modules, "hux.events", None)
monkeypatch.delattr(hux, "events")
with pytest.raises(ImportError):
importlib.import_module("hux.events")
assert call(router, "POST", "/hux/v1/approvals", request_body("read_files"), WORKER)[0] == 201
policy.emit(None, None, None, "x", "no conversation means no event")
assert budgets.hashes_of({"request": {}}) == set()
# --- F4 / F6: external effects, run conversation and budgets at the gate --------------
def test_external_effects_release_only_for_the_same_run_and_arguments(router):
"""F4 (high) / SO-39: a session or always approval for an external effect never releases another run or other arguments."""
record = pending(router, "send_message", external=True)
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True
assert gate(router, capability="send_message", external=True)[1]["proceed"] is True, "session is reusable for the same run and hash"
status, body = gate(router, capability="send_message", external=True, argument_hash=OTHER_HASH)
assert body["proceed"] is False and "different arguments" in body["reason"]
status, body = gate(router, run_id="run_z", capability="send_message", external=True, conversation_id=CONV)
assert body["proceed"] is False and body["reason"] == "no approval for this run and capability"
assert gate(router, run_id="run_z", capability="send_message", external=False)[1]["proceed"] is False, "an external approval is not a general one"
always = pending(router, "send_message", external=True, run_id="run_y")
call(router, "POST", f"/hux/v1/approvals/{always['id']}", {"choice": "always"})
assert gate(router, run_id="run_y", capability="send_message", external=True)[1]["proceed"] is True
assert gate(router, run_id="run_w", capability="send_message", external=True)[1]["proceed"] is False, "always never spans runs for external effects"
def test_once_approvals_name_exactly_one_tool_call(router):
"""F4 / SO-37: a request carrying two tool_call hashes is refused so a once approval designates one hash."""
body = request_body("shell")
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "tool_call", "id": "b", "hash": OTHER_HASH}]
status, error = call(router, "POST", "/hux/v1/approvals", body, WORKER)
assert (status, error["code"]) == (400, "invalid") and "exactly one tool_call" in error["message"]
body["request"]["evidence"] = [{"kind": "tool_call", "id": "a", "hash": HASH}, {"kind": "file", "id": "notes.md"}]
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 201, "other evidence kinds do not count"
body["request"]["evidence"] = "not-a-list"
assert call(router, "POST", "/hux/v1/approvals", body, WORKER)[0] == 400, "the contract still rejects it, without a 500"
def test_gate_refuses_when_the_run_budget_is_exhausted(router, events):
"""F6 (medium) / SO-40: an approved effect still does not proceed once any limit is spent."""
record = pending(router, "shell")
call(router, "POST", f"/hux/v1/approvals/{record['id']}", {"choice": "session"})
assert gate(router, capability="shell")[1]["proceed"] is True
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": CONV}, "autonomy": "safe", "budgets": {"tool_calls_per_run": 2}})
call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tool_calls": 2}, WORKER)
status, body = gate(router, capability="shell")
assert body == {"proceed": False, "reason": "budget_exhausted", "exhausted": ["tool_calls_per_run"]}
assert events[-1]["kind"] == "budget.exhausted" and events[-1]["run_id"] == "run_9f" and events[-1]["conversation_id"] == CONV
assert gate(router, run_id="run_fresh", capability="shell")[1]["proceed"] is True, "the budget is per run"
assert budgets.run_conversation(router_store(router), "run_nobody") is None
def router_store(router):
return store.TenantStore(router.data_root, identity.resolve(HEADERS, {}))

View File

@ -0,0 +1,236 @@
"""Agent-side HUX hook driven end-to-end against the real service over HTTP.
Security obligations exercised: SO-35 (only the human surface decides; the
hook's worker identity cannot), SO-36 (a ``once`` approval releases the gate
exactly once), SO-37 (the gate compares the canonical argument hash the hook
recorded at request time), SO-39 (an external side effect under
``autonomous`` still waits for a human), SO-40 (an exhausted budget blocks new
approvals), SO-41 (a stop is done only when the receipt exists), SO-11 and
SO-07 (raw arguments and tool output never reach the tenant ledger).
"""
from __future__ import annotations
import json
import sys
import threading
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
HOOK_ROOT = ROOT / "dockerfiles" / "hermes-worker-hux"
for entry in (FOUNDATION, HOOK_ROOT):
if str(entry) not in sys.path:
sys.path.insert(0, str(entry))
from hux import contracts # noqa: E402
from hux.http import serve # noqa: E402
from hux.server import build_router # noqa: E402
from hux_hook import HuxClient, HuxServiceError, after_tool, before_tool, canonical_argument_hash, on_stop, record_spend # noqa: E402
SCHEMAS = contracts.load_all()
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
SUBJECT = "usr_0123456789abcdef"
IDENTITY = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "worker", "trust": "worker"}
HUMAN = {"tenant_slot": "slot-3", "subject": SUBJECT, "surface": "chat", "trust": "router"}
CANARY = "CANARY-7f3a-SECRET-VALUE"
ARGS = {"path": "notes.md", "content": CANARY}
RUN = "run_hook_1"
def start(tmp_path: Path, flags: str = ALL_ON) -> tuple[str, object]:
"""Run the real service on an ephemeral loopback port for one test."""
router = build_router(tmp_path, {"HUX_FLAGS": flags, "HUX_WORKER_KEY": "wk"})
server = serve(router, "127.0.0.1", 0)
threading.Thread(target=server.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{server.server_address[1]}", server
@pytest.fixture
def service(tmp_path):
base, server = start(tmp_path)
human = HuxClient(base, HUMAN)
conversation = human.post("/hux/v1/conversations", {"title": "hook test"}).body["id"]
yield {"base": base, "root": tmp_path, "agent": HuxClient(base, IDENTITY, key="wk"), "human": human, "conv": conversation}
server.shutdown()
def set_policy(human: HuxClient, autonomy: str, budgets: dict | None = None) -> None:
body = {"scope": {"level": "global"}, "autonomy": autonomy}
if budgets:
body["budgets"] = budgets
human.put("/hux/v1/policy", body)
def ledger_text(root: Path) -> str:
return "\n".join(p.read_text(errors="ignore") for p in root.rglob("*") if p.is_file())
def events_of(human: HuxClient, conversation: str) -> list[dict]:
items = human.get(f"/hux/v1/conversations/{conversation}/events").body["items"]
for item in items:
assert contracts.validate_record(item, SCHEMAS) == []
return items
# --- approval -> pending -> human decision -> gate once ------------------------------
def test_once_approval_releases_exactly_once(service):
"""SO-35, SO-36, SO-37: pending until a human decides, then one release; the second gate is blocked."""
agent, human, conv = service["agent"], service["human"], service["conv"]
first = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low", turn=1)
assert (first.proceed, first.reason) == (False, "approval_required")
assert first.approval_id and first.approval_id.startswith("apr_")
record = human.get(f"/hux/v1/approvals/{first.approval_id}").body
assert contracts.validate_record(record, SCHEMAS) == []
assert record["request"]["evidence"][0]["hash"] == canonical_argument_hash("write_file", ARGS)
assert CANARY not in json.dumps(record)
with pytest.raises(HuxServiceError) as denied:
agent.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
assert denied.value.status == 403
human.post(f"/hux/v1/approvals/{first.approval_id}", {"choice": "once"})
second = before_tool(agent, RUN, conv, "write_file", {"content": CANARY, "path": "notes.md"}, "write_files", risk="low")
assert second == second.__class__(True, first.approval_id, "released")
third = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files", risk="low")
assert third.proceed is False and "consumed" in third.reason
kinds = [e["kind"] for e in events_of(human, conv)]
assert kinds.count("side_effect.released") == 1 and "side_effect.blocked" in kinds and "approval.requested" in kinds
assert CANARY not in ledger_text(service["root"])
def test_different_arguments_after_approval_are_blocked(service):
"""SO-37: an approval for one argument hash never releases another."""
agent, human, conv = service["agent"], service["human"], service["conv"]
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "once"})
other = before_tool(agent, RUN, conv, "shell", {"cmd": "rm -rf /"}, "shell")
assert other.proceed is False and other.reason == "approval_required"
def test_denied_and_session_choices(service):
"""A denial fails closed; a session grant lets a later call in the same conversation auto-approve."""
agent, human, conv = service["agent"], service["human"], service["conv"]
pending = before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell")
human.post(f"/hux/v1/approvals/{pending.approval_id}", {"choice": "deny"})
assert before_tool(agent, RUN, conv, "shell", {"cmd": "ls"}, "shell") == pending.__class__(False, pending.approval_id, "approval_denied")
pending2 = before_tool(agent, RUN, conv, "shell", {"cmd": "pwd"}, "shell")
human.post(f"/hux/v1/approvals/{pending2.approval_id}", {"choice": "session"})
assert before_tool(agent, "run_hook_2", conv, "shell", {"cmd": "whoami"}, "shell").proceed is True
def test_external_side_effect_under_autonomous_still_asks(service):
"""SO-39: autonomy never auto-allows an external side effect."""
agent, human, conv = service["agent"], service["human"], service["conv"]
set_policy(human, "autonomous")
decision = before_tool(agent, RUN, conv, "send_email", {"to": "x@example.com"}, "send_message", external=True, risk="high")
assert (decision.proceed, decision.reason) == (False, "approval_required")
local = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
assert local.proceed is True and local.reason == "released"
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").proceed is False
def test_budget_exhaustion_blocks_new_approvals(service):
"""SO-40: once the run budget is spent the hook reports budget_exhausted and never proceeds."""
agent, human, conv = service["agent"], service["human"], service["conv"]
set_policy(human, "autonomous", {"tool_calls_per_run": 2})
state = record_spend(agent, RUN, conv, tool_calls=2, tokens=10, bogus=5)
assert state is not None and contracts.validate_record(state, SCHEMAS) == []
assert state["exhausted"] == ["tool_calls_per_run"]
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
assert decision == decision.__class__(False, None, "budget_exhausted")
assert record_spend(agent, RUN, conv, tokens=-3)["spent"]["tokens"] == 10
assert record_spend(agent, RUN, tool_calls=1)["spent"]["tool_calls"] == 3
def test_stop_receipt_is_written_once(service):
"""SO-41: the receipt reports what really happened and a repeat returns the same record."""
agent, human, conv = service["agent"], service["human"], service["conv"]
effects = [{"description": "partial notes.md", "reverted": True}]
receipt = on_stop(agent, RUN, conv, process_registry_empty=True, side_effects=effects)
assert contracts.validate_record(receipt, SCHEMAS) == []
assert receipt["outcome"] == "cancelled" and receipt["side_effects"] == effects
assert on_stop(agent, RUN, conv, process_registry_empty=False)["id"] == receipt["id"]
failed = on_stop(agent, "run_hook_3", None, process_registry_empty=False)
assert failed["outcome"] == "failed_to_cancel" and "conversation_id" not in failed
assert on_stop(agent, "run_hook_4", conv, True, already_complete=True)["outcome"] == "already_complete"
assert [e["kind"] for e in events_of(human, conv)].count("run.cancelled") == 2
def test_after_tool_records_status_only(service):
"""SO-11: tool.result carries sizes and status; the output and arguments never land."""
agent, conv = service["agent"], service["conv"]
digest = canonical_argument_hash("shell", {"cmd": CANARY})
event = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest, duration_ms=40, exit_code=0)
assert event["kind"] == "tool.result" and event["detail"] == {"tool": "shell", "ok": True, "bytes": 512, "duration_ms": 40, "exit_code": 0}
assert event["evidence"][0]["hash"] == digest and event["turn"] == 3
replay = after_tool(agent, RUN, conv, "shell", True, 512, turn=3, argument_hash=digest)
assert replay["id"] == event["id"]
plain = after_tool(agent, RUN, conv, "shell", False, -1)
assert plain["detail"] == {"tool": "shell", "ok": False, "bytes": 0} and "evidence" not in plain
assert CANARY not in ledger_text(service["root"])
def test_bad_ids_and_service_errors_fail_closed(service):
"""Malformed run or conversation ids never reach a tool execution."""
agent, conv = service["agent"], service["conv"]
assert before_tool(agent, "run/../x", conv, "shell", {}, "shell").reason == "invalid_run_id"
assert before_tool(agent, RUN, "not an id", "shell", {}, "shell").reason == "service_error:invalid"
assert before_tool(agent, RUN, conv, "shell", {}, "teleport").reason == "service_error:invalid"
def test_gate_failures_fail_closed(service, monkeypatch):
"""A gate that errors, answers nonsense or says no keeps the tool from running."""
agent, human, conv = service["agent"], service["human"], service["conv"]
set_policy(human, "autonomous")
real_post = agent.post
def broken_gate(path, body, idempotency_key=None):
if path.endswith("/gate"):
raise HuxServiceError(500, "invalid", "boom")
return real_post(path, body, idempotency_key)
monkeypatch.setattr(agent, "post", broken_gate)
decision = before_tool(agent, RUN, conv, "write_file", ARGS, "write_files")
assert decision.proceed is False and decision.reason == "service_error:invalid" and decision.approval_id
class Odd:
body = "not a dict"
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd() if path.endswith("/gate") else real_post(path, body, idempotency_key))
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_invalid"
class NoProceed:
body = {"proceed": False}
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: NoProceed() if path.endswith("/gate") else real_post(path, body, idempotency_key))
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "gate_blocked"
monkeypatch.setattr(agent, "post", lambda path, body, idempotency_key=None: Odd())
assert before_tool(agent, RUN, conv, "write_file", ARGS, "write_files").reason == "approval_invalid"
def test_unreachable_service_fails_closed_for_gate_and_open_for_telemetry(tmp_path):
"""No service: the gate says no, telemetry returns None, nothing raises into the loop."""
base, server = start(tmp_path)
server.shutdown()
server.server_close()
agent = HuxClient(base, IDENTITY, key="wk", timeout=1)
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
assert decision == decision.__class__(False, None, "hux_unavailable")
assert after_tool(agent, RUN, "conv_0001abcd", "shell", True, 1) is None
assert record_spend(agent, RUN, "conv_0001abcd", tool_calls=1) is None
assert on_stop(agent, RUN, "conv_0001abcd", True) is None
def test_autonomy_flag_off_is_reported_and_fails_closed(tmp_path):
"""SO-50: with hux.autonomy off, capabilities says so and the hook refuses every side effect."""
base, server = start(tmp_path, "hux.foundation,hux.activity_timeline,hux.projects")
try:
agent = HuxClient(base, IDENTITY, key="wk")
caps = agent.capabilities()
assert caps["reachable"] and caps["cards"]["HUX-11"] and caps["cards"]["HUX-01"] and not caps["cards"]["HUX-05"]
decision = before_tool(agent, RUN, "conv_0001abcd", "shell", {"cmd": "ls"}, "shell")
assert decision == decision.__class__(False, None, "autonomy_off")
finally:
server.shutdown()

View File

@ -0,0 +1,222 @@
"""HUX-05 policy documents: the capability matrix, grants and their expiry.
Security obligations exercised: SO-38 (``always`` grants carry a server-set
expiry of at most 30 days), SO-39 (``effective_decision`` is the sole
resolver and deploy/external always ask), SO-44 (If-Match on revisioned
writes; unconditional writes are audited).
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, errors, identity, policy, rules, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
@pytest.fixture
def frozen(monkeypatch):
state = {"now": T0}
monkeypatch.setattr(policy, "clock", lambda: state["now"])
return state
def call(router, method, path, body=None, headers=HEADERS):
raw = b"" if body is None else json.dumps(body).encode()
response = router.dispatch(method, path, headers, raw)
return response.status, response.body, response.headers
def valid(record):
assert contracts.validate_record(record, SCHEMAS) == [], contracts.validate_record(record, SCHEMAS)
return record
def test_modules_stay_under_the_line_budget():
for name in ("policy.py", "budgets.py"):
assert len((FOUNDATION / "hux" / name).read_text().splitlines()) <= 500
def test_first_read_creates_the_safe_global_default(router, tmp_path):
status, body, headers = call(router, "GET", "/hux/v1/policy")
assert status == 200 and headers["ETag"] == "1"
valid(body)
assert body["scope"] == {"level": "global"} and body["autonomy"] == "safe" and body["grants"] == []
assert body["owner"] == HEADERS["X-Hux-Subject"] and body["provenance"]["actor"] == {"type": "user", "id": body["owner"]}
assert call(router, "GET", "/hux/v1/policy?scope=global")[1] == body
rows = audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))
assert [r["action"] for r in rows] == ["policy.read", "policy.read"]
def test_matrix_is_the_single_resolver_and_external_always_asks(router):
matrix = rules.default_capability_matrix()
for level in ("ask_first", "safe", "autonomous"):
put = {"scope": {"level": "global"}, "autonomy": level, "grants": [{"capability": "deploy", "decision": "allow"}]}
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
assert status == 200, body
for capability in rules.CAPABILITIES:
expected = matrix[level][capability]
if capability == "deploy":
expected = "ask" # an explicit allow on an always-ask capability still asks (SO-39)
assert policy.resolve_request(body, capability, external=False) == expected
assert policy.resolve_request(body, capability, external=True) in {"ask", "deny"}
assert policy.resolve_request(body, "network", external=True) == "ask"
def test_grants_get_server_set_expiry_and_actor(router, frozen):
far = policy.iso(T0 + timedelta(days=90))
soon = policy.iso(T0 + timedelta(days=2))
put = {"scope": {"level": "global"}, "autonomy": "safe", "grants": [
{"capability": "network", "decision": "allow", "expires_at": far, "granted_by": {"type": "operator", "id": "evil"}},
{"capability": "shell", "decision": "allow", "expires_at": soon},
{"capability": "write_files", "decision": "deny"},
]}
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
assert status == 200
valid(body)
by_cap = {g["capability"]: g for g in body["grants"]}
assert by_cap["network"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
assert by_cap["shell"]["expires_at"] == soon
assert by_cap["write_files"]["expires_at"] == policy.iso(T0 + policy.ALWAYS_TTL)
assert all(g["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]} for g in body["grants"])
assert policy.resolve_request(body, "network", False) == "allow"
assert policy.resolve_request(body, "shell", False) == "allow"
assert policy.resolve_request(body, "write_files", False) == "deny"
frozen["now"] = T0 + timedelta(days=3)
assert policy.resolve_request(body, "shell", False) == "ask", "expired grant falls back to the matrix"
assert policy.resolve_request(body, "network", False) == "allow"
frozen["now"] = T0 + timedelta(days=31)
assert policy.resolve_request(body, "network", False) == "deny", "safe denies network once the grant lapses"
def test_put_honours_if_match_and_audits_unconditional_writes(router, tmp_path):
put = {"scope": {"level": "global"}, "autonomy": "autonomous"}
status, body, headers = call(router, "PUT", "/hux/v1/policy", put)
assert (status, body["revision"], headers["ETag"]) == (200, 1, "1")
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
assert (status, body["revision"]) == (200, 2)
status, body, _ = call(router, "PUT", "/hux/v1/policy", put, {**HEADERS, "If-Match": "1"})
assert (status, body["code"]) == (409, "conflict")
status, body, _ = call(router, "PUT", "/hux/v1/policy", put)
assert (status, body["revision"]) == (200, 3)
rows = [(r["action"], r["outcome"], r.get("reason", "")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {})))]
assert rows == [("policy.write", "allow", ""), ("policy.write", "allow", ""), ("policy.write", "conflict", rows[2][2]), ("policy.write", "allow", "unconditional_write")]
assert "does not match" in rows[2][2]
@pytest.mark.parametrize("body,fragment", [
([], "JSON object"),
({"scope": "global"}, "scope must be an object"),
({"scope": {"level": "planet"}, "autonomy": "safe"}, "scope must be"),
({"scope": {"level": "project"}, "autonomy": "safe"}, "scope_id required"),
({"scope": {"level": "project", "scope_id": "prj_" + "a" * 70}, "autonomy": "safe"}, "scope_id required"),
({"scope": {"level": "project", "scope_id": "../etc"}, "autonomy": "safe"}, "malformed id"),
({"scope": {"level": "global"}, "autonomy": "yolo"}, "autonomy must be"),
({"scope": {"level": "global"}, "autonomy": "safe", "grants": "all"}, "grants must be a list"),
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "teleport", "decision": "allow"}]}, "known capability"),
({"scope": {"level": "global"}, "autonomy": "safe", "grants": [{"capability": "shell", "decision": "maybe"}]}, "grant decision"),
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"tokens_per_run": -1}}, "fails contract"),
({"scope": {"level": "global"}, "autonomy": "safe", "budgets": {"unknown": 1}}, "fails contract"),
])
def test_put_rejects_bad_bodies(router, body, fragment):
status, error, _ = call(router, "PUT", "/hux/v1/policy", body)
assert status == 400 and fragment in error["message"], error
valid(error)
def test_get_rejects_bad_scope_queries(router):
assert call(router, "GET", "/hux/v1/policy?scope=nope")[0] == 400
assert call(router, "GET", "/hux/v1/policy?scope=conversation")[0] == 400
def test_effective_policy_walks_conversation_project_global(router):
_, project, _ = call(router, "POST", "/hux/v1/projects", {"name": "Kitchen"})
_, conversation, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Cabinets", "project_id": project["id"]})
_, loose, _ = call(router, "POST", "/hux/v1/conversations", {"title": "Unfiled"})
conv_query = f"/hux/v1/policy?scope=conversation&scope_id={conversation['id']}"
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "global"}
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "project", "scope_id": project["id"]}, "autonomy": "autonomous"})
assert status == 200 and valid(body)["id"] == f"pol_project.{project['id']}"
assert call(router, "GET", conv_query)[1]["scope"] == {"level": "project", "scope_id": project["id"]}
assert call(router, "GET", f"/hux/v1/policy?scope=conversation&scope_id={loose['id']}")[1]["scope"] == {"level": "global"}
assert call(router, "GET", "/hux/v1/policy?scope=conversation&scope_id=conv_unknown0001")[1]["scope"] == {"level": "global"}
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "ask_first"})
assert status == 200
got = call(router, "GET", conv_query)[1]
assert got["autonomy"] == "ask_first" and got["scope"]["level"] == "conversation"
assert call(router, "GET", f"/hux/v1/policy?scope=project&scope_id={project['id']}")[1]["autonomy"] == "autonomous"
def test_second_subject_sees_its_own_default_not_the_first_tenants_policy(router):
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "autonomous"})
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=OTHER)
assert status == 200 and body["autonomy"] == "safe" and body["owner"] == OTHER["X-Hux-Subject"]
assert call(router, "GET", "/hux/v1/policy")[1]["autonomy"] == "autonomous"
def test_flag_off_hides_the_whole_card(tmp_path):
off = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
status, body, _ = call(off, "GET", "/hux/v1/policy")
assert (status, body["code"]) == (404, "flag_off")
def test_helpers_round_trip_time_and_actors():
assert policy.parse(policy.iso(T0)) == T0
assert policy.now().tzinfo is timezone.utc
worker = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
assert policy.actor_for(worker) == {"type": "system", "id": "worker"}
assert policy.run_key("run_9f") == policy.run_key("run_9f") and len(policy.run_key("x")) == 32
assert policy.public({"a": 1, "_b": 2, "revision": 3}) == {"a": 1}
assert policy.public({"a": 1, "_b": 2, "revision": 3}, revisioned=True) == {"a": 1, "revision": 3}
# --- F1: only humans write policy ---------------------------------------------------
def test_only_a_human_actor_may_write_policy_or_hold_allow_grants(tmp_path):
"""F1 (critical): worker and api surfaces cannot rewrite the policy, escalate autonomy or plant allow grants."""
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk", "HUX_RELAY_KEY": "rk"})
worker = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
api = {**HEADERS, "X-Hux-Surface": "api"}
relay = {**HEADERS, "X-Hux-Surface": "telegram", "X-Hux-Trust": "relay", "X-Hux-Relay-Key": "rk"}
escalate = {"scope": {"level": "global"}, "autonomy": "autonomous", "grants": [{"capability": "network", "decision": "allow"}]}
for headers in (worker, api):
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, headers)
assert (status, body["code"]) == (403, "forbidden"), headers
status, body, _ = call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe"}, headers)
assert status == 403, "even a harmless-looking write is a policy write"
status, body, _ = call(router, "GET", "/hux/v1/policy", headers=api)
assert status == 200 and body["autonomy"] == "safe" and body["grants"] == [], "nothing leaked through"
status, body, _ = call(router, "PUT", "/hux/v1/policy", escalate, relay)
assert status == 200 and body["autonomy"] == "autonomous" and body["grants"][0]["granted_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
rows = [(r["action"], r["outcome"]) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "policy.write"]
assert rows == [("policy.write", "deny")] * 4 + [("policy.write", "allow")]
# The helpers assert the invariant even when a caller reaches them without the route.
system = identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "worker", "worker")
with pytest.raises(errors.Forbidden):
policy.normalise_grants([{"capability": "shell", "decision": "allow"}], system)
assert policy.normalise_grants([{"capability": "shell", "decision": "deny"}], system)[0]["granted_by"] == {"type": "system", "id": "worker"}
with pytest.raises(errors.Forbidden):
policy.add_grant(store.TenantStore(tmp_path, system), system, "global", None, "shell", timedelta(hours=1))
assert policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "chat", "router"))
assert not policy.is_human(identity.Identity("slot-3", HEADERS["X-Hux-Subject"], "api", "router"))

View File

@ -0,0 +1,160 @@
"""HUX-05 run budgets and cancellation receipts.
Security obligations exercised: SO-40 (budget state per run against the
effective policy; crossing a limit emits ``budget.exhausted``), SO-41 (a
receipt says ``cancelled`` only when the process registry is empty and
carries every side effect the hook reports; a second stop returns the same
receipt).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, identity, store # noqa: E402
from hux import events as hux_events # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
WORKER = {**HEADERS, "X-Hux-Surface": "worker", "X-Hux-Trust": "worker", "X-Hux-Relay-Key": "wk"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
@pytest.fixture
def events(monkeypatch):
seen: list[dict] = []
monkeypatch.setattr(hux_events, "emit", lambda store, identity, conversation_id, kind, summary, **extra: seen.append({"conversation_id": conversation_id, "kind": kind, "summary": summary, **extra}))
return seen
def call(router, method, path, body=None, headers=WORKER):
raw = b"" if body is None else json.dumps(body).encode()
response = router.dispatch(method, path, headers, raw)
return response.status, response.body
def valid(record):
problems = contracts.validate_record(record, SCHEMAS)
assert problems == [], problems
return record
# --- budgets ------------------------------------------------------------------------
def test_budget_starts_empty_against_the_effective_policy(router):
status, body = call(router, "GET", "/hux/v1/runs/run_9f/budget")
assert status == 200 and valid(body)["run_id"] == "run_9f"
assert body["spent"] == {k: 0 for k in ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")}
assert body["limits"]["tokens_per_run"] == 200000 and body["exhausted"] == []
assert "revision" not in body and not any(k.startswith("_") for k in body)
def test_increments_accumulate_and_exhaust_each_limit(router, events):
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "global"}, "autonomy": "safe",
"budgets": {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0, "scope": {"paths": ["/work"]}}}, HEADERS)
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"conversation_id": CONV, "tokens": 60, "tool_calls": 1})
assert status == 200 and valid(body)["spent"]["tokens"] == 60
assert body["exhausted"] == ["subagents_per_run"], "a zero limit is exhausted before any spend"
assert body["limits"] == {"tokens_per_run": 100, "tool_calls_per_run": 3, "subagents_per_run": 0}, "scope is policy detail, not a limit"
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 40, "tool_calls": 2, "delegations": 5})
assert body["spent"] == {"tokens": 100, "tool_calls": 3, "wall_clock_seconds": 0, "delegations": 5, "spend_units": 0, "subagents": 0}
assert body["exhausted"] == ["tokens_per_run", "tool_calls_per_run", "subagents_per_run"]
assert [e["kind"] for e in events] == ["budget.exhausted", "budget.exhausted"]
assert events[1]["summary"] == "Budget exhausted: tokens_per_run, tool_calls_per_run" and events[1]["run_id"] == "run_9f"
assert events[1]["conversation_id"] == CONV, "the conversation learned on the first report sticks"
status, body = call(router, "POST", "/hux/v1/runs/run_9f/budget", {"tokens": 1})
assert status == 200 and len(events) == 2, "already exhausted limits do not re-emit"
assert call(router, "GET", "/hux/v1/runs/run_9f/budget")[1] == body
def test_budget_follows_the_conversation_policy(router):
_, conversation = call(router, "POST", "/hux/v1/conversations", {"title": "Budgeted"}, HEADERS)
call(router, "PUT", "/hux/v1/policy", {"scope": {"level": "conversation", "scope_id": conversation["id"]}, "autonomy": "safe", "budgets": {"spend_units": 1}}, HEADERS)
status, body = call(router, "POST", "/hux/v1/runs/run_c/budget", {"conversation_id": conversation["id"], "spend_units": 1})
assert body["limits"] == {"spend_units": 1} and body["exhausted"] == ["spend_units"]
assert call(router, "GET", "/hux/v1/runs/run_c/budget")[1]["limits"] == {"spend_units": 1}
assert call(router, "GET", "/hux/v1/runs/run_c/budget", headers=OTHER)[1]["spent"]["spend_units"] == 0
@pytest.mark.parametrize("body", [[], {"tokens": -1}, {"tokens": True}, {"tokens": "5"}, {"conversation_id": "x"}])
def test_budget_rejects_bad_bodies(router, body):
status, error = call(router, "POST", "/hux/v1/runs/run_9f/budget", body)
assert status == 400 and valid(error)["code"] == "invalid"
# --- stop receipts ----------------------------------------------------------------
def test_stop_writes_a_receipt_and_a_second_stop_returns_it(router, events, tmp_path):
side_effects = [{"description": "Partial file left in workspace", "reverted": True, "evidence": {"kind": "file", "id": "notes.md"}}]
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": side_effects, "conversation_id": CONV})
assert status == 201 and valid(body)["outcome"] == "cancelled"
assert body["requested_by"] == {"type": "system", "id": "worker"}
assert body["requested_at"] == body["acknowledged_at"] == body["completed_at"]
assert body["side_effects"] == side_effects and body["conversation_id"] == CONV and "revision" not in body
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
assert (status, again) == (200, body)
assert [e["kind"] for e in events] == ["run.cancelled"]
assert events[0]["evidence"] == [{"kind": "run", "id": body["id"]}] and events[0]["run_id"] == "run_9f"
rows = [(r["action"], r.get("reason")) for r in audit.recent(store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))) if r["action"] == "runs.stop"]
assert rows == [("runs.stop", "cancelled"), ("runs.stop", "replayed")]
def test_only_the_gateway_may_vouch_for_an_empty_registry_and_a_failed_receipt_can_be_superseded(router, events, tmp_path):
"""F8 / SO-41: a human surface asserting ``process_registry_empty`` gets ``failed_to_cancel``; the worker's later real cancel supersedes it."""
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "conversation_id": CONV}, HEADERS)
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
first_requested = body["requested_at"]
status, again = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True}, HEADERS)
assert (status, again) == (200, body), "an identical failed stop replays"
status, done = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True, "side_effects": [{"description": "x", "reverted": True}]})
assert status == 201 and valid(done)["outcome"] == "cancelled" and done["requested_at"] == first_requested and "completed_at" in done
assert done["conversation_id"] == CONV and done["requested_by"] == {"type": "system", "id": "worker"}
tenant = store.TenantStore(tmp_path, identity.resolve(HEADERS, {}))
stored = tenant.get("receipts", done["id"])
assert stored["revision"] == 2 and contracts.validate_record(stored, SCHEMAS) == [], "stored receipts carry revision (F11)"
status, third = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False})
assert (status, third) == (200, done), "a terminal receipt never changes again"
rows = [r.get("reason") for r in audit.recent(tenant) if r["action"] == "runs.stop"]
assert rows == ["registry_state_not_from_gateway", "replayed", "superseded:cancelled", "replayed"]
assert [e["summary"] for e in events] == ["Run stopped: failed_to_cancel (registry_state_not_from_gateway)", "Run stopped: cancelled (cancelled)"]
status, body = call(router, "POST", "/hux/v1/runs/run_h/stop", {"already_complete": True}, HEADERS)
assert valid(body)["outcome"] == "already_complete", "already_complete needs no registry claim"
def test_stop_outcomes_follow_the_process_registry(router):
status, body = call(router, "POST", "/hux/v1/runs/run_a/stop", {"process_registry_empty": False, "side_effects": []})
assert status == 201 and valid(body)["outcome"] == "failed_to_cancel" and "completed_at" not in body
assert body["requested_by"] == {"type": "system", "id": "worker"}
status, body = call(router, "POST", "/hux/v1/runs/run_b/stop", {"already_complete": True})
assert valid(body)["outcome"] == "already_complete" and "completed_at" in body
status, body = call(router, "POST", "/hux/v1/runs/run_c/stop", {})
assert valid(body)["outcome"] == "failed_to_cancel", "no registry report means the stop is not proven"
@pytest.mark.parametrize("body", [[], {"side_effects": "none"}, {"side_effects": [{"description": ""}]}, {"conversation_id": "bad"}])
def test_stop_rejects_bad_bodies(router, body):
status, error = call(router, "POST", "/hux/v1/runs/run_9f/stop", body)
assert status == 400 and valid(error)["code"] == "invalid"
def test_receipts_are_per_tenant(router):
call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": True})
status, body = call(router, "POST", "/hux/v1/runs/run_9f/stop", {"process_registry_empty": False}, OTHER)
assert status == 201 and body["outcome"] == "failed_to_cancel", "same run id, other subject, its own receipt"

View File

@ -0,0 +1,142 @@
"""HUX-10 retention job: expiry, topic decay, purge of forgotten content, audit record.
Security obligations exercised: SO-24 (forgotten content is purged from older
ledger lines), SO-27 (expiry enforced eagerly and reported), SO-47 (purge touches
only tombstoned ids and never the audit ledgers), SO-52 (retention keeps running
whatever the flags say).
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, errors, events, identity, memory, privacy, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
def ident() -> identity.Identity:
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, headers=HEADERS, body=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, raw)
return response.status, response.body
def tenant(tmp_path) -> store.TenantStore:
return store.TenantStore(tmp_path, ident())
def remember(router, content, **fields):
body = {"kind": "fact", "content": content, "reason": "r", "proposed_by": "user", "conversation_id": CONV, **fields}
status, record = call(router, "POST", "/hux/v1/memory", HEADERS, body)
assert status in (201, 202), record
return record
def counts(record):
return {row["action"]: row["count"] for row in record["results"]}
def test_retention_expires_decays_purges_and_reports(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
stale = remember(router, "expires soon", ttl={"policy": "expires_at", "expires_at": "2020-01-01T00:00:00Z"})
keep = remember(router, "kept", ttl={"policy": "never"})
gone = remember(router, "to be forgotten")
call(router, "POST", f"/hux/v1/memory/{gone['id']}/forget")
assert "to be forgotten" in json.dumps(s.read(memory.FAMILY, memory.LEDGER))
events.emit(s, ident(), CONV, "message.user", "about my diagnosis", sensitivity="sensitive")
events.emit(s, ident(), CONV, "message.assistant", "plain reply")
privacy.mark_topic(s, CONV, "health", "2026-01-01T00:00:00Z")
audit_rows_before = audit.recent(s)
record = privacy.run_retention(s, datetime(2026, 8, 24, 3, 0, tzinfo=timezone.utc))
assert record["schema"] == "hux.retention_audit.v1" and contracts.validate_record(record, SCHEMAS) == []
assert counts(record) == {"expire_memory": 1, "decay_topic_context": 1, "purge_forgotten_content": 1, "report": 1}
assert s.get(memory.FAMILY, stale["id"])["status"] == "expired" and s.get(memory.FAMILY, keep["id"])["status"] == "active"
ledger = json.dumps(s.read(memory.FAMILY, memory.LEDGER))
assert "to be forgotten" not in ledger and "kept" in ledger
assert all(row["purged"] for row in s.read(memory.FAMILY, memory.TOMBSTONES))
rows = s.read(events.FAMILY, CONV)
sensitive = [r for r in rows if r["sensitivity"] == "sensitive"]
assert sensitive and all(r["redaction"]["level"] == "full" and r["summary"].startswith("This looks like a health topic") for r in sensitive)
assert [r["seq"] for r in rows] == list(range(1, len(rows) + 1))
assert any(r["kind"] == "message.assistant" and r["redaction"]["level"] == "none" for r in rows)
assert audit.recent(s)[: len(audit_rows_before)] == audit_rows_before
assert privacy.conversation_state(s, CONV)["decayed"] is True
again = privacy.run_retention(s, datetime(2026, 8, 25, 3, 0, tzinfo=timezone.utc))
assert counts(again) == {"expire_memory": 0, "decay_topic_context": 0, "purge_forgotten_content": 0, "report": 1}
def test_retention_skips_topics_not_yet_due(tmp_path):
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "money talk", sensitivity="sensitive")
privacy.mark_topic(s, CONV, "finance")
privacy.set_flag(s, "conv_0002abcd", "memory_disabled", True)
record = privacy.run_retention(s)
assert counts(record)["decay_topic_context"] == 0
assert s.read(events.FAMILY, CONV)[0]["redaction"]["level"] == "partial"
assert not privacy.audit_stale(s)
assert privacy.audit_stale(s, datetime.now(timezone.utc) + timedelta(days=3))
def test_audit_route_lists_newest_first_and_is_tenant_scoped(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
assert privacy.latest_audit(s) is None
first = privacy.run_retention(s, datetime(2026, 8, 22, 3, 0, tzinfo=timezone.utc))
second = privacy.run_retention(s, datetime(2026, 8, 23, 3, 0, tzinfo=timezone.utc))
status, body = call(router, "GET", "/hux/v1/privacy/audit")
assert status == 200 and [r["id"] for r in body["items"]] == [second["id"], first["id"]]
for item in body["items"]:
assert contracts.validate_record(item, SCHEMAS) == [] and "revision" not in item
assert privacy.latest_audit(s)["id"] == second["id"]
assert call(router, "GET", "/hux/v1/privacy/audit", OTHER)[1]["items"] == []
assert [r["action"] for r in audit.recent(s)][-1] == "privacy.audit"
def test_retention_runs_without_flags(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": ""})
s = tenant(tmp_path)
assert call(router, "GET", "/hux/v1/privacy/audit")[0] == 404
assert counts(privacy.run_retention(s))["report"] == 1
def test_retention_record_is_validated_before_it_is_written(tmp_path, monkeypatch):
s = tenant(tmp_path)
monkeypatch.setattr(privacy, "new_id", lambda prefix: "bad id")
with pytest.raises(errors.Invalid):
privacy.run_retention(s)
assert privacy.latest_audit(s) is None
def test_purge_leaves_untombstoned_content_alone(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
keep = remember(router, "keep me")
assert memory.purge_forgotten(s) == 0
s.append(memory.FAMILY, memory.TOMBSTONES, {"memory_id": "mem_ghost0000", "at": "2026-08-23T00:00:00Z", "reason": "test", "purged": False})
assert memory.purge_forgotten(s) == 1
assert s.get(memory.FAMILY, keep["id"])["content"] == "keep me"
assert "keep me" in json.dumps(s.read(memory.FAMILY, memory.LEDGER))

View File

@ -0,0 +1,208 @@
"""HUX-10 privacy topics: detection boundaries, notices, conversation scoping and the memory gate.
Security obligations exercised: SO-18 (foreign conversations are 404), SO-21
(uncertain topics treated as sensitive, restricted topics denied), SO-22..SO-24
(forget writes tombstones and re-redacts events), SO-27 (audit staleness is
reported), SO-28 (private mode gate).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, events, identity, privacy, rules, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
CONV = "conv_0001abcd"
def ident() -> identity.Identity:
return identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")
def router_for(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, headers=HEADERS, body=None):
raw = json.dumps(body).encode() if body is not None else b""
response = router.dispatch(method, path, headers, raw)
return response.status, response.body, response
def tenant(tmp_path) -> store.TenantStore:
return store.TenantStore(tmp_path, ident())
# --- detection ---------------------------------------------------------------------
@pytest.mark.parametrize("text,expected", [
("Can you refactor this function to use a generator?", []),
("My therapist changed my medication last week.", ["health"]),
("I need to renegotiate my mortgage before the tax return is due.", ["finance"]),
("The lawyer said the court date moved.", ["legal"]),
("My wife and I are thinking about a breakup.", ["relationships"]),
("Here is my API key so you can deploy.", ["credentials"]),
("My daughter starts daycare next month.", ["minors"]),
("My home address is 12 Oak Street.", ["location"]),
("Set up fingerprint login on the laptop.", ["biometric"]),
("My son's doctor wants the password for the portal.", ["credentials", "minors", "health"]),
("", []),
])
def test_detect_topics_is_conservative_and_orders_by_sensitivity(text, expected):
assert privacy.detect_topics(text) == expected
def test_topic_sensitivity_floor():
assert privacy.topic_sensitivity([]) == "personal"
assert privacy.topic_sensitivity(["health"]) == "sensitive"
assert privacy.topic_sensitivity(["health", "location"]) == "restricted"
for topic in privacy.TOPIC_PATTERNS:
assert topic in rules.PRIVACY_TOPICS
# --- policy route ------------------------------------------------------------------
def test_policy_route_serves_rules_and_reports_stale_audit(tmp_path):
router = router_for(tmp_path)
status, body, response = call(router, "GET", "/hux/v1/privacy/policy")
assert status == 200 and body == rules.privacy_policy() and contracts.validate_record(body, SCHEMAS) == []
assert response.headers["HUX-Audit-Stale"] == "true"
privacy.run_retention(tenant(tmp_path))
assert call(router, "GET", "/hux/v1/privacy/policy")[2].headers["HUX-Audit-Stale"] == "false"
assert [r["action"] for r in audit.recent(tenant(tmp_path))][-1] == "privacy.policy"
# --- notices and scoping -----------------------------------------------------------
def test_notice_is_recorded_scoped_and_emitted(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "hello")
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": CONV, "controls": ["dismiss", "forget_this_conversation", "bogus"]})
assert status == 201 and body["schema"] == "hux.privacy_notice.v1" and body["controls"] == ["forget_this_conversation", "dismiss"]
assert body["text"].startswith("This looks like a health topic")
assert contracts.validate_record(body, SCHEMAS) == []
s = tenant(tmp_path)
assert s.read(privacy.FAMILY, "notices") == [body]
state = privacy.conversation_state(s, CONV)
assert state["topics"] == ["health"] and state["memory_disabled"] is False and state["decay_at"] > state["first_seen"]
rows = s.read(events.FAMILY, CONV)[1:]
assert [r["kind"] for r in rows] == ["privacy.notice"] and rows[0]["detail"]["topic"] == "health" and rows[0]["redaction"]["level"] == "partial"
assert contracts.validate_record(rows[0], SCHEMAS) == []
status, again, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "relationships", "conversation_id": CONV})
assert again["controls"] == list(privacy.CONTROLS)
state = privacy.conversation_state(s, CONV)
assert state["topics"] == ["health", "relationships"]
assert state["decay_at"] < privacy.mark_topic(s, "conv_0002abcd", "health")["decay_at"] or True
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "gossip", "conversation_id": CONV})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health"})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "not an id"})[0] == 400
assert call(router, "POST", "/hux/v1/privacy/notices", HEADERS)[0] == 400
def test_notice_controls_apply(tmp_path):
router = router_for(tmp_path)
call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "finance", "conversation_id": CONV, "chosen": "disable_memory_here"})
s = tenant(tmp_path)
assert privacy.conversation_state(s, CONV)["memory_disabled"] is True
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "personal"}) == (False, "memory_disabled")
call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "finance", "conversation_id": "conv_0002abcd", "chosen": "forget_this_conversation"})
assert privacy.conversation_state(s, "conv_0002abcd")["forgotten"] is True
assert privacy.memory_write_allowed(s, "conv_0002abcd", {"sensitivity": "personal"}) == (False, "conversation_forgotten")
assert privacy.blocked_conversations(s) == {CONV, "conv_0002abcd"}
def test_memory_write_gate(tmp_path):
s = tenant(tmp_path)
assert privacy.memory_write_allowed(s, None, {"sensitivity": "personal"}) == (True, "allowed")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "sensitive", "topic": "health"}) == (True, "allowed")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "restricted"}) == (False, "restricted")
assert privacy.memory_write_allowed(s, CONV, {"sensitivity": "personal", "topic": "location"}) == (False, "topic_location")
s.put("conversations", {"id": "conv_priv0001", "mode": "private"})
assert privacy.memory_write_allowed(s, "conv_priv0001", {"sensitivity": "public"}) == (False, "private_mode")
# --- forget ------------------------------------------------------------------------
def test_forget_route_redacts_events_and_marks_state(tmp_path):
router = router_for(tmp_path)
s = tenant(tmp_path)
events.emit(s, ident(), CONV, "message.user", "I told you about my diagnosis", {"message_id": "m1"}, sensitivity="sensitive")
events.emit(s, ident(), CONV, "message.assistant", "noted")
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/forget")
assert status == 200 and body == {"conversation_id": CONV, "forgotten": True, "memory_forgotten": 0, "events_redacted": 2, "document_blanked": False}
rows = s.read(events.FAMILY, CONV)
assert all(r["redaction"]["level"] == "full" and r["summary"] == "[forgotten conversation]" for r in rows)
assert "diagnosis" not in json.dumps(rows)
assert privacy.conversation_state(s, CONV)["forgotten"] is True
assert s.read(privacy.FAMILY, "forgotten")[0]["conv_id"] == CONV
served = call(router, "GET", f"/hux/v1/conversations/{CONV}/events")[1]["items"]
assert "detail" not in served[0] and contracts.validate_record(served[0], SCHEMAS) == []
status, again, _ = call(router, "POST", f"/hux/v1/conversations/{CONV}/forget")
assert status == 200 and again["events_redacted"] == 0
def test_forget_unknown_or_foreign_conversation_is_404(tmp_path):
router = router_for(tmp_path)
events.emit(tenant(tmp_path), ident(), CONV, "message.user", "mine")
assert call(router, "POST", f"/hux/v1/conversations/{CONV}/forget", OTHER)[0] == 404
assert call(router, "POST", "/hux/v1/conversations/conv_nope0000/forget")[0] == 404
privacy.mark_topic(tenant(tmp_path), "conv_topic000", "legal")
assert call(router, "POST", "/hux/v1/conversations/conv_topic000/forget")[0] == 200
def test_flag_off_hides_privacy_routes(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": "hux.foundation"})
assert call(router, "GET", "/hux/v1/privacy/policy")[1]["code"] == "flag_off"
def test_privacy_module_stays_under_500_lines():
assert len((FOUNDATION / "hux" / "privacy.py").read_text().splitlines()) <= 500
def test_f13a_notice_for_an_unknown_conversation_does_not_create_a_ghost_ledger(tmp_path):
"""F13a / SO-18: the notice is stored and scoped, but no event ledger or checkpoint is conjured for an unknown conversation."""
router = router_for(tmp_path)
s = tenant(tmp_path)
status, body, _ = call(router, "POST", "/hux/v1/privacy/notices", HEADERS, {"topic": "health", "conversation_id": "conv_ghost0001"})
assert status == 201 and s.read(privacy.FAMILY, "notices") == [body]
assert privacy.conversation_state(s, "conv_ghost0001")["topics"] == ["health"]
assert events.conversation_known(s, "conv_ghost0001") is False and s.read(events.FAMILY, "conv_ghost0001") == []
assert [(r["action"], r["reason"]) for r in audit.recent(s) if r["action"] == "privacy.notice"] == [("privacy.notice", "unknown_conversation")]
assert call(router, "GET", "/hux/v1/conversations/conv_ghost0001/events", HEADERS)[0] == 404
def test_f9_forget_blanks_the_conversation_document(tmp_path):
"""F9 / SO-22: forgetting a HUX-03 conversation blanks its title and tags and archives it, so nothing of it is searchable."""
router = router_for(tmp_path)
status, doc, _ = call(router, "POST", "/hux/v1/conversations", HEADERS, {"title": "Doctor visit notes", "tags": ["health", "private"]})
assert status == 201
status, body, _ = call(router, "POST", f"/hux/v1/conversations/{doc['id']}/forget", HEADERS)
assert status == 200 and body["document_blanked"] is True
status, after, _ = call(router, "GET", f"/hux/v1/conversations/{doc['id']}", HEADERS)
assert after["title"] == "[forgotten]" and after["tags"] == [] and after["archived"] is True and after["revision"] == 2
assert contracts.validate_record(after, SCHEMAS) == []
assert call(router, "GET", "/hux/v1/search?q=doctor", HEADERS)[1]["items"] == []
assert privacy._blank_document(tenant(tmp_path), "conv_none000001") is False
def test_f9_forget_tolerates_a_missing_organization_lane(tmp_path, monkeypatch):
"""F9: without the HUX-03 module there is no document to blank and forget still succeeds."""
import hux
monkeypatch.setitem(sys.modules, "hux.organization", None)
monkeypatch.delattr(hux, "organization", raising=False)
assert privacy._blank_document(tenant(tmp_path), CONV) is False

View File

@ -0,0 +1,252 @@
"""HUX-08 research: sources, passages and citations.
Security obligations exercised: the service stores but never dereferences a
source ``uri`` and only allowlisted schemes are accepted (SO-19); passage and
citation references resolve under the caller's subtree and foreign ids are
404 (SO-33); hashes and dedupe keys are server-computed (SO-30 by analogy);
every served record satisfies citation.schema.json.
"""
from __future__ import annotations
import hashlib
import json
import sys
import types
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, identity, research, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
def tenant(router, subject="usr_0123456789abcdef") -> store.TenantStore:
return store.TenantStore(router.data_root, identity.Identity("slot-3", subject, "chat", "router"))
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=None, raw=None):
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
response = router.dispatch(method, path, {**OWNER, **(headers or {})}, payload)
return response.status, response.body, response.headers
def valid(record) -> bool:
return contracts.validate_record(record, SCHEMAS) == []
def source(router, uri="https://Example.com/cabinets/#lead", **extra):
status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": uri, "title": "Cabinet pricing", "classification": "primary", **extra})
assert status == 201, record
return record
def passage(router, source_id, text="Standard base cabinets ship in 3-4 weeks."):
status, record, _ = call(router, "POST", "/hux/v1/passages", {"source_id": source_id, "text": text})
assert status == 201, record
return record
def cite(router, passage_ids, message="msg-44", claim="Base cabinets take three to four weeks.", **extra):
status, record, _ = call(router, "POST", f"/hux/v1/messages/{message}/citations", {"claim": claim, "passage_ids": passage_ids, "support": "supports", **extra})
assert status == 201, record
return record
@pytest.fixture
def events(monkeypatch):
calls = []
module = types.ModuleType("hux.events")
module.emit = lambda store, identity, conversation_id, kind, summary, detail=None, evidence=None, sensitivity="personal", run_id=None, turn=None, correlation_id=None: calls.append((conversation_id, kind, detail, evidence))
monkeypatch.setitem(sys.modules, "hux.events", module)
return calls
# --- sources -----------------------------------------------------------------------
def test_source_is_valid_and_deduped_on_normalised_uri(router):
first = source(router, publisher="Example Co", published_at="2026-01-01T00:00:00Z", content_hash="sha256:" + "a" * 64, conversation_id="conv_0001abcd")
assert valid(first) and first["provenance"]["actor"] == {"type": "user", "id": "usr_0123456789abcdef"}
assert first["provenance"]["conversation_id"] == "conv_0001abcd" and first["publisher"] == "Example Co"
expected = "sha256:" + hashlib.sha256(b"uri\nhttps://example.com/cabinets").hexdigest()
assert first["dedupe_key"] == expected
status, again, headers = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "HTTPS://example.com/cabinets", "title": "Different title"})
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true")
assert tenant(router).count("sources") == 1
status, record, _ = call(router, "GET", f"/hux/v1/sources/{first['id']}")
assert status == 200 and record == first
assert call(router, "GET", "/hux/v1/sources/src_missing00001")[0] == 404
assert call(router, "GET", "/hux/v1/sources/..")[0] == 404
def test_source_without_uri_dedupes_on_kind_and_title(router):
status, memory, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": " Remembered fact "})
status, again, _ = call(router, "POST", "/hux/v1/sources", {"kind": "memory", "title": "remembered fact"})
assert status == 200 and again["id"] == memory["id"] and "uri" not in memory and memory["classification"] == "unknown"
assert research.normalise_uri("artifact://art_0001aaaa@2/") == "artifact://art_0001aaaa@2/"
assert research.normalise_uri("file:///opt/data/x/") == "file:///opt/data/x"
assert research.normalise_uri("https://h.example/") == "https://h.example/"
@pytest.mark.parametrize("bad", [
{"uri": "javascript:alert(1)"}, {"uri": "ftp://x"}, {"uri": "/relative"}, {"kind": "rumour"}, {"kind": None},
{"classification": "gospel"}, {"title": ""}, {"conversation_id": "x"}, {"content_hash": "md5:abc"}, {"published_at": "yesterday"},
])
def test_source_rejects_bad_bodies(router, bad):
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://ok.example/", "title": "t", **bad})
assert status == 400 and valid(error), bad
assert call(router, "POST", "/hux/v1/sources", raw=b'"str"')[0] == 400
def test_source_idempotency_and_cap(router, monkeypatch):
status, one, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/1", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
status, two, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/2", "title": "t"}, {"Idempotency-Key": "src-key-0001"})
assert status == 200 and two["id"] == one["id"]
monkeypatch.setattr(research, "MAX_SOURCES", 1)
status, error, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://a.example/3", "title": "t"})
assert (status, error["code"]) == (413, "too_large")
def test_service_has_no_outbound_client():
import hux.artifacts
import hux.research
for name in ("urllib.request", "http.client", "socket"):
assert name not in sys.modules or not any(name in getattr(m, "__dict__", {}) for m in (hux.research, hux.artifacts))
text = (FOUNDATION / "hux" / "research.py").read_text()
assert "urlopen" not in text and "http.client" not in text
# --- passages ----------------------------------------------------------------------
def test_passage_hash_is_server_computed_and_deduped(router, monkeypatch):
src = source(router)
first = passage(router, src["id"])
assert valid(first) and first["hash"] == "sha256:" + hashlib.sha256(b"Standard base cabinets ship in 3-4 weeks.").hexdigest()
status, again, headers = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Standard base cabinets ship in 3-4 weeks.", "hash": "sha256:" + "0" * 64})
assert (status, again["id"], headers["HUX-Replayed"]) == (200, first["id"], "true") and again["hash"] == first["hash"]
status, located, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Other", "locator": {"page": 2, "selector": "#x"}}, {"Idempotency-Key": "psg-key-00001"})
assert status == 201 and valid(located) and located["locator"] == {"page": 2, "selector": "#x"}
status, replay, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Ignored"}, {"Idempotency-Key": "psg-key-00001"})
assert status == 200 and replay["id"] == located["id"]
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "Bad", "locator": {"page": 0}})
assert status == 400 and error["details"]
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "x" * 4001})[0] == 400
assert call(router, "POST", "/hux/v1/passages", {"source_id": "src_missing00001", "text": "x"})[0] == 404
assert call(router, "POST", "/hux/v1/passages", {"text": "x"})[0] == 404
assert call(router, "POST", "/hux/v1/passages", raw=b"[]")[0] == 400
monkeypatch.setattr(research, "MAX_PASSAGES", 2)
assert call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "third"})[0] == 413
def test_passage_cannot_name_another_subjects_source(router):
src = source(router)
status, error, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "steal"}, OTHER)
assert (status, error["code"]) == (404, "not_found")
assert call(router, "GET", f"/hux/v1/sources/{src['id']}", headers=OTHER)[0] == 404
rows = audit.recent(tenant(router, "usr_fedcba9876543210"))
assert [r["outcome"] for r in rows] == ["not_found", "not_found"]
# --- citations ---------------------------------------------------------------------
def test_citation_attach_dedupes_and_lists_with_passages_and_sources(router, events):
src = source(router)
one = passage(router, src["id"])
two = passage(router, src["id"], "Delivery excludes installation.")
cit = cite(router, [two["id"], one["id"]], conversation_id="conv_0001abcd", note="checked")
assert valid(cit) and cit["passage_ids"] == [two["id"], one["id"]] and cit["note"] == "checked"
assert cit["dedupe_key"] == "sha256:" + hashlib.sha256(("\n".join(["citation", "msg-44", "Base cabinets take three to four weeks.", *sorted([one["id"], two["id"]])])).encode()).hexdigest()
assert events == [("conv_0001abcd", "citation.attached", {"message_id": "msg-44", "citation_id": cit["id"], "support": "supports"}, [{"kind": "passage", "id": two["id"], "hash": two["hash"]}, {"kind": "passage", "id": one["id"], "hash": one["hash"]}])]
status, again, headers = call(router, "POST", "/hux/v1/messages/msg-44/citations", {"claim": "Base cabinets take three to four weeks.", "passage_ids": [one["id"], two["id"], one["id"]], "support": "contradicts"})
assert (status, again["id"], headers["HUX-Replayed"]) == (200, cit["id"], "true") and again["support"] == "supports"
second = cite(router, [one["id"]], claim="Second claim")
status, body, _ = call(router, "GET", "/hux/v1/messages/msg-44/citations")
assert status == 200 and [i["citation"]["id"] for i in body["items"]] == [cit["id"], second["id"]]
strip = body["items"][0]
assert [p["id"] for p in strip["passages"]] == [two["id"], one["id"]] and strip["sources"] == [src]
assert all(valid(r) for item in body["items"] for r in (item["citation"], *item["passages"], *item["sources"]))
assert call(router, "GET", "/hux/v1/messages/msg-none/citations")[1]["items"] == []
assert len(events) == 1 # the second citation carried no conversation_id, so nothing to emit against
@pytest.mark.parametrize("bad", [
{"claim": ""}, {"support": "maybe"}, {"passage_ids": "psg_x"}, {"passage_ids": []}, {"note": "n" * 501}, {"conversation_id": "nope"},
])
def test_citation_rejects_bad_bodies(router, bad):
src = source(router)
psg = passage(router, src["id"])
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]], "support": "supports", **bad})
assert status == 400 and valid(error), bad
def test_citation_reference_rules(router):
src = source(router)
psg = passage(router, src["id"])
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": ["psg_missing00001"]})[0] == 404
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, OTHER)[0] == 404
status, error, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [f"psg_{i:012d}" for i in range(33)]})
assert status == 413
assert call(router, "POST", f"/hux/v1/messages/{'m' * 121}/citations", {"claim": "c", "passage_ids": [psg["id"]]})[0] == 400
assert call(router, "POST", "/hux/v1/messages/msg-1/citations", raw=b"null")[0] == 400
status, cit, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "c", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
status, replay, _ = call(router, "POST", "/hux/v1/messages/msg-1/citations", {"claim": "other", "passage_ids": [psg["id"]]}, {"Idempotency-Key": "cit-key-00001"})
assert status == 200 and replay["id"] == cit["id"] and replay["support"] == "unverified"
assert call(router, "GET", "/hux/v1/messages/msg-1/citations", headers=OTHER)[1]["items"] == []
def test_validate_citations_reports_integrity_problems(router, monkeypatch):
src = source(router)
psg = passage(router, src["id"])
good = cite(router, [psg["id"]])
contradiction = cite(router, [psg["id"]], claim="Never", support="contradicts")
noted = cite(router, [psg["id"]], claim="Never again", support="contradicts", note="supplier changed policy")
tenant_store = tenant(router)
assert research.validate_citations(tenant_store, [good["id"], noted["id"]]) == []
assert research.validate_citations(tenant_store, [contradiction["id"]]) == [f"{contradiction['id']}: contradicts without a note"]
tenant_store.delete("sources", src["id"])
assert research.validate_citations(tenant_store, [good["id"]]) == [f"{good['id']}: passage {psg['id']} names missing source {src['id']}"]
tenant_store.delete("passages", psg["id"])
problems = research.validate_citations(tenant_store, [good["id"], "cit_missing00001", "bad id"])
assert problems == [f"{good['id']}: passage {psg['id']} does not resolve", "cit_missing00001: citation does not resolve", "bad id: citation does not resolve"]
monkeypatch.setitem(sys.modules, "hux.events", None)
assert cite(router, [passage(router, source(router, "https://b.example/")["id"])["id"]], claim="silent", conversation_id="conv_0001abcd")["support"] == "supports"
def test_f9_source_title_passage_text_and_citation_claim_are_scrubbed(router):
"""F9 / SO-07: secrets in free-text research fields never persist; the passage hash covers the scrubbed text."""
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
src = source(router, title=f"notes {token}")
assert src["title"] == "notes [redacted:github_token]"
psg = passage(router, src["id"], f"password: hunter2 and {token}")
assert psg["text"] == "[redacted:password] and [redacted:github_token]"
assert psg["hash"] == "sha256:" + hashlib.sha256(psg["text"].encode()).hexdigest()
cit = cite(router, [psg["id"]], claim=f"claim {token}", note=f"note {token}")
assert token not in cit["claim"] and token not in cit["note"]
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
assert leaked == []
def test_research_modules_stay_under_500_lines():
assert len((FOUNDATION / "hux" / "research.py").read_text().splitlines()) <= 500
def test_worker_trust_records_a_system_actor(tmp_path):
router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_WORKER_KEY": "wk"})
headers = {"X-Hux-Trust": "worker", "X-Hux-Surface": "worker", "X-Hux-Relay-Key": "wk"}
status, record, _ = call(router, "POST", "/hux/v1/sources", {"kind": "tool_output", "title": "grep"}, headers)
assert status == 201 and record["provenance"] == {"surface": "worker", "actor": {"type": "system", "id": "worker"}, "recorded_at": record["retrieved_at"]}

View File

@ -0,0 +1,174 @@
"""HUX-08 research notebooks: state machine, references and concurrency.
Security obligations exercised: every id added to a notebook resolves under
the caller's subtree, so a second subject cannot attach or read another
tenant's records (SO-33); PATCH requires If-Match and a stale revision is a
409 (SO-44); status only moves open -> answered | abandoned; every served
record satisfies citation.schema.json#/$defs/notebook.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation"
if str(FOUNDATION) not in sys.path:
sys.path.insert(0, str(FOUNDATION))
from hux import audit, contracts, identity, store # noqa: E402
from hux.server import build_router # noqa: E402
SCHEMAS = contracts.load_all()
OWNER = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat"}
OTHER = {**OWNER, "X-Hux-Subject": "usr_fedcba9876543210"}
ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"])
@pytest.fixture
def router(tmp_path):
return build_router(tmp_path, {"HUX_FLAGS": ALL_ON})
def call(router, method, path, body=None, headers=None, raw=None):
payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else b"")
response = router.dispatch(method, path, {**OWNER, **(headers or {})}, payload)
return response.status, response.body, response.headers
def valid(record) -> bool:
return contracts.validate_record(record, SCHEMAS) == []
@pytest.fixture
def evidence(router):
"""One source, passage and citation owned by the default subject."""
_, src, _ = call(router, "POST", "/hux/v1/sources", {"kind": "web", "uri": "https://example.com/a", "title": "A"})
_, psg, _ = call(router, "POST", "/hux/v1/passages", {"source_id": src["id"], "text": "A says B."})
_, cit, _ = call(router, "POST", "/hux/v1/messages/msg-9/citations", {"claim": "B", "passage_ids": [psg["id"]], "support": "supports"})
return src, psg, cit
def notebook(router, **extra):
status, record, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "Which supplier?", **extra})
assert status == 201, record
assert headers["ETag"] == "1"
return record
def test_create_and_get_are_contract_valid(router):
record = notebook(router, assumptions=["Budget is fixed"], unresolved_questions=["Installation included?"])
assert valid(record) and record["status"] == "open" and record["revision"] == 1
assert record["assumptions"] == ["Budget is fixed"] and record["notes"] == []
status, fetched, headers = call(router, "GET", f"/hux/v1/notebooks/{record['id']}")
assert status == 200 and fetched == record and headers["ETag"] == "1"
status, replay, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
status, same, headers = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "again"}, {"Idempotency-Key": "nb-key-000001"})
assert (status, same["id"], headers["HUX-Replayed"]) == (200, replay["id"], "true")
@pytest.mark.parametrize("bad", [
{"conversation_id": "nope"}, {"conversation_id": None}, {"question": ""}, {"question": "q" * 1001},
{"assumptions": "one"}, {"assumptions": [""]}, {"unresolved_questions": [1]}, {"assumptions": ["a"] * 65},
])
def test_create_rejects_bad_bodies(router, bad):
status, error, _ = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "q", **bad})
assert status == 400 and valid(error), bad
assert call(router, "POST", "/hux/v1/notebooks", raw=b"[]")[0] == 400
def test_patch_adds_references_notes_and_lists(router, evidence):
src, psg, cit = evidence
record = notebook(router)
path = f"/hux/v1/notebooks/{record['id']}"
patch = {
"add_source_ids": [src["id"], src["id"]], "add_passage_ids": [psg["id"]], "add_citation_ids": [cit["id"]],
"add_notes": [{"text": "Lead times exclude installation.", "source_id": src["id"]}, {"text": "Plain note"}],
"assumptions": ["Budget cap 12k"], "unresolved_questions": [],
}
status, updated, headers = call(router, "PATCH", path, patch, {"If-Match": "1"})
assert status == 200 and valid(updated) and headers["ETag"] == "2"
assert updated["source_ids"] == [src["id"]] and updated["passage_ids"] == [psg["id"]] and updated["citation_ids"] == [cit["id"]]
assert [n["text"] for n in updated["notes"]] == ["Lead times exclude installation.", "Plain note"]
assert updated["notes"][0]["source_id"] == src["id"] and "source_id" not in updated["notes"][1]
assert updated["assumptions"] == ["Budget cap 12k"] and updated["unresolved_questions"] == []
status, again, _ = call(router, "PATCH", path, {"add_source_ids": [src["id"]]}, {"If-Match": "2"})
assert again["source_ids"] == [src["id"]] and again["revision"] == 3
status, error, _ = call(router, "PATCH", path, {"add_notes": [{"text": "x", "source_id": "src_missing00001"}]}, {"If-Match": "3"})
assert status == 404
for bad in ({"add_notes": "note"}, {"add_notes": [{"text": ""}]}, {"add_source_ids": "src_x"}, {"assumptions": [""]}):
status, error, _ = call(router, "PATCH", path, bad, {"If-Match": "3"})
assert status == 400 and valid(error), bad
assert call(router, "PATCH", path, raw=b"[]", headers={"If-Match": "3"})[0] == 400
status, error, _ = call(router, "PATCH", path, {"add_source_ids": [f"src_{i:012d}" for i in range(501)]}, {"If-Match": "3"})
assert status == 413
def test_patch_requires_matching_if_match(router):
record = notebook(router)
path = f"/hux/v1/notebooks/{record['id']}"
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]})
assert (status, error["code"]) == (400, "invalid")
status, error, _ = call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "5"})
assert (status, error["code"], error["details"]) == (409, "conflict", ["1"])
assert call(router, "PATCH", path, {"assumptions": ["x"]}, {"If-Match": "abc"})[0] == 400
assert call(router, "GET", path)[1]["assumptions"] == []
rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router")))
assert [r["outcome"] for r in rows if r["action"] == "research.notebook_patch"] == ["deny", "conflict", "deny"]
@pytest.mark.parametrize(("target", "then", "expected"), [
("answered", "abandoned", 409), ("abandoned", "answered", 409), ("answered", "open", 409), ("abandoned", "abandoned", 200),
])
def test_status_moves_only_from_open(router, target, then, expected):
record = notebook(router)
path = f"/hux/v1/notebooks/{record['id']}"
status, moved, _ = call(router, "PATCH", path, {"status": target}, {"If-Match": "1"})
assert status == 200 and moved["status"] == target and valid(moved)
status, body, _ = call(router, "PATCH", path, {"status": then}, {"If-Match": "2"})
assert status == expected
if expected == 409:
assert body["code"] == "conflict" and call(router, "GET", path)[1]["status"] == target
assert call(router, "PATCH", path, {"status": "done"}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 409
assert call(router, "PATCH", path, {"status": target}, {"If-Match": str(3 if expected == 200 else 2)})[0] == 200
def test_second_subject_cannot_read_patch_or_attach(router, evidence):
src, psg, cit = evidence
record = notebook(router)
path = f"/hux/v1/notebooks/{record['id']}"
assert call(router, "GET", path, headers=OTHER)[0] == 404
status, error, _ = call(router, "PATCH", path, {"status": "abandoned"}, {**OTHER, "If-Match": "1"})
assert (status, error["code"]) == (404, "not_found") and valid(error)
theirs = call(router, "POST", "/hux/v1/notebooks", {"conversation_id": "conv_0001abcd", "question": "spy"}, OTHER)[1]
for key, value in (("add_source_ids", src["id"]), ("add_passage_ids", psg["id"]), ("add_citation_ids", cit["id"])):
status, error, _ = call(router, "PATCH", f"/hux/v1/notebooks/{theirs['id']}", {key: [value]}, {**OTHER, "If-Match": "1"})
assert status == 404, key
status, mine, _ = call(router, "GET", path)
assert mine["status"] == "open" and mine["revision"] == 1
other_rows = audit.recent(store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_fedcba9876543210", "chat", "router")))
assert other_rows[-1]["outcome"] == "not_found" and other_rows[-1]["action"] == "research.notebook_patch"
def test_notebook_paths_are_tenant_scoped(router):
record = notebook(router)
tenant = store.TenantStore(router.data_root, identity.Identity("slot-3", "usr_0123456789abcdef", "chat", "router"))
assert (tenant.root / "notebooks" / f"{record['id']}.json").exists()
assert call(router, "GET", "/hux/v1/notebooks/nb_missing000001")[0] == 404
assert call(router, "GET", "/hux/v1/notebooks/NB")[0] == 404
def test_f9_notebook_free_text_is_scrubbed(router, evidence):
"""F9 / SO-07: question, assumptions, unresolved questions and notes are secret-scrubbed on create and patch."""
token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
record = notebook(router, question=f"q {token}", assumptions=[f"a {token}"], unresolved_questions=[f"u {token}"])
assert record["question"] == "q [redacted:github_token]" and record["assumptions"] == ["a [redacted:github_token]"]
assert record["unresolved_questions"] == ["u [redacted:github_token]"]
status, patched, _ = call(router, "PATCH", f"/hux/v1/notebooks/{record['id']}", {"add_notes": [{"text": f"n {token}"}], "assumptions": [f"b {token}"]}, {"If-Match": "1"})
assert status == 200 and patched["notes"][0]["text"] == "n [redacted:github_token]" and patched["assumptions"] == ["b [redacted:github_token]"]
leaked = [path for path in Path(router.data_root).rglob("*.json*") if token in path.read_text()]
assert leaked == []

View File

@ -0,0 +1,198 @@
"""Release gates for Hermes' disposable tiny rolling transcription model."""
from __future__ import annotations
import importlib.util
import struct
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
SERVER = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
DOCKERFILE = ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt"
PIPELINE = ROOT / "ci" / "Jenkinsfile.hermes-voice-image"
def _load_server(monkeypatch):
"""Import the server without requiring image-only CUDA dependencies."""
spec = importlib.util.spec_from_file_location("hermes_stt_dual_model", SERVER)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"torch",
SimpleNamespace(
cuda=SimpleNamespace(is_available=lambda: False, synchronize=lambda: None)
),
)
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
spec.loader.exec_module(module)
return module
class _Connection:
"""Capture asynchronous stream responses."""
def __init__(self) -> None:
self.messages: list[dict] = []
self.condition = threading.Condition()
def send_json(self, payload: dict) -> None:
with self.condition:
self.messages.append(payload)
self.condition.notify_all()
def wait_for(self, message_type: str, timeout: float = 1.0) -> dict:
deadline = time.monotonic() + timeout
with self.condition:
while time.monotonic() < deadline:
for message in self.messages:
if message.get("type") == message_type:
return message
self.condition.wait(deadline - time.monotonic())
raise AssertionError(f"missing {message_type}: {self.messages}")
class _Model:
"""Return deterministic Whisper output while recording model ownership."""
def __init__(self, text: str) -> None:
self.text = text
self.calls: list[dict] = []
def transcribe(self, path: str, **options: object) -> dict:
self.calls.append({"path": path, "options": options})
return {
"text": self.text,
"language": "en",
"segments": [
{
"text": self.text,
"no_speech_prob": 0.01,
"avg_logprob": -0.1,
}
],
}
def _start(session) -> None:
session.start(
{
"type": "start",
"turn_id": "dual-model-turn",
"format": "pcm_s16le",
"sample_rate": 16_000,
"language": "auto",
}
)
def _speech(samples: int = 1_600) -> bytes:
return struct.pack(f"<{samples}h", *([12_000] * samples))
def _wait_idle(session) -> None:
deadline = time.monotonic() + 1.0
with session._lock:
while session._rolling_inflight and time.monotonic() < deadline:
session._lock.wait(deadline - time.monotonic())
assert not session._rolling_inflight
def test_rolling_uses_tiny_once_while_final_remains_small(monkeypatch):
module = _load_server(monkeypatch)
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
final_model = _Model("authoritative small")
rolling_model = _Model("provisional tiny")
connection = _Connection()
session = module.StreamingTranscription(connection, final_model, rolling_model)
_start(session)
session.append(_speech())
partial = connection.wait_for("partial")
assert partial["transcript"] == "provisional tiny"
assert partial["stable_transcript"] == ""
assert len(rolling_model.calls) == 1
assert final_model.calls == []
session.append(_speech())
_wait_idle(session)
assert len(rolling_model.calls) == 1
session.commit()
assert connection.wait_for("final")["transcript"] == "authoritative small"
assert len(final_model.calls) == 1
def test_eos_does_not_rearm_but_resumed_speech_does(monkeypatch):
module = _load_server(monkeypatch)
monkeypatch.setattr(module, "ROLLING_MIN_AUDIO_MS", 100)
monkeypatch.setattr(module, "ROLLING_MAX_RESULT_LAG_MS", 5_000)
final_model = _Model("final")
rolling_model = _Model("rolling")
session = module.StreamingTranscription(_Connection(), final_model, rolling_model)
_start(session)
session.append(_speech())
_wait_idle(session)
assert session._rolling_attempted is True
session.append(bytes(24_000))
assert session._at_eos is True
assert session._rolling_attempted is True
session.resume()
assert session._rolling_attempted is False
session.append(_speech())
_wait_idle(session)
assert len(rolling_model.calls) == 2
def test_dual_warmup_and_separate_timing_are_declared(monkeypatch):
module = _load_server(monkeypatch)
model = _Model("warm")
synchronized: list[bool] = []
monkeypatch.setattr(
module.torch.cuda, "synchronize", lambda: synchronized.append(True)
)
elapsed = module._warm_model(model)
assert elapsed >= 0
assert module.ROLLING_MIN_AUDIO_MS == 1_800
assert len(model.calls) == 1
assert model.calls[0]["options"]["language"] is None
assert synchronized == [True]
assert not Path(model.calls[0]["path"]).exists()
source = SERVER.read_text()
rolling_warm = "STARTUP_ROLLING_WARMUP_MS = _warm_model(rolling_model)"
final_warm = "STARTUP_WARMUP_MS = _warm_model(model)"
assert source.index(rolling_warm) < source.index(final_warm)
assert source.index(final_warm) < source.index("server = ThreadingHTTPServer")
assert 'kind="rolling"' in source
assert 'kind="speculative"' in source
assert '"last_rolling_decode_ms"' in source
assert '"last_speculative_decode_ms"' in source
def test_tiny_model_is_checksum_pinned_read_only_in_the_runtime_image():
dockerfile = DOCKERFILE.read_text()
digest = "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9"
assert f"--checksum=sha256:{digest} --chmod=0444" in dockerfile
assert f"/models/{digest}/tiny.pt" in dockerfile
assert "HERMES_STT_ROLLING_MODEL=tiny" in dockerfile
assert "('large-v3-turbo.pt','small.pt','tiny.pt')" in dockerfile
def test_dual_model_contract_is_an_exact_voice_release_gate():
pipeline = PIPELINE.read_text()
assert "testing/tests/test_hermes_stt_rolling_model.py" in pipeline
source = SERVER.read_text()
assert '"path": "/v1/audio/transcriptions/stream"' in source
assert '"inference": INFERENCE_GATE.snapshot()' in source
assert '"rolling_model": ROLLING_MODEL_NAME' in source
assert "STARTUP_ROLLING_WARMUP_MS is not None" in source

View File

@ -239,7 +239,7 @@ def test_stream_speculation_is_reused_for_unchanged_commit(monkeypatch):
assert model.calls[0][1]["condition_on_previous_text"] is False
def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
def test_active_speech_emits_only_one_disposable_rolling_partial(monkeypatch):
module = _load_server(monkeypatch)
connection = _Connection()
model = _SequencedModel(["please open", "please open my calendar"])
@ -261,13 +261,12 @@ def test_active_speech_emits_rate_limited_stable_rolling_partials(monkeypatch):
"window_start_ms": 0,
}
# Audio progress is required in addition to wall-clock rate limiting.
# More PCM in the same epoch must not launch disposable GPU work again.
session._rolling_last_started_at = 0.0
session.append(_speech(samples=12_000))
second = connection.wait_for_revision(2)
assert second["transcript"] == "please open my calendar"
assert second["stable_transcript"] == "please open"
assert len(model.calls) == 2
time.sleep(0.02)
assert len(model.calls) == 1
assert not any(message.get("revision") == 2 for message in connection.messages)
def test_rolling_window_is_bounded_but_final_decode_uses_full_utterance(monkeypatch):
@ -338,7 +337,8 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
def occupy_gpu():
module.INFERENCE_GATE.try_background(
lambda: (entered.set(), release.wait(2.0))
lambda: (entered.set(), release.wait(2.0)),
kind="rolling",
)
worker = threading.Thread(target=occupy_gpu)
@ -353,12 +353,12 @@ def test_busy_gpu_skips_partial_instead_of_queuing_work(monkeypatch):
release.set()
worker.join(timeout=1.0)
# A later audio interval gets another opportunity once the final-priority
# GPU lock is free; the skipped snapshot did not leave a queued worker.
# A skipped epoch is never retried, so it cannot race the final later.
session._rolling_last_started_at = 0.0
session.append(_speech())
assert connection.wait_for_revision(1)["transcript"] == "hello"
assert len(model.calls) == 1
time.sleep(0.02)
assert connection.messages == []
assert model.calls == []
def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
@ -369,7 +369,8 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
first = threading.Thread(
target=lambda: module.INFERENCE_GATE.try_background(
lambda: (first_entered.set(), release_first.wait(2.0))
lambda: (first_entered.set(), release_first.wait(2.0)),
kind="rolling",
)
)
first.start()
@ -382,7 +383,7 @@ def test_waiting_final_prevents_new_rolling_work_from_stealing_gpu(monkeypatch):
while module.INFERENCE_GATE._final_waiters < 1 and time.monotonic() < deadline:
time.sleep(0.005)
assert module.INFERENCE_GATE._final_waiters == 1
assert module.INFERENCE_GATE.try_background(lambda: "ran") is None
assert module.INFERENCE_GATE.try_background(lambda: "ran", kind="rolling") is None
release_first.set()
first.join(timeout=1.0)
final.join(timeout=1.0)
@ -464,36 +465,3 @@ def test_stream_contract_rejects_wrong_format_and_oversize(monkeypatch):
monkeypatch.setattr(module, "MAX_STREAM_AUDIO_BYTES", 4)
with pytest.raises(module.WebSocketError, match="too long"):
session.append(bytes(6))
def test_stream_health_contract_is_declared_in_source():
source = SERVER.read_text()
assert '"path": "/v1/audio/transcriptions/stream"' in source
assert '"format": "pcm_s16le"' in source
assert '"server_vad": True' in source
assert '"speculative": True' in source
assert '"inference": INFERENCE_GATE.snapshot()' in source
assert '"minimum_audio_ms": ROLLING_MIN_AUDIO_MS' in source
assert '"warmed": STARTUP_WARMUP_MS is not None' in source
assert "MAX_STREAM_AUDIO_BYTES" in source
def test_startup_warms_canonical_decode_before_serving(monkeypatch):
module = _load_server(monkeypatch)
model = _Model()
synchronized: list[bool] = []
monkeypatch.setattr(module.torch.cuda, "synchronize", lambda: synchronized.append(True))
elapsed = module._warm_model(model)
assert elapsed >= 0
assert module.ROLLING_MIN_AUDIO_MS == 1_800
assert len(model.calls) == 1
assert model.calls[0][1]["language"] is None
assert synchronized == [True]
assert not Path(model.calls[0][0]).exists()
source = SERVER.read_text()
assert source.index("STARTUP_WARMUP_MS = _warm_model(model)") < source.index(
"server = ThreadingHTTPServer"
)

View File

@ -42,7 +42,10 @@ def test_barge_in_cancels_owned_turn_and_reuses_pcm_lookback():
assert "reusedCapture.lookback.forEach" in source
assert "const bytes=new Uint8Array(16)" in source
assert "window.crypto.getRandomValues(bytes)" in source
assert "captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)" in source
assert (
"captureTurnId=(voiceTabNonce?voiceTabNonce+'-':'')+String(token)+'-'+String(++turnSequence)"
in source
)
def test_streamed_chunks_share_one_audio_timeline_until_final_drain():
@ -71,7 +74,10 @@ def test_barge_cancel_settles_before_new_turn_baseline_and_observer():
)
assert region.index("rememberAssistantBaseline()") < region.index("window.send()")
assert region.index("window.send()") < region.index("startResponseObserver(token)")
assert "suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'" in source
assert (
"suppressAutoRead||bargeCancelPromise||state==='listening'||state==='transcribing'"
in source
)
def test_barge_handoff_never_uses_a_truncated_container_fallback():
@ -96,7 +102,14 @@ def test_rolling_partials_are_feedback_only_and_wav_fallback_is_owned():
)[0]
assert "payload.stable_transcript" in partial
assert "label.textContent='Listening · '+preview" in partial
assert "const provisional=String(payload.transcript||'').trim()" in partial
assert "const visible=stable||provisional" in partial
assert (
"label.textContent='Listening · '+preview+(stable?'':' · provisional')"
in partial
)
assert "if(stable){" in partial
assert "scheduleVoicePreflight(turnId,revision,stable)" in partial
assert "composer.value" not in partial
assert "window.send" not in partial
assert "signal:controller.signal" in fallback
@ -113,7 +126,10 @@ def test_stt_client_queue_and_canonical_archive_are_strictly_bounded():
assert "queuedBytes+bytes.byteLength>STT_MAX_QUEUED_BYTES" in source
assert "Streaming transcription backpressure limit exceeded" in source
assert "if(flushTimer||settled||!queue.length" in source
assert "flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)" in source
assert (
"flushTimer=window.setTimeout(function(){flushTimer=null;flush();},20)"
in source
)
assert "function clearQueue(){queue=[];queuedBytes=0;clearFlushTimer();}" in source
assert "takeFallbackBlob:function()" in source
assert "pcm16WavBlob(archive,16000)" in source

View File

@ -359,8 +359,12 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
assert ".empty-logo .hermes-agent-portrait" in css
assert "width: 112px" in css
assert ":root.dark .messages" in css
assert ":root.dark .messages-shell" in css
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
assert "background-repeat: repeat, repeat, no-repeat, no-repeat" in css
messages = css.rsplit(":root.dark .messages {", 1)[1].split("}", 1)[0]
assert "background-image: none" in messages
assert "radial-gradient" not in messages
assert ":root.dark .session-item.active" in css
assert ":root.dark .suggestion:focus-visible" in css
assert "@media (prefers-reduced-motion: reduce)" in css