atlas-iac/services/hermes/scripts/hux_contracts.py
jenkins d3cb6e9045 hermes(hux): add contract-only foundation for the chat UX program
Schemas, examples and a flag registry for the twelve HUX cards, a dependency-free
validator, the governance rules (memory ledger, autonomy matrix, friendly modes
mapped to real Switchyard routes, privacy defaults, suggestion gating, release
state machine) and the contract doc UI work codes against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-23 22:30:41 -03:00

238 lines
9.3 KiB
Python

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