Add a dev memo-forwarding fixture and deduplicated intake notifications.
Pose, replay, and conflict stay on an injected transport with local retry evidence. Notification receipts keep only memo id, question, class, and submitter, and a resolve conflict stays readable. Assistant: grok Assistant-Session: 01a10329-31cb-78d3-b7a3-789e1fb83139
This commit is contained in:
parent
42ab047e87
commit
49eeddbfed
10 changed files with 1612 additions and 1 deletions
|
|
@ -13,7 +13,7 @@ from api.database import engine
|
||||||
from api.events import shutdown_publisher
|
from api.events import shutdown_publisher
|
||||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||||
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, memo_notifications, capability_requests, tpsc, services
|
||||||
from api.routers import token_events
|
from api.routers import token_events
|
||||||
from api.routers import interface_changes
|
from api.routers import interface_changes
|
||||||
from api.routers import flows
|
from api.routers import flows
|
||||||
|
|
@ -130,6 +130,7 @@ app.include_router(repo_goals.router)
|
||||||
app.include_router(contributions.router)
|
app.include_router(contributions.router)
|
||||||
app.include_router(sbom.router)
|
app.include_router(sbom.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
|
app.include_router(memo_notifications.router)
|
||||||
app.include_router(capability_requests.router)
|
app.include_router(capability_requests.router)
|
||||||
app.include_router(suggestions.router)
|
app.include_router(suggestions.router)
|
||||||
app.include_router(tpsc.router)
|
app.include_router(tpsc.router)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from api.models.contribution import Contribution, ContributionType, Contribution
|
||||||
from api.models.sbom_snapshot import SBOMSnapshot
|
from api.models.sbom_snapshot import SBOMSnapshot
|
||||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||||
from api.models.agent_message import AgentMessage, MessageReceipt
|
from api.models.agent_message import AgentMessage, MessageReceipt
|
||||||
|
from api.models.memo_notification import MemoNotificationReceipt
|
||||||
from api.models.capability_catalog import CapabilityCatalog
|
from api.models.capability_catalog import CapabilityCatalog
|
||||||
from api.models.capability_request import CapabilityRequest
|
from api.models.capability_request import CapabilityRequest
|
||||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||||
|
|
@ -77,6 +78,7 @@ __all__ = [
|
||||||
"SBOMEntry", "Ecosystem",
|
"SBOMEntry", "Ecosystem",
|
||||||
"AgentMessage",
|
"AgentMessage",
|
||||||
"MessageReceipt",
|
"MessageReceipt",
|
||||||
|
"MemoNotificationReceipt",
|
||||||
"CapabilityCatalog",
|
"CapabilityCatalog",
|
||||||
"CapabilityRequest",
|
"CapabilityRequest",
|
||||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||||
|
|
|
||||||
37
api/models/memo_notification.py
Normal file
37
api/models/memo_notification.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from api.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MemoNotificationReceipt(Base):
|
||||||
|
"""Durable receipt for one informed-decision delivery id.
|
||||||
|
|
||||||
|
The delivery id is the sender's stable idempotency key. The body is only
|
||||||
|
memo id, question, class, and submitter. Rows are retained, not expired.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "memo_notification_receipts"
|
||||||
|
|
||||||
|
delivery_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
body_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
memo_id: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
question: Mapped[str] = mapped_column(String(400), nullable=False)
|
||||||
|
memo_class: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
submitter: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||||
|
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("agent_messages.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
authoritative_review: Mapped[str] = mapped_column(
|
||||||
|
String(40), nullable=False, default="signed-in-overview"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
97
api/routers/memo_notifications.py
Normal file
97
api/routers/memo_notifications.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Notification intake for informed-decision delivery ids.
|
||||||
|
|
||||||
|
Intentionally absent from the 14-day write-idempotency allowlist: retention
|
||||||
|
is this receipt row, and a conflicting reuse must stay readable by GET.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.database import get_session
|
||||||
|
from api.models.memo_notification import MemoNotificationReceipt
|
||||||
|
from api.services.informed_decision_contract import (
|
||||||
|
MAX_NOTIFICATION_BYTES,
|
||||||
|
ContractError,
|
||||||
|
parse_delivery_id,
|
||||||
|
parse_notification,
|
||||||
|
)
|
||||||
|
from api.services.memo_notification import NotificationConflict, accept_notification
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/informed-decision", tags=["informed-decision-notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
def _error(status: int, code: str) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status,
|
||||||
|
content={"error": code},
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(receipt: MemoNotificationReceipt, *, status: int, replay: bool = False) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status,
|
||||||
|
content={
|
||||||
|
"delivery_id": receipt.delivery_id,
|
||||||
|
"memo_id": receipt.memo_id,
|
||||||
|
"question": receipt.question,
|
||||||
|
"memo_class": receipt.memo_class,
|
||||||
|
"submitter": receipt.submitter,
|
||||||
|
"message_id": str(receipt.message_id),
|
||||||
|
"status": "accepted",
|
||||||
|
"authoritative_review": receipt.authoritative_review,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
**({"X-StateHub-Idempotency-Replay": "true"} if replay else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications")
|
||||||
|
async def post_notification(
|
||||||
|
request: Request, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> JSONResponse:
|
||||||
|
raw = await request.body()
|
||||||
|
if len(raw) > MAX_NOTIFICATION_BYTES:
|
||||||
|
return _error(413, "notification_too_large")
|
||||||
|
content_type = request.headers.get("content-type", "")
|
||||||
|
if content_type.split(";")[0].strip().lower() != "application/json":
|
||||||
|
return _error(415, "json_required")
|
||||||
|
try:
|
||||||
|
delivery_id = parse_delivery_id(request.headers.get("idempotency-key"))
|
||||||
|
notice = parse_notification(json.loads(raw))
|
||||||
|
except ContractError as exc:
|
||||||
|
return _error(exc.status, exc.code)
|
||||||
|
except (json.JSONDecodeError, UnicodeError):
|
||||||
|
return _error(400, "invalid_json")
|
||||||
|
try:
|
||||||
|
receipt, replay = await accept_notification(session, delivery_id, notice)
|
||||||
|
except NotificationConflict as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={
|
||||||
|
"error": "notification_conflict",
|
||||||
|
"delivery_id": exc.receipt.delivery_id,
|
||||||
|
},
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return _payload(receipt, status=200 if replay else 201, replay=replay)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/{delivery_id}")
|
||||||
|
async def get_notification(
|
||||||
|
delivery_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
checked = parse_delivery_id(delivery_id)
|
||||||
|
except ContractError as exc:
|
||||||
|
return _error(exc.status, exc.code)
|
||||||
|
receipt = await session.get(MemoNotificationReceipt, checked)
|
||||||
|
if receipt is None:
|
||||||
|
return _error(404, "notification_not_found")
|
||||||
|
return _payload(receipt, status=200)
|
||||||
382
api/services/informed_decision_contract.py
Normal file
382
api/services/informed_decision_contract.py
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
"""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"}
|
||||||
|
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"])
|
||||||
|
decision_id = _serves(envelope["serves"])
|
||||||
|
key = _text(envelope["idempotency_key"], pattern=_KEY_RE, code="idempotency_key_invalid")
|
||||||
|
if key != stable_idempotency_key(decision_id, memo_id):
|
||||||
|
raise ContractError("idempotency_key_not_stable")
|
||||||
|
_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 type(node) in (int, float) or node is None:
|
||||||
|
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)
|
||||||
440
api/services/memo_forwarding.py
Normal file
440
api/services/memo_forwarding.py
Normal file
|
|
@ -0,0 +1,440 @@
|
||||||
|
"""Dev-only pose, replay, and conflict fixture.
|
||||||
|
|
||||||
|
Not imported by the API runtime and not scheduled. It never reads a token
|
||||||
|
file, mints a credential, or dials a default host. Tests inject the transport
|
||||||
|
and, when a pose should carry a header, a token callable. The callable's
|
||||||
|
value is not retained.
|
||||||
|
|
||||||
|
Choosing the workload identity that projects an intake-audience token, and
|
||||||
|
reviewing that identity into the submitter registry, is an owner step. This
|
||||||
|
fixture does not name or admit an account.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterator
|
||||||
|
|
||||||
|
from api.services.informed_decision_contract import (
|
||||||
|
PROVENANCE_NOTE,
|
||||||
|
ContractError,
|
||||||
|
bounded_receipt,
|
||||||
|
correlate_resolution,
|
||||||
|
resolution_applies,
|
||||||
|
safe_error_code,
|
||||||
|
submission_digest,
|
||||||
|
validate_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
Transport = Callable[[str, str, dict, dict], tuple[int, dict]]
|
||||||
|
TokenReader = Callable[[], str]
|
||||||
|
|
||||||
|
_RETRYABLE = frozenset({408, 429})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForwardResult:
|
||||||
|
status: str
|
||||||
|
idempotency_key: str
|
||||||
|
receipt: dict | None
|
||||||
|
drafted_by: str | None
|
||||||
|
drafted_by_provenance: str
|
||||||
|
provenance_note: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DevIntakeFixture:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
transport: Transport,
|
||||||
|
intake_base_url: str,
|
||||||
|
hub_base_url: str,
|
||||||
|
token_reader: TokenReader | None = None,
|
||||||
|
clock: Callable[[], float] = time.time,
|
||||||
|
) -> None:
|
||||||
|
self.path = str(path)
|
||||||
|
self.transport = transport
|
||||||
|
self.intake_base_url = _base_url(intake_base_url)
|
||||||
|
self.hub_base_url = _base_url(hub_base_url)
|
||||||
|
self.token_reader = token_reader
|
||||||
|
self.clock = clock
|
||||||
|
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS pose_ledger (
|
||||||
|
idempotency_key TEXT PRIMARY KEY,
|
||||||
|
submission_digest TEXT NOT NULL,
|
||||||
|
envelope_json TEXT NOT NULL,
|
||||||
|
drafted_by TEXT,
|
||||||
|
drafted_by_provenance TEXT NOT NULL
|
||||||
|
CHECK (drafted_by_provenance IN ('asserted', 'absent')),
|
||||||
|
provenance_note TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL
|
||||||
|
CHECK (state IN ('pending', 'delivered', 'blocked')),
|
||||||
|
receipt_json TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt REAL NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
blocked_reason TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resolution_ledger (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
decision_id TEXT NOT NULL,
|
||||||
|
memo_id TEXT NOT NULL,
|
||||||
|
verb TEXT NOT NULL,
|
||||||
|
resolves TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_type TEXT,
|
||||||
|
payload_json TEXT,
|
||||||
|
next_attempt REAL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit(self, envelope: dict) -> ForwardResult:
|
||||||
|
checked = validate_envelope(envelope)
|
||||||
|
key = checked["idempotency_key"]
|
||||||
|
body_digest = submission_digest(checked)
|
||||||
|
drafted_by = checked.get("drafted_by")
|
||||||
|
provenance = "asserted" if drafted_by else "absent"
|
||||||
|
now = self.clock()
|
||||||
|
early: tuple[str, str] | None = None
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO pose_ledger (
|
||||||
|
idempotency_key, submission_digest, envelope_json, drafted_by,
|
||||||
|
drafted_by_provenance, provenance_note, state, attempts,
|
||||||
|
next_attempt, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, 0, ?, ?)""",
|
||||||
|
(
|
||||||
|
key, body_digest, _dump(checked), drafted_by, provenance,
|
||||||
|
PROVENANCE_NOTE, _iso(now), _iso(now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif row["submission_digest"] != body_digest:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE pose_ledger SET blocked_reason='idempotency_conflict',
|
||||||
|
updated_at=? WHERE idempotency_key=?""",
|
||||||
|
(_iso(now), key),
|
||||||
|
)
|
||||||
|
if row["state"] != "delivered":
|
||||||
|
db.execute(
|
||||||
|
"UPDATE pose_ledger SET state='blocked' WHERE idempotency_key=?",
|
||||||
|
(key,),
|
||||||
|
)
|
||||||
|
early = ("blocked", "idempotency_conflict")
|
||||||
|
elif row["state"] == "delivered":
|
||||||
|
early = ("replay", "replay")
|
||||||
|
elif row["state"] == "blocked":
|
||||||
|
early = ("blocked", row["blocked_reason"] or "blocked")
|
||||||
|
elif row["next_attempt"] > now:
|
||||||
|
early = ("pending", row["last_error"] or "waiting")
|
||||||
|
if early is not None:
|
||||||
|
return self._result(key, *early)
|
||||||
|
return self._attempt(key)
|
||||||
|
|
||||||
|
def run_due(self) -> ForwardResult | None:
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT idempotency_key FROM pose_ledger
|
||||||
|
WHERE state='pending' AND next_attempt<=?
|
||||||
|
ORDER BY created_at LIMIT 1""",
|
||||||
|
(now,),
|
||||||
|
).fetchone()
|
||||||
|
key = None if row is None else row["idempotency_key"]
|
||||||
|
if key is None:
|
||||||
|
return None
|
||||||
|
return self._attempt(key)
|
||||||
|
|
||||||
|
def pose(self, key: str) -> dict | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
def observe_resolution(
|
||||||
|
self, *, decision_id: str, memo_id: str, verb: str, serves: dict | None, payload: dict | None
|
||||||
|
) -> str:
|
||||||
|
resolves = serves.get("resolves") if isinstance(serves, dict) else None
|
||||||
|
if not resolution_applies(verb, serves):
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status="not_applicable", error_type=None, payload=None, retry_at=None,
|
||||||
|
)
|
||||||
|
return "not_applicable"
|
||||||
|
prior = self._latest_resolution(decision_id, memo_id)
|
||||||
|
if prior and prior["status"] in {"resolved", "already_resolved", "conflict"}:
|
||||||
|
return prior["status"]
|
||||||
|
if prior and prior["status"] == "failed":
|
||||||
|
if prior["next_attempt"] is not None and prior["next_attempt"] > self.clock():
|
||||||
|
return "failed"
|
||||||
|
if payload is None and prior["payload_json"]:
|
||||||
|
payload = json.loads(prior["payload_json"])
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("resolution payload is required")
|
||||||
|
encoded = _dump(payload)
|
||||||
|
if len(encoded.encode("utf-8")) > 8192:
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status="conflict", error_type="payload_unbounded", payload=None, retry_at=None,
|
||||||
|
)
|
||||||
|
return "conflict"
|
||||||
|
try:
|
||||||
|
status, _body = self.transport(
|
||||||
|
"POST",
|
||||||
|
f"{self.hub_base_url}/decisions/{decision_id}/resolve",
|
||||||
|
payload,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
status = int(status)
|
||||||
|
except OSError as exc:
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status="failed", error_type=type(exc).__name__, payload=payload,
|
||||||
|
retry_at=self.clock() + _backoff(1),
|
||||||
|
)
|
||||||
|
return "failed"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status="failed", error_type="transport_invalid", payload=payload,
|
||||||
|
retry_at=self.clock() + _backoff(1),
|
||||||
|
)
|
||||||
|
return "failed"
|
||||||
|
existing = None
|
||||||
|
if status == 409:
|
||||||
|
try:
|
||||||
|
read_status, existing = self.transport(
|
||||||
|
"GET", f"{self.hub_base_url}/decisions/{decision_id}", {}, {}
|
||||||
|
)
|
||||||
|
if int(read_status) != 200 or not isinstance(existing, dict):
|
||||||
|
existing = None
|
||||||
|
raise OSError("resolution_unreadable")
|
||||||
|
except OSError as exc:
|
||||||
|
error = exc.__class__.__name__ if str(exc) != "resolution_unreadable" else "resolution_unreadable"
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status="failed", error_type=error, payload=payload,
|
||||||
|
retry_at=self.clock() + _backoff(1),
|
||||||
|
)
|
||||||
|
return "failed"
|
||||||
|
outcome = correlate_resolution(memo_id, post_status=status, existing=existing)
|
||||||
|
self._append_resolution(
|
||||||
|
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
|
||||||
|
status=outcome, error_type=None if outcome != "failed" else f"http_{status}",
|
||||||
|
payload=payload, retry_at=None if outcome != "failed" else self.clock() + _backoff(1),
|
||||||
|
)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
def run_due_resolution(self) -> str | None:
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT * FROM resolution_ledger
|
||||||
|
WHERE status='failed' AND next_attempt IS NOT NULL AND next_attempt<=?
|
||||||
|
ORDER BY id DESC LIMIT 1""",
|
||||||
|
(now,),
|
||||||
|
).fetchone()
|
||||||
|
pending = dict(row) if row is not None else None
|
||||||
|
if pending is None:
|
||||||
|
return None
|
||||||
|
payload = json.loads(pending["payload_json"]) if pending["payload_json"] else None
|
||||||
|
serves = {"hub_decision_id": pending["decision_id"], "resolves": pending["resolves"]}
|
||||||
|
return self.observe_resolution(
|
||||||
|
decision_id=pending["decision_id"],
|
||||||
|
memo_id=pending["memo_id"],
|
||||||
|
verb=pending["verb"],
|
||||||
|
serves=serves,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolutions(self) -> list[dict]:
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM resolution_ledger ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def _attempt(self, key: str) -> ForwardResult:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
current = dict(row) if row is not None else None
|
||||||
|
if current is None or current["state"] != "pending":
|
||||||
|
return self._result(key, current["state"] if current else "blocked", "not_pending")
|
||||||
|
envelope = json.loads(current["envelope_json"])
|
||||||
|
token, token_error = self._read_token()
|
||||||
|
if token_error:
|
||||||
|
self._mark_retry(key, token_error)
|
||||||
|
return self._result(key, "pending", token_error)
|
||||||
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
status, body = self.transport(
|
||||||
|
"POST", f"{self.intake_base_url}/v1/memos", envelope, headers
|
||||||
|
)
|
||||||
|
status = int(status)
|
||||||
|
except OSError as exc:
|
||||||
|
self._mark_retry(key, type(exc).__name__)
|
||||||
|
return self._result(key, "pending", type(exc).__name__)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self._mark_retry(key, "transport_invalid")
|
||||||
|
return self._result(key, "pending", "transport_invalid")
|
||||||
|
if status in (200, 201):
|
||||||
|
try:
|
||||||
|
receipt = bounded_receipt(body, envelope["memo"]["id"])
|
||||||
|
except ContractError as exc:
|
||||||
|
code = safe_error_code(exc.code, "receipt_mismatch")
|
||||||
|
self._mark_blocked(key, code)
|
||||||
|
return self._result(key, "blocked", code)
|
||||||
|
self._mark_delivered(key, receipt)
|
||||||
|
return self._result(key, "delivered", "delivered")
|
||||||
|
if status == 409 or (status not in _RETRYABLE and 400 <= status < 500):
|
||||||
|
code = safe_error_code(
|
||||||
|
body.get("error") if isinstance(body, dict) else None,
|
||||||
|
"conflict" if status == 409 else f"http_{status}",
|
||||||
|
)
|
||||||
|
self._mark_blocked(key, code)
|
||||||
|
return self._result(key, "blocked", code)
|
||||||
|
self._mark_retry(key, f"http_{status}")
|
||||||
|
return self._result(key, "pending", f"http_{status}")
|
||||||
|
|
||||||
|
def _read_token(self) -> tuple[str | None, str | None]:
|
||||||
|
if self.token_reader is None:
|
||||||
|
return None, "projected_token_absent"
|
||||||
|
try:
|
||||||
|
token = self.token_reader()
|
||||||
|
except Exception as exc:
|
||||||
|
return None, type(exc).__name__
|
||||||
|
if not isinstance(token, str) or not token.strip() or token != token.strip():
|
||||||
|
return None, "projected_token_absent"
|
||||||
|
if len(token) > 16384:
|
||||||
|
return None, "projected_token_absent"
|
||||||
|
return token, None
|
||||||
|
|
||||||
|
def _mark_retry(self, key: str, error: str) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as db:
|
||||||
|
attempts = db.execute(
|
||||||
|
"SELECT attempts FROM pose_ledger WHERE idempotency_key=?", (key,)
|
||||||
|
).fetchone()["attempts"] + 1
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE pose_ledger SET state='pending', attempts=?, last_error=?,
|
||||||
|
next_attempt=?, updated_at=? WHERE idempotency_key=?""",
|
||||||
|
(attempts, error, now + _backoff(attempts), _iso(now), key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mark_blocked(self, key: str, reason: str) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE pose_ledger SET state='blocked', blocked_reason=?, last_error=?,
|
||||||
|
updated_at=? WHERE idempotency_key=?""",
|
||||||
|
(reason, reason, _iso(now), key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mark_delivered(self, key: str, receipt: dict) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE pose_ledger SET state='delivered', receipt_json=?, last_error=NULL,
|
||||||
|
blocked_reason=NULL, updated_at=? WHERE idempotency_key=?""",
|
||||||
|
(_dump(receipt), _iso(now), key),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result(self, key: str, status: str, detail: str) -> ForwardResult:
|
||||||
|
row = self.pose(key)
|
||||||
|
receipt = json.loads(row["receipt_json"]) if row and row["receipt_json"] else None
|
||||||
|
return ForwardResult(
|
||||||
|
status=status,
|
||||||
|
idempotency_key=key,
|
||||||
|
receipt=receipt,
|
||||||
|
drafted_by=row["drafted_by"] if row else None,
|
||||||
|
drafted_by_provenance=row["drafted_by_provenance"] if row else "absent",
|
||||||
|
provenance_note=row["provenance_note"] if row else PROVENANCE_NOTE,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _append_resolution(
|
||||||
|
self, *, decision_id, memo_id, verb, resolves, status, error_type, payload, retry_at
|
||||||
|
) -> None:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE resolution_ledger SET next_attempt=NULL
|
||||||
|
WHERE decision_id=? AND memo_id=? AND status='failed'""",
|
||||||
|
(decision_id, memo_id),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO resolution_ledger (
|
||||||
|
decision_id, memo_id, verb, resolves, status, error_type,
|
||||||
|
payload_json, next_attempt, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
decision_id, memo_id, verb, resolves, status, error_type,
|
||||||
|
_dump(payload) if payload is not None else None,
|
||||||
|
retry_at, _iso(self.clock()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _latest_resolution(self, decision_id: str, memo_id: str) -> dict | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT * FROM resolution_ledger
|
||||||
|
WHERE decision_id=? AND memo_id=? ORDER BY id DESC LIMIT 1""",
|
||||||
|
(decision_id, memo_id),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row is not None else None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
db = sqlite3.connect(self.path)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(value: str) -> str:
|
||||||
|
if (
|
||||||
|
not isinstance(value, str)
|
||||||
|
or not (value.startswith("http://") or value.startswith("https://"))
|
||||||
|
):
|
||||||
|
raise ValueError("base_url is required")
|
||||||
|
if "127.0.0.1" in value or "localhost" in value:
|
||||||
|
raise ValueError("base_url must be an injected test host")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _backoff(attempts: int) -> float:
|
||||||
|
return float(min(3600, 2 ** min(attempts, 11)))
|
||||||
|
|
||||||
|
|
||||||
|
def _dump(value: dict) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(timestamp: float) -> str:
|
||||||
|
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
|
||||||
67
api/services/memo_notification.py
Normal file
67
api/services/memo_notification.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Deduplicated informed-decision notification receipts.
|
||||||
|
|
||||||
|
The signed-in overview is the review entry point. A receipt stores and
|
||||||
|
announces only memo id, question, class, and submitter. Replays of the same
|
||||||
|
delivery id return the retained receipt; a different body conflicts and leaves
|
||||||
|
the original readable.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.models.agent_message import AgentMessage
|
||||||
|
from api.models.memo_notification import MemoNotificationReceipt
|
||||||
|
from api.services.informed_decision_contract import Notification
|
||||||
|
from api.services.repository_aliases import canonicalize_repository_slug
|
||||||
|
|
||||||
|
NOTIFICATION_SENDER = "informed-decision"
|
||||||
|
NOTIFICATION_RECIPIENT = "bernd"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationConflict(Exception):
|
||||||
|
def __init__(self, receipt: MemoNotificationReceipt) -> None:
|
||||||
|
self.receipt = receipt
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_notification(
|
||||||
|
session: AsyncSession, delivery_id: str, notice: Notification
|
||||||
|
) -> tuple[MemoNotificationReceipt, bool]:
|
||||||
|
existing = await session.get(MemoNotificationReceipt, delivery_id)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.body_digest != notice.body_digest:
|
||||||
|
raise NotificationConflict(existing)
|
||||||
|
return existing, True
|
||||||
|
|
||||||
|
message = AgentMessage(
|
||||||
|
from_agent=await canonicalize_repository_slug(session, NOTIFICATION_SENDER),
|
||||||
|
to_agent=await canonicalize_repository_slug(session, NOTIFICATION_RECIPIENT),
|
||||||
|
subject=notice.message_subject(),
|
||||||
|
body=notice.message_text(),
|
||||||
|
kind="message",
|
||||||
|
)
|
||||||
|
session.add(message)
|
||||||
|
await session.flush()
|
||||||
|
receipt = MemoNotificationReceipt(
|
||||||
|
delivery_id=delivery_id,
|
||||||
|
body_digest=notice.body_digest,
|
||||||
|
memo_id=notice.memo_id,
|
||||||
|
question=notice.question,
|
||||||
|
memo_class=notice.memo_class,
|
||||||
|
submitter=notice.submitter,
|
||||||
|
message_id=message.id,
|
||||||
|
authoritative_review="signed-in-overview",
|
||||||
|
)
|
||||||
|
session.add(receipt)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
existing = await session.get(MemoNotificationReceipt, delivery_id)
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
if existing.body_digest != notice.body_digest:
|
||||||
|
raise NotificationConflict(existing)
|
||||||
|
return existing, True
|
||||||
|
await session.refresh(receipt)
|
||||||
|
return receipt, False
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""memo notification receipts
|
||||||
|
|
||||||
|
Revision ID: a0b1c2d3e4f5
|
||||||
|
Revises: f9a0b1c2d3e4
|
||||||
|
Create Date: 2026-10-03
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "a0b1c2d3e4f5"
|
||||||
|
down_revision = "f9a0b1c2d3e4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"memo_notification_receipts",
|
||||||
|
sa.Column("delivery_id", sa.String(length=128), primary_key=True),
|
||||||
|
sa.Column("body_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("memo_id", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("question", sa.String(length=400), nullable=False),
|
||||||
|
sa.Column("memo_class", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column("submitter", sa.String(length=63), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"message_id",
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("agent_messages.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("authoritative_review", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_memo_notification_receipts_message_id",
|
||||||
|
"memo_notification_receipts",
|
||||||
|
["message_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_memo_notification_receipts_message_id",
|
||||||
|
table_name="memo_notification_receipts",
|
||||||
|
)
|
||||||
|
op.drop_table("memo_notification_receipts")
|
||||||
380
tests/test_memo_forwarding.py
Normal file
380
tests/test_memo_forwarding.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""Dev fixture for hub memo forwarding. No live intake, token, or submitter."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.services.informed_decision_contract import (
|
||||||
|
PROVENANCE_NOTE,
|
||||||
|
ContractError,
|
||||||
|
build_envelope,
|
||||||
|
correlate_resolution,
|
||||||
|
resolution_applies,
|
||||||
|
stable_idempotency_key,
|
||||||
|
validate_envelope,
|
||||||
|
)
|
||||||
|
from api.services.memo_forwarding import DevIntakeFixture
|
||||||
|
from api.services.write_idempotency import route_rule_for
|
||||||
|
|
||||||
|
DECISION = "62aa95e4-3ccc-4867-8806-5e3db005b483"
|
||||||
|
MEMO = "infd-20261003-p01"
|
||||||
|
OTHER = "infd-20261003-p02"
|
||||||
|
APPROVAL = "ba5ce2d8-8b6d-40be-af89-2e8c147029a3"
|
||||||
|
BINDING = "sha256:" + "ab" * 32
|
||||||
|
TOKEN = "dev-fixture-not-a-credential"
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 1_000.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class Transport:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, method, url, body, headers):
|
||||||
|
self.calls.append((method, url, body, dict(headers)))
|
||||||
|
item = self.responses.pop(0)
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(**overrides):
|
||||||
|
memo = {"id": MEMO, "question": "Accept the forwarded draft?"}
|
||||||
|
packet = [{"item_id": "brief", "media_type": "text/plain", "content": "Bounded brief."}]
|
||||||
|
intent = {
|
||||||
|
"action": "accept",
|
||||||
|
"principal": "pending-human-session",
|
||||||
|
"purpose": "Review the draft.",
|
||||||
|
"target": {"id": MEMO, "type": "decision-memo", "system": "informed-decision"},
|
||||||
|
}
|
||||||
|
fields = dict(
|
||||||
|
decision_id=DECISION,
|
||||||
|
memo=memo,
|
||||||
|
packet=packet,
|
||||||
|
memo_class="work-record-accept",
|
||||||
|
review_group="net-kingdom-admins",
|
||||||
|
approval_intent=intent,
|
||||||
|
drafted_by="claude@the-custodian",
|
||||||
|
resolves="on-answer",
|
||||||
|
)
|
||||||
|
fields.update(overrides)
|
||||||
|
return build_envelope(**fields)
|
||||||
|
|
||||||
|
|
||||||
|
def receipt(memo_id=MEMO):
|
||||||
|
return {
|
||||||
|
"memo_id": memo_id,
|
||||||
|
"approval_id": APPROVAL,
|
||||||
|
"approval_binding_digest": BINDING,
|
||||||
|
"echo": "should-not-be-kept",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(tmp_path, transport, clock=None, token=lambda: TOKEN):
|
||||||
|
return DevIntakeFixture(
|
||||||
|
tmp_path / "forward.sqlite",
|
||||||
|
transport=transport,
|
||||||
|
intake_base_url="http://intake.test",
|
||||||
|
hub_base_url="http://hub.test",
|
||||||
|
token_reader=token,
|
||||||
|
clock=clock or Clock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stored_bytes(path) -> bytes:
|
||||||
|
data = Path(path).read_bytes()
|
||||||
|
for suffix in ("-wal", "-shm"):
|
||||||
|
side = Path(str(path) + suffix)
|
||||||
|
if side.exists():
|
||||||
|
data += side.read_bytes()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotency_key_is_stable_and_drafted_by_is_not_identity():
|
||||||
|
first = envelope()
|
||||||
|
second = envelope()
|
||||||
|
assert first["idempotency_key"] == second["idempotency_key"]
|
||||||
|
assert first["idempotency_key"] == stable_idempotency_key(DECISION, MEMO)
|
||||||
|
assert "drafted_by" not in first["idempotency_key"]
|
||||||
|
assert "submitter" not in first
|
||||||
|
changed = dict(first)
|
||||||
|
changed["idempotency_key"] = "unstable-key-value"
|
||||||
|
with pytest.raises(ContractError) as error:
|
||||||
|
validate_envelope(changed)
|
||||||
|
assert error.value.code == "idempotency_key_not_stable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_is_bounded_and_does_not_echo_secrets():
|
||||||
|
huge = envelope()
|
||||||
|
huge["packet"][0]["content"] = "x" * (256 * 1024 + 1)
|
||||||
|
with pytest.raises(ContractError) as too_big:
|
||||||
|
validate_envelope(huge)
|
||||||
|
assert too_big.value.code == "packet_too_large"
|
||||||
|
leaked = envelope()
|
||||||
|
leaked["packet"][0]["content"] = "-----BEGIN PRIVATE KEY-----"
|
||||||
|
with pytest.raises(ContractError) as secret:
|
||||||
|
validate_envelope(leaked)
|
||||||
|
assert secret.value.code == "secret_marker"
|
||||||
|
assert "PRIVATE KEY" not in str(secret.value)
|
||||||
|
extra = envelope()
|
||||||
|
extra["highlights"] = ["not-a-notice"]
|
||||||
|
with pytest.raises(ContractError) as unknown:
|
||||||
|
validate_envelope(extra)
|
||||||
|
assert unknown.value.code == "unknown_field"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pose_replay_conflict_and_asserted_provenance(tmp_path):
|
||||||
|
clock = Clock()
|
||||||
|
transport = Transport([(201, receipt())])
|
||||||
|
fix = fixture(tmp_path, transport, clock)
|
||||||
|
posed = fix.submit(envelope())
|
||||||
|
assert posed.status == "delivered"
|
||||||
|
assert posed.drafted_by == "claude@the-custodian"
|
||||||
|
assert posed.drafted_by_provenance == "asserted"
|
||||||
|
assert posed.provenance_note == PROVENANCE_NOTE
|
||||||
|
assert "asserted provenance" in posed.provenance_note
|
||||||
|
assert set(posed.receipt) == {"memo_id", "approval_id", "approval_binding_digest"}
|
||||||
|
sent = transport.calls[0]
|
||||||
|
assert sent[0:2] == ("POST", "http://intake.test/v1/memos")
|
||||||
|
assert sent[2]["drafted_by"] == "claude@the-custodian"
|
||||||
|
assert "drafted_by_provenance" not in sent[2]
|
||||||
|
assert "submitter" not in sent[2]
|
||||||
|
assert sent[3]["Authorization"] == f"Bearer {TOKEN}"
|
||||||
|
assert TOKEN.encode() not in stored_bytes(fix.path)
|
||||||
|
|
||||||
|
restarted = DevIntakeFixture(
|
||||||
|
fix.path,
|
||||||
|
transport=Transport([(201, receipt())]),
|
||||||
|
intake_base_url="http://intake.test",
|
||||||
|
hub_base_url="http://hub.test",
|
||||||
|
token_reader=lambda: TOKEN,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
replay = restarted.submit(envelope())
|
||||||
|
assert replay.status == "replay"
|
||||||
|
assert replay.receipt == posed.receipt
|
||||||
|
assert restarted.transport.calls == []
|
||||||
|
|
||||||
|
conflict = envelope()
|
||||||
|
conflict["packet"][0]["content"] = "Different brief."
|
||||||
|
blocked = restarted.submit(conflict)
|
||||||
|
assert blocked.status == "blocked"
|
||||||
|
assert blocked.detail == "idempotency_conflict"
|
||||||
|
kept = restarted.pose(posed.idempotency_key)
|
||||||
|
assert kept["state"] == "delivered"
|
||||||
|
assert "Different brief." not in kept["envelope_json"]
|
||||||
|
assert restarted.transport.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_failure_keeps_local_evidence_and_retries(tmp_path):
|
||||||
|
clock = Clock()
|
||||||
|
transport = Transport([
|
||||||
|
ConnectionRefusedError("down"),
|
||||||
|
(503, {"error": "intake_unavailable", "body": "LEAKED-BODY"}),
|
||||||
|
(201, receipt()),
|
||||||
|
])
|
||||||
|
fix = fixture(tmp_path, transport, clock)
|
||||||
|
failed = fix.submit(envelope())
|
||||||
|
assert failed.status == "pending"
|
||||||
|
assert failed.detail == "ConnectionRefusedError"
|
||||||
|
row = fix.pose(failed.idempotency_key)
|
||||||
|
assert row["state"] == "pending"
|
||||||
|
assert "Bounded brief." in row["envelope_json"]
|
||||||
|
assert "LEAKED-BODY" not in row["envelope_json"]
|
||||||
|
assert fix.run_due() is None
|
||||||
|
|
||||||
|
clock.now += 2
|
||||||
|
again = fix.run_due()
|
||||||
|
assert again.status == "pending"
|
||||||
|
assert again.detail == "http_503"
|
||||||
|
assert b"LEAKED-BODY" not in stored_bytes(fix.path)
|
||||||
|
assert fix.run_due() is None
|
||||||
|
|
||||||
|
clock.now += 4
|
||||||
|
delivered = fix.run_due()
|
||||||
|
assert delivered.status == "delivered"
|
||||||
|
assert delivered.receipt["memo_id"] == MEMO
|
||||||
|
assert fix.run_due() is None
|
||||||
|
assert len(transport.calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_intake_conflict_blocks_without_retry(tmp_path):
|
||||||
|
transport = Transport([(409, {"error": "idempotency_conflict", "packet": "LEAK"})])
|
||||||
|
fix = fixture(tmp_path, transport)
|
||||||
|
blocked = fix.submit(envelope())
|
||||||
|
assert blocked.status == "blocked"
|
||||||
|
assert blocked.detail == "idempotency_conflict"
|
||||||
|
assert b"LEAK" not in stored_bytes(fix.path)
|
||||||
|
assert fix.run_due() is None
|
||||||
|
assert len(transport.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_token_is_not_invented(tmp_path):
|
||||||
|
transport = Transport([(201, receipt())])
|
||||||
|
fix = fixture(tmp_path, transport, token=None)
|
||||||
|
pending = fix.submit(envelope())
|
||||||
|
assert pending.status == "pending"
|
||||||
|
assert pending.detail == "projected_token_absent"
|
||||||
|
assert transport.calls == []
|
||||||
|
assert fix.pose(pending.idempotency_key)["state"] == "pending"
|
||||||
|
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("sk-or-this-must-not-be-stored-0123456789")
|
||||||
|
|
||||||
|
fix.token_reader = boom
|
||||||
|
fix.clock = Clock()
|
||||||
|
fix.clock.now = 1_000 + 2
|
||||||
|
retried = fix.run_due()
|
||||||
|
assert retried.status == "pending"
|
||||||
|
assert retried.detail == "RuntimeError"
|
||||||
|
assert transport.calls == []
|
||||||
|
assert b"sk-or-this-must-not-be-stored" not in stored_bytes(fix.path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_hosts_and_runtime_binding_are_refused(tmp_path):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
DevIntakeFixture(
|
||||||
|
tmp_path / "no.sqlite",
|
||||||
|
transport=Transport([]),
|
||||||
|
intake_base_url="http://127.0.0.1:8000",
|
||||||
|
hub_base_url="http://hub.test",
|
||||||
|
)
|
||||||
|
root = Path(__file__).resolve().parents[1] / "api"
|
||||||
|
main = (root / "main.py").read_text()
|
||||||
|
assert "memo_notifications" in main
|
||||||
|
assert "memo_forwarding" not in main
|
||||||
|
assert "DevIntakeFixture" not in main
|
||||||
|
owned = [
|
||||||
|
root / "main.py",
|
||||||
|
root / "services" / "memo_forwarding.py",
|
||||||
|
root / "services" / "informed_decision_contract.py",
|
||||||
|
root / "services" / "memo_notification.py",
|
||||||
|
root / "routers" / "memo_notifications.py",
|
||||||
|
root / "models" / "memo_notification.py",
|
||||||
|
]
|
||||||
|
for path in owned:
|
||||||
|
text = path.read_text()
|
||||||
|
assert "INFD_HUB_WRITEBACK" not in text
|
||||||
|
assert "IntakeDeliveryWorker" not in text
|
||||||
|
assert "/var/run/secrets" not in text
|
||||||
|
assert "token_file" not in text
|
||||||
|
assert route_rule_for("POST", "/informed-decision/notifications") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("verb,serves", [
|
||||||
|
("return", {"hub_decision_id": DECISION, "resolves": "on-answer"}),
|
||||||
|
("discuss", {"hub_decision_id": DECISION, "resolves": "on-answer"}),
|
||||||
|
("accept", {"hub_decision_id": DECISION, "resolves": "never"}),
|
||||||
|
("decline", None),
|
||||||
|
])
|
||||||
|
def test_non_terminal_answers_do_not_resolve(tmp_path, verb, serves):
|
||||||
|
assert resolution_applies(verb, serves) is False
|
||||||
|
transport = Transport([(200, {})])
|
||||||
|
fix = fixture(tmp_path, transport)
|
||||||
|
status = fix.observe_resolution(
|
||||||
|
decision_id=DECISION, memo_id=MEMO, verb=verb, serves=serves, payload={"rationale": "no"},
|
||||||
|
)
|
||||||
|
assert status == "not_applicable"
|
||||||
|
assert transport.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolution_failure_retries_and_other_memo_blocks(tmp_path):
|
||||||
|
clock = Clock()
|
||||||
|
payload = {
|
||||||
|
"decided_by": "Bernd Worsch via informed-decision",
|
||||||
|
"rationale": f"accept in informed-decision memo {MEMO} v1; ",
|
||||||
|
"write_log": False,
|
||||||
|
}
|
||||||
|
transport = Transport([
|
||||||
|
ConnectionRefusedError("down"),
|
||||||
|
(200, {"status": "resolved"}),
|
||||||
|
(409, {"error": "already resolved"}),
|
||||||
|
(200, {"rationale": "resolved by hand"}),
|
||||||
|
])
|
||||||
|
fix = fixture(tmp_path, transport, clock)
|
||||||
|
failed = fix.observe_resolution(
|
||||||
|
decision_id=DECISION, memo_id=MEMO, verb="accept",
|
||||||
|
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||||
|
)
|
||||||
|
assert failed == "failed"
|
||||||
|
assert fix.resolutions()[0]["payload_json"]
|
||||||
|
assert fix.run_due_resolution() is None
|
||||||
|
clock.now += 2
|
||||||
|
assert fix.run_due_resolution() == "resolved"
|
||||||
|
assert [row["status"] for row in fix.resolutions()] == ["failed", "resolved"]
|
||||||
|
assert fix.run_due_resolution() is None
|
||||||
|
|
||||||
|
conflict = fix.observe_resolution(
|
||||||
|
decision_id=DECISION, memo_id=OTHER, verb="decline",
|
||||||
|
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||||
|
)
|
||||||
|
assert conflict == "conflict"
|
||||||
|
calls = len(transport.calls)
|
||||||
|
assert transport.calls[-2][0] == "POST"
|
||||||
|
assert transport.calls[-1][0] == "GET"
|
||||||
|
assert fix.observe_resolution(
|
||||||
|
decision_id=DECISION, memo_id=OTHER, verb="decline",
|
||||||
|
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||||
|
) == "conflict"
|
||||||
|
assert len(transport.calls) == calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_409_stays_retryable(tmp_path):
|
||||||
|
clock = Clock()
|
||||||
|
transport = Transport([
|
||||||
|
(409, {}),
|
||||||
|
(503, {}),
|
||||||
|
(409, {}),
|
||||||
|
(200, {"rationale": f"decline in informed-decision memo {MEMO} v1; "}),
|
||||||
|
])
|
||||||
|
fix = fixture(tmp_path, transport, clock)
|
||||||
|
payload = {"rationale": "kept locally", "decided_by": "reviewer", "write_log": False}
|
||||||
|
assert fix.observe_resolution(
|
||||||
|
decision_id=DECISION, memo_id=MEMO, verb="decline",
|
||||||
|
serves={"hub_decision_id": DECISION, "resolves": "on-answer"}, payload=payload,
|
||||||
|
) == "failed"
|
||||||
|
assert fix.resolutions()[0]["error_type"] == "resolution_unreadable"
|
||||||
|
clock.now += 2
|
||||||
|
assert fix.run_due_resolution() == "already_resolved"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hub_409_still_returns_the_existing_resolution(client):
|
||||||
|
domain = await client.post("/domains/", json={"slug": "testdomain", "name": "Test Domain"})
|
||||||
|
assert domain.status_code == 201, domain.text
|
||||||
|
topic = await client.post("/topics/", json={
|
||||||
|
"slug": "testtopic", "title": "Test Topic", "domain": "testdomain",
|
||||||
|
})
|
||||||
|
assert topic.status_code == 201, topic.text
|
||||||
|
created = await client.post("/decisions/", json={
|
||||||
|
"title": "Should the draft stand?", "topic_id": topic.json()["id"],
|
||||||
|
})
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
decision_id = created.json()["id"]
|
||||||
|
rationale = f"accept in informed-decision memo {MEMO} v1; approval {APPROVAL}"
|
||||||
|
resolved = await client.post(f"/decisions/{decision_id}/resolve", json={
|
||||||
|
"rationale": rationale,
|
||||||
|
"decided_by": "Bernd Worsch via informed-decision",
|
||||||
|
"write_log": False,
|
||||||
|
})
|
||||||
|
assert resolved.status_code == 200, resolved.text
|
||||||
|
conflict = await client.post(f"/decisions/{decision_id}/resolve", json={
|
||||||
|
"rationale": "resolved by hand",
|
||||||
|
"decided_by": "someone else",
|
||||||
|
"write_log": False,
|
||||||
|
})
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
current = await client.get(f"/decisions/{decision_id}")
|
||||||
|
assert current.status_code == 200
|
||||||
|
body = current.json()
|
||||||
|
assert body["rationale"] == rationale
|
||||||
|
assert correlate_resolution(MEMO, post_status=409, existing=body) == "already_resolved"
|
||||||
|
assert correlate_resolution(OTHER, post_status=409, existing=body) == "conflict"
|
||||||
152
tests/test_memo_notification.py
Normal file
152
tests/test_memo_notification.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""Deduplicated informed-decision notification contract."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
NOTICE = {
|
||||||
|
"memo_id": "infd-20261003-p01",
|
||||||
|
"question": "Accept the forwarded draft?",
|
||||||
|
"memo_class": "work-record-accept",
|
||||||
|
"submitter": "example-submitter",
|
||||||
|
}
|
||||||
|
KEY = "state-hub.delivery.0001"
|
||||||
|
|
||||||
|
|
||||||
|
async def _messages(client):
|
||||||
|
listed = await client.get("/messages/", params={"to_agent": "bernd"})
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
return listed.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notification_is_deduplicated_and_limited_to_four_fields(client):
|
||||||
|
first = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=NOTICE,
|
||||||
|
headers={"Idempotency-Key": KEY},
|
||||||
|
)
|
||||||
|
assert first.status_code == 201, first.text
|
||||||
|
body = first.json()
|
||||||
|
assert body["status"] == "accepted"
|
||||||
|
assert body["authoritative_review"] == "signed-in-overview"
|
||||||
|
assert body["delivery_id"] == KEY
|
||||||
|
|
||||||
|
raw = json.dumps({
|
||||||
|
"submitter": NOTICE["submitter"],
|
||||||
|
"memo_class": NOTICE["memo_class"],
|
||||||
|
"question": NOTICE["question"],
|
||||||
|
"memo_id": NOTICE["memo_id"],
|
||||||
|
})
|
||||||
|
second = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
content=raw,
|
||||||
|
headers={"Idempotency-Key": KEY, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert second.status_code == 200, second.text
|
||||||
|
assert second.headers["x-statehub-idempotency-replay"] == "true"
|
||||||
|
assert second.json()["message_id"] == body["message_id"]
|
||||||
|
|
||||||
|
messages = await _messages(client)
|
||||||
|
assert len(messages) == 1
|
||||||
|
text = messages[0]["body"]
|
||||||
|
assert text.startswith("The signed-in overview is the authoritative review entry point.\n")
|
||||||
|
assert set(json.loads(text.split("\n", 1)[1])) == set(NOTICE)
|
||||||
|
assert "://" not in text
|
||||||
|
assert "packet" not in text
|
||||||
|
assert "highlight" not in text
|
||||||
|
assert messages[0]["from_agent"] == "informed-decision"
|
||||||
|
assert messages[0]["to_agent"] == "bernd"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_conflict_leaves_the_original_receipt_readable(client):
|
||||||
|
created = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=NOTICE,
|
||||||
|
headers={"Idempotency-Key": KEY},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
changed = dict(NOTICE, question="Decline the forwarded draft?")
|
||||||
|
conflict = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=changed,
|
||||||
|
headers={"Idempotency-Key": KEY},
|
||||||
|
)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
assert conflict.json() == {"error": "notification_conflict", "delivery_id": KEY}
|
||||||
|
assert "Decline" not in conflict.text
|
||||||
|
current = await client.get(f"/informed-decision/notifications/{KEY}")
|
||||||
|
assert current.status_code == 200
|
||||||
|
assert current.json()["question"] == NOTICE["question"]
|
||||||
|
assert len(await _messages(client)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notification_refuses_extra_secret_and_link_without_echo(client):
|
||||||
|
extra = dict(NOTICE, packet="SECRET-PACKET-VALUE")
|
||||||
|
refused = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=extra,
|
||||||
|
headers={"Idempotency-Key": KEY},
|
||||||
|
)
|
||||||
|
assert refused.status_code == 400
|
||||||
|
assert refused.json() == {"error": "unknown_field"}
|
||||||
|
assert "SECRET-PACKET-VALUE" not in refused.text
|
||||||
|
|
||||||
|
secret = dict(NOTICE, question="Is Bearer " + ("A" * 16) + " acceptable?")
|
||||||
|
marked = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=secret,
|
||||||
|
headers={"Idempotency-Key": "state-hub.delivery.0002"},
|
||||||
|
)
|
||||||
|
assert marked.status_code == 400
|
||||||
|
assert marked.json() == {"error": "secret_marker"}
|
||||||
|
assert "AAAA" not in marked.text
|
||||||
|
|
||||||
|
linked = dict(NOTICE, question="Open https://overview.example/review?")
|
||||||
|
link = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=linked,
|
||||||
|
headers={"Idempotency-Key": "state-hub.delivery.0003"},
|
||||||
|
)
|
||||||
|
assert link.status_code == 400
|
||||||
|
assert link.json() == {"error": "notification_has_link"}
|
||||||
|
assert "overview.example" not in link.text
|
||||||
|
assert await _messages(client) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delivery_id_and_size_are_required(client):
|
||||||
|
missing = await client.post("/informed-decision/notifications", json=NOTICE)
|
||||||
|
assert missing.status_code == 400
|
||||||
|
assert missing.json()["error"] == "delivery_id_required"
|
||||||
|
short = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=NOTICE,
|
||||||
|
headers={"Idempotency-Key": "short"},
|
||||||
|
)
|
||||||
|
assert short.status_code == 400
|
||||||
|
assert short.json()["error"] == "delivery_id_invalid"
|
||||||
|
huge = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
content=b"{" + b"x" * 9000,
|
||||||
|
headers={"Idempotency-Key": KEY, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert huge.status_code == 413
|
||||||
|
assert huge.json()["error"] == "notification_too_large"
|
||||||
|
assert b"xxx" not in huge.content
|
||||||
|
missing_receipt = await client.get("/informed-decision/notifications/" + KEY)
|
||||||
|
assert missing_receipt.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_distinct_delivery_ids_are_distinct_notices(client):
|
||||||
|
first = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=NOTICE,
|
||||||
|
headers={"Idempotency-Key": "state-hub.delivery.0001"},
|
||||||
|
)
|
||||||
|
second_body = dict(NOTICE, memo_id="infd-20261003-p02", question="Decline the other draft?")
|
||||||
|
second = await client.post(
|
||||||
|
"/informed-decision/notifications",
|
||||||
|
json=second_body,
|
||||||
|
headers={"Idempotency-Key": "state-hub.delivery.0002"},
|
||||||
|
)
|
||||||
|
assert first.status_code == 201 and second.status_code == 201
|
||||||
|
assert first.json()["message_id"] != second.json()["message_id"]
|
||||||
|
assert len(await _messages(client)) == 2
|
||||||
Loading…
Add table
Add a link
Reference in a new issue