feat: implement committee decision workspace

This commit is contained in:
2026-08-01 17:48:25 +02:00
parent 232329ac52
commit 758b59ca03
28 changed files with 4909 additions and 32 deletions
+281
View File
@@ -0,0 +1,281 @@
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_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 _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",
]