Compare commits

...

8 Commits

Author SHA1 Message Date
jenkins
124206b748 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:27:38 -03:00
jenkins
1cb6f07c78 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:25:23 -03:00
jenkins
b3de70bacb 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:21:51 -03:00
jenkins
ada74ce0d4 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:20:06 -03:00
jenkins
fc8ec28db3 docs(hux): record draft PR #55 in the handoff ledger 2026-08-24 00:13:37 -03:00
jenkins
700d6fc326 docs(hux): start the hermes-next handoff ledger with HUX-11 2026-08-24 00:12:49 -03:00
jenkins
7cf9a714a4 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:08:56 -03:00
jenkins
47a9fd8354 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:04:37 -03:00
68 changed files with 10181 additions and 342 deletions

View File

@ -0,0 +1,407 @@
"""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 _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")
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"])
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
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}")
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,203 @@
"""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 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 run's conversation when an approval names it."""
reason = "no approval for this run and capability"
for record in store.scan(policy.APPROVALS):
record = policy.refresh(store, record)
same_run = record["run_id"] == run_id
if same_run:
conversation_id = conversation_id or record["conversation_id"]
same_conv = bool(conversation_id) and record["conversation_id"] == conversation_id
choice = record.get("decision", {}).get("choice")
if record["capability"] != capability or not (same_run or (choice == "session" 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 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
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 = body.get("conversation_id")
if conversation_id is not None:
conversation_id = check_id(conversation_id)
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(request: Request) -> Response:
"""``POST /hux/v1/runs/{id}/stop``: write the cancellation receipt; a repeat returns it."""
body = policy.body_dict(request)
run_id = run_id_from(request)
receipt_id = f"rcpt_{policy.run_key(run_id)}"
with request.store.lock(RECEIPTS):
if request.store.exists(RECEIPTS, receipt_id):
existing = request.store.get(RECEIPTS, receipt_id)
request.audit("runs.stop", receipt_id, reason="replayed")
return Response(200, policy.public(existing))
side_effects = body.get("side_effects", [])
if not isinstance(side_effects, list):
raise Invalid("side_effects must be a list")
if body.get("already_complete"):
outcome = "already_complete"
elif body.get("process_registry_empty") is True:
outcome = "cancelled"
else:
outcome = "failed_to_cancel"
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": 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")
if conversation_id is not None:
record["conversation_id"] = check_id(conversation_id)
stored = request.store.put(RECEIPTS, policy.checked(record))
request.audit("runs.stop", receipt_id, reason=outcome)
policy.emit(request.store, request.identity, record.get("conversation_id"), "run.cancelled", f"Run stopped: {outcome}",
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,283 @@
"""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 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 conversation_known(store: TenantStore, conversation_id: str) -> bool:
"""Ownership check (SO-18): the conversation exists somewhere in this subject's tree."""
return (
store.exists("conversations", conversation_id)
or store.exists(SEQ_FAMILY, _seq_id(conversation_id))
)
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"] = int(checkpoint["next_seq"])
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,71 @@
"""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.
"""
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/v1/modes", "/hux/v1/conversations/{id}/mode"],
"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/v1/suggestions", "/hux/v1/suggestions/{id}/{action}"],
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit"],
"HUX-12": ["/hux/v1/releases"],
}
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,214 @@
"""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 HuxError, Invalid, NotFound, TooLarge
from hux.flags import Flags, build_from_environ
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:
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())
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": "1.0.0"}))
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,387 @@
"""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]:
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, expected)
if target == "forgotten":
_tombstone(store, stored["id"], action)
return stored
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 _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)."""
from hux import privacy
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")
hits: list[str] = []
content = redaction.scrub_value(content.strip(), hits)
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
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()
conversation_id = body.get("conversation_id") if isinstance(body.get("conversation_id"), str) else None
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": 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"]
allowed, why = privacy.memory_write_allowed(request.store, conversation_id, entry)
if why == "private_mode":
raise Forbidden("memory writes are refused in private mode")
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")
if not allowed or wants == "no_store":
# Content-free 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.
why = why if not allowed else "declined"
entry.pop("topic")
entry.update(status="no_store", approval_mode="ask" if sensitivity == "sensitive" else "no_store", content="", retrievable=False,
audit=[{"at": stamp, "action": "no_store", "actor": actor, "note": f"{why}; topic={topic}"[:200]}])
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 post_memory(request: Request) -> Response:
"""``POST /hux/v1/memory``: propose an entry; policy decides active, proposed (ask) or no_store (202)."""
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()
if key:
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"})
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]:
body = request.body if isinstance(request.body, dict) else {}
content = body.get("content")
if not isinstance(content, str) or not content.strip():
raise Invalid("edit needs new content")
old = _transition(request.store, request.identity, record, "forgotten", "superseded", actor, "edited", expected)
from hux import events
events.redact_memory_references(request.store, old["id"])
stamp = now_iso()
fresh = {
**{k: v for k, v in record.items() if k not in {"revision", "supersedes"}}, "id": new_id("mem"), "content": redaction.scrub_value(content.strip(), [])[:2000],
"status": "active", "approval_mode": "automatic", "retrievable": True, "supersedes": old["id"], "created_at": stamp, "updated_at": stamp,
"audit": [{"at": stamp, "action": "edited", "actor": actor, "note": f"supersedes {old['id']}"}, {"at": stamp, "action": "approved", "actor": actor}],
}
if fresh["sensitivity"] == "sensitive":
fresh.update(status="proposed", approval_mode="ask", retrievable=False)
fresh["audit"] = fresh["audit"][:1]
return _persist(request.store, fresh, None)
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)
_emit(store, identity, stored, "memory.committed" if stored["status"] == "active" else "memory.proposed", f"Memory edited, supersedes {record['id']}")
else:
if record["status"] != "active":
raise Conflict("retrieval can only change on active entries")
retrievable = action == "restore_retrieval"
stamp = now_iso()
stored = _persist(store, {**record, "retrievable": retrievable, "updated_at": stamp, "audit": [*record["audit"], {"at": stamp, "action": "retrieval_removed" if not retrievable else "approved", "actor": actor, "note": "retrieval restored" if retrievable else ""}]}, 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,297 @@
"""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
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; ids, owner, timestamps and revision are server-set."""
return {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_titles(store: TenantStore, conversation: dict[str, Any]) -> list[str]:
"""Artifact titles for a conversation via the artifacts lane, or nothing when it is absent."""
try:
from hux import artifacts
except ModuleNotFoundError:
return []
helper = getattr(artifacts, "artifact_titles", None)
return list(helper(store, conversation["id"])) if helper else []
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)}
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))
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,354 @@
"""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 actor_for(identity: Identity) -> dict[str, str]:
"""The actor a record attributes to this caller: humans are users, hops are system."""
if identity.trust in HUMAN_TRUSTS and identity.surface in HUMAN_SURFACES:
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")
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."""
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")
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."""
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))
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,260 @@
"""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}
store.append(FAMILY, "forgotten", {"conv_id": conversation_id, "requested_at": now_iso(), "purged_at": "", "counts": counts})
return {"conversation_id": conversation_id, "forgotten": True, **counts}
# -- 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)
events.emit(request.store, request.identity, conversation_id, "privacy.notice", text, notice, sensitivity=rules.PRIVACY_TOPICS[topic]["sensitivity"])
request.audit("privacy.notice", conversation_id)
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,225 @@
"""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.
path = kept.get("target_path")
if not (isinstance(path, str) and path.startswith(WORKSPACE_PREFIX + "/")):
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,378 @@
"""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.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 _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 = _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 = _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 = _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"] = 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 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": _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": _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

@ -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,80 @@
# 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`).
## 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.

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

@ -0,0 +1,64 @@
# 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).

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

@ -0,0 +1,247 @@
# 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.

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,337 @@
"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"
}
}
},
"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

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

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,366 @@
"""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_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,365 @@
"""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}

View File

@ -0,0 +1,236 @@
"""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
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))
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_search_uses_artifact_titles_when_the_artifacts_lane_offers_them(router, monkeypatch):
conversation = make_conversation(router, title="Plain")
fake = types.ModuleType("hux.artifacts")
fake.artifact_titles = lambda store, conversation_id: ["Supplier comparison sheet"] if conversation_id == conversation["id"] else []
monkeypatch.setitem(sys.modules, "hux.artifacts", fake)
monkeypatch.setattr(hux, "artifacts", fake, raising=False)
assert [c["id"] for c in call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"]] == [conversation["id"]]
bare = types.ModuleType("hux.artifacts")
monkeypatch.setitem(sys.modules, "hux.artifacts", bare)
monkeypatch.setattr(hux, "artifacts", bare, raising=False)
assert call(router, "GET", "/hux/v1/search?q=supplier")[1]["items"] == []
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_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,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,309 @@
"""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", conversation_id=CONV)[1]["proceed"] is True
assert gate(router, run_id="run_later", capability="shell")[1]["proceed"] is False
assert gate(router, run_id="run_later", capability="shell", conversation_id="conv_elsewhere01")[1]["proceed"] is False
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] == 400
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()

View File

@ -0,0 +1,191 @@
"""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, 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}

View File

@ -0,0 +1,138 @@
"""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}, HEADERS)
assert status == 201 and valid(body)["outcome"] == "cancelled"
assert body["requested_by"] == {"type": "user", "id": HEADERS["X-Hux-Subject"]}
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_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,172 @@
"""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)
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)
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}
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

View File

@ -0,0 +1,238 @@
"""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_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,162 @@
"""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