Files
govoplan-committee/src/govoplan_committee/backend/ballots.py
T

409 lines
15 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import datetime
import re
from typing import Protocol, runtime_checkable
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalReference,
)
from govoplan_core.core.voting import (
CAPABILITY_VOTING_BALLOTS,
VotingBallotProvider,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
get_workspace_object,
record_workspace_object,
)
CAPABILITY_COMMITTEE_BALLOT_FINALIZER = "committee.ballot_finalizer"
_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,79}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
def ballot_adapter_capability(provider_id: str) -> str:
value = str(provider_id or "").strip()
if not _PROVIDER_RE.fullmatch(value):
raise CommitteeWorkspaceError("Committee ballot provider id is invalid.")
return f"committee.ballot_adapter.{value}"
@dataclass(frozen=True, slots=True)
class BallotFinalizationRequest:
tenant_id: str
vote_id: str
provider_id: str
provider_ballot_ref: str
choices: tuple[str, ...]
eligible_count: int
requested_at: datetime
idempotency_key: str
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.tenant_id.strip() or not self.vote_id.strip():
raise CommitteeWorkspaceError(
"Committee ballot tenant and vote identifiers are required."
)
if not self.provider_ballot_ref.strip():
raise CommitteeWorkspaceError(
"Committee provider ballot reference is required."
)
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
if self.eligible_count < 0:
raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative."
)
if self.requested_at.tzinfo is None:
raise CommitteeWorkspaceError(
"Committee ballot requested_at must include a timezone."
)
if not self.idempotency_key.strip():
raise CommitteeWorkspaceError(
"Committee ballot idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class BallotFinalizationResult:
provider_id: str
provider_ballot_ref: str
counts: Mapping[str, int]
cast_count: int
quorum_met: bool
receipt_ref: str
result_sha256: str
evidence: tuple[EvidenceReference, ...]
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.provider_ballot_ref.strip() or not self.receipt_ref.strip():
raise CommitteeWorkspaceError(
"Committee ballot provider and receipt references are required."
)
if not _SHA256_RE.fullmatch(self.result_sha256):
raise CommitteeWorkspaceError(
"Committee ballot result hash must be a lowercase SHA-256 digest."
)
if self.cast_count < 0 or any(
not isinstance(value, int) or value < 0 for value in self.counts.values()
):
raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
if not self.evidence:
raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence."
)
@runtime_checkable
class CommitteeBallotAdapter(Protocol):
def finalize_ballot(
self,
session: object,
principal: object,
*,
request: BallotFinalizationRequest,
) -> BallotFinalizationResult: ...
class CommitteeBallotFinalizer:
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def finalize(
self,
session: Session,
principal: object,
*,
vote_id: str,
provider_id: str,
provider_ballot_ref: str,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized by a ballot provider."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_provider = str(current.attributes.get("provider_id") or "").strip()
if configured_provider and configured_provider != provider_id:
raise CommitteeWorkspaceError(
"Committee vote is bound to another ballot provider."
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
eligible_count = int(current.attributes.get("eligible_count") or 0)
request = BallotFinalizationRequest(
tenant_id=current.tenant_id,
vote_id=current.object_id,
provider_id=provider_id,
provider_ballot_ref=provider_ballot_ref,
choices=choices,
eligible_count=eligible_count,
requested_at=recorded_at,
idempotency_key=idempotency_key,
)
adapter = _capability(
self._registry,
ballot_adapter_capability(provider_id),
)
if not isinstance(adapter, CommitteeBallotAdapter):
raise CommitteeWorkspaceError(
f"Committee ballot provider is unavailable: {provider_id}."
)
result = adapter.finalize_ballot(
session,
principal,
request=request,
)
_validate_result(result, request=request)
counts = {choice: int(result.counts.get(choice, 0)) for choice in choices}
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"provider_id": provider_id,
"provider_ballot_ref": provider_ballot_ref,
"provider_receipt_ref": result.receipt_ref,
"provider_result_sha256": result.result_sha256,
"counts": counts,
"cast_count": result.cast_count,
"quorum_met": result.quorum_met,
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, result.evidence),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def finalize_voting_ballot(
self,
session: Session,
principal: object,
*,
vote_id: str,
voting_ballot_id: str,
voting_expected_revision: int,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
"""Close a Voting-owned ballot and project its aggregate into Committee."""
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_id = str(current.attributes.get("voting_ballot_id") or "").strip()
if not configured_id or configured_id != voting_ballot_id:
raise CommitteeWorkspaceError(
"Committee vote is not bound to the requested Voting ballot."
)
provider = _capability(self._registry, CAPABILITY_VOTING_BALLOTS)
if not isinstance(provider, VotingBallotProvider):
raise CommitteeWorkspaceError("Voting ballot capability is unavailable.")
ballot = provider.get_ballot(
session,
principal,
ballot_id=voting_ballot_id,
)
if ballot is None:
raise CommitteeWorkspaceError("Referenced Voting ballot was not found.")
context = ballot.get("context")
if isinstance(context, Mapping):
context_module = str(context.get("module") or "").strip()
context_id = str(context.get("resource_id") or "").strip()
if context_module and context_module != "committee":
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another module context."
)
if context_id and context_id != vote_id:
raise CommitteeWorkspaceError(
"Referenced Voting ballot belongs to another Committee vote."
)
result = provider.close_ballot(
session,
principal,
ballot_id=voting_ballot_id,
expected_revision=voting_expected_revision,
idempotency_key=f"committee:{idempotency_key}",
)
assurance_profile = str(
ballot.get("assurance_profile") or "recorded"
).strip()
if assurance_profile != "recorded" and not result.evidence:
raise CommitteeWorkspaceError(
"Provider-backed Voting results require sanitized provider evidence."
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
if set(result.counts) != set(choices):
raise CommitteeWorkspaceError(
"Voting result options do not match the Committee vote choices."
)
evidence = EvidenceReference(
kind="snapshot",
owner_module="voting",
evidence_id=f"result-{result.result_sha256}",
tenant_id=current.tenant_id,
version=str(result.revision),
checksum=result.result_sha256,
source_ref=f"voting:{voting_ballot_id}",
captured_at=recorded_at,
)
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"voting_ballot_id": voting_ballot_id,
"voting_ballot_revision": result.revision,
"voting_result_sha256": result.result_sha256,
"voting_assurance_profile": assurance_profile,
"voting_provider_id": ballot.get("provider_id"),
"voting_provider_ballot_ref": ballot.get("provider_ballot_ref"),
"voting_provider_evidence": [
dict(item) for item in result.evidence
],
"counts": {key: int(value) for key, value in result.counts.items()},
"weighted_counts": {
key: int(value) for key, value in result.weighted_counts.items()
},
"cast_count": result.cast_count,
"cast_weight": result.cast_weight,
"quorum_met": result.quorum_met,
"threshold_met": result.threshold_met,
"winning_options": list(result.winning_options),
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, (evidence,)),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def _validate_result(
result: BallotFinalizationResult,
*,
request: BallotFinalizationRequest,
) -> None:
if (
result.provider_id != request.provider_id
or result.provider_ballot_ref != request.provider_ballot_ref
):
raise CommitteeWorkspaceError(
"Committee ballot provider returned a result for another ballot."
)
if set(result.counts) != set(request.choices):
raise CommitteeWorkspaceError(
"Committee ballot provider result must cover exactly the configured choices."
)
if sum(result.counts.values()) != result.cast_count:
raise CommitteeWorkspaceError(
"Committee ballot provider counts do not match cast_count."
)
if result.cast_count > request.eligible_count:
raise CommitteeWorkspaceError(
"Committee ballot provider cast_count exceeds eligible_count."
)
if any(item.tenant_id != request.tenant_id for item in result.evidence):
raise CommitteeWorkspaceError(
"Committee ballot provider evidence cannot cross tenants."
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _merge_evidence(
existing: tuple[EvidenceReference, ...],
incoming: tuple[EvidenceReference, ...],
) -> tuple[EvidenceReference, ...]:
merged: list[EvidenceReference] = []
seen: set[tuple[str, str, str, str, str]] = set()
for item in (*existing, *incoming):
key = (
item.kind,
item.owner_module,
item.evidence_id,
item.tenant_id,
item.version,
)
if key in seen:
continue
seen.add(key)
merged.append(item)
return tuple(merged)
__all__ = [
"CAPABILITY_COMMITTEE_BALLOT_FINALIZER",
"BallotFinalizationRequest",
"BallotFinalizationResult",
"CommitteeBallotAdapter",
"CommitteeBallotFinalizer",
"ballot_adapter_capability",
]