"""HUX-12 immutable, hash-chained deployment follow-through. Each release is bound to one project and conversation. The current view is reconstructed from append-only entries; every read verifies sequence, hash chain and contract before returning anything. ``live_verified`` is reachable only from a digest-converged pod with a passing health check. """ from __future__ import annotations import hashlib import json import re from typing import Any from urllib.parse import urlsplit from hux import contracts, redaction, release_security from hux.errors import Conflict, Invalid, NotFound, TooLarge from hux.http import Request, Response, Router, page from hux.store import check_id, new_id, now_iso CARD = "HUX-12" FAMILY = "release_ledger" MAX_RELEASES = 500 ZERO_HASH = "sha256:" + "0" * 64 ORDER = ("reviewed", "merged", "built", "verified", "deployed", "converged", "live_verified") WORKLOADS = frozenset({"hermes-chat-router", "hermes-chat-tenant", "hermes-webui", "hermes-agent", "hermes-switchyard"}) EVIDENCE_FIELDS = frozenset({"review_url", "merge_commit", "ci_build_url", "image_ref", "image_digest", "harbor_digest", "flux_revision", "pod_digest", "health_check", "rollback_target"}) SHA = re.compile(r"^sha256:[0-9a-f]{64}$") COMMIT = re.compile(r"^[0-9a-f]{40}$") IMAGE = re.compile(r"^[a-z0-9./-]+:[A-Za-z0-9._-]+@(sha256:[0-9a-f]{64})$") SCHEMAS = contracts.load_all() SCHEMAS["release-ledger.schema.json"] = contracts.load_schema("release-ledger.schema.json") def _body(request: Request, allowed: set[str]) -> dict[str, Any]: if not isinstance(request.body, dict): raise Invalid("body must be a JSON object") extra = set(request.body) - allowed if extra: raise Invalid("unexpected release fields", sorted(extra)) return request.body def _scope(request: Request) -> tuple[str, str]: from hux import organization project_id, conversation_id = request.params["project_id"], request.params["id"] try: conversation = request.store.get(organization.CONVERSATIONS, conversation_id) except (Invalid, NotFound) as error: raise NotFound("project or conversation not found") from error if conversation.get("project_id") != project_id or not organization.project_exists(request.store, project_id): raise NotFound("project or conversation not found") return project_id, conversation_id def _actor(request: Request) -> dict[str, str]: if request.identity.trust == "evidence": return {"type": "system", "id": "release-evidence-producer"} if request.identity.trust == "worker": return {"type": "system", "id": "release-worker"} return {"type": "user", "id": request.identity.subject} def _safe_url(value: Any, *, relative: bool = False) -> str: if not isinstance(value, str) or not value or len(value) > 500: raise Invalid("evidence URL is missing or too long") parsed = urlsplit(value) if parsed.query or parsed.fragment or parsed.username or parsed.password: raise Invalid("evidence URLs may not contain credentials, queries or fragments") if relative: if not ((not parsed.scheme and value.startswith("/")) or parsed.scheme == "https"): raise Invalid("health URL must be HTTPS or a local absolute path") elif parsed.scheme != "https" or not parsed.netloc: raise Invalid("evidence URL must use HTTPS") if redaction.scrub_text(value)[1]: raise Invalid("evidence URL appears to contain a secret") return value def _canonical(data: Any) -> bytes: return json.dumps(data, sort_keys=True, separators=(",", ":")).encode() def _entry_hash(entry: dict[str, Any]) -> str: unsigned = {key: value for key, value in entry.items() if key != "entry_hash"} return "sha256:" + hashlib.sha256(_canonical(unsigned)).hexdigest() def _validate_release(release: dict[str, Any]) -> None: problems = contracts.validate("release.schema.json", release, SCHEMAS) if problems: raise Invalid("release failed contract validation", problems) def _validate_entry(entry: dict[str, Any]) -> None: problems = contracts.validate("release-ledger.schema.json", entry, SCHEMAS) if problems: raise Invalid("release ledger entry failed contract validation", problems) def _rows(request: Request, release_id: str) -> list[dict[str, Any]]: try: check_id(release_id) except Invalid as error: raise NotFound("release not found") from error rows = request.store.read(FAMILY, release_id) if not rows: raise NotFound("release not found") previous = ZERO_HASH for sequence, row in enumerate(rows, 1): if row.get("sequence") != sequence or row.get("previous_hash") != previous or row.get("entry_hash") != _entry_hash(row): raise Invalid("release ledger integrity check failed") _validate_entry(row) previous = row["entry_hash"] return rows def _append(request: Request, release: dict[str, Any], project_id: str, conversation_id: str, previous: list[dict[str, Any]]) -> dict[str, Any]: entry = { "schema": "hux.release_ledger_entry.v1", "release_id": release["id"], "owner": request.identity.subject, "project_id": project_id, "conversation_id": conversation_id, "sequence": len(previous) + 1, "at": now_iso(), "previous_hash": previous[-1]["entry_hash"] if previous else ZERO_HASH, "snapshot": release, } entry["entry_hash"] = _entry_hash(entry) _validate_entry(entry) request.store.append(FAMILY, release["id"], entry) return entry def _view(entry: dict[str, Any]) -> dict[str, Any]: return { "release": entry["snapshot"], "scope": {"project_id": entry["project_id"], "conversation_id": entry["conversation_id"]}, "revision": entry["sequence"], "ledger_hash": entry["entry_hash"], } def _key(request: Request) -> str: key = request.idempotency_key() if not key: raise Invalid("Idempotency-Key is required") return key def _fingerprint(body: dict[str, Any], path: str) -> str: return hashlib.sha256(_canonical({"body": body, "path": path})).hexdigest() def _replay(request: Request, key: str, fingerprint: str) -> dict[str, Any] | None: for row in request.store.read(FAMILY, "idempotency"): if row.get("key") != key: continue if row.get("fingerprint") != fingerprint: raise Conflict("Idempotency-Key was already used for different release evidence") rows = _rows(request, row["release_id"]) if row["sequence"] > len(rows): raise Invalid("release idempotency ledger is inconsistent") return _view(rows[row["sequence"] - 1]) return None def _remember(request: Request, key: str, fingerprint: str, release_id: str, sequence: int) -> None: request.store.append(FAMILY, "idempotency", {"key": key, "fingerprint": fingerprint, "release_id": release_id, "sequence": sequence, "at": now_iso()}) def _initial_evidence(body: dict[str, Any]) -> dict[str, Any]: evidence = body.get("evidence") if not isinstance(evidence, dict) or set(evidence) != {"review_url"}: raise Invalid("reviewed release requires exactly evidence.review_url") return {"review_url": _safe_url(evidence["review_url"])} def create_release(request: Request) -> Response: """Create the sole reviewed ledger for one scoped workload commit.""" release_security.require_review_creator(request.identity.trust) body = _body(request, {"workload", "commit", "feature_flags", "evidence"}) project_id, conversation_id = _scope(request) key, fingerprint = _key(request), _fingerprint(body, request.path) if request.if_match() != 0: raise Invalid("If-Match: 0 is required when creating a release") workload, commit = body.get("workload"), body.get("commit") if workload not in WORKLOADS or not isinstance(commit, str) or not COMMIT.fullmatch(commit): raise Invalid("workload and exact 40-character commit are required") flags = body.get("feature_flags", []) if not isinstance(flags, list) or len(flags) > 32: raise Invalid("feature_flags must be a bounded list") with request.store.lock(FAMILY): replayed = _replay(request, key, fingerprint) if replayed is not None: return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"}) releases = [name for name in request.store.ledgers(FAMILY) if name.startswith("rel_")] if len(releases) >= MAX_RELEASES: raise TooLarge("release ledger limit reached") for name in releases: entry = _rows(request, name)[0] release = entry["snapshot"] if entry["project_id"] == project_id and entry["conversation_id"] == conversation_id and release["workload"] == workload and release["commit"] == commit: raise Conflict("a release already exists for this scoped workload commit") release = { "schema": "hux.release.v1", "id": new_id("rel"), "workload": workload, "feature_flags": flags, "commit": commit, "state": "reviewed", "evidence": _initial_evidence(body), "transitions": [], } _validate_release(release) entry = _append(request, release, project_id, conversation_id, []) result = _view(entry) _remember(request, key, fingerprint, release["id"], 1) request.audit("releases.create", release["id"]) return Response(201, result, {"ETag": "1", "Cache-Control": "no-store"}) def _transition_evidence(current: dict[str, Any], target: str, supplied: Any) -> dict[str, Any]: if not isinstance(supplied, dict) or set(supplied) - EVIDENCE_FIELDS: raise Invalid("evidence must be a bounded object of known fields") expected = { "merged": {"merge_commit"}, "built": {"ci_build_url", "image_ref", "image_digest", "harbor_digest"}, "verified": set(), "deployed": {"flux_revision"}, "converged": {"pod_digest"}, "live_verified": {"health_check"}, "rolled_back": {"rollback_target"}, }[target] if set(supplied) != expected: raise Invalid(f"{target} requires exactly: {', '.join(sorted(expected)) or 'no new evidence'}") evidence = {**current.get("evidence", {}), **supplied} if target == "merged" and not COMMIT.fullmatch(str(evidence.get("merge_commit", ""))): raise Invalid("merged requires an exact merge commit") if target == "built": match = IMAGE.fullmatch(str(evidence.get("image_ref", ""))) digest = evidence.get("image_digest") if not match or not SHA.fullmatch(str(digest)) or evidence.get("harbor_digest") != digest or match.group(1) != digest: raise Invalid("image ref, image digest and Harbor digest must match exactly") evidence["ci_build_url"] = _safe_url(evidence.get("ci_build_url")) if target == "verified" and evidence.get("harbor_digest") != evidence.get("image_digest"): raise Invalid("verified image does not match Harbor") if target == "deployed" and not re.fullmatch(r"main@sha1:[0-9a-f]{40}", str(evidence.get("flux_revision", ""))): raise Invalid("deployed requires an exact Flux revision") if target == "converged" and evidence.get("pod_digest") != evidence.get("image_digest"): raise Invalid("pod digest does not equal the built image digest") if target == "live_verified": health = evidence.get("health_check") if not isinstance(health, dict) or set(health) != {"url", "status", "at"} or health.get("status") != "pass": raise Invalid("live verification requires one passing health check") health["url"] = _safe_url(health["url"], relative=True) if target == "rolled_back" and not SHA.fullmatch(str(evidence.get("rollback_target", ""))): raise Invalid("rollback requires an exact target digest") return evidence def _evidence_refs(target: str, evidence: dict[str, Any]) -> list[dict[str, str]]: mapping = { "merged": ("build", "merge_commit"), "built": ("build", "image_digest"), "verified": ("build", "harbor_digest"), "deployed": ("flux", "flux_revision"), "converged": ("pod", "pod_digest"), "live_verified": ("receipt", "health_check"), "rolled_back": ("pod", "rollback_target"), } kind, key = mapping[target] value = evidence[key] identifier = value["at"] if key == "health_check" else value return [{"kind": kind, "id": str(identifier)[:200]}] def transition_release(request: Request) -> Response: """Append exactly one legal, evidence-backed release transition.""" release_security.require_evidence_producer(request.identity.trust) body = _body(request, {"to", "evidence"}) project_id, conversation_id = _scope(request) key, fingerprint = _key(request), _fingerprint(body, request.path) expected = request.if_match() if expected is None: raise Invalid("If-Match is required") with request.store.lock(FAMILY): replayed = _replay(request, key, fingerprint) if replayed is not None: return Response(200, replayed, {"HUX-Replayed": "true", "Cache-Control": "no-store"}) rows = _rows(request, request.params["release_id"]) head, current = rows[-1], rows[-1]["snapshot"] if head["project_id"] != project_id or head["conversation_id"] != conversation_id: raise NotFound("release not found") if expected != len(rows): raise Conflict("release revision does not match If-Match") target = body.get("to") if target == "rolled_back": if current["state"] not in {"built", "verified", "deployed", "converged", "live_verified"}: raise Conflict("there is no deployed image to roll back") elif current["state"] == "rolled_back" or target not in ORDER or ORDER.index(target) != ORDER.index(current["state"]) + 1: raise Conflict("release transition skips, reverses, or follows a terminal state") evidence = _transition_evidence(current, target, body.get("evidence")) stamp = now_iso() transition = {"from": current["state"], "to": target, "at": stamp, "by": _actor(request), "evidence": _evidence_refs(target, evidence)} release = {**current, "state": target, "evidence": evidence, "transitions": [*current["transitions"], transition]} _validate_release(release) entry = _append(request, release, project_id, conversation_id, rows) result = _view(entry) _remember(request, key, fingerprint, release["id"], entry["sequence"]) request.audit("releases.transition", release["id"], reason=target) return Response(200, result, {"ETag": str(entry["sequence"]), "Cache-Control": "no-store"}) def get_release(request: Request) -> Response: """Read one verified release view within its project/conversation scope.""" project_id, conversation_id = _scope(request) rows = _rows(request, request.params["release_id"]) head = rows[-1] if head["project_id"] != project_id or head["conversation_id"] != conversation_id: raise NotFound("release not found") request.audit("releases.read", request.params["release_id"]) return Response(200, _view(head), {"ETag": str(len(rows)), "Cache-Control": "no-store"}) def list_releases(request: Request) -> Response: """List verified heads for one scope, newest ledger first.""" project_id, conversation_id = _scope(request) items = [] for name in request.store.ledgers(FAMILY): if not name.startswith("rel_"): continue head = _rows(request, name)[-1] if head["project_id"] == project_id and head["conversation_id"] == conversation_id: items.append(_view(head)) items.sort(key=lambda row: row["release"]["transitions"][-1]["at"] if row["release"]["transitions"] else "", reverse=True) request.audit("releases.list", conversation_id) response = page(items) response.headers["Cache-Control"] = "no-store" return response def register(router: Router) -> None: """Attach HUX-12 routes.""" base = "/hux/v1/projects/{project_id}/conversations/{id}/releases" router.add("POST", base, CARD, "releases.create", create_release) router.add("GET", base, CARD, "releases.list", list_releases) router.add("GET", base + "/{release_id}", CARD, "releases.read", get_release) router.add("POST", base + "/{release_id}/transitions", CARD, "releases.transition", transition_release)