Compare commits
2 Commits
af5ee4697b
...
7cf9a714a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf9a714a4 | ||
|
|
47a9fd8354 |
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
0
dockerfiles/hermes-hux-foundation/hux/__init__.py
Normal file
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal file
40
dockerfiles/hermes-hux-foundation/hux/audit.py
Normal 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:]
|
||||
@ -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",
|
||||
76
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
76
dockerfiles/hermes-hux-foundation/hux/errors.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""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 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"
|
||||
71
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal file
71
dockerfiles/hermes-hux-foundation/hux/flags.py
Normal 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.0.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", "")}
|
||||
24
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal file
24
dockerfiles/hermes-hux-foundation/hux/foundation.py
Normal 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)
|
||||
213
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
213
dockerfiles/hermes-hux-foundation/hux/http.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""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
|
||||
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) -> None:
|
||||
"""Register a handler; ``action`` is the audit action name (family.verb)."""
|
||||
self.routes.append(Route(method, template, card, action, handler))
|
||||
|
||||
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)
|
||||
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) -> Any:
|
||||
if not body:
|
||||
return None
|
||||
if len(body) > MAX_BODY_BYTES:
|
||||
raise TooLarge("body exceeds 1 MiB")
|
||||
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
|
||||
80
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal file
80
dockerfiles/hermes-hux-foundation/hux/identity.py
Normal 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)
|
||||
@ -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": {
|
||||
@ -140,12 +141,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 +172,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
|
||||
|
||||
48
dockerfiles/hermes-hux-foundation/hux/server.py
Normal file
48
dockerfiles/hermes-hux-foundation/hux/server.py
Normal 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()
|
||||
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal file
223
dockerfiles/hermes-hux-foundation/hux/store.py
Normal 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
|
||||
@ -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.0.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,11 +54,12 @@ tenant. Responses are the records below, wrapped as `{"items": [...],
|
||||
|
||||
| Area | Routes | Record |
|
||||
|---|---|---|
|
||||
| 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 /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` |
|
||||
| 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 /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, `GET /hux/v1/runs/{id}/budget` | `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` |
|
||||
| Onboarding (HUX-09) | `GET /hux/v1/suggestions?context=`, `POST /hux/v1/suggestions/{id}/{dismiss,never,acted}` | `hux.suggestion.v1`, `hux.suggestion_state.v1` |
|
||||
|
||||
69
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
69
docs/hux/ADR-0001-hux-v1-contract-freeze.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 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.
|
||||
|
||||
## 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
353
docs/hux/DATA-MODEL.md
Normal 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.
|
||||
247
docs/hux/THREAT-MODEL.md
Normal file
247
docs/hux/THREAT-MODEL.md
Normal 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.
|
||||
1
services/hermes/contracts/hux/VERSION
Normal file
1
services/hermes/contracts/hux/VERSION
Normal file
@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,82 +7,320 @@
|
||||
"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}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -25,98 +25,317 @@
|
||||
},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
20
services/hermes/contracts/hux/examples/budget_state.json
Normal file
20
services/hermes/contracts/hux/examples/budget_state.json
Normal 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": []
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
33
services/hermes/contracts/hux/examples/capabilities.json
Normal file
33
services/hermes/contracts/hux/examples/capabilities.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
9
services/hermes/contracts/hux/examples/error.json
Normal file
9
services/hermes/contracts/hux/examples/error.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
8
services/hermes/contracts/hux/examples/manifest.json
Normal file
8
services/hermes/contracts/hux/examples/manifest.json
Normal 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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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.0.0"
|
||||
}
|
||||
|
||||
157
services/hermes/contracts/hux/identity.schema.json
Normal file
157
services/hermes/contracts/hux/identity.schema.json
Normal file
@ -0,0 +1,157 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
340
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
340
testing/tests/test_hermes_hux_contract_foundation.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""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
|
||||
@ -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"
|
||||
Loading…
x
Reference in New Issue
Block a user