informed-decision/informed_decision/intake.py
tegwick a973d3656a INFD: frictionless decision intake design and workplan (CUST-WP-0075)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3775@bnt-lap001
Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
2026-10-03 09:21:05 +02:00

292 lines
13 KiB
Python

"""Memo intake validation (INFD-WP-0005, docs/specs/FrictionlessDecisionIntake.md §3).
Pure and offline: validates a submission envelope from an authenticated
workload and returns the parsed draft plus its submission digest. It creates no
approval, writes no store row and binds nothing. The submitter id comes from
the caller's verified workload identity, never from the body.
Every refusal is an ``IntakeRefused`` with a stable reason code and a field
path. A secret-marker hit never echoes the matched text.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any, Mapping
from .memo import BindingLevel, Memo, StepKind
from .records import memo_from
KIND = "informed-decision.memo-intake"
VERSION = 1
DRAFT_PRINCIPAL = "pending-human-session"
REQUESTER_ACTOR = "informed-decision"
MAX_QUESTION = 400
MAX_PACKET_BYTES = 256 * 1024
ENVELOPE_KEYS = {"kind", "version", "idempotency_key", "memo_class", "review_group",
"drafted_by", "serves", "approval_intent", "memo", "packet"}
ENVELOPE_REQUIRED = ENVELOPE_KEYS - {"drafted_by", "serves"}
MEMO_KEYS = {"id", "version", "question", "requested_act", "binding_level", "brief",
"binding", "step_kind", "packet", "highlights", "locale", "ui_release",
"approval_id", "approval_binding_digest", "sealed"}
INTENT_KEYS = {"action", "principal", "purpose", "target"}
TARGET_KEYS = {"id", "type", "system"}
HIGHLIGHT_SEVERITIES = {"informational", "warning", "critical"}
_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}$")
#: Secret markers (§3.3.6). Shapes, not values; deliberately broad.
SECRET_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = (
("pem_private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}")),
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("vault_token", re.compile(r"\b(hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
("provider_key", 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,})")),
("credential_assignment", 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,;)]|$)")),
("url_credentials", re.compile(r"://[^/@\s:]+:[^/@\s]+@")),
)
class IntakeRefused(ValueError):
def __init__(self, reason: str, path: str = "") -> None:
super().__init__(f"{reason} at {path or '$'}")
self.reason = reason
self.path = path
@dataclass(frozen=True)
class SubmitterGrant:
"""One row of deploy/intake-submitters.json, keyed by verified workload."""
submitter_id: str
memo_classes: frozenset[str]
review_groups: frozenset[str]
@dataclass(frozen=True)
class ValidatedSubmission:
submitter_id: str
idempotency_key: str
submission_digest: str
memo_class: str
review_group: str
drafted_by: str | None
serves: dict | None
approval_binding: dict
memo: Memo
packet: dict[str, bytes]
def _closed(value: Any, keys: set[str], required: set[str], path: str) -> dict:
if not isinstance(value, dict):
raise IntakeRefused("not_an_object", path)
extra = set(value) - keys
if extra:
raise IntakeRefused("unknown_field", f"{path}.{sorted(extra)[0]}")
missing = required - set(value)
if missing:
raise IntakeRefused("missing_field", f"{path}.{sorted(missing)[0]}")
return value
def _text(value: Any, path: str, *, max_len: int = 4000, pattern: re.Pattern[str] | None = None) -> str:
if not isinstance(value, str) or not value.strip() or len(value) > max_len:
raise IntakeRefused("invalid_text", path)
if pattern is not None and not pattern.match(value):
raise IntakeRefused("invalid_format", path)
return value
def _strings(value: Any, path: str):
if isinstance(value, str):
yield path, value
elif isinstance(value, dict):
for key, item in value.items():
yield from _strings(item, f"{path}.{key}")
elif isinstance(value, list):
for i, item in enumerate(value):
yield from _strings(item, f"{path}[{i}]")
def scan_secret_markers(value: Any, path: str = "$") -> None:
"""Refuse on the first secret-shaped string. Reports the path, never the text."""
for where, text in _strings(value, path):
for name, pattern in SECRET_MARKERS:
if pattern.search(text):
raise IntakeRefused(f"secret_marker:{name}", where)
def check_one_question(question: str, path: str = "$.memo.question") -> None:
if "\n" in question or "\r" in question:
raise IntakeRefused("question_multiline", path)
if len(question) > MAX_QUESTION:
raise IntakeRefused("question_too_long", path)
if question.count("?") != 1 or not question.rstrip().endswith("?"):
raise IntakeRefused("not_one_question", path)
def canonical(value: Any) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
allow_nan=False).encode("utf-8")
def submission_digest(submitter_id: str, envelope: Mapping[str, Any]) -> str:
body = {k: v for k, v in envelope.items() if k != "idempotency_key"}
return "sha256:" + hashlib.sha256(canonical({"submitter": submitter_id, "envelope": body})).hexdigest()
def idempotency_outcome(stored_digest: str | None, digest: str) -> str:
"""``new`` | ``replay`` for the ledger key (submitter, idempotency_key)."""
if stored_digest is None:
return "new"
if stored_digest == digest:
return "replay"
raise IntakeRefused("idempotency_conflict", "$.idempotency_key")
def _check_memo(memo: Any, packet_hashes: dict[str, str]) -> Memo:
memo = _closed(memo, MEMO_KEYS, MEMO_KEYS - {"locale", "ui_release"}, "$.memo")
_text(memo["id"], "$.memo.id", max_len=63, pattern=_MEMO_ID_RE)
if memo["version"] != 1 or isinstance(memo["version"], bool):
raise IntakeRefused("draft_version_must_be_1", "$.memo.version")
if memo["sealed"] is not False:
raise IntakeRefused("draft_must_be_unsealed", "$.memo.sealed")
for field in ("approval_id", "approval_binding_digest"):
if memo[field] is not None:
raise IntakeRefused("approval_pinned_by_submitter", f"$.memo.{field}")
check_one_question(_text(memo["question"], "$.memo.question"))
_text(memo["requested_act"], "$.memo.requested_act", max_len=40, pattern=_SLUG_RE)
_text(memo["brief"], "$.memo.brief", max_len=20000)
try:
BindingLevel(memo["binding_level"])
StepKind(memo["step_kind"])
except ValueError:
raise IntakeRefused("unknown_enum", "$.memo.step_kind") from None
binding = memo["binding"]
if not isinstance(binding, dict) or not isinstance(binding.get("principal"), dict):
raise IntakeRefused("invalid_binding", "$.memo.binding")
if binding["principal"].get("id") != DRAFT_PRINCIPAL:
raise IntakeRefused("principal_prebound", "$.memo.binding.principal.id")
items = memo["packet"]
if not isinstance(items, list) or not items:
raise IntakeRefused("packet_required", "$.memo.packet")
seen: set[str] = set()
for i, item in enumerate(items):
item = _closed(item, {"item_id", "label", "hash"}, {"item_id", "label", "hash"}, f"$.memo.packet[{i}]")
if item["item_id"] in seen:
raise IntakeRefused("duplicate_packet_item", f"$.memo.packet[{i}]")
seen.add(item["item_id"])
if item["item_id"] not in packet_hashes:
raise IntakeRefused("packet_content_missing", f"$.memo.packet[{i}]")
if item["hash"] != packet_hashes[item["item_id"]]:
raise IntakeRefused("packet_digest_mismatch", f"$.memo.packet[{i}].hash")
orphans = set(packet_hashes) - seen
if orphans:
raise IntakeRefused("orphan_packet_content", "$.packet")
highlights = memo["highlights"]
if not isinstance(highlights, list):
raise IntakeRefused("invalid_highlights", "$.memo.highlights")
ids: set[str] = set()
work_records: set[tuple] = set()
for i, h in enumerate(highlights):
if not isinstance(h, dict) or h.get("id") in ids or not h.get("id"):
raise IntakeRefused("duplicate_highlight", f"$.memo.highlights[{i}]")
ids.add(h["id"])
if h.get("item_id") not in seen:
raise IntakeRefused("highlight_item_unknown", f"$.memo.highlights[{i}].item_id")
if h.get("severity", "informational") not in HIGHLIGHT_SEVERITIES:
raise IntakeRefused("unknown_enum", f"$.memo.highlights[{i}].severity")
loc = h.get("locator") or {}
if loc.get("kind") == "work_record":
work_records.add((loc.get("workplan"), loc.get("task")))
if len(work_records) > 1:
raise IntakeRefused("bundled_acts", "$.memo.highlights")
try:
return memo_from(memo)
except (TypeError, ValueError, KeyError):
raise IntakeRefused("memo_unparseable", "$.memo") from None
def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGrant | None) -> ValidatedSubmission:
"""Validate one intake envelope for an already-authenticated submitter."""
if grant is None or grant.submitter_id != submitter_id:
raise IntakeRefused("submitter_unknown", "$")
env = _closed(envelope, ENVELOPE_KEYS, ENVELOPE_REQUIRED, "$")
if env["kind"] != KIND or env["version"] != VERSION:
raise IntakeRefused("unsupported_kind", "$.kind")
key = _text(env["idempotency_key"], "$.idempotency_key", max_len=128, pattern=_KEY_RE)
memo_class = _text(env["memo_class"], "$.memo_class", max_len=63, pattern=_SLUG_RE)
review_group = _text(env["review_group"], "$.review_group", max_len=63, pattern=_SLUG_RE)
drafted_by = env.get("drafted_by")
if drafted_by is not None:
_text(drafted_by, "$.drafted_by", max_len=200)
# Secret scan before anything is parsed further or echoed anywhere.
scan_secret_markers(env)
serves = env.get("serves")
if serves is not None:
serves = _closed(serves, {"hub_decision_id", "resolves"}, {"hub_decision_id", "resolves"}, "$.serves")
_text(serves["hub_decision_id"], "$.serves.hub_decision_id", max_len=36, pattern=_UUID_RE)
if serves["resolves"] not in ("on-answer", "never"):
raise IntakeRefused("unknown_enum", "$.serves.resolves")
intent = _closed(env["approval_intent"], INTENT_KEYS, INTENT_KEYS, "$.approval_intent")
for field in ("action", "principal", "purpose"):
_text(intent[field], f"$.approval_intent.{field}", max_len=400)
target = _closed(intent["target"], TARGET_KEYS, TARGET_KEYS, "$.approval_intent.target")
for field in TARGET_KEYS:
_text(target[field], f"$.approval_intent.target.{field}", max_len=200)
packet = env["packet"]
if not isinstance(packet, list) or not packet:
raise IntakeRefused("packet_required", "$.packet")
contents: dict[str, bytes] = {}
hashes: dict[str, str] = {}
for i, item in enumerate(packet):
item = _closed(item, {"item_id", "media_type", "content"}, {"item_id", "media_type", "content"}, f"$.packet[{i}]")
if item["media_type"] not in ("text/markdown", "text/plain", "application/json"):
raise IntakeRefused("unsupported_media_type", f"$.packet[{i}].media_type")
if not isinstance(item["content"], str):
raise IntakeRefused("invalid_text", f"$.packet[{i}].content")
data = item["content"].encode("utf-8")
if len(data) > MAX_PACKET_BYTES:
raise IntakeRefused("packet_too_large", f"$.packet[{i}]")
if item["item_id"] in contents:
raise IntakeRefused("duplicate_packet_item", f"$.packet[{i}]")
contents[item["item_id"]] = data
hashes[item["item_id"]] = "sha256:" + hashlib.sha256(data).hexdigest()
memo = _check_memo(env["memo"], hashes)
if memo_class not in grant.memo_classes:
raise IntakeRefused("memo_class_not_admitted", "$.memo_class")
if review_group not in grant.review_groups:
raise IntakeRefused("review_group_not_admitted", "$.review_group")
return ValidatedSubmission(
submitter_id=submitter_id,
idempotency_key=key,
submission_digest=submission_digest(submitter_id, env),
memo_class=memo_class,
review_group=review_group,
drafted_by=drafted_by,
serves=dict(serves) if serves else None,
approval_binding={**{k: intent[k] for k in ("action", "principal", "purpose")},
"actor": REQUESTER_ACTOR, "target": dict(target)},
memo=memo,
packet=contents,
)