"""Pure contract for informed-decision forwarding and notifications. Dev and request validation only. This module does not read a token, open a socket, register a submitter, or decide a hub decision. ``drafted_by`` is asserted provenance; it is never an authenticated identity. """ from __future__ import annotations import hashlib import json import re from dataclasses import dataclass from typing import Any, Mapping KIND = "informed-decision.memo-intake" VERSION = 1 OVERVIEW_LINE = "The signed-in overview is the authoritative review entry point." PROVENANCE_NOTE = ( "drafted_by is asserted provenance, not authenticated identity. " "A forwarded token proves only the hub submitter." ) RESOLVING_VERBS = frozenset({"accept", "decline"}) NOTIFICATION_FIELDS = ("memo_id", "question", "memo_class", "submitter") MAX_QUESTION = 400 MAX_DRAFTED_BY = 200 MAX_PACKET_BYTES = 256 * 1024 MAX_ENVELOPE_BYTES = 1024 * 1024 MAX_PACKET_ITEMS = 32 MAX_NOTIFICATION_BYTES = 8192 _KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") _MEMO_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{2,62}$") _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}$") _UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") _SHA_RE = re.compile(r"^sha256:[0-9a-f]{64}$") _ERROR_RE = re.compile(r"^[a-z0-9_]{1,64}$") ENVELOPE_KEYS = { "kind", "version", "idempotency_key", "memo_class", "review_group", "drafted_by", "serves", "approval_intent", "memo", "packet", } ENVELOPE_REQUIRED = ENVELOPE_KEYS - {"drafted_by", "serves"} INTENT_KEYS = {"action", "principal", "purpose", "target"} TARGET_KEYS = {"id", "type", "system"} PACKET_KEYS = {"item_id", "media_type", "content"} MEDIA_TYPES = {"text/markdown", "text/plain", "application/json"} # Shapes only. A hit is refused by code; the matched text is never echoed. _SECRET_MARKERS = ( re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}"), re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), re.compile(r"\b(hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}"), re.compile( r"\b(sk-(or-)?[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|" r"github_pat_[A-Za-z0-9_]{30,}|glpat-[A-Za-z0-9_-]{20,}|" r"AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,})" ), re.compile( r"(?i)\b(password|passwd|client_secret|secret|api[_-]?key|access[_-]?token|token)" r"\s*[:=]\s*['\"]?(?=[A-Za-z0-9_+.=-]*\d)[A-Za-z0-9_+.=-]{12,}(?=['\"\s,;)]|$)" ), re.compile(r"://[^/@\s:]+:[^/@\s]+@"), ) class ContractError(ValueError): def __init__(self, code: str, status: int = 400) -> None: super().__init__(code) self.code = code self.status = status def canonical(value: Any) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def digest(value: Any) -> str: return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest() def scan_secrets(value: Any) -> None: for text in _strings(value): for pattern in _SECRET_MARKERS: if pattern.search(text): raise ContractError("secret_marker") def stable_idempotency_key(decision_id: str, memo_id: str) -> str: _uuid(decision_id, "hub_decision_id") _memo_id(memo_id) return f"state-hub.{decision_id}.{memo_id}" def resolution_applies(verb: str, serves: Mapping | None) -> bool: """Terminal human accept/decline with resolves=on-answer only.""" if verb not in RESOLVING_VERBS: return False if not isinstance(serves, Mapping): return False return serves.get("resolves") == "on-answer" def correlate_resolution( memo_id: str, *, post_status: int | None, existing: Mapping | None ) -> str: """Classify a resolve attempt. A 409 is a conflict only after the existing resolution was read; an unreadable 409 stays retryable.""" _memo_id(memo_id) if post_status is None: return "failed" if 200 <= post_status < 300: return "resolved" if post_status != 409: return "failed" if not isinstance(existing, Mapping): return "failed" rationale = existing.get("rationale") if not isinstance(rationale, str): return "failed" if f"memo {memo_id} " in rationale: return "already_resolved" return "conflict" def safe_error_code(value: Any, fallback: str) -> str: if isinstance(value, str) and _ERROR_RE.match(value): return value return fallback @dataclass(frozen=True) class Notification: memo_id: str question: str memo_class: str submitter: str def as_body(self) -> dict[str, str]: return { "memo_id": self.memo_id, "question": self.question, "memo_class": self.memo_class, "submitter": self.submitter, } @property def body_digest(self) -> str: return digest(self.as_body()) def message_subject(self) -> str: return f"Decision memo {self.memo_id}" def message_text(self) -> str: return OVERVIEW_LINE + "\n" + canonical(self.as_body()) def parse_delivery_id(value: str | None) -> str: if value is None or value == "": raise ContractError("delivery_id_required") if not isinstance(value, str) or not _KEY_RE.match(value): raise ContractError("delivery_id_invalid") return value def parse_notification(payload: Any) -> Notification: if not isinstance(payload, dict): raise ContractError("invalid_notification") if set(payload) - set(NOTIFICATION_FIELDS): raise ContractError("unknown_field") if set(NOTIFICATION_FIELDS) - set(payload): raise ContractError("missing_field") scan_secrets(payload) if any("://" in text for text in _strings(payload)): raise ContractError("notification_has_link") return Notification( memo_id=_memo_id(payload["memo_id"]), question=_one_question(payload["question"]), memo_class=_slug(payload["memo_class"], "memo_class"), submitter=_slug(payload["submitter"], "submitter"), ) def build_envelope( *, decision_id: str, memo: Mapping, packet: list, memo_class: str, review_group: str, approval_intent: Mapping, drafted_by: str | None = None, resolves: str = "on-answer", ) -> dict: if not isinstance(memo, Mapping) or not isinstance(memo.get("id"), str): raise ContractError("invalid_memo") envelope: dict[str, Any] = { "kind": KIND, "version": VERSION, "idempotency_key": stable_idempotency_key(decision_id, memo["id"]), "memo_class": memo_class, "review_group": review_group, "serves": {"hub_decision_id": decision_id, "resolves": resolves}, "approval_intent": dict(approval_intent), "memo": dict(memo), "packet": list(packet), } if drafted_by is not None: envelope["drafted_by"] = drafted_by return validate_envelope(envelope) def validate_envelope(envelope: Any) -> dict: if not isinstance(envelope, dict): raise ContractError("invalid_envelope") extra = set(envelope) - ENVELOPE_KEYS if extra: raise ContractError("unknown_field") missing = ENVELOPE_REQUIRED - set(envelope) if missing: raise ContractError("missing_field") if envelope["kind"] != KIND or type(envelope["version"]) is not int or envelope["version"] != VERSION: raise ContractError("unsupported_kind") scan_secrets(envelope) _packet(envelope["packet"]) _bounded_tree(envelope) encoded = canonical(envelope).encode("utf-8") if len(encoded) > MAX_ENVELOPE_BYTES: raise ContractError("envelope_too_large") memo = envelope["memo"] if not isinstance(memo, dict): raise ContractError("invalid_memo") memo_id = _memo_id(memo.get("id")) if "question" in memo: _one_question(memo["question"]) key = _text(envelope["idempotency_key"], pattern=_KEY_RE, code="idempotency_key_invalid") if "serves" in envelope: decision_id = _serves(envelope["serves"]) if key != stable_idempotency_key(decision_id, memo_id): raise ContractError("idempotency_key_not_stable") elif not (memo_id.startswith("review-") and _UUID_RE.fullmatch(memo_id[7:]) and key == memo_id): # Lifecycle service supplies its retained snapshot id/key. Never invent # a Hub decision relationship or rewrite that immutable envelope. raise ContractError("lifecycle_handoff_key_invalid") _slug(envelope["memo_class"], "memo_class") _slug(envelope["review_group"], "review_group") drafted_by = envelope.get("drafted_by") if drafted_by is not None: _text(drafted_by, max_len=MAX_DRAFTED_BY, code="drafted_by_invalid") _intent(envelope["approval_intent"]) if "submitter" in envelope or "actor" in envelope.get("approval_intent", {}): raise ContractError("unknown_field") return json.loads(canonical(envelope)) def submission_digest(envelope: Mapping) -> str: body = {key: value for key, value in envelope.items() if key != "idempotency_key"} return digest(body) def bounded_receipt(body: Any, expected_memo_id: str) -> dict[str, str]: if not isinstance(body, dict): raise ContractError("receipt_incomplete") memo_id = body.get("memo_id") approval_id = body.get("approval_id") binding = body.get("approval_binding_digest") if memo_id != expected_memo_id or not isinstance(approval_id, str) or not _UUID_RE.match(approval_id): raise ContractError("receipt_mismatch") if not isinstance(binding, str) or not _SHA_RE.match(binding): raise ContractError("receipt_incomplete") return { "memo_id": memo_id, "approval_id": approval_id, "approval_binding_digest": binding, } def _serves(serves: Any) -> str: if not isinstance(serves, dict) or set(serves) != {"hub_decision_id", "resolves"}: raise ContractError("invalid_serves") if serves["resolves"] not in ("on-answer", "never"): raise ContractError("unknown_enum") return _uuid(serves["hub_decision_id"], "hub_decision_id") def _intent(intent: Any) -> None: if not isinstance(intent, dict) or set(intent) != INTENT_KEYS: raise ContractError("invalid_intent") for field in ("action", "principal", "purpose"): _text(intent[field], max_len=400, code="invalid_intent") target = intent["target"] if not isinstance(target, dict) or set(target) != TARGET_KEYS: raise ContractError("invalid_intent") for field in TARGET_KEYS: _text(target[field], max_len=200, code="invalid_intent") def _packet(packet: Any) -> None: if not isinstance(packet, list) or not packet or len(packet) > MAX_PACKET_ITEMS: raise ContractError("packet_required" if not packet else "packet_unbounded") seen: set[str] = set() for item in packet: if not isinstance(item, dict) or set(item) != PACKET_KEYS: raise ContractError("invalid_packet") if item["media_type"] not in MEDIA_TYPES: raise ContractError("unsupported_media_type") content = item["content"] if not isinstance(content, str) or len(content.encode("utf-8")) > MAX_PACKET_BYTES: raise ContractError("packet_too_large") item_id = _text(item["item_id"], max_len=200, code="invalid_packet") if item_id in seen: raise ContractError("duplicate_packet_item") seen.add(item_id) def _one_question(value: Any) -> str: text = _text(value, max_len=MAX_QUESTION, code="question_invalid") if "\n" in text or "\r" in text or "://" in text: raise ContractError("question_invalid") if text.count("?") != 1 or not text.endswith("?"): raise ContractError("question_not_one_question") return text def _memo_id(value: Any) -> str: return _text(value, pattern=_MEMO_ID_RE, code="memo_id_invalid") def _slug(value: Any, code: str) -> str: return _text(value, pattern=_SLUG_RE, code=code + "_invalid") def _uuid(value: Any, code: str) -> str: return _text(value, pattern=_UUID_RE, code=code + "_invalid") def _text(value: Any, *, max_len: int = 128, pattern: re.Pattern[str] | None = None, code: str) -> str: if not isinstance(value, str) or not value or len(value) > max_len: raise ContractError(code) if pattern is not None and not pattern.match(value): raise ContractError(code) return value def _bounded_tree(value: Any) -> None: total = 0 def walk(node: Any, key: str | None = None) -> None: nonlocal total if isinstance(node, str): size = len(node.encode("utf-8")) if size > MAX_PACKET_BYTES: raise ContractError("field_too_large") if key == "question": _one_question(node) total += size elif isinstance(node, dict): for child_key, child in node.items(): if not isinstance(child_key, str): raise ContractError("invalid_type") walk(child_key, None) walk(child, child_key) elif isinstance(node, list): for child in node: walk(child, key) elif isinstance(node, bool) or type(node) in (int, float) or node is None: # type() excludes bool; bool subclasses int, so it must be named first. return else: raise ContractError("invalid_type") if total > MAX_ENVELOPE_BYTES: raise ContractError("envelope_too_large") walk(value) def _strings(value: Any): if isinstance(value, str): yield value elif isinstance(value, dict): for key, child in value.items(): if isinstance(key, str): yield key yield from _strings(child) elif isinstance(value, list): for child in value: yield from _strings(child)