hermes(hux): contract 1.1.0 additive revision and Wave A consolidation

Adds receipt evidence kind, 422 unprocessable, optional revision on research
records, audit_stale on the privacy policy, per-route body caps (25 MiB
artifact uploads), promotion checks the project exists, memory rules skip
content-free statuses. Handoff ledger covers every Wave A card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
This commit is contained in:
jenkins 2026-08-24 00:27:38 -03:00
parent 1cb6f07c78
commit 124206b748
16 changed files with 302 additions and 65 deletions

View File

@ -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)

View File

@ -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."""

View File

@ -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"],

View File

@ -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:

View File

@ -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:

View File

@ -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,

View File

@ -60,6 +60,17 @@ format with Codex: a change to a fixture is a change to the contract.
4. Anything outside 13 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.

View File

@ -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 99100%). 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).

View File

@ -1 +1 @@
1.0.0
1.1.0

View File

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

View File

@ -216,7 +216,8 @@
"file",
"build",
"flux",
"pod"
"pod",
"receipt"
]
},
"id": {

View File

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

View File

@ -125,7 +125,8 @@
"too_large",
"rate_limited",
"approval_required",
"budget_exhausted"
"budget_exhausted",
"unprocessable"
]
},
"message": {

View File

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

View File

@ -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"]

View File

@ -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}