hermes(hux): add contract-only foundation for the chat UX program

Schemas, examples and a flag registry for the twelve HUX cards, a dependency-free
validator, the governance rules (memory ledger, autonomy matrix, friendly modes
mapped to real Switchyard routes, privacy defaults, suggestion gating, release
state machine) and the contract doc UI work codes against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
This commit is contained in:
jenkins 2026-08-23 22:07:38 -03:00
parent d254931a14
commit d3cb6e9045
35 changed files with 1916 additions and 2 deletions

View File

@ -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_<hash>`: 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/-]+$"}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": [
{

View File

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