Compare commits
1 Commits
d3cb6e9045
...
1faf2a8a84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1faf2a8a84 |
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
|
||||
@ -102,7 +102,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",
|
||||
@ -275,7 +277,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