feat: govern attachment exceptions and ownership transfers

This commit is contained in:
2026-07-30 17:42:10 +02:00
parent cd223cbb95
commit 5f7503598c
28 changed files with 2229 additions and 180 deletions
@@ -1,6 +1,8 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@@ -157,7 +159,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"send_without_attachments_behavior": "block",
"global": [],
"zip": {"enabled": False, "archives": []},
"missing_behavior": "ask",
"missing_behavior": "warn",
"ambiguous_behavior": "ask",
},
"entries": {
@@ -175,7 +177,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
},
},
"validation_policy": {
"missing_required_attachment": "ask",
"missing_required_attachment": "block",
"missing_optional_attachment": "warn",
"ambiguous_attachment_match": "ask",
"unsent_attachment_files": "warn",
@@ -832,6 +834,7 @@ def update_campaign_review_state(
version_id: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
issue_decisions: list[dict[str, Any]] | None = None,
user_id: str | None,
commit: bool = True,
) -> CampaignVersion:
@@ -854,13 +857,22 @@ def update_campaign_review_state(
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
build_token = _campaign_review_build_token(version)
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
normalized_decisions: list[dict[str, Any]] = []
if inspection_complete:
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
normalized_reviewed, normalized_decisions = _complete_campaign_review(
session,
version,
normalized_reviewed,
issue_decisions or [],
user_id=user_id,
build_token=build_token,
)
_write_campaign_review_state(
version,
build_token=build_token,
inspection_complete=inspection_complete,
reviewed_message_keys=normalized_reviewed,
issue_decisions=normalized_decisions,
user_id=user_id,
)
session.add(version)
@@ -885,11 +897,15 @@ def _campaign_review_build_token(version: CampaignVersion) -> str:
return build_token
def _complete_campaign_review_keys(
def _complete_campaign_review(
session: Session,
version: CampaignVersion,
reviewed_message_keys: list[str],
) -> list[str]:
issue_decisions: list[dict[str, Any]],
*,
user_id: str | None,
build_token: str,
) -> tuple[list[str], list[dict[str, Any]]]:
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -904,7 +920,123 @@ def _complete_campaign_review_keys(
raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
)
return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
decisions = _normalize_review_issue_decisions(
jobs,
issue_decisions,
user_id=user_id,
build_token=build_token,
)
return (
list(
dict.fromkeys(
[*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]
)
),
decisions,
)
def _normalize_review_issue_decisions(
jobs: list[CampaignJob],
requested: list[dict[str, Any]],
*,
user_id: str | None,
build_token: str,
decided_at: datetime | None = None,
) -> list[dict[str, Any]]:
jobs_by_id = {job.id: job for job in jobs}
requested_by_job: dict[str, dict[str, Any]] = {}
for item in requested:
job_id = str(item.get("job_id") or "").strip()
if not job_id or job_id not in jobs_by_id:
raise CampaignPersistenceError(
"A review decision references a message outside the current build."
)
if jobs_by_id[job_id].validation_status != "needs_review":
raise CampaignPersistenceError(
"Review decisions are accepted only for messages requiring review."
)
if job_id in requested_by_job:
raise CampaignPersistenceError(
"Only one review decision may be recorded per built message."
)
if str(item.get("decision") or "accept") != "accept":
raise CampaignPersistenceError(
"Unsupported campaign review decision."
)
requested_by_job[job_id] = item
timestamp = (decided_at or datetime.now(UTC)).isoformat()
normalized: list[dict[str, Any]] = []
for job in jobs:
reviewable_issues = [
issue
for issue in (job.issues_snapshot or [])
if isinstance(issue, dict)
and str(issue.get("behavior") or "").lower() == "ask"
]
attachment_overrides = [
issue
for issue in reviewable_issues
if str(issue.get("source") or "").startswith("attachments")
]
decision = requested_by_job.get(job.id)
if attachment_overrides and decision is None:
raise CampaignPersistenceError(
"Attachment exceptions require an explicit reason before review can be completed "
f"for message {job.entry_id or job.entry_index}."
)
if decision is None:
continue
reason = str(decision.get("reason") or "").strip()
if attachment_overrides and not reason:
raise CampaignPersistenceError(
"Attachment exception decisions require a reason."
)
evidence_issues = reviewable_issues or [
issue
for issue in (job.issues_snapshot or [])
if isinstance(issue, dict)
]
issue_payload = [
{
"code": str(issue.get("code") or ""),
"behavior": str(issue.get("behavior") or ""),
"source": str(issue.get("source") or ""),
"details": issue.get("details")
if isinstance(issue.get("details"), dict)
else {},
}
for issue in evidence_issues
]
fingerprint = hashlib.sha256(
json.dumps(
issue_payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
normalized.append(
{
"job_id": job.id,
"review_key": str(job.entry_id or job.entry_index),
"decision": "accept",
"reason": reason or None,
"actor_user_id": user_id,
"decided_at": timestamp,
"build_token": build_token,
"message_sha256": job.eml_sha256,
"issue_fingerprint": fingerprint,
"issue_codes": sorted(
{
str(issue.get("code") or "")
for issue in evidence_issues
if issue.get("code")
}
),
}
)
return normalized
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
@@ -929,6 +1061,7 @@ def _write_campaign_review_state(
build_token: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
issue_decisions: list[dict[str, Any]],
user_id: str | None,
) -> None:
editor_state = copy.deepcopy(version.editor_state or {})
@@ -936,6 +1069,7 @@ def _write_campaign_review_state(
"build_token": build_token,
"inspection_complete": bool(inspection_complete),
"reviewed_message_keys": reviewed_message_keys,
"issue_decisions": issue_decisions,
"updated_at": datetime.now(UTC).isoformat(),
"updated_by_user_id": user_id,
}