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:
parent
d254931a14
commit
d3cb6e9045
135
docs/hermes_hux_contracts.md
Normal file
135
docs/hermes_hux_contracts.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
69
services/hermes/contracts/hux/artifact.schema.json
Normal file
69
services/hermes/contracts/hux/artifact.schema.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
88
services/hermes/contracts/hux/citation.schema.json
Normal file
88
services/hermes/contracts/hux/citation.schema.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
122
services/hermes/contracts/hux/common.schema.json
Normal file
122
services/hermes/contracts/hux/common.schema.json
Normal 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}$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
services/hermes/contracts/hux/event.schema.json
Normal file
42
services/hermes/contracts/hux/event.schema.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
1
services/hermes/contracts/hux/examples/approval.json
Normal file
1
services/hermes/contracts/hux/examples/approval.json
Normal 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"}
|
||||
1
services/hermes/contracts/hux/examples/artifact.json
Normal file
1
services/hermes/contracts/hux/examples/artifact.json
Normal 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"}
|
||||
@ -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"}}]}
|
||||
1
services/hermes/contracts/hux/examples/citation.json
Normal file
1
services/hermes/contracts/hux/examples/citation.json
Normal 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"}
|
||||
1
services/hermes/contracts/hux/examples/conversation.json
Normal file
1
services/hermes/contracts/hux/examples/conversation.json
Normal 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"}
|
||||
1
services/hermes/contracts/hux/examples/event.json
Normal file
1
services/hermes/contracts/hux/examples/event.json
Normal 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"}}
|
||||
1
services/hermes/contracts/hux/examples/memory.json
Normal file
1
services/hermes/contracts/hux/examples/memory.json
Normal 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"}}]}
|
||||
1
services/hermes/contracts/hux/examples/notebook.json
Normal file
1
services/hermes/contracts/hux/examples/notebook.json
Normal 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"}
|
||||
1
services/hermes/contracts/hux/examples/passage.json
Normal file
1
services/hermes/contracts/hux/examples/passage.json
Normal 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}}
|
||||
1
services/hermes/contracts/hux/examples/policy.json
Normal file
1
services/hermes/contracts/hux/examples/policy.json
Normal 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"}
|
||||
@ -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"}
|
||||
1
services/hermes/contracts/hux/examples/project.json
Normal file
1
services/hermes/contracts/hux/examples/project.json
Normal 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"}
|
||||
1
services/hermes/contracts/hux/examples/release.json
Normal file
1
services/hermes/contracts/hux/examples/release.json
Normal 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"}]}]}
|
||||
@ -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}]}
|
||||
1
services/hermes/contracts/hux/examples/source.json
Normal file
1
services/hermes/contracts/hux/examples/source.json
Normal 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"}}}
|
||||
1
services/hermes/contracts/hux/examples/suggestion.json
Normal file
1
services/hermes/contracts/hux/examples/suggestion.json
Normal 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}}
|
||||
@ -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}
|
||||
18
services/hermes/contracts/hux/flags.json
Normal file
18
services/hermes/contracts/hux/flags.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
58
services/hermes/contracts/hux/memory.schema.json
Normal file
58
services/hermes/contracts/hux/memory.schema.json
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
services/hermes/contracts/hux/mode.schema.json
Normal file
61
services/hermes/contracts/hux/mode.schema.json
Normal 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/-]+$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
services/hermes/contracts/hux/permission.schema.json
Normal file
130
services/hermes/contracts/hux/permission.schema.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
94
services/hermes/contracts/hux/privacy.schema.json
Normal file
94
services/hermes/contracts/hux/privacy.schema.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
69
services/hermes/contracts/hux/project.schema.json
Normal file
69
services/hermes/contracts/hux/project.schema.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
57
services/hermes/contracts/hux/release.schema.json
Normal file
57
services/hermes/contracts/hux/release.schema.json
Normal 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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
services/hermes/contracts/hux/suggestion.schema.json
Normal file
69
services/hermes/contracts/hux/suggestion.schema.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
237
services/hermes/scripts/hux_contracts.py
Normal file
237
services/hermes/scripts/hux_contracts.py
Normal 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)
|
||||
313
services/hermes/scripts/hux_policy.py
Normal file
313
services/hermes/scripts/hux_policy.py
Normal 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
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
324
testing/tests/test_hermes_hux_contracts.py
Normal file
324
testing/tests/test_hermes_hux_contracts.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user