diff --git a/docs/hermes_hux_contracts.md b/docs/hermes_hux_contracts.md new file mode 100644 index 00000000..f318f5fa --- /dev/null +++ b/docs/hermes_hux_contracts.md @@ -0,0 +1,135 @@ +# Hermes chat UX program (HUX): shared contracts + +This is the contract-only foundation for the twelve-card program that makes +`chat.bstein.dev` a first-class assistant surface. Nothing here changes runtime +behaviour. It fixes the shapes, state machines and ownership that every later +slice codes against, so the UI side and the backend side can work in parallel +without inventing informal shapes. + +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. + +## Where the backend lives + +The chat product has exactly one repo-owned HTTP surface on the request path: +the Go tenant router (`services/hermes/router/`). It already owns the Keycloak +identity to slot mapping, the admin-surface deny list, durable state on its +PVC, and the JS/CSS bridge it injects into every WebUI page. New HUX APIs are +therefore router routes under `/hux/v1/`, storing per-tenant records on the +tenant PVC beside the existing Telegram media store. The upstream WebUI is a +pinned image patched at build time; UI work goes through the injected bridge +and, where unavoidable, the fail-closed patch scripts in `dockerfiles/`. + +`/hux/v1` returns 404 until `hux.foundation` is enabled. Every other flag +depends on it (see `flags.json`), and the router refuses to serve a flag whose +dependencies are off. + +## Identity and provenance rules + +- Users are `usr_`: the hashed Keycloak subject the router already uses. + Raw subjects, emails and Telegram ids never appear in a HUX record. +- Every record carries `provenance` (surface, actor, time, session/run, route, + build). `route.requested` is a friendly mode or a route id; a vendor model + name never appears in a contract. `build` pins the commit and image digest + so the timeline can say which release produced a decision. +- Evidence is referenced, never inlined (`evidence_ref`). The UI expands a + reference through the API that owns it, which is how redaction stays in one + place. +- `sensitivity` and `redaction` are mandatory on events and memory. Records + leaving a tenant are redacted according to `redaction.level` first. + +## API surface (contract only) + +All routes are tenant-scoped through the router; the caller never names the +tenant. Responses are the records below, wrapped as `{"items": [...], +"next": cursor}` for lists. + +| Area | Routes | Record | +|---|---|---| +| Events (HUX-01) | `GET /hux/v1/conversations/{id}/events?after_seq=N` (JSON), `GET .../events/stream` (SSE, `id:` = seq) | `hux.event.v1` | +| Memory (HUX-02) | `GET/POST /hux/v1/memory`, `POST /hux/v1/memory/{id}/{approve,reject,forget}`, `GET /hux/v1/memory/export` | `hux.memory.v1` | +| Projects (HUX-03) | `GET/POST/PATCH /hux/v1/projects`, `GET/PATCH /hux/v1/conversations`, `POST /hux/v1/conversations/{id}/branch`, `GET /hux/v1/search?q=` | `hux.project.v1`, `hux.conversation.v1` | +| 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` | +| 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` | +| 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` | +| 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, +lineage for variants) and events for transcript corrections +(`kind: artifact.version` on the transcript artifact). It has no schema of +its own by design. + +## State machines and rules that are fixed here + +- **Memory** is a ledger: `proposed -> active|rejected`, `active -> + expired|forgotten`, `expired -> forgotten`. Nothing edits content in place; + a correction is a new entry whose `source` points at the old one. Sensitive + entries need `approval_mode: ask` and must expire or decay; restricted + content and the credentials/minors/location/biometric topics are never + written. Forgotten entries keep their audit trail and drop their content. +- **Autonomy** has three levels. Read-only capabilities are always allowed, + mutating ones ask unless the level is `autonomous`, `network` is denied in + `safe`, and `deploy` always asks whatever the level or grant says. Explicit + grants override the matrix; an unexpired `deny` beats everything. See + `default_capability_matrix()` and `effective_decision()`. +- **Approvals** move once from `pending` and are then terminal. Choices are + the gateway's existing `once|session|always|deny`. +- **Cancellation** produces a receipt with an outcome and the side effects + observed, each marked reverted or not. "Stop" is not done until the receipt + exists. +- **Modes** are intents mapped to Switchyard constraints, never to a vendor. + Fast, Thoughtful, Research and Create all allow both hosted providers; + Private is local-only, memory off, web off, ephemeral, and cannot be + overridden to a hosted route. The advanced drawer may pin an exact + `atlas/manual/...` route id; the test suite checks each default route + exists in `switchyard-configmap.yaml`. +- **Privacy** scopes sensitive topics to the conversation, never shares them + across surfaces, decays them within 30 days, and shows a just-in-time notice + with real controls (`forget_this_conversation`, `switch_to_private`, + `disable_memory_here`, `dismiss`). A daily retention audit record proves the + rules ran. +- **Suggestions** need a trigger context, are always dismissable, honour + `max_shows` (at most 5) and a cooldown of at least an hour, and stop for + good on `never_again`, dismissal or action. +- **Release** is linear: `reviewed -> merged -> built -> verified -> + deployed -> converged -> live_verified`, with `rolled_back` reachable from + any state that has an image. Each step names its evidence: merge commit, + CI build plus image digest, Harbor digest equal to the image digest, Flux + revision, running pod digest equal to the image digest, passing health + probe. `release_transition_problems()` is the single source of truth the + Worker's task state must call before it claims anything is deployed. + +## Cards, owners, waves, flags + +`flags.json` is the registry. All twelve flags default off and are read from +the `HUX_FLAGS` comma list; a flag counts only when its dependency chain is on. + +| Card | Flag | Wave | Backend (Claude) | Frontend (Codex) | +|---|---|---|---|---| +| HUX-11 Shared foundation | `hux.foundation` | A | `/hux/v1` router scaffold, provenance, redaction | Bridge integration in Chat, Worker, Telegram, voice | +| HUX-01 Activity timeline | `hux.activity_timeline` | A | Event store and stream | Timeline UI, decision cards, evidence expansion | +| HUX-02 Memory control | `hux.memory_control` | A | Ledger, provenance, TTL, approval, migration | Memory page, per-message controls, export | +| HUX-05 Autonomy controls | `hux.autonomy` | A | Policy engine, grants, budgets, approvals, receipts | Ask First/Safe/Autonomous, permission matrix, stop UX | +| HUX-10 Privacy behaviour | `hux.privacy` | A | Threat model, topic policy, scoping, audit | Notices, controls, regression tests | +| HUX-12 Deployment follow-through | `hux.release_followthrough` | A | Release state machine in the Worker | Exact-SHA CI, Flux convergence, live evidence, rollback UI | +| HUX-03 Projects/chats | `hux.projects` | B | Data model, indexes, branch lineage | Navigation, search, branch graph, migration UI | +| HUX-04 Artifact workspace | `hux.artifacts` | B | Typed versions, diffs, lineage, promotion | Preview/edit/diff/share workspace | +| HUX-06 Friendly modes | `hux.friendly_modes` | B | Mode catalog and Switchyard mapping | Chooser and advanced route drawer | +| HUX-08 Research/citations | `hux.research` | B | Sources, passages, citations, notebook | Citation strip, source drawer, integrity E2E | +| HUX-07 Multimodal | `hux.multimodal` | C | Attachment lineage, transcript correction, permissions | Drag/drop, previews, annotation, consent | +| HUX-09 Onboarding | `hux.onboarding` | C | Suggestion policy and suppression | Dismissible UI, restraint tests | + +## Versioning + +Record schemas carry their version in the `schema` field (`hux.event.v1`). +Adding optional fields is a compatible change; anything else is a `v2` record +served beside `v1` until every surface has moved. Consumers must ignore +unknown optional fields and must not reuse record ids across versions. diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md index 6bc5b605..70f8cf25 100644 --- a/services/hermes/NOTES.md +++ b/services/hermes/NOTES.md @@ -12,6 +12,14 @@ configuration surfaces. `/terminal/` remains the raw full-screen TUI. The community WebUI is intentionally reserved for isolated consumer chat, where its conversation-first layout is a better fit. +## Chat UX program (HUX) contracts + +The twelve-card chat improvement program codes against the contracts in +`services/hermes/contracts/hux/` (schemas, examples, `flags.json`) and the +rules in `scripts/hux_policy.py`. Read `docs/hermes_hux_contracts.md` before +adding a route to the tenant router or a panel to the WebUI bridge; every +feature is behind a `HUX_FLAGS` entry that defaults off. + ## Consumer chat and Telegram `chat.bstein.dev` uses the pinned Hermes WebUI rather than the operator diff --git a/services/hermes/contracts/hux/artifact.schema.json b/services/hermes/contracts/hux/artifact.schema.json new file mode 100644 index 00000000..8e1efae7 --- /dev/null +++ b/services/hermes/contracts/hux/artifact.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/artifact.schema.json", + "title": "HUX artifact", + "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"], + "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}, + "versions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "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}, + "content_ref": { + "type": "object", + "additionalProperties": false, + "required": ["hash", "bytes", "mime"], + "properties": { + "hash": {"$ref": "common.schema.json#/$defs/sha256"}, + "bytes": {"type": "integer", "minimum": 0}, + "mime": {"type": "string", "maxLength": 120} + } + }, + "diff_from": {"type": "integer", "minimum": 1}, + "lineage": { + "type": "object", + "additionalProperties": false, + "required": ["artifact_id", "version"], + "properties": { + "artifact_id": {"$ref": "common.schema.json#/$defs/id"}, + "version": {"type": "integer", "minimum": 1} + } + }, + "note": {"type": "string", "maxLength": 200} + } + } + }, + "promotion": { + "type": "object", + "additionalProperties": false, + "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"} + } + }, + "sensitivity": {"$ref": "common.schema.json#/$defs/sensitivity"}, + "created_at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "updated_at": {"$ref": "common.schema.json#/$defs/timestamp"} + } +} diff --git a/services/hermes/contracts/hux/citation.schema.json b/services/hermes/contracts/hux/citation.schema.json new file mode 100644 index 00000000..0c21695d --- /dev/null +++ b/services/hermes/contracts/hux/citation.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/citation.schema.json", + "title": "HUX sources, passages, citations, research notebook", + "description": "Research model (HUX-08). A source is where evidence came from; a passage is the exact excerpt; a citation ties a claim in a message to passages with an explicit support verdict; a notebook collects them per research question with assumptions and unresolved questions kept visible.", + "$defs": { + "source": { + "type": "object", + "additionalProperties": false, + "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"} + } + }, + "passage": { + "type": "object", + "additionalProperties": false, + "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"}, + "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} + } + } + } + }, + "citation": { + "type": "object", + "additionalProperties": false, + "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} + } + }, + "notebook": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "conversation_id", "question", "status", "source_ids", "passage_ids", "citation_ids", "assumptions", "unresolved_questions", "updated_at"], + "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"} + } + } + }, + "oneOf": [ + {"$ref": "#/$defs/source"}, + {"$ref": "#/$defs/passage"}, + {"$ref": "#/$defs/citation"}, + {"$ref": "#/$defs/notebook"} + ] +} diff --git a/services/hermes/contracts/hux/common.schema.json b/services/hermes/contracts/hux/common.schema.json new file mode 100644 index 00000000..0ae1dd0f --- /dev/null +++ b/services/hermes/contracts/hux/common.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/common.schema.json", + "title": "HUX shared definitions", + "description": "Cross-surface primitives reused by every HUX contract. Identity, time, provenance, sensitivity and evidence are defined once here so Chat, Worker, Telegram, voice and future clients agree on them.", + "$defs": { + "id": { + "type": "string", + "description": "Opaque, prefix-typed identifier. Prefix names the record kind (evt, mem, prj, conv, art, pol, apr, rcpt, src, psg, cit, nb, sug, rel).", + "pattern": "^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$" + }, + "user_ref": { + "type": "string", + "description": "Hashed Keycloak subject as already used by the chat router. Never a raw subject, email, or Telegram id.", + "pattern": "^usr_[0-9a-f]{16,64}$" + }, + "timestamp": { + "type": "string", + "description": "RFC 3339 UTC timestamp with a trailing Z.", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "surface": { + "type": "string", + "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"] + }, + "effort": { + "type": "string", + "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"] + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["level"], + "properties": { + "level": {"type": "string", "enum": ["none", "partial", "full"]}, + "reason": {"type": "string", "maxLength": 200} + } + }, + "actor": { + "type": "object", + "additionalProperties": false, + "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} + } + }, + "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"], + "properties": { + "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"} + } + }, + "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"], + "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"} + } + }, + "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"], + "properties": { + "kind": { + "type": "string", + "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"} + } + }, + "tags": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"} + } + } +} diff --git a/services/hermes/contracts/hux/event.schema.json b/services/hermes/contracts/hux/event.schema.json new file mode 100644 index 00000000..0a4a8788 --- /dev/null +++ b/services/hermes/contracts/hux/event.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/event.schema.json", + "title": "HUX activity event", + "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"], + "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}, + "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", + "citation.attached", + "mode.changed", + "run.started", "run.cancelled", "run.completed", "run.failed", + "privacy.notice", + "suggestion.shown", "suggestion.dismissed", + "release.transition" + ] + }, + "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"} + } +} diff --git a/services/hermes/contracts/hux/examples/approval.json b/services/hermes/contracts/hux/examples/approval.json new file mode 100644 index 00000000..6da1c8b6 --- /dev/null +++ b/services/hermes/contracts/hux/examples/approval.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/artifact.json b/services/hermes/contracts/hux/examples/artifact.json new file mode 100644 index 00000000..7ac57de8 --- /dev/null +++ b/services/hermes/contracts/hux/examples/artifact.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/cancel_receipt.json b/services/hermes/contracts/hux/examples/cancel_receipt.json new file mode 100644 index 00000000..2fb50533 --- /dev/null +++ b/services/hermes/contracts/hux/examples/cancel_receipt.json @@ -0,0 +1 @@ +{"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"}}]} diff --git a/services/hermes/contracts/hux/examples/citation.json b/services/hermes/contracts/hux/examples/citation.json new file mode 100644 index 00000000..8da220f2 --- /dev/null +++ b/services/hermes/contracts/hux/examples/citation.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/conversation.json b/services/hermes/contracts/hux/examples/conversation.json new file mode 100644 index 00000000..8c02212d --- /dev/null +++ b/services/hermes/contracts/hux/examples/conversation.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/event.json b/services/hermes/contracts/hux/examples/event.json new file mode 100644 index 00000000..f63fd30c --- /dev/null +++ b/services/hermes/contracts/hux/examples/event.json @@ -0,0 +1 @@ +{"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"}} diff --git a/services/hermes/contracts/hux/examples/memory.json b/services/hermes/contracts/hux/examples/memory.json new file mode 100644 index 00000000..6ed79d61 --- /dev/null +++ b/services/hermes/contracts/hux/examples/memory.json @@ -0,0 +1 @@ +{"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"}}]} diff --git a/services/hermes/contracts/hux/examples/notebook.json b/services/hermes/contracts/hux/examples/notebook.json new file mode 100644 index 00000000..cc2795c2 --- /dev/null +++ b/services/hermes/contracts/hux/examples/notebook.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/passage.json b/services/hermes/contracts/hux/examples/passage.json new file mode 100644 index 00000000..4d738beb --- /dev/null +++ b/services/hermes/contracts/hux/examples/passage.json @@ -0,0 +1 @@ +{"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}} diff --git a/services/hermes/contracts/hux/examples/policy.json b/services/hermes/contracts/hux/examples/policy.json new file mode 100644 index 00000000..7a5bda96 --- /dev/null +++ b/services/hermes/contracts/hux/examples/policy.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/privacy_notice.json b/services/hermes/contracts/hux/examples/privacy_notice.json new file mode 100644 index 00000000..c7d74c51 --- /dev/null +++ b/services/hermes/contracts/hux/examples/privacy_notice.json @@ -0,0 +1 @@ +{"schema":"hux.privacy_notice.v1","topic":"health","conversation_id":"conv_0001abcd","text":"This looks like a health topic. It stays in this conversation and is not remembered unless you say so.","controls":["forget_this_conversation","switch_to_private","disable_memory_here","dismiss"],"shown_at":"2026-08-23T10:00:00Z"} diff --git a/services/hermes/contracts/hux/examples/project.json b/services/hermes/contracts/hux/examples/project.json new file mode 100644 index 00000000..f22d2ef7 --- /dev/null +++ b/services/hermes/contracts/hux/examples/project.json @@ -0,0 +1 @@ +{"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"} diff --git a/services/hermes/contracts/hux/examples/release.json b/services/hermes/contracts/hux/examples/release.json new file mode 100644 index 00000000..e061b045 --- /dev/null +++ b/services/hermes/contracts/hux/examples/release.json @@ -0,0 +1 @@ +{"schema":"hux.release.v1","id":"rel_0001aaaa","workload":"hermes-chat-router","feature_flags":["hux.foundation"],"commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","state":"built","evidence":{"review_url":"https://gitea.bstein.dev/atlas/titan-iac/pulls/1","merge_commit":"d3cbeb06d3cbeb06d3cbeb06d3cbeb06d3cbeb06","ci_build_url":"https://jenkins.bstein.dev/job/hermes-chat-router/12/","image_ref":"registry.bstein.dev/bstein/hermes-chat-router:git-d3cbeb06-build-12@sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd","image_digest":"sha256:4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd4a385fbd"},"transitions":[{"from":"reviewed","to":"merged","at":"2026-08-23T08:00:00Z","by":{"type":"operator","id":"brad"},"evidence":[{"kind":"build","id":"merge"}]},{"from":"merged","to":"built","at":"2026-08-23T08:30:00Z","by":{"type":"system","id":"jenkins"},"evidence":[{"kind":"build","id":"12"}]}]} diff --git a/services/hermes/contracts/hux/examples/retention_audit.json b/services/hermes/contracts/hux/examples/retention_audit.json new file mode 100644 index 00000000..27bfa425 --- /dev/null +++ b/services/hermes/contracts/hux/examples/retention_audit.json @@ -0,0 +1 @@ +{"schema":"hux.retention_audit.v1","id":"aud_0001aaaa","ran_at":"2026-08-23T03:00:00Z","policy_version":1,"results":[{"action":"expire_memory","count":2},{"action":"decay_topic_context","count":1},{"action":"purge_forgotten_content","count":0},{"action":"report","count":1}]} diff --git a/services/hermes/contracts/hux/examples/source.json b/services/hermes/contracts/hux/examples/source.json new file mode 100644 index 00000000..8bd0ecdb --- /dev/null +++ b/services/hermes/contracts/hux/examples/source.json @@ -0,0 +1 @@ +{"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"}}} diff --git a/services/hermes/contracts/hux/examples/suggestion.json b/services/hermes/contracts/hux/examples/suggestion.json new file mode 100644 index 00000000..44ce044d --- /dev/null +++ b/services/hermes/contracts/hux/examples/suggestion.json @@ -0,0 +1 @@ +{"schema":"hux.suggestion.v1","id":"sug_0001aaaa","kind":"project","trigger":{"surface":"chat","context":"after_artifact"},"title":"Keep this in a project?","body":"You have three related chats and an artifact. A project keeps them together.","action":{"type":"create_project","payload":{"prefill_title":"Kitchen renovation"}},"priority":40,"suppression":{"dismissable":true,"max_shows":2,"cooldown_seconds":86400,"never_again_supported":true}} diff --git a/services/hermes/contracts/hux/examples/suggestion_state.json b/services/hermes/contracts/hux/examples/suggestion_state.json new file mode 100644 index 00000000..dd931b7b --- /dev/null +++ b/services/hermes/contracts/hux/examples/suggestion_state.json @@ -0,0 +1 @@ +{"schema":"hux.suggestion_state.v1","owner":"usr_0123456789abcdef","suggestion_id":"sug_0001aaaa","shows":1,"last_shown_at":"2026-08-22T10:00:00Z","never_again":false} diff --git a/services/hermes/contracts/hux/flags.json b/services/hermes/contracts/hux/flags.json new file mode 100644 index 00000000..85b8b654 --- /dev/null +++ b/services/hermes/contracts/hux/flags.json @@ -0,0 +1,18 @@ +{ + "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"} + ] +} diff --git a/services/hermes/contracts/hux/memory.schema.json b/services/hermes/contracts/hux/memory.schema.json new file mode 100644 index 00000000..dfc39c48 --- /dev/null +++ b/services/hermes/contracts/hux/memory.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/memory.schema.json", + "title": "HUX memory ledger entry", + "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"], + "properties": { + "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"], + "properties": { + "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"]}, + "ttl": { + "type": "object", + "additionalProperties": false, + "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} + } + }, + "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"], + "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} + } + } + } + } +} diff --git a/services/hermes/contracts/hux/mode.schema.json b/services/hermes/contracts/hux/mode.schema.json new file mode 100644 index 00000000..3a610120 --- /dev/null +++ b/services/hermes/contracts/hux/mode.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/mode.schema.json", + "title": "HUX friendly mode intent", + "description": "Stable intent contract for Fast/Thoughtful/Research/Create/Private (HUX-06). A mode never names a vendor model; it names constraints that Switchyard resolves. The advanced drawer may pin an exact route id, which must exist in the Switchyard catalog.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "mode", "label", "intent", "constraints", "switchyard"], + "properties": { + "schema": {"const": "hux.mode.v1"}, + "mode": {"type": "string", "enum": ["fast", "thoughtful", "research", "create", "private"]}, + "label": {"type": "string", "minLength": 1, "maxLength": 40}, + "intent": {"type": "string", "minLength": 1, "maxLength": 400}, + "constraints": { + "type": "object", + "additionalProperties": false, + "required": ["providers", "local_only", "effort", "tools", "memory", "citations_required", "retention"], + "properties": { + "providers": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "common.schema.json#/$defs/provider"}}, + "local_only": {"type": "boolean"}, + "effort": { + "type": "object", + "additionalProperties": false, + "required": ["min", "max"], + "properties": { + "min": {"$ref": "common.schema.json#/$defs/effort"}, + "max": {"$ref": "common.schema.json#/$defs/effort"} + } + }, + "tools": { + "type": "object", + "additionalProperties": false, + "required": ["web", "shell", "artifacts", "delegate"], + "properties": { + "web": {"type": "string", "enum": ["required", "allowed", "denied"]}, + "shell": {"type": "string", "enum": ["allowed", "denied"]}, + "artifacts": {"type": "string", "enum": ["encouraged", "allowed", "denied"]}, + "delegate": {"type": "string", "enum": ["allowed", "denied"]} + } + }, + "memory": { + "type": "object", + "additionalProperties": false, + "required": ["read", "write"], + "properties": {"read": {"type": "boolean"}, "write": {"type": "boolean"}} + }, + "citations_required": {"type": "boolean"}, + "retention": {"type": "string", "enum": ["default", "ephemeral"]} + } + }, + "switchyard": { + "type": "object", + "additionalProperties": false, + "required": ["route_id"], + "properties": { + "route_id": {"type": "string", "pattern": "^atlas/(auto|manual|fallback|worker)/[a-z0-9/-]+$"}, + "override_route_id": {"type": "string", "pattern": "^atlas/(auto|manual)/[a-z0-9/-]+$"} + } + } + } +} diff --git a/services/hermes/contracts/hux/permission.schema.json b/services/hermes/contracts/hux/permission.schema.json new file mode 100644 index 00000000..2d87eab4 --- /dev/null +++ b/services/hermes/contracts/hux/permission.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/permission.schema.json", + "title": "HUX autonomy, grants, approvals, cancellation", + "description": "Policy engine records (HUX-05). A policy fixes the autonomy level and explicit capability grants for a scope; approvals are queued items answered with the existing gateway choices (once|session|always|deny); a cancellation receipt proves what a stop actually did.", + "$defs": { + "capability": { + "type": "string", + "enum": ["read_files", "write_files", "shell", "network", "web_search", "send_message", "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy"] + }, + "decision": {"type": "string", "enum": ["allow", "ask", "deny"]}, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "owner", "scope", "autonomy", "grants", "budgets", "provenance", "updated_at"], + "properties": { + "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"], + "properties": { + "level": {"type": "string", "enum": ["global", "project", "conversation"]}, + "scope_id": {"$ref": "common.schema.json#/$defs/id"} + } + }, + "autonomy": {"type": "string", "enum": ["ask_first", "safe", "autonomous"]}, + "grants": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "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"} + } + } + }, + "budgets": { + "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} + } + }, + "provenance": {"$ref": "common.schema.json#/$defs/provenance"}, + "updated_at": {"$ref": "common.schema.json#/$defs/timestamp"} + } + }, + "approval": { + "type": "object", + "additionalProperties": false, + "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"}, + "request": { + "type": "object", + "additionalProperties": false, + "required": ["summary", "risk"], + "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"}} + } + }, + "status": {"type": "string", "enum": ["pending", "approved", "denied", "expired", "cancelled"]}, + "decision": { + "type": "object", + "additionalProperties": false, + "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"} + } + }, + "requested_at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "expires_at": {"$ref": "common.schema.json#/$defs/timestamp"} + } + }, + "cancellation_receipt": { + "type": "object", + "additionalProperties": false, + "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"]}, + "side_effects": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["description", "reverted"], + "properties": { + "description": {"type": "string", "minLength": 1, "maxLength": 280}, + "reverted": {"type": "boolean"}, + "evidence": {"$ref": "common.schema.json#/$defs/evidence_ref"} + } + } + } + } + } + }, + "oneOf": [ + {"$ref": "#/$defs/policy"}, + {"$ref": "#/$defs/approval"}, + {"$ref": "#/$defs/cancellation_receipt"} + ] +} diff --git a/services/hermes/contracts/hux/privacy.schema.json b/services/hermes/contracts/hux/privacy.schema.json new file mode 100644 index 00000000..9d2f756d --- /dev/null +++ b/services/hermes/contracts/hux/privacy.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/privacy.schema.json", + "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"]}, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "version", "topics", "topic_scoping", "retention_audit"], + "properties": { + "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"], + "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_scoping": { + "type": "object", + "additionalProperties": false, + "required": ["scope_to_conversation", "cross_surface_sharing"], + "properties": { + "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"], + "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"]}} + } + } + } + }, + "notice": { + "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"], + "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"} + } + }, + "audit": { + "type": "object", + "additionalProperties": false, + "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}, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["action", "count"], + "properties": { + "action": {"type": "string", "enum": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"]}, + "count": {"type": "integer", "minimum": 0} + } + } + } + } + } + }, + "oneOf": [ + {"$ref": "#/$defs/policy"}, + {"$ref": "#/$defs/notice"}, + {"$ref": "#/$defs/audit"} + ] +} diff --git a/services/hermes/contracts/hux/project.schema.json b/services/hermes/contracts/hux/project.schema.json new file mode 100644 index 00000000..db41955c --- /dev/null +++ b/services/hermes/contracts/hux/project.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/project.schema.json", + "title": "HUX projects and conversations", + "description": "Organisation model for chats (HUX-03): projects own conversations and promoted artifacts; conversations carry tags, pins and branch lineage. Search indexes the fields listed in $defs/search_index.", + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "owner", "name", "tags", "pinned", "archived", "created_at", "updated_at"], + "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"} + } + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "owner", "title", "tags", "pinned", "archived", "artifact_ids", "created_at", "updated_at"], + "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"]}, + "branch": { + "type": "object", + "additionalProperties": false, + "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} + } + }, + "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"} + } + }, + "search_index": { + "type": "object", + "description": "Fields the tenant indexes; the UI may not assume anything else is searchable.", + "additionalProperties": false, + "required": ["conversation"], + "properties": { + "conversation": {"type": "array", "items": {"type": "string", "enum": ["title", "tags", "message_text", "artifact_titles", "project_name"]}} + } + } + }, + "oneOf": [ + {"$ref": "#/$defs/project"}, + {"$ref": "#/$defs/conversation"} + ] +} diff --git a/services/hermes/contracts/hux/release.schema.json b/services/hermes/contracts/hux/release.schema.json new file mode 100644 index 00000000..5d48e57c --- /dev/null +++ b/services/hermes/contracts/hux/release.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/release.schema.json", + "title": "HUX release state", + "description": "Deployment follow-through (HUX-12). One record per reviewed commit per workload. The state machine is linear and every forward transition requires named evidence, so merged, built and deployed can never be conflated: built needs a Harbor digest, deployed needs the Flux revision, converged needs the running pod digest to equal the built digest, live_verified needs a health probe.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "workload", "commit", "state", "evidence", "transitions"], + "properties": { + "schema": {"const": "hux.release.v1"}, + "id": {"$ref": "common.schema.json#/$defs/id"}, + "workload": {"type": "string", "enum": ["hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"]}, + "feature_flags": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^hux\\.[a-z_]+$"}}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "state": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]}, + "evidence": { + "type": "object", + "additionalProperties": false, + "properties": { + "review_url": {"type": "string", "maxLength": 500}, + "merge_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "ci_build_url": {"type": "string", "maxLength": 500}, + "image_ref": {"type": "string", "pattern": "^[a-z0-9./-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$"}, + "image_digest": {"$ref": "common.schema.json#/$defs/sha256"}, + "harbor_digest": {"$ref": "common.schema.json#/$defs/sha256"}, + "flux_revision": {"type": "string", "pattern": "^main@sha1:[0-9a-f]{40}$"}, + "pod_digest": {"$ref": "common.schema.json#/$defs/sha256"}, + "health_check": { + "type": "object", + "additionalProperties": false, + "required": ["url", "status", "at"], + "properties": { + "url": {"type": "string", "maxLength": 500}, + "status": {"type": "string", "enum": ["pass", "fail"]}, + "at": {"$ref": "common.schema.json#/$defs/timestamp"} + } + }, + "rollback_target": {"$ref": "common.schema.json#/$defs/sha256"} + } + }, + "transitions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to", "at", "by"], + "properties": { + "from": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]}, + "to": {"type": "string", "enum": ["reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified", "rolled_back"]}, + "at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "by": {"$ref": "common.schema.json#/$defs/actor"}, + "evidence": {"type": "array", "items": {"$ref": "common.schema.json#/$defs/evidence_ref"}} + } + } + } + } +} diff --git a/services/hermes/contracts/hux/suggestion.schema.json b/services/hermes/contracts/hux/suggestion.schema.json new file mode 100644 index 00000000..a45bb776 --- /dev/null +++ b/services/hermes/contracts/hux/suggestion.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hermes.bstein.dev/contracts/hux/v1/suggestion.schema.json", + "title": "HUX onboarding suggestion and suppression state", + "description": "Restrained onboarding (HUX-09). A suggestion declares exactly when it may appear and how often; suppression state is per user and wins over the suggestion. Nothing is shown without a trigger, and every suggestion is dismissable.", + "$defs": { + "suggestion": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "id", "kind", "trigger", "title", "body", "priority", "suppression"], + "properties": { + "schema": {"const": "hux.suggestion.v1"}, + "id": {"$ref": "common.schema.json#/$defs/id"}, + "kind": {"type": "string", "enum": ["workflow", "project", "feature", "tip"]}, + "trigger": { + "type": "object", + "additionalProperties": false, + "required": ["surface", "context"], + "properties": { + "surface": {"$ref": "common.schema.json#/$defs/surface"}, + "context": {"type": "string", "enum": ["first_session", "empty_project", "after_artifact", "after_research", "after_approval", "idle"]} + } + }, + "title": {"type": "string", "minLength": 1, "maxLength": 80}, + "body": {"type": "string", "minLength": 1, "maxLength": 280}, + "action": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": {"type": "string", "enum": ["open_mode", "create_project", "open_memory", "open_artifacts", "start_workflow", "none"]}, + "payload": {"type": "object"} + } + }, + "priority": {"type": "integer", "minimum": 0, "maximum": 100}, + "suppression": { + "type": "object", + "additionalProperties": false, + "required": ["dismissable", "max_shows", "cooldown_seconds", "never_again_supported"], + "properties": { + "dismissable": {"const": true}, + "max_shows": {"type": "integer", "minimum": 1, "maximum": 5}, + "cooldown_seconds": {"type": "integer", "minimum": 3600}, + "never_again_supported": {"const": true} + } + } + } + }, + "state": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "owner", "suggestion_id", "shows", "never_again"], + "properties": { + "schema": {"const": "hux.suggestion_state.v1"}, + "owner": {"$ref": "common.schema.json#/$defs/user_ref"}, + "suggestion_id": {"$ref": "common.schema.json#/$defs/id"}, + "shows": {"type": "integer", "minimum": 0}, + "last_shown_at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "dismissed_at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "acted_at": {"$ref": "common.schema.json#/$defs/timestamp"}, + "never_again": {"type": "boolean"} + } + } + }, + "oneOf": [ + {"$ref": "#/$defs/suggestion"}, + {"$ref": "#/$defs/state"} + ] +} diff --git a/services/hermes/scripts/hux_contracts.py b/services/hermes/scripts/hux_contracts.py new file mode 100644 index 00000000..5c466594 --- /dev/null +++ b/services/hermes/scripts/hux_contracts.py @@ -0,0 +1,237 @@ +"""Load and validate the HUX (Hermes user-experience) contract schemas. + +The schemas under ``services/hermes/contracts/hux`` are plain JSON Schema +2020-12 so browser and Go consumers can validate with their usual libraries. +CI has no ``jsonschema`` package, so this module carries a small validator for +the keyword subset the contracts actually use. Unsupported keywords fail +loudly rather than silently passing. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "hux" +SCHEMA_FILES = ( + "common.schema.json", + "event.schema.json", + "memory.schema.json", + "project.schema.json", + "artifact.schema.json", + "permission.schema.json", + "mode.schema.json", + "citation.schema.json", + "suggestion.schema.json", + "privacy.schema.json", + "release.schema.json", +) +SUPPORTED_KEYWORDS = frozenset( + { + "$schema", "$id", "$defs", "$ref", "title", "description", + "type", "const", "enum", "required", "properties", + "additionalProperties", "items", "minItems", "maxItems", + "uniqueItems", "minLength", "maxLength", "pattern", + "minimum", "maximum", "oneOf", + } +) +_TYPE_CHECKS = { + "object": lambda v: isinstance(v, dict), + "array": lambda v: isinstance(v, list), + "string": lambda v: isinstance(v, str), + "boolean": lambda v: isinstance(v, bool), + "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), + "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), + "null": lambda v: v is None, +} + + +class ContractError(ValueError): + """Raised when a schema uses something this validator does not support.""" + + +def load_schema(name: str, directory: Path = CONTRACT_DIR) -> dict[str, Any]: + """Read one schema file by name.""" + return json.loads((directory / name).read_text(encoding="utf-8")) + + +def load_all(directory: Path = CONTRACT_DIR) -> dict[str, dict[str, Any]]: + """Read every contract schema keyed by file name.""" + return {name: load_schema(name, directory) for name in SCHEMA_FILES} + + +def load_flags(directory: Path = CONTRACT_DIR) -> dict[str, Any]: + """Read the feature flag registry.""" + return json.loads((directory / "flags.json").read_text(encoding="utf-8")) + + +def _walk(node: Any, path: str, problems: list[str]) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if path.endswith("/properties") or path.endswith("/$defs"): + _walk(value, f"{path}/{key}", problems) + continue + if key not in SUPPORTED_KEYWORDS: + problems.append(f"{path}/{key}") + continue + _walk(value, f"{path}/{key}", problems) + elif isinstance(node, list): + for index, value in enumerate(node): + _walk(value, f"{path}/{index}", problems) + + +def unsupported_keywords(schema: dict[str, Any]) -> list[str]: + """Return JSON-pointer style paths of keywords the validator ignores.""" + problems: list[str] = [] + _walk(schema, "#", problems) + return problems + + +def _resolve_ref(ref: str, current: str, schemas: dict[str, dict[str, Any]]) -> tuple[dict[str, Any], str]: + file_part, _, pointer = ref.partition("#") + file_name = file_part or current + if file_name not in schemas: + raise ContractError(f"unknown schema reference {ref!r}") + node: Any = schemas[file_name] + for token in [t for t in pointer.split("/") if t]: + if not isinstance(node, dict) or token not in node: + raise ContractError(f"unresolvable pointer {ref!r}") + node = node[token] + return node, file_name + + +def _check_type(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> bool: + expected = schema.get("type") + if expected is None: + return True + if expected not in _TYPE_CHECKS: + raise ContractError(f"unsupported type {expected!r} at {path}") + if not _TYPE_CHECKS[expected](value): + errors.append(f"{path}: expected {expected}") + return False + return True + + +def _check_scalars(schema: dict[str, Any], value: Any, path: str, errors: list[str]) -> None: + if "const" in schema and value != schema["const"]: + errors.append(f"{path}: expected constant {schema['const']!r}") + if "enum" in schema and value not in schema["enum"]: + errors.append(f"{path}: {value!r} not in enum") + if isinstance(value, str): + if "minLength" in schema and len(value) < schema["minLength"]: + errors.append(f"{path}: shorter than {schema['minLength']}") + if "maxLength" in schema and len(value) > schema["maxLength"]: + errors.append(f"{path}: longer than {schema['maxLength']}") + if "pattern" in schema and not re.search(schema["pattern"], value): + errors.append(f"{path}: does not match {schema['pattern']!r}") + if isinstance(value, (int, float)) and not isinstance(value, bool): + if "minimum" in schema and value < schema["minimum"]: + errors.append(f"{path}: below minimum {schema['minimum']}") + if "maximum" in schema and value > schema["maximum"]: + errors.append(f"{path}: above maximum {schema['maximum']}") + + +def _check_object(schema, value, path, errors, current, schemas) -> None: + properties = schema.get("properties", {}) + for key in schema.get("required", []): + if key not in value: + errors.append(f"{path}: missing required {key!r}") + for key, item in value.items(): + if key in properties: + _validate(properties[key], item, f"{path}/{key}", errors, current, schemas) + elif schema.get("additionalProperties") is False: + errors.append(f"{path}: unexpected property {key!r}") + + +def _check_array(schema, value, path, errors, current, schemas) -> None: + if "minItems" in schema and len(value) < schema["minItems"]: + errors.append(f"{path}: fewer than {schema['minItems']} items") + if "maxItems" in schema and len(value) > schema["maxItems"]: + errors.append(f"{path}: more than {schema['maxItems']} items") + if schema.get("uniqueItems"): + seen = [json.dumps(item, sort_keys=True) for item in value] + if len(set(seen)) != len(seen): + errors.append(f"{path}: items are not unique") + if "items" in schema: + for index, item in enumerate(value): + _validate(schema["items"], item, f"{path}/{index}", errors, current, schemas) + + +def _check_one_of(schema, value, path, errors, current, schemas) -> None: + attempts: list[list[str]] = [] + for option in schema["oneOf"]: + sub: list[str] = [] + _validate(option, value, path, sub, current, schemas) + attempts.append(sub) + matches = sum(not sub for sub in attempts) + if matches != 1: + errors.append(f"{path}: matched {matches} oneOf branches, expected exactly 1") + if matches == 0: + errors.extend(min(attempts, key=len)) + + +def _validate(schema, value, path, errors, current, schemas) -> None: + if "$ref" in schema: + target, file_name = _resolve_ref(schema["$ref"], current, schemas) + _validate(target, value, path, errors, file_name, schemas) + return + if not _check_type(schema, value, path, errors): + return + _check_scalars(schema, value, path, errors) + if isinstance(value, dict): + _check_object(schema, value, path, errors, current, schemas) + if isinstance(value, list): + _check_array(schema, value, path, errors, current, schemas) + if "oneOf" in schema: + _check_one_of(schema, value, path, errors, current, schemas) + + +def validate( + schema_name: str, + value: Any, + schemas: dict[str, dict[str, Any]] | None = None, + pointer: str = "", +) -> list[str]: + """Validate ``value`` against a schema file, or a ``#/$defs/...`` pointer inside it. + + Returns a list of human-readable problems; an empty list means valid. + """ + schemas = schemas or load_all() + schema, file_name = _resolve_ref(f"{schema_name}#{pointer}", schema_name, schemas) + errors: list[str] = [] + _validate(schema, value, "$", errors, file_name, schemas) + return errors + + +def record_schema_names(schemas: dict[str, dict[str, Any]] | None = None) -> dict[str, str]: + """Map every ``schema`` constant (e.g. ``hux.event.v1``) to its file name.""" + schemas = schemas or load_all() + found: dict[str, str] = {} + + def visit(node: Any, file_name: str) -> None: + if isinstance(node, dict): + const = node.get("properties", {}).get("schema", {}).get("const") + if isinstance(const, str): + found[const] = file_name + for child in node.values(): + visit(child, file_name) + elif isinstance(node, list): + for child in node: + visit(child, file_name) + + for file_name, schema in schemas.items(): + visit(schema, file_name) + return found + + +def validate_record(value: Any, schemas: dict[str, dict[str, Any]] | None = None) -> list[str]: + """Validate a record by its own ``schema`` field.""" + schemas = schemas or load_all() + if not isinstance(value, dict) or not isinstance(value.get("schema"), str): + return ["$: record has no string 'schema' field"] + file_name = record_schema_names(schemas).get(value["schema"]) + if file_name is None: + return [f"$: unknown record schema {value['schema']!r}"] + return validate(file_name, value, schemas) diff --git a/services/hermes/scripts/hux_policy.py b/services/hermes/scripts/hux_policy.py new file mode 100644 index 00000000..45a43f3d --- /dev/null +++ b/services/hermes/scripts/hux_policy.py @@ -0,0 +1,313 @@ +"""Governance rules behind the HUX contracts. + +Pure functions only: state machines for memory, approvals and releases, the +autonomy capability matrix, the friendly-mode catalog and its Switchyard +mapping, the sensitive-topic policy, suggestion gating and the feature-flag +registry. The chat router and the WebUI both consume these rules; keeping them +here means one implementation is tested and the other only serialises it. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone +from typing import Any + +from hux_contracts import load_flags + +EFFORT_ORDER = ("low", "medium", "high", "xhigh") + +MEMORY_TRANSITIONS: dict[str, frozenset[str]] = { + "proposed": frozenset({"active", "rejected"}), + "active": frozenset({"expired", "forgotten"}), + "expired": frozenset({"forgotten"}), + "rejected": frozenset(), + "forgotten": frozenset(), +} +APPROVAL_TRANSITIONS: dict[str, frozenset[str]] = { + "pending": frozenset({"approved", "denied", "expired", "cancelled"}), + "approved": frozenset(), + "denied": frozenset(), + "expired": frozenset(), + "cancelled": frozenset(), +} +RELEASE_ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified") +RELEASE_EVIDENCE: dict[str, tuple[str, ...]] = { + "merged": ("merge_commit",), + "built": ("ci_build_url", "image_digest"), + "verified": ("harbor_digest",), + "deployed": ("flux_revision",), + "converged": ("pod_digest",), + "live_verified": ("health_check",), + "rolled_back": ("rollback_target",), +} + +CAPABILITIES = ( + "read_files", "write_files", "shell", "network", "web_search", "send_message", + "memory_write", "artifact_write", "spend_tokens", "delegate", "deploy", +) +_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"}) + +MODE_CATALOG: dict[str, dict[str, Any]] = { + "fast": { + "label": "Fast", + "intent": "Quick answers and small edits. Lowest latency wins; depth is not expected.", + "providers": ["codex", "claude"], "local_only": False, + "effort": {"min": "low", "max": "medium"}, + "tools": {"web": "allowed", "shell": "denied", "artifacts": "allowed", "delegate": "denied"}, + "memory": {"read": True, "write": True}, "citations_required": False, "retention": "default", + "route_id": "atlas/auto/fast", + }, + "thoughtful": { + "label": "Thoughtful", + "intent": "Careful reasoning on one problem. Takes longer, checks its own work, may run tools.", + "providers": ["codex", "claude"], "local_only": False, + "effort": {"min": "medium", "max": "xhigh"}, + "tools": {"web": "allowed", "shell": "allowed", "artifacts": "allowed", "delegate": "allowed"}, + "memory": {"read": True, "write": True}, "citations_required": False, "retention": "default", + "route_id": "atlas/auto/deep", + }, + "research": { + "label": "Research", + "intent": "Find, read and cite sources. Every factual claim carries a citation with a support verdict; assumptions and open questions stay visible.", + "providers": ["codex", "claude"], "local_only": False, + "effort": {"min": "high", "max": "xhigh"}, + "tools": {"web": "required", "shell": "denied", "artifacts": "allowed", "delegate": "allowed"}, + "memory": {"read": True, "write": True}, "citations_required": True, "retention": "default", + "route_id": "atlas/auto/deep", + }, + "create": { + "label": "Create", + "intent": "Produce and iterate on a durable artifact: documents, code, pages, images. Versions are kept and diffable.", + "providers": ["codex", "claude"], "local_only": False, + "effort": {"min": "medium", "max": "high"}, + "tools": {"web": "allowed", "shell": "allowed", "artifacts": "encouraged", "delegate": "allowed"}, + "memory": {"read": True, "write": True}, "citations_required": False, "retention": "default", + "route_id": "atlas/auto/balanced", + }, + "private": { + "label": "Private", + "intent": "Nothing leaves the cluster. Local model only, no memory writes, no web, conversation not retained beyond the session.", + "providers": ["local"], "local_only": True, + "effort": {"min": "low", "max": "medium"}, + "tools": {"web": "denied", "shell": "denied", "artifacts": "allowed", "delegate": "denied"}, + "memory": {"read": False, "write": False}, "citations_required": False, "retention": "ephemeral", + "route_id": "atlas/manual/local/qwen-14b", + }, +} + +PRIVACY_TOPICS: dict[str, dict[str, Any]] = { + "health": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30}, + "finance": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30}, + "legal": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 30}, + "relationships": {"sensitivity": "sensitive", "memory_write": "ask", "decay_days": 14}, + "credentials": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1}, + "minors": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7}, + "location": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 7}, + "biometric": {"sensitivity": "restricted", "memory_write": "deny", "decay_days": 1}, +} +_NOTICE = "This looks like a {topic} topic. It stays in this conversation and is not remembered unless you say so." + + +def transition_allowed(table: dict[str, frozenset[str]], current: str, target: str) -> bool: + """True when ``current -> target`` is a legal move in ``table``.""" + return target in table.get(current, frozenset()) + + +def memory_policy_violations(entry: dict[str, Any]) -> list[str]: + """Rules the schema cannot express: sensitivity drives approval, TTL and topic.""" + problems: list[str] = [] + sensitivity = entry.get("sensitivity") + 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": + problems.append("sensitive memory requires approval_mode=ask") + if sensitivity == "sensitive" 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": + 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: + problems.append("ttl.policy=expires_at requires expires_at") + if ttl.get("policy") == "decay" and "decay_days" not in ttl: + problems.append("ttl.policy=decay requires decay_days") + if entry.get("status") == "forgotten" and entry.get("content"): + problems.append("forgotten entries must drop their content") + return problems + + +def default_capability_matrix() -> dict[str, dict[str, str]]: + """Autonomy level -> capability -> allow|ask|deny. Deploy always asks.""" + matrix: dict[str, dict[str, str]] = {} + for level in ("ask_first", "safe", "autonomous"): + row: dict[str, str] = {} + for capability in CAPABILITIES: + if capability == "deploy": + row[capability] = "ask" + elif capability in _READ_ONLY: + row[capability] = "allow" + elif capability in _MUTATING: + row[capability] = "ask" if level != "autonomous" else "allow" + elif capability in _EXTERNAL: + row[capability] = {"ask_first": "ask", "safe": "deny", "autonomous": "allow"}[level] + matrix[level] = row + return matrix + + +def effective_decision(policy: dict[str, Any], capability: str, now: datetime | None = None) -> str: + """Resolve one capability under a policy: explicit unexpired grant beats the matrix, deny beats all.""" + now = now or datetime.now(timezone.utc) + matrix = default_capability_matrix()[policy["autonomy"]] + decision = matrix[capability] + for grant in policy.get("grants", []): + if grant["capability"] != capability: + continue + expires = grant.get("expires_at") + if expires and datetime.fromisoformat(expires.replace("Z", "+00:00")) <= now: + continue + if grant["decision"] == "deny": + return "deny" + decision = grant["decision"] + if capability == "deploy" and decision == "allow": + return "ask" + return decision + + +def release_transition_problems(record: dict[str, Any], target: str) -> list[str]: + """Why ``record`` may not move to ``target``. Empty means it may.""" + current = record["state"] + problems: list[str] = [] + if target == "rolled_back": + if current in {"reviewed", "merged"}: + problems.append("nothing to roll back before an image exists") + elif current == "rolled_back": + problems.append("a rolled back release is terminal; open a new release record") + elif RELEASE_ORDER.index(target) != RELEASE_ORDER.index(current) + 1: + problems.append(f"{current} -> {target} skips or reverses the release order") + evidence = record.get("evidence", {}) + for key in RELEASE_EVIDENCE.get(target, ()): + if key not in evidence: + problems.append(f"{target} requires evidence.{key}") + if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"): + problems.append("harbor_digest must equal image_digest") + if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"): + problems.append("pod_digest must equal image_digest") + if target == "live_verified" and evidence.get("health_check", {}).get("status") != "pass": + problems.append("health_check must pass") + return problems + + +def mode_contract(mode: str, override_route_id: str | None = None) -> dict[str, Any]: + """Serialise one friendly mode as a ``hux.mode.v1`` record.""" + spec = MODE_CATALOG[mode] + switchyard: dict[str, Any] = {"route_id": spec["route_id"]} + if override_route_id: + if spec["local_only"] and "/local/" not in override_route_id: + raise ValueError("private mode cannot be overridden to a hosted route") + switchyard["override_route_id"] = override_route_id + return { + "schema": "hux.mode.v1", + "mode": mode, + "label": spec["label"], + "intent": spec["intent"], + "constraints": { + key: spec[key] + for key in ("providers", "local_only", "effort", "tools", "memory", "citations_required", "retention") + }, + "switchyard": switchyard, + } + + +def effort_within(mode: str, effort: str) -> bool: + """True when ``effort`` sits inside the mode's allowed band.""" + band = MODE_CATALOG[mode]["effort"] + index = EFFORT_ORDER.index(effort) + return EFFORT_ORDER.index(band["min"]) <= index <= EFFORT_ORDER.index(band["max"]) + + +def privacy_policy(version: int = 1) -> dict[str, Any]: + """Serialise the sensitive-topic policy as a ``hux.privacy_policy.v1`` record.""" + return { + "schema": "hux.privacy_policy.v1", + "version": version, + "topics": [ + {"topic": topic, **rule, "notice": _NOTICE.format(topic=topic)} + for topic, rule in PRIVACY_TOPICS.items() + ], + "topic_scoping": {"scope_to_conversation": True, "cross_surface_sharing": "never"}, + "retention_audit": { + "interval_days": 1, + "actions": ["expire_memory", "decay_topic_context", "purge_forgotten_content", "report"], + }, + } + + +def suggestion_allowed(suggestion: dict[str, Any], state: dict[str, Any] | None, now: datetime) -> bool: + """Suppression state wins: never-again, dismissed, exhausted shows, or cooling down all block.""" + if state is None: + return True + if state.get("never_again") or state.get("dismissed_at") or state.get("acted_at"): + return False + rules = suggestion["suppression"] + if state.get("shows", 0) >= rules["max_shows"]: + return False + last = state.get("last_shown_at") + if last: + shown = datetime.fromisoformat(last.replace("Z", "+00:00")) + if now - shown < timedelta(seconds=rules["cooldown_seconds"]): + return False + return True + + +def flag_registry() -> dict[str, dict[str, Any]]: + """Card id -> registry entry.""" + return {card["card"]: card for card in load_flags()["cards"]} + + +def enabled_flags(environ: dict[str, str] | None = None) -> set[str]: + """Flags switched on through the HUX_FLAGS comma list; unknown names are ignored.""" + environ = os.environ if environ is None else environ + known = {card["flag"] for card in flag_registry().values()} + raw = environ.get(load_flags()["env_var"], "") + return {item.strip() for item in raw.split(",") if item.strip() in known} + + +def flag_enabled(flag: str, environ: dict[str, str] | None = None) -> bool: + """A flag counts only when it and every card it depends on are enabled.""" + registry = flag_registry() + by_flag = {card["flag"]: card for card in registry.values()} + if flag not in by_flag: + return False + on = enabled_flags(environ) + pending = [by_flag[flag]] + while pending: + card = pending.pop() + if card["flag"] not in on: + return False + pending.extend(registry[dep] for dep in card["depends_on"]) + return True + + +def dependency_order() -> list[str]: + """Cards in an order that satisfies depends_on; raises on cycles.""" + registry = flag_registry() + done: list[str] = [] + visiting: set[str] = set() + + def visit(card_id: str) -> None: + if card_id in done: + return + if card_id in visiting: + raise ValueError(f"dependency cycle through {card_id}") + visiting.add(card_id) + for dep in registry[card_id]["depends_on"]: + visit(dep) + visiting.discard(card_id) + done.append(card_id) + + for card_id in sorted(registry): + visit(card_id) + return done diff --git a/testing/quality_contract.json b/testing/quality_contract.json index cbcdefd3..4706755e 100644 --- a/testing/quality_contract.json +++ b/testing/quality_contract.json @@ -103,7 +103,9 @@ "scripts/ops/hermes_handoff_redaction.py", "scripts/ops/hermes_handoff_rules.py", "scripts/ops/hermes_handoff_run.py", - "testing/quality_handoff_mutation.py" + "testing/quality_handoff_mutation.py", + "services/hermes/scripts/hux_contracts.py", + "services/hermes/scripts/hux_policy.py" ], "lint_paths": [ "ci/scripts/hermes_image_release.py", @@ -277,7 +279,8 @@ "services/hermes/scripts/node_account_io.py", "services/hermes/scripts/stage_runtime_access.py", "services/hermes/scripts/node_polkit_audit.py", - "scripts/ops/hermes_handoff_*.py" + "scripts/ops/hermes_handoff_*.py", + "services/hermes/scripts/hux_*.py" ], "naming_rules": [ { diff --git a/testing/tests/test_hermes_hux_contracts.py b/testing/tests/test_hermes_hux_contracts.py new file mode 100644 index 00000000..b989505d --- /dev/null +++ b/testing/tests/test_hermes_hux_contracts.py @@ -0,0 +1,324 @@ +"""Contract tests for the HUX schemas and the governance rules behind them. + +Every schema must stay inside the keyword subset the in-repo validator +understands, every shipped example must validate, every friendly mode must +resolve to a Switchyard route that really exists, and the state machines must +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 +from datetime import datetime, timezone +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "services" / "hermes" / "scripts" +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") +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) + + +def _switchyard_route_ids() -> set[str]: + manifest = next(iter(yaml.safe_load_all((ROOT / "services/hermes/switchyard-configmap.yaml").read_text()))) + routes = tomllib.loads(manifest["data"]["routes.toml"])["routes"] + return {route["id"] for route in routes.values()} + + +# --- schema hygiene ----------------------------------------------------------- + +@pytest.mark.parametrize("name", contracts.SCHEMA_FILES) +def test_schema_uses_only_supported_keywords(name): + assert contracts.unsupported_keywords(SCHEMAS[name]) == [] + assert SCHEMAS[name]["$id"].endswith(name) + + +def test_every_schema_file_is_registered(): + on_disk = {path.name for path in CONTRACTS.glob("*.schema.json")} + assert on_disk == set(contracts.SCHEMA_FILES) + + +def test_walker_flags_unknown_keywords(): + assert contracts.unsupported_keywords({"type": "object", "properties": {"x": {"format": "email"}}}) == ["#/properties/x/format"] + + +# --- examples ----------------------------------------------------------------- + +@pytest.mark.parametrize("name", sorted(EXAMPLES)) +def test_example_validates_by_its_schema_field(name): + assert contracts.validate_record(EXAMPLES[name], SCHEMAS) == [] + + +def test_every_record_schema_has_an_example(): + covered = {example["schema"] for example in EXAMPLES.values()} + covered |= {"hux.mode.v1", "hux.privacy_policy.v1"} + assert set(contracts.record_schema_names(SCHEMAS)) == covered + + +def test_validate_record_rejects_unknown_or_missing_schema(): + assert contracts.validate_record({"schema": "hux.nope.v1"}, SCHEMAS) == ["$: unknown record schema 'hux.nope.v1'"] + assert contracts.validate_record(["not", "a", "record"], SCHEMAS)[0].startswith("$: record has no") + + +def test_default_loading_paths_work(): + assert contracts.validate_record(EXAMPLES["event"]) == [] + assert contracts.validate("event.schema.json", EXAMPLES["event"]) == [] + assert "HUX-01" in {card["card"] for card in contracts.load_flags()["cards"]} + + +# --- validator keyword coverage ---------------------------------------------- + +def _broken(name: str, mutate) -> list[str]: + record = copy.deepcopy(EXAMPLES[name]) + mutate(record) + return contracts.validate_record(record, SCHEMAS) + + +def test_validator_catches_each_constraint_class(): + assert any("expected integer" in e for e in _broken("event", lambda r: r.update(seq=True))) + assert any("below minimum" in e for e in _broken("event", lambda r: r.update(seq=-1))) + assert any("not in enum" in e for e in _broken("event", lambda r: r.update(kind="tool.dance"))) + assert any("unexpected property" in e for e in _broken("event", lambda r: r.update(extra=1))) + assert any("missing required" in e for e in _broken("event", lambda r: r.pop("summary"))) + assert any("longer than" in e for e in _broken("event", lambda r: r.update(summary="x" * 281))) + assert any("shorter than" in e for e in _broken("event", lambda r: r.update(summary=""))) + assert any("does not match" in e for e in _broken("event", lambda r: r.update(ts="yesterday"))) + wrong_const = copy.deepcopy(EXAMPLES["event"]) + wrong_const["schema"] = "hux.event.v0" + assert any("expected constant" in e for e in contracts.validate("event.schema.json", wrong_const, SCHEMAS)) + assert any("more than" in e for e in _broken("event", lambda r: r.update(evidence=[{"kind": "run", "id": "r"}] * 65))) + assert any("not unique" in e for e in _broken("project", lambda r: r.update(tags=["a", "a"]))) + assert any("fewer than" in e for e in _broken("memory", lambda r: r.update(audit=[]))) + assert any("above maximum" in e for e in _broken("suggestion", lambda r: r.update(priority=101))) + assert any("expected object" in e for e in _broken("event", lambda r: r.update(provenance="x"))) + + +def test_one_of_requires_exactly_one_branch(): + ambiguous = {"schema": "hux.policy.v1"} + errors = contracts.validate("permission.schema.json", ambiguous, SCHEMAS) + assert any("matched 0 oneOf branches" in e for e in errors) + + +def test_pointer_validation_and_bad_refs(): + assert contracts.validate("permission.schema.json", EXAMPLES["approval"], SCHEMAS, "/$defs/approval") == [] + with pytest.raises(contracts.ContractError): + contracts.validate("permission.schema.json", {}, SCHEMAS, "/$defs/missing") + with pytest.raises(contracts.ContractError): + contracts._resolve_ref("nowhere.schema.json#/x", "event.schema.json", SCHEMAS) + with pytest.raises(contracts.ContractError): + contracts._validate({"type": "date"}, "x", "$", [], "event.schema.json", SCHEMAS) + + +# --- memory ledger ------------------------------------------------------------- + +def test_memory_state_machine_is_append_only(): + allowed = {(a, b) for a, bs in policy.MEMORY_TRANSITIONS.items() for b in bs} + assert allowed == {("proposed", "active"), ("proposed", "rejected"), ("active", "expired"), ("active", "forgotten"), ("expired", "forgotten")} + assert policy.transition_allowed(policy.MEMORY_TRANSITIONS, "proposed", "active") + assert not policy.transition_allowed(policy.MEMORY_TRANSITIONS, "forgotten", "active") + assert not policy.transition_allowed(policy.MEMORY_TRANSITIONS, "unknown", "active") + + +def test_memory_policy_rules(): + base = EXAMPLES["memory"] + assert policy.memory_policy_violations(base) == [] + restricted = {**base, "sensitivity": "restricted"} + assert "restricted content may not be remembered" in policy.memory_policy_violations(restricted) + lax = {**base, "sensitivity": "sensitive", "approval_mode": "automatic", "ttl": {"policy": "never"}} + problems = policy.memory_policy_violations(lax) + assert "sensitive memory requires approval_mode=ask" in problems + assert "sensitive memory must expire or decay" in problems + creds = {**base, "topic": "credentials"} + assert "topic credentials may not be written to memory" in policy.memory_policy_violations(creds) + assert policy.memory_policy_violations({**creds, "status": "rejected"}) == [] + assert "ttl.policy=expires_at requires expires_at" in policy.memory_policy_violations({**base, "ttl": {"policy": "expires_at"}}) + assert "ttl.policy=decay requires decay_days" in policy.memory_policy_violations({**base, "ttl": {"policy": "decay"}}) + assert "forgotten entries must drop their content" in policy.memory_policy_violations({**base, "status": "forgotten"}) + assert policy.memory_policy_violations({**base, "status": "forgotten", "content": ""}) == [] + + +# --- autonomy ------------------------------------------------------------------- + +def test_capability_matrix_shape(): + matrix = policy.default_capability_matrix() + assert set(matrix) == {"ask_first", "safe", "autonomous"} + for level, row in matrix.items(): + assert set(row) == set(policy.CAPABILITIES) + assert row["deploy"] == "ask", level + assert row["read_files"] == "allow" + assert matrix["ask_first"]["shell"] == "ask" + assert matrix["safe"]["network"] == "deny" + assert matrix["autonomous"]["shell"] == "allow" + assert matrix["autonomous"]["network"] == "allow" + assert set(policy.CAPABILITIES) == set(SCHEMAS["permission.schema.json"]["$defs"]["capability"]["enum"]) + + +def test_effective_decision_honours_grants_expiry_and_deny(): + base = copy.deepcopy(EXAMPLES["policy"]) + assert policy.effective_decision(base, "network", NOW) == "ask" + assert policy.effective_decision(base, "network", datetime(2026, 9, 1, tzinfo=timezone.utc)) == "deny" + base["grants"] = [{"capability": "shell", "decision": "allow"}, {"capability": "shell", "decision": "deny"}] + assert policy.effective_decision(base, "shell", NOW) == "deny" + base["grants"] = [{"capability": "deploy", "decision": "allow"}] + assert policy.effective_decision(base, "deploy", NOW) == "ask" + base["grants"] = [{"capability": "write_files", "decision": "allow"}] + assert policy.effective_decision(base, "write_files") == "allow" + assert policy.effective_decision(base, "read_files") == "allow" + + +def test_approval_states_are_terminal_after_pending(): + for state, nexts in policy.APPROVAL_TRANSITIONS.items(): + assert bool(nexts) == (state == "pending") + + +# --- friendly modes ------------------------------------------------------------ + +@pytest.mark.parametrize("mode", sorted(policy.MODE_CATALOG)) +def test_mode_contract_validates_and_resolves_to_real_switchyard_route(mode): + record = policy.mode_contract(mode) + assert contracts.validate_record(record, SCHEMAS) == [] + assert record["switchyard"]["route_id"] in _switchyard_route_ids() + assert "codex" not in record["intent"].lower() and "claude" not in record["intent"].lower() + + +def test_private_mode_never_leaves_the_cluster(): + record = policy.mode_contract("private") + assert record["constraints"] == { + "providers": ["local"], "local_only": True, "effort": {"min": "low", "max": "medium"}, + "tools": {"web": "denied", "shell": "denied", "artifacts": "allowed", "delegate": "denied"}, + "memory": {"read": False, "write": False}, "citations_required": False, "retention": "ephemeral", + } + with pytest.raises(ValueError): + policy.mode_contract("private", "atlas/manual/claude/opus/high") + assert policy.mode_contract("private", "atlas/manual/local/qwen-14b")["switchyard"]["override_route_id"] == "atlas/manual/local/qwen-14b" + + +def test_hosted_modes_stay_provider_neutral_and_overridable(): + for mode in ("fast", "thoughtful", "research", "create"): + assert set(policy.MODE_CATALOG[mode]["providers"]) == {"codex", "claude"} + override = policy.mode_contract("thoughtful", "atlas/manual/codex/sol/high") + assert override["switchyard"]["override_route_id"] in _switchyard_route_ids() + assert policy.MODE_CATALOG["research"]["citations_required"] + assert policy.MODE_CATALOG["research"]["tools"]["web"] == "required" + + +def test_effort_bands(): + assert policy.effort_within("fast", "low") + assert not policy.effort_within("fast", "xhigh") + assert policy.effort_within("research", "xhigh") + assert not policy.effort_within("research", "medium") + + +# --- privacy ------------------------------------------------------------------- + +def test_privacy_policy_validates_and_is_strict_for_restricted_topics(): + record = policy.privacy_policy() + assert contracts.validate_record(record, SCHEMAS) == [] + topics = {row["topic"]: row for row in record["topics"]} + assert set(topics) == set(SCHEMAS["privacy.schema.json"]["$defs"]["topic"]["enum"]) + for row in topics.values(): + assert (row["sensitivity"] == "restricted") == (row["memory_write"] == "deny") + assert row["decay_days"] <= 30 + assert record["topic_scoping"]["cross_surface_sharing"] == "never" + + +# --- suggestions --------------------------------------------------------------- + +def test_suggestion_gating(): + suggestion = EXAMPLES["suggestion"] + assert policy.suggestion_allowed(suggestion, None, NOW) + state = copy.deepcopy(EXAMPLES["suggestion_state"]) + assert policy.suggestion_allowed(suggestion, state, NOW) + assert not policy.suggestion_allowed(suggestion, {**state, "never_again": True}, NOW) + assert not policy.suggestion_allowed(suggestion, {**state, "dismissed_at": "2026-08-22T11:00:00Z"}, NOW) + assert not policy.suggestion_allowed(suggestion, {**state, "shows": 2}, NOW) + assert not policy.suggestion_allowed(suggestion, {**state, "last_shown_at": "2026-08-23T11:30:00Z"}, NOW) + assert policy.suggestion_allowed(suggestion, {**state, "last_shown_at": None}, NOW) + + +# --- release follow-through ---------------------------------------------------- + +def test_release_cannot_skip_states_or_claim_deployed_without_evidence(): + record = copy.deepcopy(EXAMPLES["release"]) + assert policy.release_transition_problems(record, "verified") == ["verified requires evidence.harbor_digest", "harbor_digest must equal image_digest"] + assert "built -> deployed skips or reverses the release order" in policy.release_transition_problems(record, "deployed") + record["evidence"]["harbor_digest"] = record["evidence"]["image_digest"] + assert policy.release_transition_problems(record, "verified") == [] + record["state"] = "deployed" + record["evidence"]["pod_digest"] = "sha256:" + "0" * 64 + assert "pod_digest must equal image_digest" in policy.release_transition_problems(record, "converged") + record["evidence"]["pod_digest"] = record["evidence"]["image_digest"] + assert policy.release_transition_problems(record, "converged") == [] + record["state"] = "converged" + record["evidence"]["health_check"] = {"url": "https://chat.bstein.dev/healthz", "status": "fail", "at": "2026-08-23T10:00:00Z"} + assert policy.release_transition_problems(record, "live_verified") == ["health_check must pass"] + record["evidence"]["health_check"]["status"] = "pass" + assert policy.release_transition_problems(record, "live_verified") == [] + assert policy.release_transition_problems(record, "rolled_back") == ["rolled_back requires evidence.rollback_target"] + record["state"] = "rolled_back" + assert policy.release_transition_problems(record, "merged")[0].startswith("a rolled back release is terminal") + assert policy.release_transition_problems({"state": "merged", "evidence": {}}, "rolled_back")[0].startswith("nothing to roll back") + assert policy.release_transition_problems({"state": "reviewed", "evidence": {}}, "merged") == ["merged requires evidence.merge_commit"] + + +# --- flags --------------------------------------------------------------------- + +def test_flag_registry_matches_program(): + registry = policy.flag_registry() + assert sorted(registry) == [f"HUX-{n:02d}" for n in range(1, 13)] + waves = {"A": {"HUX-01", "HUX-02", "HUX-05", "HUX-10", "HUX-11", "HUX-12"}, "B": {"HUX-03", "HUX-04", "HUX-06", "HUX-08"}, "C": {"HUX-07", "HUX-09"}} + for wave, cards in waves.items(): + assert {card for card, entry in registry.items() if entry["wave"] == wave} == cards + for entry in registry.values(): + assert entry["default"] is False + assert entry["backend_owner"] == "claude" and entry["frontend_owner"] == "codex" + assert entry["rollback"] + for schema in entry["contracts"]: + assert schema in contracts.SCHEMA_FILES + for dep in entry["depends_on"]: + assert dep in registry + order = policy.dependency_order() + assert order.index("HUX-11") < order.index("HUX-01") < order.index("HUX-05") + + +def test_dependency_cycle_is_rejected(monkeypatch): + registry = policy.flag_registry() + registry["HUX-11"]["depends_on"] = ["HUX-01"] + monkeypatch.setattr(policy, "flag_registry", lambda: registry) + with pytest.raises(ValueError): + policy.dependency_order() + + +def test_flags_default_off_and_require_dependencies(): + assert policy.enabled_flags({}) == set() + assert policy.enabled_flags({"HUX_FLAGS": "hux.activity_timeline, hux.bogus"}) == {"hux.activity_timeline"} + assert not policy.flag_enabled("hux.activity_timeline", {"HUX_FLAGS": "hux.activity_timeline"}) + assert policy.flag_enabled("hux.activity_timeline", {"HUX_FLAGS": "hux.activity_timeline,hux.foundation"}) + assert not policy.flag_enabled("hux.bogus", {"HUX_FLAGS": "hux.bogus"}) + assert not policy.flag_enabled("hux.foundation", {"HUX_FLAGS": ""}) + + +def test_flags_read_process_environment(monkeypatch): + monkeypatch.setenv("HUX_FLAGS", "hux.foundation") + assert policy.flag_enabled("hux.foundation")