Re-enforcement prerequisites, code-side complete: the autonomy runtime now docks pending-approval cards above the composer (newest first, cap three, aria-live, allow-once / always / deny wired to the existing decide route with idempotency; polling gated to active turns and fail-tolerant), so parked tool calls are never silent. The default capability matrix no longer denies by default: network and web_search ask below autonomous (visible prompt) and nothing resolves to deny except explicit grants or private mode; SO-39 stays intact - deploy and external side effects always ask and external never auto-allows. rules.py at 100% line+branch; 744 hux-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
321 lines
14 KiB
Python
321 lines
14 KiB
Python
"""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", "external_side_effect",
|
|
)
|
|
_READ_ONLY = frozenset({"read_files", "spend_tokens"})
|
|
_MUTATING = frozenset({"write_files", "shell", "send_message", "memory_write", "artifact_write", "delegate"})
|
|
_NETWORK = frozenset({"network", "web_search"})
|
|
_ALWAYS_ASK = frozenset({"deploy", "external_side_effect"})
|
|
|
|
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")
|
|
stored = entry.get("status") not in {"rejected", "no_store", "forgotten"}
|
|
if sensitivity == "sensitive" and stored and entry.get("approval_mode") != "ask":
|
|
problems.append("sensitive memory requires approval_mode=ask")
|
|
if sensitivity == "sensitive" and stored 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 stored:
|
|
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. Deploy and external side effects always ask (SO-39).
|
|
|
|
Network access (``network``/``web_search``) asks below ``autonomous`` instead of
|
|
denying: for a chat assistant whose core value includes browsing, the right
|
|
default is a visible approval prompt, never a silent block. No default is
|
|
``deny``; denials only come from explicit grants or private mode.
|
|
"""
|
|
matrix: dict[str, dict[str, str]] = {}
|
|
for level in ("ask_first", "safe", "autonomous"):
|
|
row: dict[str, str] = {}
|
|
for capability in CAPABILITIES:
|
|
if capability in _ALWAYS_ASK:
|
|
row[capability] = "ask"
|
|
elif capability in _READ_ONLY:
|
|
row[capability] = "allow"
|
|
else:
|
|
# _NETWORK and _MUTATING park with a visible prompt below autonomous.
|
|
row[capability] = "ask" if level != "autonomous" else "allow"
|
|
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 in _ALWAYS_ASK 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
|