Integrate committee ballots with Voting

This commit is contained in:
2026-08-01 20:57:25 +02:00
parent 758b59ca03
commit 133e55987e
12 changed files with 565 additions and 104 deletions
+120 -6
View File
@@ -12,6 +12,10 @@ 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,
@@ -54,9 +58,7 @@ class BallotFinalizationRequest:
"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."
)
raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
if self.eligible_count < 0:
raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative."
@@ -95,9 +97,7 @@ class BallotFinalizationResult:
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."
)
raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
if not self.evidence:
raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence."
@@ -209,6 +209,120 @@ class CommitteeBallotFinalizer:
_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}",
)
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,
"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,
+31 -5
View File
@@ -30,6 +30,7 @@ from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER,
)
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
@@ -65,6 +66,7 @@ OPTIONAL_DEPENDENCIES = (
"mandates",
"decisions",
"tasks",
"voting",
"workflow_engine",
"approvals",
)
@@ -87,7 +89,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
),
known_limits=(
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.",
"External and secret ballots are delegated to provider adapters. Committee stores only verified aggregates, provider receipts, hashes, and evidence rather than individual ballots.",
"Governed ballot execution is delegated to Voting when installed. The Committee-owned provider adapter remains a 0.1 compatibility path; Committee stores only aggregate result evidence.",
"Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
),
owned_concepts=(
@@ -140,7 +142,9 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
).count(),
"committee_meetings": current.filter(
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")),
committee_models.CommitteeWorkspaceRevision.state.in_(
("scheduled", "open")
),
).count(),
}
@@ -261,6 +265,7 @@ manifest = ModuleManifest(
optional_capabilities=(
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_DECISION_REGISTRY,
CAPABILITY_VOTING_BALLOTS,
),
route_factory=_router,
nav_items=(
@@ -324,9 +329,30 @@ manifest = ModuleManifest(
),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.reconstruction", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(
name="mandates.resolution",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.formal_outcome",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.reconstruction",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_VOTING_BALLOTS,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
+43 -2
View File
@@ -20,6 +20,7 @@ from govoplan_committee.backend.manifest import (
)
from govoplan_committee.backend.schemas import (
CommitteeBallotFinalizeRequest,
CommitteeVotingFinalizeRequest,
CommitteeWorkspaceHistoryResponse,
CommitteeWorkspaceListResponse,
CommitteeWorkspaceWriteRequest,
@@ -52,7 +53,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
code = 409 if any(word in lowered for word in ("conflict", "stale", "already")) else 400
code = (
409
if any(word in lowered for word in ("conflict", "stale", "already"))
else 400
)
return HTTPException(status_code=code, detail=message)
@@ -71,7 +76,9 @@ def api_get_local_decision(
revision=revision,
)
if item is None:
raise HTTPException(status_code=404, detail="Committee Decision projection not found")
raise HTTPException(
status_code=404, detail="Committee Decision projection not found"
)
return item.to_dict(include_protected=True)
@@ -175,6 +182,40 @@ def api_finalize_provider_vote(
return item.to_dict()
@router.post(
"/workspace/vote/{vote_id}/finalize-voting",
response_model=dict[str, Any],
)
def api_finalize_voting_vote(
vote_id: str,
payload: CommitteeVotingFinalizeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, BALLOT_SCOPE)
try:
item = CommitteeBallotFinalizer(_registry).finalize_voting_ballot(
session,
principal,
vote_id=vote_id,
voting_ballot_id=payload.voting_ballot_id,
voting_expected_revision=payload.voting_expected_revision,
approval_ref=InstitutionalReference.from_mapping(payload.approval_ref),
expected_revision=payload.expected_revision,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
)
session.commit()
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (CommitteeWorkspaceError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
def api_get_workspace(
object_kind: str,
+13
View File
@@ -37,9 +37,22 @@ class CommitteeBallotFinalizeRequest(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255)
class CommitteeVotingFinalizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
voting_ballot_id: str = Field(min_length=1, max_length=255)
voting_expected_revision: int = Field(ge=1)
approval_ref: dict[str, Any]
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
__all__ = [
"CommitteeWorkspaceHistoryResponse",
"CommitteeWorkspaceListResponse",
"CommitteeWorkspaceWriteRequest",
"CommitteeBallotFinalizeRequest",
"CommitteeVotingFinalizeRequest",
]
+120 -54
View File
@@ -45,7 +45,9 @@ _PARENT_KIND: dict[str, str | None] = {
_STATES: dict[str, frozenset[str]] = {
"body": frozenset({"draft", "active", "suspended", "retired"}),
"meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}),
"agenda_item": frozenset({"draft", "scheduled", "deliberating", "decided", "withdrawn"}),
"agenda_item": frozenset(
{"draft", "scheduled", "deliberating", "decided", "withdrawn"}
),
"vote": frozenset({"draft", "open", "closed", "cancelled"}),
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
}
@@ -111,9 +113,7 @@ class CommitteeWorkspaceRecord:
_identifier(self.tenant_id, "Committee tenant id", maximum=36)
_identifier(self.object_id, "Committee object id", maximum=255)
if self.revision < 1:
raise CommitteeWorkspaceError(
"Committee object revision must be positive."
)
raise CommitteeWorkspaceError("Committee object revision must be positive.")
if self.state not in _STATES[self.object_kind]:
raise CommitteeWorkspaceError(
f"Unsupported {self.object_kind} state: {self.state!r}."
@@ -137,9 +137,7 @@ class CommitteeWorkspaceRecord:
"Committee institutional context belongs to another tenant."
)
if any(item.tenant_id != self.tenant_id for item in self.evidence):
raise CommitteeWorkspaceError(
"Committee evidence cannot cross tenants."
)
raise CommitteeWorkspaceError("Committee evidence cannot cross tenants.")
if any(
item.tenant_id != self.tenant_id or item.kind != "record"
for item in self.record_refs
@@ -171,19 +169,23 @@ class CommitteeWorkspaceRecord:
context = value.get("context")
attributes = value.get("attributes") or {}
if not isinstance(attributes, Mapping):
raise CommitteeWorkspaceError(
"Committee attributes must be an object."
)
raise CommitteeWorkspaceError("Committee attributes must be an object.")
return cls(
tenant_id=_required_text(value.get("tenant_id"), "Committee tenant id", maximum=36),
tenant_id=_required_text(
value.get("tenant_id"), "Committee tenant id", maximum=36
),
object_kind=_object_kind(value.get("object_kind")),
object_id=_required_text(value.get("object_id"), "Committee object id", maximum=255),
object_id=_required_text(
value.get("object_id"), "Committee object id", maximum=255
),
revision=_positive_int(value.get("revision"), "Committee revision"),
state=_required_text(value.get("state"), "Committee state", maximum=30),
title=_required_text(value.get("title"), "Committee title", maximum=500),
parent_id=_optional_text(value.get("parent_id"), maximum=255),
recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"),
change_reason=_required_text(value.get("change_reason"), "Committee change reason", maximum=1_000),
change_reason=_required_text(
value.get("change_reason"), "Committee change reason", maximum=1_000
),
attributes=dict(attributes),
context=(
GovernedContextEnvelope.from_mapping(context)
@@ -196,7 +198,9 @@ class CommitteeWorkspaceRecord:
),
record_refs=tuple(
InstitutionalReference.from_mapping(item)
for item in _mapping_items(value.get("record_refs"), "Committee record references")
for item in _mapping_items(
value.get("record_refs"), "Committee record references"
)
),
)
@@ -264,22 +268,29 @@ def record_workspace_object(
f"Committee {record.object_kind} transition {current.state!r} to "
f"{record.state!r} is not allowed."
)
current_payload = current.payload if isinstance(current.payload, Mapping) else {}
current_payload = (
current.payload if isinstance(current.payload, Mapping) else {}
)
current_attributes = current_payload.get("attributes")
provider_id = (
str(current_attributes.get("provider_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
voting_ballot_id = (
str(current_attributes.get("voting_ballot_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
if (
record.object_kind == "vote"
and current.state == "open"
and record.state == "closed"
and provider_id
and (provider_id or voting_ballot_id)
and not _provider_finalization
):
raise CommitteeWorkspaceError(
"A provider-bound Committee vote must be finalized through its ballot adapter."
"A managed Committee vote must be finalized through Voting or its compatibility ballot adapter."
)
current.superseded_at = record.recorded_at
_validate_parent(session, record)
@@ -389,9 +400,7 @@ def list_workspace_objects(
CommitteeWorkspaceRevision.superseded_at.is_(None),
)
if parent_id is not None:
statement = statement.filter(
CommitteeWorkspaceRevision.parent_id == parent_id
)
statement = statement.filter(CommitteeWorkspaceRevision.parent_id == parent_id)
if states:
statement = statement.filter(
CommitteeWorkspaceRevision.state.in_(tuple(states))
@@ -441,11 +450,39 @@ def workspace_history(
class SqlCommitteeWorkspace:
def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord:
return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision)
def record(
self,
session: object,
principal: object,
*,
record: CommitteeWorkspaceRecord,
idempotency_key: str,
expected_revision: int | None = None,
) -> CommitteeWorkspaceRecord:
return record_workspace_object(
_session(session),
principal,
record=record,
idempotency_key=idempotency_key,
expected_revision=expected_revision,
)
def get(self, session: object, principal: object, *, object_kind: str, object_id: str, revision: int | None = None) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision)
def get(
self,
session: object,
principal: object,
*,
object_kind: str,
object_id: str,
revision: int | None = None,
) -> CommitteeWorkspaceRecord | None:
return get_workspace_object(
_session(session),
principal,
object_kind=object_kind,
object_id=object_id,
revision=revision,
)
def record_local_decision(
self,
@@ -466,8 +503,17 @@ class SqlCommitteeWorkspace:
expected_revision=expected_revision,
)
def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None:
return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision)
def get_local_decision(
self,
session: object,
principal: object,
*,
decision_id: str,
revision: str | None = None,
) -> FormalDecision | None:
return get_local_decision(
_session(session), principal, decision_id=decision_id, revision=revision
)
def record_local_decision(
@@ -489,12 +535,15 @@ def record_local_decision(
"Committee Decision projections cannot cross tenants."
)
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
if get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
) is None:
if (
get_workspace_object(
session,
principal,
object_kind=kind,
object_id=object_id,
)
is None
):
raise CommitteeWorkspaceError(
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
)
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
if record.state == "closed":
counts = attributes.get("counts")
if not isinstance(counts, Mapping):
raise CommitteeWorkspaceError(
"A closed vote requires result counts."
)
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()}
raise CommitteeWorkspaceError("A closed vote requires result counts.")
clean_counts = {
str(key): _non_negative_int(value, "Vote count")
for key, value in counts.items()
}
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
raise CommitteeWorkspaceError(
"Closed vote counts must match the configured choices and cast_count."
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
required=True,
)
if not record.evidence:
raise CommitteeWorkspaceError(
"A closed vote requires result evidence."
)
raise CommitteeWorkspaceError("A closed vote requires result evidence.")
elif record.object_kind == "minute":
_reference(
attributes.get("content_ref"),
@@ -757,27 +805,39 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
)
def _replay(session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str) -> CommitteeWorkspaceRecord | None:
row = session.query(CommitteeWorkspaceEvent).filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
).one_or_none()
def _replay(
session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
) -> CommitteeWorkspaceRecord | None:
row = (
session.query(CommitteeWorkspaceEvent)
.filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
)
.one_or_none()
)
if row is None:
return None
if row.request_sha256 != request_sha256:
raise CommitteeWorkspaceError(
"Committee idempotency conflict: the key was used for another request."
)
revision = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
).one()
revision = (
session.query(CommitteeWorkspaceRevision)
.filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision,
)
.one()
)
return _workspace_from_row(revision)
def _current_row(session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool) -> CommitteeWorkspaceRevision | None:
def _current_row(
session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool
) -> CommitteeWorkspaceRevision | None:
query = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == object_kind,
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
return result # type: ignore[return-value]
def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None:
def _reference(
value: object, kind: str | None, tenant_id: str, *, required: bool
) -> InstitutionalReference | None:
if value is None:
if required:
raise CommitteeWorkspaceError(
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
return result
def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]:
def _references(
value: object, kind: str | None, tenant_id: str
) -> tuple[InstitutionalReference, ...]:
return tuple(
item
for item in (
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
def _sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
json.dumps(
_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
).hexdigest()