aps-agent/server/agent_core/approval_migrate.py

664 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 审批文件后端 -> database 后端存量迁移(moduleId: approval-migrate)
# Round 67: fail-closed validation, stable source snapshot, one DB transaction.
# ============================================================
from __future__ import annotations
import copy
import hashlib
import json
import math
import os
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, OperationalError
from server.agent_core.approval_db_store import DatabaseApprovalStore
from server.agent_core.approval_store import default_approval_path, token_digest
from server.db.database import get_session
from server.db.models import (
ApprovalEventRecord,
ApprovalGrantRecord,
ApprovalRequestRecord,
)
_SCHEMA_VERSION = 1
_EMPTY_SOURCE = {"schemaVersion": 1, "pending": {}, "grants": {}, "history": []}
_REQUEST_STATUS_FORWARD = {
"PENDING": frozenset({"PENDING", "APPROVED", "REJECTED", "EXPIRED"}),
"APPROVED": frozenset({"APPROVED"}),
"REJECTED": frozenset({"REJECTED"}),
"EXPIRED": frozenset({"EXPIRED"}),
}
_GRANT_STATUS_FORWARD = {
"ACTIVE": frozenset({"ACTIVE", "CONSUMED", "EXPIRED"}),
"CONSUMED": frozenset({"CONSUMED"}),
"EXPIRED": frozenset({"EXPIRED"}),
}
_EVENT_STATUSES = frozenset({
"APPROVED", "CONSUMED", "EXECUTED", "EXPIRED", "GRANT_EXPIRED",
"PARTIALLY_APPROVED", "PENDING", "REJECTED", "SOD_DENIED", "TRANSFERRED",
})
class ApprovalMigrationError(RuntimeError):
"""The source or target cannot be migrated without risking approval data."""
def _optional_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _optional_str(value: Any) -> str | None:
return str(value) if value is not None else None
def _canonical_json(value: Any) -> str:
try:
return json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
)
except (TypeError, ValueError) as exc:
raise ApprovalMigrationError("approval migration record is not canonical JSON") from exc
def _canonical_digest(value: Any) -> str:
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
def _reject_json_constant(value: str) -> Any:
raise ApprovalMigrationError(f"approval source contains invalid JSON constant: {value}")
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ApprovalMigrationError("approval source contains duplicate JSON object keys")
result[key] = value
return result
def _required_string(record: dict[str, Any], key: str, context: str) -> str:
value = record.get(key)
if not isinstance(value, str) or not value.strip():
raise ApprovalMigrationError(f"{context}.{key} must be a non-empty string")
return value
def _required_epoch(record: dict[str, Any], key: str, context: str) -> float:
value = record.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ApprovalMigrationError(f"{context}.{key} must be a finite epoch number")
parsed = float(value)
if not math.isfinite(parsed) or parsed <= 0:
raise ApprovalMigrationError(f"{context}.{key} must be a positive finite epoch")
return parsed
def _nonnegative_int(record: dict[str, Any], key: str, default: int, context: str) -> int:
value = record.get(key, default)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ApprovalMigrationError(f"{context}.{key} must be a non-negative integer")
return value
def _validate_owner(record: dict[str, Any], context: str) -> None:
owner = record.get("ownerUserId")
if owner is not None and (isinstance(owner, bool) or not isinstance(owner, int)):
raise ApprovalMigrationError(f"{context}.ownerUserId must be an integer or null")
def _validate_identity(record: dict[str, Any], context: str) -> None:
for key in ("confirmId", "action", "power", "tenantUuid", "projectId", "paramsHash"):
_required_string(record, key, context)
if record["power"] not in {"P2", "P3"}:
raise ApprovalMigrationError(f"{context}.power must be P2 or P3")
_validate_owner(record, context)
def _validate_pending(confirm_id: str, record: Any) -> None:
context = f"pending[{confirm_id!r}]"
if not isinstance(record, dict):
raise ApprovalMigrationError(f"{context} must be an object")
_validate_identity(record, context)
if record["confirmId"] != confirm_id:
raise ApprovalMigrationError(f"{context}.confirmId does not match its key")
created = _required_epoch(record, "createdAtEpoch", context)
expires = _required_epoch(record, "expiresAtEpoch", context)
if expires < created:
raise ApprovalMigrationError(f"{context}.expiresAtEpoch precedes createdAtEpoch")
approvals = record.get("approvals", [])
if not isinstance(approvals, list) or any(not isinstance(item, dict) for item in approvals):
raise ApprovalMigrationError(f"{context}.approvals must be an array of objects")
required = _nonnegative_int(
record, "requiredApprovals", 2 if record["power"] == "P3" else 1, context
)
if required < 1:
raise ApprovalMigrationError(f"{context}.requiredApprovals must be positive")
step = _nonnegative_int(record, "approvalStep", len(approvals), context)
if step > required or len(approvals) < step:
raise ApprovalMigrationError(f"{context} approval progress is inconsistent")
status = record.get("status", "PENDING")
if status != "PENDING":
raise ApprovalMigrationError(f"{context}.status must be PENDING")
_nonnegative_int(record, "revision", 0, context)
_canonical_json(record)
def _validate_grant(raw_token: str, record: Any) -> None:
context = "grant"
if not isinstance(raw_token, str) or not raw_token:
raise ApprovalMigrationError("grant token key must be a non-empty string")
if not isinstance(record, dict):
raise ApprovalMigrationError(f"{context} must be an object")
_validate_identity(record, context)
created = _required_epoch(record, "createdAtEpoch", context)
expires = _required_epoch(record, "expiresAtEpoch", context)
if expires < created:
raise ApprovalMigrationError(f"{context}.expiresAtEpoch precedes createdAtEpoch")
status = str(record.get("status") or "ACTIVE")
if status not in _GRANT_STATUS_FORWARD:
raise ApprovalMigrationError(f"{context}.status is invalid")
consumed = record.get("consumedAtEpoch")
if status == "CONSUMED":
consumed_epoch = _required_epoch(record, "consumedAtEpoch", context)
if consumed_epoch < created:
raise ApprovalMigrationError(f"{context}.consumedAtEpoch precedes createdAtEpoch")
elif consumed is not None:
raise ApprovalMigrationError(f"{context}.consumedAtEpoch requires CONSUMED status")
_nonnegative_int(record, "revision", 0, context)
_canonical_json(record)
def _validate_event(event: Any, index: int) -> None:
context = f"history[{index}]"
if not isinstance(event, dict):
raise ApprovalMigrationError(f"{context} must be an object")
for key in ("confirmId", "action", "power", "status", "tenantUuid", "projectId"):
_required_string(event, key, context)
if event["power"] not in {"P2", "P3"}:
raise ApprovalMigrationError(f"{context}.power must be P2 or P3")
if event["status"] not in _EVENT_STATUSES:
raise ApprovalMigrationError(f"{context}.status is invalid")
_validate_owner(event, context)
if "decidedAtEpoch" in event:
_required_epoch(event, "decidedAtEpoch", context)
else:
decided_at = event.get("decidedAt")
if not isinstance(decided_at, str) or not decided_at.strip():
raise ApprovalMigrationError(
f"{context} requires decidedAtEpoch or decidedAt"
)
try:
normalized = decided_at.strip().replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
epoch = parsed.timestamp()
except (TypeError, ValueError, OverflowError) as exc:
raise ApprovalMigrationError(f"{context}.decidedAt is invalid") from exc
if not math.isfinite(epoch) or epoch <= 0:
raise ApprovalMigrationError(f"{context}.decidedAt is invalid")
event["decidedAtEpoch"] = epoch
_canonical_json(event)
def _validate_source(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise ApprovalMigrationError("approval source root must be an object")
expected = {"schemaVersion", "pending", "grants", "history"}
if set(data) != expected or data.get("schemaVersion") != _SCHEMA_VERSION:
raise ApprovalMigrationError("approval source schema is invalid")
pending, grants, history = data["pending"], data["grants"], data["history"]
if not isinstance(pending, dict) or not isinstance(grants, dict) or not isinstance(history, list):
raise ApprovalMigrationError("approval source collections have invalid shapes")
for confirm_id, record in pending.items():
if not isinstance(confirm_id, str) or not confirm_id:
raise ApprovalMigrationError("pending key must be a non-empty string")
_validate_pending(confirm_id, record)
seen_confirm_ids: set[str] = set()
seen_digests: set[str] = set()
for raw_token, record in grants.items():
_validate_grant(raw_token, record)
confirm_id = str(record["confirmId"])
digest = token_digest(raw_token)
if confirm_id in seen_confirm_ids or digest in seen_digests:
raise ApprovalMigrationError("approval source grants are not one-to-one")
seen_confirm_ids.add(confirm_id)
seen_digests.add(digest)
for index, event in enumerate(history):
_validate_event(event, index)
_validate_cross_references(pending, grants, history)
return copy.deepcopy(data)
def _identity_values(record: dict[str, Any]) -> tuple[Any, ...]:
return tuple(record.get(key) for key in ("confirmId", "action", "power", "tenantUuid", "projectId", "paramsHash"))
def _validate_cross_references(
pending: dict[str, dict[str, Any]],
grants: dict[str, dict[str, Any]],
history: list[dict[str, Any]],
) -> None:
identities: dict[str, dict[str, Any]] = dict(pending)
history_by_confirm: dict[str, list[dict[str, Any]]] = {}
for event in history:
history_by_confirm.setdefault(str(event["confirmId"]), []).append(event)
terminal_evidence = {
"APPROVED",
"CONSUMED",
"EXECUTED",
"EXPIRED",
"GRANT_EXPIRED",
}
for record in grants.values():
confirm_id = str(record["confirmId"])
existing = identities.get(confirm_id)
if existing is not None and _identity_values(existing) != _identity_values(record):
raise ApprovalMigrationError(f"approval source identity conflict for {confirm_id}")
if existing is None:
evidence = history_by_confirm.get(confirm_id, [])
if not evidence or not any(
event.get("status") in terminal_evidence for event in evidence
):
raise ApprovalMigrationError(
f"approval grant {confirm_id} lacks terminal request history"
)
identities.setdefault(confirm_id, record)
for event in history:
confirm_id = str(event["confirmId"])
existing = identities.get(confirm_id)
if existing is None:
continue
for key in ("action", "power", "tenantUuid", "projectId", "paramsHash"):
if key in event and event.get(key) != existing.get(key):
raise ApprovalMigrationError(f"approval history identity conflict for {confirm_id}")
def _parse_source_bytes(raw: bytes, source: str) -> dict[str, Any]:
try:
text = raw.decode("utf-8")
parsed = json.loads(
text, object_pairs_hook=_unique_object, parse_constant=_reject_json_constant
)
except ApprovalMigrationError:
raise
except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc:
raise ApprovalMigrationError(f"approval source is invalid JSON: {source}") from exc
return _validate_source(parsed)
def _read_source_snapshot(source: str) -> tuple[dict[str, Any], str | None]:
try:
with open(source, "rb") as handle:
raw = handle.read()
except FileNotFoundError:
return copy.deepcopy(_EMPTY_SOURCE), None
except OSError as exc:
raise ApprovalMigrationError(f"approval source cannot be read: {source}") from exc
return _parse_source_bytes(raw, source), hashlib.sha256(raw).hexdigest()
def load_file_store(file_path: str | None = None) -> dict[str, Any]:
"""Read and strictly validate a file approval store; only a missing file is empty."""
source = os.fspath(file_path or default_approval_path())
data, _digest = _read_source_snapshot(source)
return data
@contextmanager
def _source_lock(source: str) -> Iterator[None]:
lock_path = f"{source}.lock"
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
with open(lock_path, "a+b") as handle:
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
try:
yield
finally:
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _required_approvals(record: dict[str, Any]) -> int:
return int(record.get("requiredApprovals", 2 if record.get("power") == "P3" else 1))
def _approval_step(record: dict[str, Any]) -> int:
return int(record.get("approvalStep", len(record.get("approvals") or [])))
def _request_status(record: dict[str, Any], *, reconstructed: bool) -> str:
return "APPROVED" if reconstructed else str(record.get("status") or "PENDING")
def _reconstruct_request(
grant: dict[str, Any], history: list[dict[str, Any]]
) -> dict[str, Any]:
confirm_id = str(grant["confirmId"])
approved = [
event for event in history
if event.get("confirmId") == confirm_id and event.get("status") == "APPROVED"
]
evidence = max(approved, key=lambda item: float(item["decidedAtEpoch"])) if approved else {}
record = copy.deepcopy(grant)
for key in ("sessionId", "worldKey", "requester", "approvals", "createdAt", "expiresAt"):
if key not in record and key in evidence:
record[key] = copy.deepcopy(evidence[key])
record.pop("status", None)
record.pop("consumedAtEpoch", None)
record["confirmId"] = confirm_id
required = max(2 if record.get("power") == "P3" else 1, len(record.get("approvals") or []))
record["requiredApprovals"] = required
record["approvalStep"] = required
record["needsSecondConfirm"] = False
return record
def _prepare_requests(data: dict[str, Any]) -> dict[str, tuple[dict[str, Any], bool]]:
requests = {key: (copy.deepcopy(value), False) for key, value in data["pending"].items()}
for grant in data["grants"].values():
confirm_id = str(grant["confirmId"])
if confirm_id not in requests:
requests[confirm_id] = (_reconstruct_request(grant, data["history"]), True)
return requests
def _same_number(left: Any, right: Any) -> bool:
try:
return math.isclose(float(left), float(right), rel_tol=0.0, abs_tol=1e-9)
except (TypeError, ValueError):
return False
def _check_payload_identity(
payload: Any,
record: dict[str, Any],
*,
context: str,
) -> None:
if not isinstance(payload, dict):
raise ApprovalMigrationError(f"{context} payload must be an object")
for key in ("confirmId", "tenantUuid", "projectId", "action", "power", "paramsHash"):
if payload.get(key) != record.get(key):
raise ApprovalMigrationError(f"{context} payload immutable conflict")
if _optional_int(payload.get("ownerUserId")) != _optional_int(record.get("ownerUserId")):
raise ApprovalMigrationError(f"{context} payload owner conflict")
def _check_request_row(
row: ApprovalRequestRecord, record: dict[str, Any], source_status: str
) -> None:
expected = {
"confirm_id": record["confirmId"],
"tenant_uuid": record["tenantUuid"],
"project_id": record["projectId"],
"action": record["action"],
"power": record["power"],
"params_hash": record["paramsHash"],
"required_approvals": _required_approvals(record),
}
for field, value in expected.items():
if getattr(row, field) != value:
raise ApprovalMigrationError(f"approval request immutable conflict for {record['confirmId']}")
_check_payload_identity(
row.payload,
record,
context=f"approval request {record['confirmId']}",
)
if not _same_number(row.created_at_epoch, record["createdAtEpoch"]) or not _same_number(
row.expires_at_epoch, record["expiresAtEpoch"]
):
raise ApprovalMigrationError(f"approval request timestamp conflict for {record['confirmId']}")
if row.status not in _REQUEST_STATUS_FORWARD[source_status]:
raise ApprovalMigrationError(f"approval request state regression for {record['confirmId']}")
if row.approval_step < _approval_step(record) or row.revision < int(record.get("revision") or 0):
raise ApprovalMigrationError(f"approval request progress regression for {record['confirmId']}")
def _request_row(record: dict[str, Any], status: str) -> ApprovalRequestRecord:
return ApprovalRequestRecord(
confirm_id=str(record["confirmId"]),
tenant_uuid=str(record["tenantUuid"]),
project_id=str(record["projectId"]),
owner_user_id=_optional_int(record.get("ownerUserId")),
action=str(record["action"]),
power=str(record["power"]),
params_hash=str(record["paramsHash"]),
status=status,
approval_step=_approval_step(record),
required_approvals=_required_approvals(record),
created_at_epoch=float(record["createdAtEpoch"]),
expires_at_epoch=float(record["expiresAtEpoch"]),
revision=int(record.get("revision") or 0),
payload=copy.deepcopy(record),
)
def _check_grant_row(row: ApprovalGrantRecord, record: dict[str, Any]) -> None:
expected = {
"confirm_id": record["confirmId"],
"tenant_uuid": record["tenantUuid"],
"project_id": record["projectId"],
"owner_user_id": _optional_int(record.get("ownerUserId")),
"action": record["action"],
"params_hash": record["paramsHash"],
}
for field, value in expected.items():
if getattr(row, field) != value:
raise ApprovalMigrationError(f"approval grant immutable conflict for {record['confirmId']}")
_check_payload_identity(
row.payload,
record,
context=f"approval grant {record['confirmId']}",
)
if not _same_number(row.created_at_epoch, record["createdAtEpoch"]) or not _same_number(
row.expires_at_epoch, record["expiresAtEpoch"]
):
raise ApprovalMigrationError(f"approval grant timestamp conflict for {record['confirmId']}")
source_status = str(record.get("status") or "ACTIVE")
if row.status not in _GRANT_STATUS_FORWARD[source_status]:
raise ApprovalMigrationError(f"approval grant state regression for {record['confirmId']}")
if row.revision < int(record.get("revision") or 0):
raise ApprovalMigrationError(f"approval grant revision regression for {record['confirmId']}")
if row.status == "CONSUMED" and row.consumed_at_epoch is None:
raise ApprovalMigrationError(f"approval grant consumed state is incomplete for {record['confirmId']}")
def _grant_row(token_hash: str, record: dict[str, Any]) -> ApprovalGrantRecord:
return ApprovalGrantRecord(
token_hash=token_hash,
confirm_id=str(record["confirmId"]),
tenant_uuid=str(record["tenantUuid"]),
project_id=str(record["projectId"]),
owner_user_id=_optional_int(record.get("ownerUserId")),
action=str(record["action"]),
params_hash=str(record["paramsHash"]),
status=str(record.get("status") or "ACTIVE"),
created_at_epoch=float(record["createdAtEpoch"]),
expires_at_epoch=float(record["expiresAtEpoch"]),
consumed_at_epoch=_optional_float(record.get("consumedAtEpoch")),
revision=int(record.get("revision") or 0),
payload=copy.deepcopy(record),
)
def _event_row(event: dict[str, Any]) -> ApprovalEventRecord:
return ApprovalEventRecord(
confirm_id=_optional_str(event.get("confirmId")),
tenant_uuid=str(event["tenantUuid"]),
project_id=str(event["projectId"]),
owner_user_id=_optional_int(event.get("ownerUserId")),
action=str(event["action"]),
power=str(event["power"]),
status=str(event["status"]),
decided_at_epoch=float(event["decidedAtEpoch"]),
payload=copy.deepcopy(event),
)
def _assert_source_stable(source: str, expected_digest: str | None) -> None:
try:
with open(source, "rb") as handle:
current_digest = hashlib.sha256(handle.read()).hexdigest()
except FileNotFoundError:
current_digest = None
except OSError as exc:
raise ApprovalMigrationError("approval source cannot be revalidated") from exc
if current_digest != expected_digest:
raise ApprovalMigrationError("approval source changed while migration lock was held")
def migrate_file_to_db(file_path: str | None = None, *, dry_run: bool = False) -> dict[str, Any]:
"""Atomically migrate a quiesced file approval store to the database backend."""
backend = (os.environ.get("APS_APPROVAL_BACKEND") or "file").strip().lower()
if backend != "database":
raise RuntimeError(
"approval migration requires APS_APPROVAL_BACKEND=database(请先配置 database 后端)"
)
if os.environ.get("APS_APPROVAL_MIGRATION_QUIESCED") != "1":
raise ApprovalMigrationError("approval migration requires APS_APPROVAL_MIGRATION_QUIESCED=1")
source = os.fspath(file_path or default_approval_path())
DatabaseApprovalStore() # Fail before touching source/target when DB/schema is unavailable.
result: dict[str, Any] = {
"pending": {"migrated": 0, "skipped": 0},
"grants": {"migrated": 0, "skipped": 0},
"events": {"migrated": 0, "skipped": 0},
"dryRun": bool(dry_run),
"source": str(source),
}
with _source_lock(source):
data, source_digest = _read_source_snapshot(source)
result["sourceDigest"] = source_digest
requests = _prepare_requests(data)
session = get_session()
try:
request_ids = sorted(requests)
existing_requests = {
row.confirm_id: row
for row in session.execute(
select(ApprovalRequestRecord).where(
ApprovalRequestRecord.confirm_id.in_(request_ids)
) if request_ids else select(ApprovalRequestRecord).where(False)
).scalars()
}
for confirm_id in request_ids:
record, reconstructed = requests[confirm_id]
status = _request_status(record, reconstructed=reconstructed)
row = existing_requests.get(confirm_id)
if row is None:
if not dry_run:
row = _request_row(record, status)
session.add(row)
result["pending"]["migrated"] += 1
else:
_check_request_row(row, record, status)
result["pending"]["skipped"] += 1
existing_grants = list(session.execute(select(ApprovalGrantRecord)).scalars())
grants_by_hash = {row.token_hash: row for row in existing_grants}
grants_by_confirm = {row.confirm_id: row for row in existing_grants}
for raw_token, record in sorted(data["grants"].items()):
expected_hash = token_digest(raw_token)
confirm_id = str(record["confirmId"])
digest_row = grants_by_hash.get(expected_hash)
raw_row = grants_by_hash.get(raw_token) if raw_token != expected_hash else None
confirm_row = grants_by_confirm.get(confirm_id)
if digest_row is not None:
if raw_row is not None or (confirm_row is not None and confirm_row is not digest_row):
raise ApprovalMigrationError(f"approval grant duplicate identity for {confirm_id}")
_check_grant_row(digest_row, record)
result["grants"]["skipped"] += 1
continue
if raw_row is not None:
if confirm_row is not None and confirm_row is not raw_row:
raise ApprovalMigrationError(f"approval grant legacy identity conflict for {confirm_id}")
_check_grant_row(raw_row, record)
if not dry_run:
del grants_by_hash[raw_token]
raw_row.token_hash = expected_hash
grants_by_hash[expected_hash] = raw_row
result["grants"]["migrated"] += 1
continue
if confirm_row is not None:
raise ApprovalMigrationError(f"approval grant token conflict for {confirm_id}")
if not dry_run:
row = _grant_row(expected_hash, record)
session.add(row)
grants_by_hash[expected_hash] = row
grants_by_confirm[confirm_id] = row
result["grants"]["migrated"] += 1
existing_event_payloads: dict[str, str] = {}
for payload in session.execute(select(ApprovalEventRecord.payload)).scalars():
canonical = _canonical_json(payload)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
previous = existing_event_payloads.setdefault(digest, canonical)
if previous != canonical:
raise ApprovalMigrationError("approval event digest collision in target database")
for event in data["history"]:
canonical = _canonical_json(event)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
previous = existing_event_payloads.get(digest)
if previous is not None:
if previous != canonical:
raise ApprovalMigrationError("approval event digest collision")
result["events"]["skipped"] += 1
continue
existing_event_payloads[digest] = canonical
if not dry_run:
session.add(_event_row(event))
result["events"]["migrated"] += 1
if not dry_run:
session.flush()
_assert_source_stable(source, source_digest)
if dry_run:
session.rollback()
else:
session.commit()
except ApprovalMigrationError:
session.rollback()
raise
except (IntegrityError, OperationalError) as exc:
session.rollback()
raise ApprovalMigrationError("approval database migration failed atomically") from exc
except BaseException:
session.rollback()
raise
finally:
session.close()
return result