From 1b1a14e972c75fab0a76e13e53bd14565d994219 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 00:27:38 -0300 Subject: [PATCH] hermes(hux): contract 1.1.0 additive revision and Wave A consolidation Adds receipt evidence kind, 422 unprocessable, optional revision on research records, audit_stale on the privacy policy, per-route body caps (25 MiB artifact uploads), promotion checks the project exists, memory rules skip content-free statuses. Handoff ledger covers every Wave A card. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM --- .../hermes-hux-foundation/hux/artifacts.py | 18 +- .../hermes-hux-foundation/hux/errors.py | 6 + .../hermes-hux-foundation/hux/flags.py | 2 +- dockerfiles/hermes-hux-foundation/hux/http.py | 15 +- .../hermes-hux-foundation/hux/rules.py | 7 +- docs/hermes_hux_contracts.md | 14 +- docs/hux/ADR-0001-hux-v1-contract-freeze.md | 11 + docs/hux/HANDOFF.md | 16 ++ services/hermes/contracts/hux/VERSION | 2 +- .../hermes/contracts/hux/citation.schema.json | 9 + .../hermes/contracts/hux/common.schema.json | 3 +- services/hermes/contracts/hux/flags.json | 2 +- .../hermes/contracts/hux/identity.schema.json | 3 +- .../hermes/contracts/hux/privacy.schema.json | 217 +++++++++++++++--- .../test_hermes_hux_artifact_versions.py | 17 +- .../test_hermes_hux_contract_foundation.py | 25 ++ 16 files changed, 302 insertions(+), 65 deletions(-) diff --git a/dockerfiles/hermes-hux-foundation/hux/artifacts.py b/dockerfiles/hermes-hux-foundation/hux/artifacts.py index 6eea3776..cb198d4c 100644 --- a/dockerfiles/hermes-hux-foundation/hux/artifacts.py +++ b/dockerfiles/hermes-hux-foundation/hux/artifacts.py @@ -23,6 +23,18 @@ from hux.http import Request, Response, Router, page from hux.identity import Identity from hux.store import TenantStore, check_id, new_id, now_iso + +MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54) + + +def _project_exists(store, project_id: str) -> bool: + """Ask the organization family when it is present; otherwise only the id shape is known.""" + try: + from hux.organization import project_exists + except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts + return True + return project_exists(store, project_id) + FAMILY = "artifacts" MAX_VERSION_BYTES = 25 * 1024 * 1024 MAX_VERSIONS = 200 @@ -364,6 +376,8 @@ def promote(request: Request) -> Response: """``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy.""" body = _body(request) project_id = check_id(body.get("project_id")) + if not _project_exists(request.store, project_id): + raise NotFound("project not found") expected = request.if_match() with request.store.lock(FAMILY): artifact = _load_owned(request) @@ -384,10 +398,10 @@ def promote(request: Request) -> Response: def register(router: Router) -> None: """Attach HUX-04 routes.""" card = "HUX-04" - router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create) + router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY) router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts) router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get) - router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version) + router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY) router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version) router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff) router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote) diff --git a/dockerfiles/hermes-hux-foundation/hux/errors.py b/dockerfiles/hermes-hux-foundation/hux/errors.py index e6aa66e3..126fd3bf 100644 --- a/dockerfiles/hermes-hux-foundation/hux/errors.py +++ b/dockerfiles/hermes-hux-foundation/hux/errors.py @@ -58,6 +58,12 @@ class Invalid(HuxError): status, code = 400, "invalid" +class Unprocessable(HuxError): + """Well-formed body that violates a rule (hash mismatch, policy violation).""" + + status, code = 422, "unprocessable" + + class TooLarge(HuxError): """Body, record or family exceeds its bound.""" diff --git a/dockerfiles/hermes-hux-foundation/hux/flags.py b/dockerfiles/hermes-hux-foundation/hux/flags.py index 2b151d35..0dc138bd 100644 --- a/dockerfiles/hermes-hux-foundation/hux/flags.py +++ b/dockerfiles/hermes-hux-foundation/hux/flags.py @@ -15,7 +15,7 @@ from hux.errors import FlagOff from hux.identity import Identity from hux.rules import flag_enabled, flag_registry -CONTRACT_VERSION = "1.0.0" +CONTRACT_VERSION = "1.1.0" CARD_ROUTES: dict[str, list[str]] = { "HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest"], "HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"], diff --git a/dockerfiles/hermes-hux-foundation/hux/http.py b/dockerfiles/hermes-hux-foundation/hux/http.py index cda03197..27528609 100644 --- a/dockerfiles/hermes-hux-foundation/hux/http.py +++ b/dockerfiles/hermes-hux-foundation/hux/http.py @@ -89,6 +89,7 @@ class Route: card: str action: str handler: Handler + max_body: int = MAX_BODY_BYTES pattern: re.Pattern = field(init=False) def __post_init__(self) -> None: @@ -106,9 +107,9 @@ class Router: 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 add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None: + """Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap.""" + self.routes.append(Route(method, template, card, action, handler, max_body)) def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]: """Return (route, params, path_known).""" @@ -137,7 +138,7 @@ class Router: return Response(405 if known else 404, error.record()) try: self.flags.require(route.card) - payload = self._decode(body) + payload = self._decode(body, route.max_body) request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build) response = route.handler(request) except HuxError as error: @@ -147,11 +148,11 @@ class Router: return response @staticmethod - def _decode(body: bytes) -> Any: + def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any: if not body: return None - if len(body) > MAX_BODY_BYTES: - raise TooLarge("body exceeds 1 MiB") + if len(body) > max_body: + raise TooLarge(f"body exceeds {max_body} bytes") try: return json.loads(body) except json.JSONDecodeError as error: diff --git a/dockerfiles/hermes-hux-foundation/hux/rules.py b/dockerfiles/hermes-hux-foundation/hux/rules.py index 5e427c42..cd2b4ff2 100644 --- a/dockerfiles/hermes-hux-foundation/hux/rules.py +++ b/dockerfiles/hermes-hux-foundation/hux/rules.py @@ -124,11 +124,12 @@ def memory_policy_violations(entry: dict[str, Any]) -> list[str]: topic = entry.get("topic", "general") if sensitivity == "restricted" and entry.get("status") in {"proposed", "active"}: problems.append("restricted content may not be remembered") - if sensitivity == "sensitive" and entry.get("approval_mode") != "ask": + stored = entry.get("status") not in {"rejected", "no_store", "forgotten"} + if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask": problems.append("sensitive memory requires approval_mode=ask") - if sensitivity == "sensitive" and entry.get("ttl", {}).get("policy") == "never": + if sensitivity == "sensitive" and stored and entry.get("ttl", {}).get("policy") == "never": problems.append("sensitive memory must expire or decay") - if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and entry.get("status") != "rejected": + if topic in PRIVACY_TOPICS and PRIVACY_TOPICS[topic]["memory_write"] == "deny" and stored: problems.append(f"topic {topic} may not be written to memory") ttl = entry.get("ttl", {}) if ttl.get("policy") == "expires_at" and "expires_at" not in ttl: diff --git a/docs/hermes_hux_contracts.md b/docs/hermes_hux_contracts.md index b1dd638a..102d8882 100644 --- a/docs/hermes_hux_contracts.md +++ b/docs/hermes_hux_contracts.md @@ -14,7 +14,7 @@ suggestion gating, flag dependencies) live in 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 +`1.1.0` (see `docs/hux/ADR-0001-hux-v1-contract-freeze.md` for identity headers, `If-Match`, idempotency and compatibility rules). ## Where the backend lives @@ -55,15 +55,15 @@ 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` | +| Events (HUX-01) | `GET/POST /hux/v1/conversations/{id}/events?after_seq=N&limit=` (JSON, ≤200), `GET .../events/stream` (SSE, `id:` = seq, resumes from `Last-Event-ID`) | `hux.event.v1` | +| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `GET /hux/v1/memory/{id}`, `POST /hux/v1/memory/{id}/{approve,reject,forget,edit,remove_retrieval,restore_retrieval}`, `GET /hux/v1/memory/export` | `hux.memory.v1` | +| Projects (HUX-03) | `GET/POST /hux/v1/projects`, `GET/PATCH /hux/v1/projects/{id}`, `GET/POST /hux/v1/conversations`, `GET/PATCH /hux/v1/conversations/{id}`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/conversations/{id}/lineage`, `GET /hux/v1/search?q=&project_id=` (message_text not indexed yet; response says `indexed`/`not_indexed`) | `hux.project.v1`, `hux.conversation.v1` | | Artifacts (HUX-04) | `GET/POST /hux/v1/artifacts`, `POST /hux/v1/artifacts/{id}/versions`, `GET .../versions/{n}/diff?from=`, `POST .../promote` | `hux.artifact.v1` | -| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET /hux/v1/approvals`, `POST /hux/v1/approvals/{id}` (`once|session|always|deny`, the gateway's own choices), `POST /hux/v1/runs/{id}/stop` returns the receipt, `GET /hux/v1/runs/{id}/budget` | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1`, `hux.budget_state.v1` | +| Autonomy (HUX-05) | `GET/PUT /hux/v1/policy?scope=`, `GET/POST /hux/v1/approvals` (agent hook creates with `request.evidence[{kind: tool_call, id, hash}]`), `GET/POST /hux/v1/approvals/{id}` (human decision `once|session|always|deny`; worker/api trust gets 403), `POST /hux/v1/runs/{id}/gate` (`{capability, argument_hash, external}` → `{proceed, approval_id?, reason}`), `GET/POST /hux/v1/runs/{id}/budget`, `POST /hux/v1/runs/{id}/stop` returns the receipt (repeat returns the same one) | `hux.policy.v1`, `hux.approval.v1`, `hux.cancel_receipt.v1`, `hux.budget_state.v1` | | Modes (HUX-06) | `GET /hux/v1/modes`, `PUT /hux/v1/conversations/{id}/mode` | `hux.mode.v1` | -| Research (HUX-08) | `GET /hux/v1/messages/{id}/citations`, `GET /hux/v1/sources/{id}`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.v1` | +| Research (HUX-08) | `POST /hux/v1/sources`, `GET /hux/v1/sources/{id}`, `POST /hux/v1/passages`, `GET/POST /hux/v1/messages/{id}/citations`, `POST /hux/v1/notebooks`, `GET/PATCH /hux/v1/notebooks/{id}` | `hux.source.v1`, `hux.passage.v1`, `hux.citation.v1`, `hux.research_notebook.v1` | | Onboarding (HUX-09) | `GET /hux/v1/suggestions?context=`, `POST /hux/v1/suggestions/{id}/{dismiss,never,acted}` | `hux.suggestion.v1`, `hux.suggestion_state.v1` | -| Privacy (HUX-10) | `GET /hux/v1/privacy/policy`, `POST /hux/v1/conversations/{id}/forget` | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` | +| Privacy (HUX-10) | `GET /hux/v1/privacy/policy` (+ `HUX-Audit-Stale` header), `POST /hux/v1/privacy/notices` (optional `chosen` control), `POST /hux/v1/conversations/{id}/forget`, `GET /hux/v1/privacy/audit` | `hux.privacy_policy.v1`, `hux.privacy_notice.v1`, `hux.retention_audit.v1` | | Release (HUX-12) | `GET /hux/v1/releases` (operator surface, worker only) | `hux.release.v1` | Multimodal (HUX-07) reuses artifacts for images/audio (`type` image/audio, diff --git a/docs/hux/ADR-0001-hux-v1-contract-freeze.md b/docs/hux/ADR-0001-hux-v1-contract-freeze.md index 82329546..cf72db0c 100644 --- a/docs/hux/ADR-0001-hux-v1-contract-freeze.md +++ b/docs/hux/ADR-0001-hux-v1-contract-freeze.md @@ -60,6 +60,17 @@ format with Codex: a change to a fixture is a change to the contract. 4. Anything outside 1–3 is `hux.v2`, served beside v1 until every surface has moved. +## Revision 1.1.0 (2026-08-24, additive) + +Raised after the three Wave A lanes reported. Adds `receipt` to +`evidence_ref.kind`, `unprocessable` (422) to error codes, optional +`revision` on source/passage/citation, optional `audit_stale` on the privacy +policy, and per-route body caps (artifact uploads up to 25 MiB content). The +argument hash a `once` approval is released against travels as +`request.evidence[] = {kind: "tool_call", id, hash}`; the agent hook must +send it or the gate can never release. Memory rules skip content-free +statuses (`no_store`, `forgotten`, `rejected`). + ## Consequences - Codex codes UI against the fixtures, not against the running service. diff --git a/docs/hux/HANDOFF.md b/docs/hux/HANDOFF.md index 61ccb62f..ef69299d 100644 --- a/docs/hux/HANDOFF.md +++ b/docs/hux/HANDOFF.md @@ -46,3 +46,19 @@ Run everything with the CI interpreter: | Flag | `hux.foundation` (root of every dependency chain) | | Risks | Rate limiting (SO-53) and hash-chained audit (SO-46) not yet implemented — tracked for the Wave A review; relay callers still carry `X-Hux-Subject` (deviates from SO-06; the slot→owner mapping lives in the router, so the header is redundant but harmless and lets the service pin the subject) | | Codex needs | Wire the service into the tenant pod + Worker, NetworkPolicy, router header contract above, `HUX_FLAGS=hux.foundation` for the first canary; `testing/quality_contract.json` may add `dockerfiles/hermes-hux-foundation/**/*.py` to `line_limit_globs` and the two moved modules to `managed_modules` (Claude does not edit that file) | + +## Wave A backends — DONE, awaiting adversarial review and Codex integration + +Full suite: `testing/tests/test_hermes_hux_*.py` = 325 tests, 99% line / 99% branch over `dockerfiles/hermes-hux-foundation/hux/` (every family module 99–100%). Every module ≤ 500 LOC (guarded by tests). Contract revised additively to 1.1.0 (ADR-0001). + +| Card | Commit | Files | Tests | Flag | Codex needs | +|---|---|---|---|---|---| +| HUX-01 activity timeline | `1cb6f07c` | `hux/events.py`, `hux/redaction.py` | `test_hermes_hux_contract_events.py` (34): ordering, idempotency, replay/reconnect, redaction by surface, cancellation receipts, cross-tenant | `hux.activity_timeline` | Router forwards `Last-Event-ID`; telegram/voice surfaces get partial redaction; agent hook posts events with `Idempotency-Key`; `HUX_CANARY_FILE=/opt/data/.env` so secrets are scrubbed | +| HUX-02 memory center | `1cb6f07c` | `hux/memory.py` | `test_hermes_hux_memory_ledger.py` (20), `test_hermes_hux_memory_retrieval.py` (7): no-store, suggest-only, edit/supersede, forget, retrieval removal, export, If-Match | `hux.memory_control` (needs `hux.privacy`) | Agent memory tool must call `hux.memory.retrieve` semantics (tombstones first) instead of the upstream memory file; UI per-message controls call `/memory/{id}/{action}` with `If-Match` | +| HUX-10 privacy | `1cb6f07c` | `hux/privacy.py` | `test_hermes_hux_privacy_topics.py` (19), `test_hermes_hux_privacy_retention.py` (6) | `hux.privacy` | Schedule `run_retention` daily (no thread inside the service by design); surface `HUX-Audit-Stale`; show notices and post `chosen` | +| HUX-04 artifacts | `ada74ce0` (+ upload caps, project check in the 1.1.0 batch) | `hux/artifacts.py`, `hux/diffs.py` | `test_hermes_hux_artifact_versions.py`, `test_hermes_hux_artifact_auth.py` (immutable versions, concurrency, lineage forgery 404, caps 413, diffs, promotion) | `hux.artifacts` (needs `hux.projects`) | Version content served with `nosniff` + attachment; uploads ≤ 25 MiB content via base64; sharing intentionally not implemented (`access.mode` always `owner`) | +| HUX-08 research | `ada74ce0` | `hux/research.py` | `test_hermes_hux_research_citations.py`, `test_hermes_hux_research_notebook.py` (dedupe, integrity, notebook state machine) | `hux.research` | Service never fetches URIs (SO-19): the web tool records sources/passages after its own fetch; citation strip = `GET /messages/{id}/citations` (passages + sources embedded) | +| HUX-05 autonomy | `b3de70ba` | `hux/policy.py`, `hux/budgets.py` | `test_hermes_hux_policy_matrix.py` (22), `_approvals.py` (22), `_receipts.py` (13) | `hux.autonomy` (needs `hux.activity_timeline`) | Agent hook: `POST /approvals` before any external side effect, `POST /runs/{id}/gate` immediately before executing with the canonical argument hash, `POST /runs/{id}/budget` per turn, `POST /runs/{id}/stop` with `process_registry_empty` from the real process registry; decisions only from human surfaces (worker trust → 403) | +| HUX-03 organization API | `b3de70ba` | `hux/organization.py` | `test_hermes_hux_contract_organization.py` (14) | `hux.projects` | Codex owns the UI + migration of upstream WebUI projects; search over `message_text` is a later increment | + +Known gaps carried to the Wave A review: rate limiting (SO-53), hash-chained audit (SO-46), retention scheduler ownership (Codex cron vs service thread), approval expiry applied lazily on read, linear scans for idempotency/dedupe (fine at documented caps). diff --git a/services/hermes/contracts/hux/VERSION b/services/hermes/contracts/hux/VERSION index 3eefcb9d..9084fa2f 100644 --- a/services/hermes/contracts/hux/VERSION +++ b/services/hermes/contracts/hux/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/services/hermes/contracts/hux/citation.schema.json b/services/hermes/contracts/hux/citation.schema.json index 4162e5e1..87f7704f 100644 --- a/services/hermes/contracts/hux/citation.schema.json +++ b/services/hermes/contracts/hux/citation.schema.json @@ -70,6 +70,9 @@ "type": "string", "description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).", "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "$ref": "common.schema.json#/$defs/revision" } } }, @@ -135,6 +138,9 @@ "type": "string", "description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).", "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "$ref": "common.schema.json#/$defs/revision" } } }, @@ -192,6 +198,9 @@ "type": "string", "description": "Stable key used to merge duplicates: sha256 of normalised uri (source), of source_id+text hash (passage), of message_id+claim+passages (citation).", "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "$ref": "common.schema.json#/$defs/revision" } } }, diff --git a/services/hermes/contracts/hux/common.schema.json b/services/hermes/contracts/hux/common.schema.json index 02167526..aaf73b94 100644 --- a/services/hermes/contracts/hux/common.schema.json +++ b/services/hermes/contracts/hux/common.schema.json @@ -216,7 +216,8 @@ "file", "build", "flux", - "pod" + "pod", + "receipt" ] }, "id": { diff --git a/services/hermes/contracts/hux/flags.json b/services/hermes/contracts/hux/flags.json index 61dbdae5..32c4d706 100644 --- a/services/hermes/contracts/hux/flags.json +++ b/services/hermes/contracts/hux/flags.json @@ -199,5 +199,5 @@ "rollback": "disable flag; release lane evidence archive remains the source of truth" } ], - "contract_version": "1.0.0" + "contract_version": "1.1.0" } diff --git a/services/hermes/contracts/hux/identity.schema.json b/services/hermes/contracts/hux/identity.schema.json index 6dc4a4ac..3807d161 100644 --- a/services/hermes/contracts/hux/identity.schema.json +++ b/services/hermes/contracts/hux/identity.schema.json @@ -125,7 +125,8 @@ "too_large", "rate_limited", "approval_required", - "budget_exhausted" + "budget_exhausted", + "unprocessable" ] }, "message": { diff --git a/services/hermes/contracts/hux/privacy.schema.json b/services/hermes/contracts/hux/privacy.schema.json index 9d2f756d..7f30289e 100644 --- a/services/hermes/contracts/hux/privacy.schema.json +++ b/services/hermes/contracts/hux/privacy.schema.json @@ -4,47 +4,129 @@ "title": "HUX privacy policy, topic scoping and retention", "description": "Sensitive-topic behaviour (HUX-10). The policy names each topic class, whether memory may be written from it, how long conversation-scoped context survives, and which just-in-time notice the surface must show. A retention audit record proves the rules ran.", "$defs": { - "topic": {"type": "string", "enum": ["health", "finance", "legal", "relationships", "credentials", "minors", "location", "biometric"]}, + "topic": { + "type": "string", + "enum": [ + "health", + "finance", + "legal", + "relationships", + "credentials", + "minors", + "location", + "biometric" + ] + }, "policy": { "type": "object", "additionalProperties": false, - "required": ["schema", "version", "topics", "topic_scoping", "retention_audit"], + "required": [ + "schema", + "version", + "topics", + "topic_scoping", + "retention_audit" + ], "properties": { - "schema": {"const": "hux.privacy_policy.v1"}, - "version": {"type": "integer", "minimum": 1}, + "schema": { + "const": "hux.privacy_policy.v1" + }, + "version": { + "type": "integer", + "minimum": 1 + }, "topics": { "type": "array", "minItems": 8, "items": { "type": "object", "additionalProperties": false, - "required": ["topic", "sensitivity", "memory_write", "decay_days", "notice"], + "required": [ + "topic", + "sensitivity", + "memory_write", + "decay_days", + "notice" + ], "properties": { - "topic": {"$ref": "#/$defs/topic"}, - "sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"}, - "memory_write": {"type": "string", "enum": ["ask", "deny"]}, - "decay_days": {"type": "integer", "minimum": 1, "maximum": 365}, - "notice": {"type": "string", "minLength": 1, "maxLength": 280} + "topic": { + "$ref": "#/$defs/topic" + }, + "sensitivity": { + "$ref": "common.schema.json#/$defs/sensitivity" + }, + "memory_write": { + "type": "string", + "enum": [ + "ask", + "deny" + ] + }, + "decay_days": { + "type": "integer", + "minimum": 1, + "maximum": 365 + }, + "notice": { + "type": "string", + "minLength": 1, + "maxLength": 280 + } } } }, "topic_scoping": { "type": "object", "additionalProperties": false, - "required": ["scope_to_conversation", "cross_surface_sharing"], + "required": [ + "scope_to_conversation", + "cross_surface_sharing" + ], "properties": { - "scope_to_conversation": {"const": true}, - "cross_surface_sharing": {"type": "string", "enum": ["never", "same_owner_only"]} + "scope_to_conversation": { + "const": true + }, + "cross_surface_sharing": { + "type": "string", + "enum": [ + "never", + "same_owner_only" + ] + } } }, "retention_audit": { "type": "object", "additionalProperties": false, - "required": ["interval_days", "actions"], + "required": [ + "interval_days", + "actions" + ], "properties": { - "interval_days": {"type": "integer", "minimum": 1, "maximum": 30}, - "actions": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}} + "interval_days": { + "type": "integer", + "minimum": 1, + "maximum": 30 + }, + "actions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "expire_memory", + "decay_topic_context", + "purge_forgotten_content", + "report" + ] + } + } } + }, + "audit_stale": { + "type": "boolean", + "description": "True when the last retention audit is older than 48 hours (SO-27)." } } }, @@ -52,34 +134,95 @@ "type": "object", "description": "Just-in-time notice a surface shows when a sensitive topic is detected. Emitted as event kind privacy.notice.", "additionalProperties": false, - "required": ["schema", "topic", "conversation_id", "text", "controls", "shown_at"], + "required": [ + "schema", + "topic", + "conversation_id", + "text", + "controls", + "shown_at" + ], "properties": { - "schema": {"const": "hux.privacy_notice.v1"}, - "topic": {"$ref": "#/$defs/topic"}, - "conversation_id": {"$ref": "common.schema.json#/$defs/id"}, - "text": {"type": "string", "minLength": 1, "maxLength": 280}, - "controls": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "enum": ["forget_this_conversation", "switch_to_private", "disable_memory_here", "dismiss"]}}, - "shown_at": {"$ref": "common.schema.json#/$defs/timestamp"} + "schema": { + "const": "hux.privacy_notice.v1" + }, + "topic": { + "$ref": "#/$defs/topic" + }, + "conversation_id": { + "$ref": "common.schema.json#/$defs/id" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 280 + }, + "controls": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "forget_this_conversation", + "switch_to_private", + "disable_memory_here", + "dismiss" + ] + } + }, + "shown_at": { + "$ref": "common.schema.json#/$defs/timestamp" + } } }, "audit": { "type": "object", "additionalProperties": false, - "required": ["schema", "id", "ran_at", "policy_version", "results"], + "required": [ + "schema", + "id", + "ran_at", + "policy_version", + "results" + ], "properties": { - "schema": {"const": "hux.retention_audit.v1"}, - "id": {"$ref": "common.schema.json#/$defs/id"}, - "ran_at": {"$ref": "common.schema.json#/$defs/timestamp"}, - "policy_version": {"type": "integer", "minimum": 1}, + "schema": { + "const": "hux.retention_audit.v1" + }, + "id": { + "$ref": "common.schema.json#/$defs/id" + }, + "ran_at": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "policy_version": { + "type": "integer", + "minimum": 1 + }, "results": { "type": "array", "items": { "type": "object", "additionalProperties": false, - "required": ["action", "count"], + "required": [ + "action", + "count" + ], "properties": { - "action": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}, - "count": {"type": "integer", "minimum": 0} + "action": { + "type": "string", + "enum": [ + "expire_memory", + "decay_topic_context", + "purge_forgotten_content", + "report" + ] + }, + "count": { + "type": "integer", + "minimum": 0 + } } } } @@ -87,8 +230,14 @@ } }, "oneOf": [ - {"$ref": "#/$defs/policy"}, - {"$ref": "#/$defs/notice"}, - {"$ref": "#/$defs/audit"} + { + "$ref": "#/$defs/policy" + }, + { + "$ref": "#/$defs/notice" + }, + { + "$ref": "#/$defs/audit" + } ] } diff --git a/testing/tests/test_hermes_hux_artifact_versions.py b/testing/tests/test_hermes_hux_artifact_versions.py index 9801c06f..9a217b00 100644 --- a/testing/tests/test_hermes_hux_artifact_versions.py +++ b/testing/tests/test_hermes_hux_artifact_versions.py @@ -281,19 +281,22 @@ def test_diff_unified_for_text_and_hashes_for_binary(router): def test_promote_sets_promotion_and_emits(router, events): record = make(router) + p1 = call(router, "POST", "/hux/v1/projects", {"name": "one"})[1]["id"] + p2 = call(router, "POST", "/hux/v1/projects", {"name": "two"})[1]["id"] call(router, "POST", f"/hux/v1/artifacts/{record['id']}/versions", {"content": "v2"}, {"If-Match": "1"}) path = f"/hux/v1/artifacts/{record['id']}/promote" - status, error, _ = call(router, "POST", path, {"project_id": "prj_0001aaaa"}, {"If-Match": "1"}) + status, error, _ = call(router, "POST", path, {"project_id": p1}, {"If-Match": "1"}) assert status == 409 - status, promoted, headers = call(router, "POST", path, {"project_id": "prj_0001aaaa"}, {"If-Match": "2"}) + status, promoted, headers = call(router, "POST", path, {"project_id": p1}, {"If-Match": "2"}) assert status == 200 and valid(promoted) and headers["ETag"] == "3" - assert promoted["promotion"]["project_id"] == "prj_0001aaaa" and promoted["promotion"]["version"] == 2 - assert promoted["project_id"] == "prj_0001aaaa" - assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == "prj_0001aaaa" - status, promoted, _ = call(router, "POST", path, {"project_id": "prj_0002aaaa", "version": 1}) + assert promoted["promotion"]["project_id"] == p1 and promoted["promotion"]["version"] == 2 + assert promoted["project_id"] == p1 + assert events[-1][1] == "artifact.promoted" and events[-1][3]["project_id"] == p1 + status, promoted, _ = call(router, "POST", path, {"project_id": p2, "version": 1}) assert status == 200 and promoted["promotion"]["version"] == 1 assert call(router, "POST", path, {"project_id": "nope"})[0] == 400 - assert call(router, "POST", path, {"project_id": "prj_0002aaaa", "version": 7})[0] == 404 + assert call(router, "POST", path, {"project_id": "prj_0009zzzz"})[0] == 404 + assert call(router, "POST", path, {"project_id": p2, "version": 7})[0] == 404 assert call(router, "POST", path, raw=b"1")[0] == 400 from hux import audit rows = [r for r in audit.recent(store.TenantStore(router.data_root, ident())) if r["action"] == "artifacts.promote" and r["outcome"] == "allow"] diff --git a/testing/tests/test_hermes_hux_contract_foundation.py b/testing/tests/test_hermes_hux_contract_foundation.py index 47be483e..412c34a6 100644 --- a/testing/tests/test_hermes_hux_contract_foundation.py +++ b/testing/tests/test_hermes_hux_contract_foundation.py @@ -338,3 +338,28 @@ def test_all_errors_serialise_to_contract(): def test_foundation_sources_stay_under_500_lines(): for path in sorted(FOUNDATION.rglob("*.py")): assert len(path.read_text().splitlines()) <= 500, path + + +def test_per_route_body_cap(tmp_path): + router = _router(tmp_path) + router.add("POST", "/hux/v1/big", "HUX-11", "test.big", lambda request: page([len(json.dumps(request.body))]), max_body=4 * 1024 * 1024) + payload = json.dumps({"blob": "x" * (2 * 1024 * 1024)}).encode() + assert _call(router, "POST", "/hux/v1/big", HEADERS, payload)[0] == 200 + assert _call(router, "POST", "/hux/v1/echo", HEADERS, payload)[0] == 404 + router.add("POST", "/hux/v1/small", "HUX-11", "test.small", lambda request: page([])) + assert _call(router, "POST", "/hux/v1/small", HEADERS, payload)[1]["code"] == "too_large" + + +def test_server_main_wires_environment(tmp_path, monkeypatch): + from hux import server + seen = {} + + class Fake: + def serve_forever(self): + seen["served"] = True + + monkeypatch.setattr(server, "serve", lambda router, host, port: seen.update(root=router.data_root, host=host, port=port) or Fake()) + monkeypatch.setenv("HUX_DATA_ROOT", str(tmp_path)) + monkeypatch.setenv("HUX_PORT", "8791") + server.main() + assert seen == {"root": tmp_path, "host": "127.0.0.1", "port": 8791, "served": True}