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
+10 -8
View File
@@ -19,12 +19,13 @@ protected local Decision projection so the outcome remains reconstructable.
The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes
workspace using the same immutable revisions and lifecycle guards as the API. workspace using the same immutable revisions and lifecycle guards as the API.
`committee.ballot_finalizer` delegates provider-bound external or secret When Voting is installed, Committee creates and follows a governed
ballots to `committee.ballot_adapter.<provider>` capabilities. Committee `voting.ballots` record while retaining only its meeting/agendum reference and
validates the exact choices, eligible/cast totals, tenant evidence, receipt, verified aggregate outcome. Voting owns the frozen electorate, casting,
and result hash, then stores only the verified aggregate. Individual ballots tallying, certification, challenge, and annulment lifecycle. The older
never enter Committee persistence, and a provider-bound vote cannot be closed `committee.ballot_finalizer` / `committee.ballot_adapter.<provider>` path
through the generic workspace endpoint. remains a 0.1 compatibility boundary for existing external integrations;
individual provider ballots never enter Committee persistence.
## Initial Ownership ## Initial Ownership
@@ -57,6 +58,7 @@ Expected optional integrations:
- tasks - tasks
- workflow - workflow
- approvals - approvals
- voting
## Development Install ## Development Install
@@ -83,8 +85,8 @@ use a separate read permission. Database restore is the semantic recovery unit;
linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain
their own recovery responsibility. their own recovery responsibility.
`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the `POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the 0.1
separate effect boundary for an installed ballot adapter. It requires the compatibility effect boundary for an installed ballot adapter. It requires the
`committee:ballot:finalize` permission and preserves provider receipt/hash and `committee:ballot:finalize` permission and preserves provider receipt/hash and
evidence without exposing or persisting individual votes. evidence without exposing or persisting individual votes.
+6 -1
View File
@@ -50,6 +50,8 @@ The current repository state is intentionally bounded:
vote results, and minutes vote results, and minutes
- a provider-neutral ballot-finalization contract for external and secret - a provider-neutral ballot-finalization contract for external and secret
ballots that retains aggregate evidence rather than individual ballots ballots that retains aggregate evidence rather than individual ballots
- an optional primary integration with Voting, which owns frozen electorates,
vote casting/replacement, tally, certification, challenge, and annulment
- Gitea issue workflow templates - Gitea issue workflow templates
- manifest and decision reconstruction contract tests - manifest and decision reconstruction contract tests
@@ -69,7 +71,10 @@ Decision reference, and meetings cannot close while agenda items remain
unfinished. Accepted or corrected minutes require a Records reference, unfinished. Accepted or corrected minutes require a Records reference,
Approval, and evidence. Approval, and evidence.
A provider-bound vote is finalized only through A governed ballot is delegated to `voting.ballots` when Voting is installed;
Committee retains the meeting/agendum linkage and verified aggregate result.
The following direct provider path remains a 0.1 compatibility contract only.
A provider-bound vote on that path is finalized through
`committee.ballot_adapter.<provider>`. The adapter receives tenant, vote, `committee.ballot_adapter.<provider>`. The adapter receives tenant, vote,
choices, eligible count, external ballot reference, request time, and choices, eligible count, external ballot reference, request time, and
idempotency key. Its result must cover exactly the configured choices, sum to idempotency key. Its result must cover exactly the configured choices, sum to
+120 -6
View File
@@ -12,6 +12,10 @@ from govoplan_core.core.institutional import (
EvidenceReference, EvidenceReference,
InstitutionalReference, InstitutionalReference,
) )
from govoplan_core.core.voting import (
CAPABILITY_VOTING_BALLOTS,
VotingBallotProvider,
)
from govoplan_committee.backend.workspace import ( from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError, CommitteeWorkspaceError,
CommitteeWorkspaceRecord, CommitteeWorkspaceRecord,
@@ -54,9 +58,7 @@ class BallotFinalizationRequest:
"Committee provider ballot reference is required." "Committee provider ballot reference is required."
) )
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)): if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("Committee ballot choices must be unique.")
"Committee ballot choices must be unique."
)
if self.eligible_count < 0: if self.eligible_count < 0:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative." "Committee ballot eligible count cannot be negative."
@@ -95,9 +97,7 @@ class BallotFinalizationResult:
if self.cast_count < 0 or any( if self.cast_count < 0 or any(
not isinstance(value, int) or value < 0 for value in self.counts.values() not isinstance(value, int) or value < 0 for value in self.counts.values()
): ):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("Committee ballot counts cannot be negative.")
"Committee ballot counts cannot be negative."
)
if not self.evidence: if not self.evidence:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence." "Committee ballot finalization requires provider evidence."
@@ -209,6 +209,120 @@ class CommitteeBallotFinalizer:
_provider_finalization=True, _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( def _validate_result(
result: BallotFinalizationResult, result: BallotFinalizationResult,
+31 -5
View File
@@ -30,6 +30,7 @@ from govoplan_core.core.institutional import (
CAPABILITY_DECISION_REGISTRY, CAPABILITY_DECISION_REGISTRY,
CAPABILITY_MANDATE_RESOLVER, CAPABILITY_MANDATE_RESOLVER,
) )
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
from govoplan_core.core.provider_governance import ( from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration, ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation, ModuleArchitectureDocumentation,
@@ -65,6 +66,7 @@ OPTIONAL_DEPENDENCIES = (
"mandates", "mandates",
"decisions", "decisions",
"tasks", "tasks",
"voting",
"workflow_engine", "workflow_engine",
"approvals", "approvals",
) )
@@ -87,7 +89,7 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
), ),
known_limits=( known_limits=(
"The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.", "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.", "Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.",
), ),
owned_concepts=( owned_concepts=(
@@ -140,7 +142,9 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
).count(), ).count(),
"committee_meetings": current.filter( "committee_meetings": current.filter(
committee_models.CommitteeWorkspaceRevision.object_kind == "meeting", committee_models.CommitteeWorkspaceRevision.object_kind == "meeting",
committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")), committee_models.CommitteeWorkspaceRevision.state.in_(
("scheduled", "open")
),
).count(), ).count(),
} }
@@ -261,6 +265,7 @@ manifest = ModuleManifest(
optional_capabilities=( optional_capabilities=(
CAPABILITY_MANDATE_RESOLVER, CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_DECISION_REGISTRY, CAPABILITY_DECISION_REGISTRY,
CAPABILITY_VOTING_BALLOTS,
), ),
route_factory=_router, route_factory=_router,
nav_items=( nav_items=(
@@ -324,9 +329,30 @@ manifest = ModuleManifest(
), ),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), name="mandates.resolution",
ModuleInterfaceRequirement(name="decisions.reconstruction", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), 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_factories={
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path, 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 ( from govoplan_committee.backend.schemas import (
CommitteeBallotFinalizeRequest, CommitteeBallotFinalizeRequest,
CommitteeVotingFinalizeRequest,
CommitteeWorkspaceHistoryResponse, CommitteeWorkspaceHistoryResponse,
CommitteeWorkspaceListResponse, CommitteeWorkspaceListResponse,
CommitteeWorkspaceWriteRequest, CommitteeWorkspaceWriteRequest,
@@ -52,7 +53,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
def _error(exc: Exception) -> HTTPException: def _error(exc: Exception) -> HTTPException:
message = str(exc) message = str(exc)
lowered = message.casefold() 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) return HTTPException(status_code=code, detail=message)
@@ -71,7 +76,9 @@ def api_get_local_decision(
revision=revision, revision=revision,
) )
if item is None: 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) return item.to_dict(include_protected=True)
@@ -175,6 +182,40 @@ def api_finalize_provider_vote(
return item.to_dict() 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]) @router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any])
def api_get_workspace( def api_get_workspace(
object_kind: str, object_kind: str,
+13
View File
@@ -37,9 +37,22 @@ class CommitteeBallotFinalizeRequest(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255) 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__ = [ __all__ = [
"CommitteeWorkspaceHistoryResponse", "CommitteeWorkspaceHistoryResponse",
"CommitteeWorkspaceListResponse", "CommitteeWorkspaceListResponse",
"CommitteeWorkspaceWriteRequest", "CommitteeWorkspaceWriteRequest",
"CommitteeBallotFinalizeRequest", "CommitteeBallotFinalizeRequest",
"CommitteeVotingFinalizeRequest",
] ]
+110 -44
View File
@@ -45,7 +45,9 @@ _PARENT_KIND: dict[str, str | None] = {
_STATES: dict[str, frozenset[str]] = { _STATES: dict[str, frozenset[str]] = {
"body": frozenset({"draft", "active", "suspended", "retired"}), "body": frozenset({"draft", "active", "suspended", "retired"}),
"meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}), "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"}), "vote": frozenset({"draft", "open", "closed", "cancelled"}),
"minute": frozenset({"draft", "proposed", "accepted", "corrected"}), "minute": frozenset({"draft", "proposed", "accepted", "corrected"}),
} }
@@ -111,9 +113,7 @@ class CommitteeWorkspaceRecord:
_identifier(self.tenant_id, "Committee tenant id", maximum=36) _identifier(self.tenant_id, "Committee tenant id", maximum=36)
_identifier(self.object_id, "Committee object id", maximum=255) _identifier(self.object_id, "Committee object id", maximum=255)
if self.revision < 1: if self.revision < 1:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("Committee object revision must be positive.")
"Committee object revision must be positive."
)
if self.state not in _STATES[self.object_kind]: if self.state not in _STATES[self.object_kind]:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
f"Unsupported {self.object_kind} state: {self.state!r}." f"Unsupported {self.object_kind} state: {self.state!r}."
@@ -137,9 +137,7 @@ class CommitteeWorkspaceRecord:
"Committee institutional context belongs to another tenant." "Committee institutional context belongs to another tenant."
) )
if any(item.tenant_id != self.tenant_id for item in self.evidence): if any(item.tenant_id != self.tenant_id for item in self.evidence):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("Committee evidence cannot cross tenants.")
"Committee evidence cannot cross tenants."
)
if any( if any(
item.tenant_id != self.tenant_id or item.kind != "record" item.tenant_id != self.tenant_id or item.kind != "record"
for item in self.record_refs for item in self.record_refs
@@ -171,19 +169,23 @@ class CommitteeWorkspaceRecord:
context = value.get("context") context = value.get("context")
attributes = value.get("attributes") or {} attributes = value.get("attributes") or {}
if not isinstance(attributes, Mapping): if not isinstance(attributes, Mapping):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("Committee attributes must be an object.")
"Committee attributes must be an object."
)
return cls( 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_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"), revision=_positive_int(value.get("revision"), "Committee revision"),
state=_required_text(value.get("state"), "Committee state", maximum=30), state=_required_text(value.get("state"), "Committee state", maximum=30),
title=_required_text(value.get("title"), "Committee title", maximum=500), title=_required_text(value.get("title"), "Committee title", maximum=500),
parent_id=_optional_text(value.get("parent_id"), maximum=255), parent_id=_optional_text(value.get("parent_id"), maximum=255),
recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"), 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), attributes=dict(attributes),
context=( context=(
GovernedContextEnvelope.from_mapping(context) GovernedContextEnvelope.from_mapping(context)
@@ -196,7 +198,9 @@ class CommitteeWorkspaceRecord:
), ),
record_refs=tuple( record_refs=tuple(
InstitutionalReference.from_mapping(item) 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"Committee {record.object_kind} transition {current.state!r} to "
f"{record.state!r} is not allowed." 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") current_attributes = current_payload.get("attributes")
provider_id = ( provider_id = (
str(current_attributes.get("provider_id") or "").strip() str(current_attributes.get("provider_id") or "").strip()
if isinstance(current_attributes, Mapping) if isinstance(current_attributes, Mapping)
else "" else ""
) )
voting_ballot_id = (
str(current_attributes.get("voting_ballot_id") or "").strip()
if isinstance(current_attributes, Mapping)
else ""
)
if ( if (
record.object_kind == "vote" record.object_kind == "vote"
and current.state == "open" and current.state == "open"
and record.state == "closed" and record.state == "closed"
and provider_id and (provider_id or voting_ballot_id)
and not _provider_finalization and not _provider_finalization
): ):
raise CommitteeWorkspaceError( 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 current.superseded_at = record.recorded_at
_validate_parent(session, record) _validate_parent(session, record)
@@ -389,9 +400,7 @@ def list_workspace_objects(
CommitteeWorkspaceRevision.superseded_at.is_(None), CommitteeWorkspaceRevision.superseded_at.is_(None),
) )
if parent_id is not None: if parent_id is not None:
statement = statement.filter( statement = statement.filter(CommitteeWorkspaceRevision.parent_id == parent_id)
CommitteeWorkspaceRevision.parent_id == parent_id
)
if states: if states:
statement = statement.filter( statement = statement.filter(
CommitteeWorkspaceRevision.state.in_(tuple(states)) CommitteeWorkspaceRevision.state.in_(tuple(states))
@@ -441,11 +450,39 @@ def workspace_history(
class SqlCommitteeWorkspace: class SqlCommitteeWorkspace:
def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord: def record(
return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision) 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: def get(
return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision) 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( def record_local_decision(
self, self,
@@ -466,8 +503,17 @@ class SqlCommitteeWorkspace:
expected_revision=expected_revision, expected_revision=expected_revision,
) )
def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None: def get_local_decision(
return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision) 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( def record_local_decision(
@@ -489,12 +535,15 @@ def record_local_decision(
"Committee Decision projections cannot cross tenants." "Committee Decision projections cannot cross tenants."
) )
for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)): for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)):
if get_workspace_object( if (
get_workspace_object(
session, session,
principal, principal,
object_kind=kind, object_kind=kind,
object_id=object_id, object_id=object_id,
) is None: )
is None
):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
f"Committee Decision projection requires an existing {kind.replace('_', ' ')}." f"Committee Decision projection requires an existing {kind.replace('_', ' ')}."
) )
@@ -715,10 +764,11 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
if record.state == "closed": if record.state == "closed":
counts = attributes.get("counts") counts = attributes.get("counts")
if not isinstance(counts, Mapping): if not isinstance(counts, Mapping):
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("A closed vote requires result counts.")
"A closed vote requires result counts." clean_counts = {
) str(key): _non_negative_int(value, "Vote count")
clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()} for key, value in counts.items()
}
if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast: if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
"Closed vote counts must match the configured choices and cast_count." "Closed vote counts must match the configured choices and cast_count."
@@ -734,9 +784,7 @@ def _validate_attributes(record: CommitteeWorkspaceRecord) -> None:
required=True, required=True,
) )
if not record.evidence: if not record.evidence:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError("A closed vote requires result evidence.")
"A closed vote requires result evidence."
)
elif record.object_kind == "minute": elif record.object_kind == "minute":
_reference( _reference(
attributes.get("content_ref"), 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: def _replay(
row = session.query(CommitteeWorkspaceEvent).filter( session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str
) -> CommitteeWorkspaceRecord | None:
row = (
session.query(CommitteeWorkspaceEvent)
.filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id, CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.idempotency_key == idempotency_key, CommitteeWorkspaceEvent.idempotency_key == idempotency_key,
).one_or_none() )
.one_or_none()
)
if row is None: if row is None:
return None return None
if row.request_sha256 != request_sha256: if row.request_sha256 != request_sha256:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
"Committee idempotency conflict: the key was used for another request." "Committee idempotency conflict: the key was used for another request."
) )
revision = session.query(CommitteeWorkspaceRevision).filter( revision = (
session.query(CommitteeWorkspaceRevision)
.filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id, CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == row.object_kind, CommitteeWorkspaceRevision.object_kind == row.object_kind,
CommitteeWorkspaceRevision.object_id == row.object_id, CommitteeWorkspaceRevision.object_id == row.object_id,
CommitteeWorkspaceRevision.revision == row.object_revision, CommitteeWorkspaceRevision.revision == row.object_revision,
).one() )
.one()
)
return _workspace_from_row(revision) 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( query = session.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id, CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.object_kind == object_kind, CommitteeWorkspaceRevision.object_kind == object_kind,
@@ -804,7 +864,9 @@ def _object_kind(value: object) -> CommitteeObjectKind:
return result # type: ignore[return-value] 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 value is None:
if required: if required:
raise CommitteeWorkspaceError( raise CommitteeWorkspaceError(
@@ -821,7 +883,9 @@ def _reference(value: object, kind: str | None, tenant_id: str, *, required: boo
return result 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( return tuple(
item item
for item in ( for item in (
@@ -910,7 +974,9 @@ def _optional_text(value: object, *, maximum: int) -> str | None:
def _sha256(value: object) -> str: def _sha256(value: object) -> str:
return hashlib.sha256( 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() ).hexdigest()
+149 -6
View File
@@ -18,6 +18,7 @@ from govoplan_core.core.institutional import (
MandateResolution, MandateResolution,
TemporalRevision, TemporalRevision,
) )
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS, VotingResult
from govoplan_committee.backend.db.models import ( from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection, CommitteeDecisionProjection,
CommitteeWorkspaceEvent, CommitteeWorkspaceEvent,
@@ -80,6 +81,61 @@ class BallotRegistry:
return self.adapter return self.adapter
class VotingBallots:
def create_ballot(self, *args, **kwargs):
raise NotImplementedError
def get_ballot(self, session, principal, *, ballot_id):
return {
"id": ballot_id,
"revision": 2,
"state": "open",
"context": {"module": "committee", "resource_id": "vote-voting"},
}
def open_ballot(self, *args, **kwargs):
raise NotImplementedError
def cast_ballot(self, *args, **kwargs):
raise NotImplementedError
def close_ballot(
self, session, principal, *, ballot_id, expected_revision, idempotency_key
):
if expected_revision != 2:
raise AssertionError("unexpected Voting revision")
return VotingResult(
ballot_id=ballot_id,
revision=3,
counts={"yes": 2, "no": 1},
weighted_counts={"yes": 3, "no": 1},
cast_count=3,
cast_weight=4,
eligible_count=4,
eligible_weight=5,
quorum_met=True,
threshold_met=True,
winning_options=("yes",),
result_sha256="c" * 64,
)
def certify_ballot(self, *args, **kwargs):
raise NotImplementedError
class VotingRegistry:
def __init__(self) -> None:
self.provider = VotingBallots()
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_VOTING_BALLOTS
def capability(self, name: str) -> object:
if name != CAPABILITY_VOTING_BALLOTS:
raise KeyError(name)
return self.provider
def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference: def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference:
return InstitutionalReference( return InstitutionalReference(
kind=kind, # type: ignore[arg-type] kind=kind, # type: ignore[arg-type]
@@ -320,7 +376,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
idempotency_key="vote-close", idempotency_key="vote-close",
) )
decision = CommitteeDecisionPath().decide( decision = (
CommitteeDecisionPath()
.decide(
self.session, self.session,
self.principal, self.principal,
proposal=proposal(), proposal=proposal(),
@@ -328,7 +386,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
competent=True, competent=True,
mandates=(mandate(),), mandates=(mandate(),),
), ),
).decision )
.decision
)
workspace = SqlCommitteeWorkspace() workspace = SqlCommitteeWorkspace()
projected = workspace.record_local_decision( projected = workspace.record_local_decision(
self.session, self.session,
@@ -387,7 +447,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
parent_id="meeting-1", parent_id="meeting-1",
attributes={ attributes={
"content_ref": ref("record", "minutes-1", "records").to_dict(), "content_ref": ref("record", "minutes-1", "records").to_dict(),
"approval_ref": ref("approval", "minutes-ok", "approvals").to_dict(), "approval_ref": ref(
"approval", "minutes-ok", "approvals"
).to_dict(),
}, },
evidence_refs=(evidence("minutes-signature"),), evidence_refs=(evidence("minutes-signature"),),
) )
@@ -400,11 +462,14 @@ class CommitteeWorkspaceTests(unittest.TestCase):
self.assertEqual([], events) self.assertEqual([], events)
self.session.commit() self.session.commit()
self.assertEqual("decision-1", get_local_decision( self.assertEqual(
"decision-1",
get_local_decision(
self.session, self.session,
self.principal, self.principal,
decision_id="decision-1", decision_id="decision-1",
).reference.object_id) ).reference.object_id,
)
agenda_history = workspace_history( agenda_history = workspace_history(
self.session, self.session,
self.principal, self.principal,
@@ -488,7 +553,9 @@ class CommitteeWorkspaceTests(unittest.TestCase):
idempotency_key="cross-tenant", idempotency_key="cross-tenant",
) )
def test_provider_ballot_finalization_persists_only_aggregate_evidence(self) -> None: def test_provider_ballot_finalization_persists_only_aggregate_evidence(
self,
) -> None:
records = ( records = (
workspace_record( workspace_record(
"body", "body",
@@ -592,6 +659,82 @@ class CommitteeWorkspaceTests(unittest.TestCase):
self.assertNotIn("ballots", closed.attributes) self.assertNotIn("ballots", closed.attributes)
self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id) self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id)
def test_voting_ballot_finalization_projects_only_aggregate_result(self) -> None:
records = (
workspace_record(
"body",
"body-voting",
state="active",
attributes={
"organization_unit_ref": ref(
"organization_unit", "board-1", "organizations"
).to_dict(),
"function_refs": [],
},
),
workspace_record(
"meeting",
"meeting-voting",
state="open",
parent_id="body-voting",
attributes={
"starts_at": NOW.isoformat(),
"ends_at": (NOW + timedelta(hours=2)).isoformat(),
},
),
workspace_record(
"agenda_item",
"agenda-voting",
state="deliberating",
parent_id="meeting-voting",
attributes={
"position": 1,
"subject_refs": [ref("case", "case-1", "cases").to_dict()],
},
),
workspace_record(
"vote",
"vote-voting",
state="open",
parent_id="agenda-voting",
attributes={
"method": "recorded",
"voting_ballot_id": "ballot-1",
"choices": ["yes", "no"],
"eligible_count": 4,
"cast_count": 0,
},
),
)
for index, item in enumerate(records):
record_workspace_object(
self.session,
self.principal,
record=item,
idempotency_key=f"voting-setup-{index}",
)
self.session.commit()
closed = CommitteeBallotFinalizer(VotingRegistry()).finalize_voting_ballot(
self.session,
self.principal,
vote_id="vote-voting",
voting_ballot_id="ballot-1",
voting_expected_revision=2,
approval_ref=ref("approval", "vote-approval", "approvals"),
expected_revision=1,
recorded_at=NOW + timedelta(minutes=5),
change_reason="Closed the governed Voting ballot.",
idempotency_key="voting-finalize-1",
)
self.session.commit()
self.assertEqual("closed", closed.state)
self.assertEqual({"yes": 2, "no": 1}, closed.attributes["counts"])
self.assertEqual("c" * 64, closed.attributes["voting_result_sha256"])
self.assertEqual("voting", closed.evidence[0].owner_module)
self.assertNotIn("selections", closed.attributes)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+37
View File
@@ -113,3 +113,40 @@ export function finalizeProviderBallot(
} }
); );
} }
export async function finalizeVotingBallot(
settings: ApiSettings,
record: CommitteeRecord,
input: {
approvalId: string;
changeReason: string;
}
): Promise<CommitteeRecord> {
const ballotId = String(record.attributes.voting_ballot_id ?? "").trim();
const ballot = await apiFetch<{ revision: number }>(
settings,
`/api/v1/voting/${encodeURIComponent(ballotId)}`
);
return apiFetch<CommitteeRecord>(
settings,
`/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-voting`,
{
method: "POST",
body: JSON.stringify({
voting_ballot_id: ballotId,
voting_expected_revision: ballot.revision,
approval_ref: {
kind: "approval",
owner_module: "approvals",
object_id: input.approvalId.trim(),
tenant_id: record.tenant_id,
version: "1"
},
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: input.changeReason.trim(),
idempotency_key: crypto.randomUUID()
})
}
);
}
@@ -8,6 +8,7 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
finalizeProviderBallot, finalizeProviderBallot,
finalizeVotingBallot,
type CommitteeRecord type CommitteeRecord
} from "../../api/committee"; } from "../../api/committee";
@@ -43,7 +44,9 @@ export default function CommitteeBallotDialog({
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const saved = await finalizeProviderBallot(settings, record, { const saved = votingBallotId
? await finalizeVotingBallot(settings, record, { approvalId, changeReason })
: await finalizeProviderBallot(settings, record, {
providerBallotRef, providerBallotRef,
approvalId, approvalId,
changeReason changeReason
@@ -58,10 +61,11 @@ export default function CommitteeBallotDialog({
} }
const providerId = String(record.attributes.provider_id ?? ""); const providerId = String(record.attributes.provider_id ?? "");
const votingBallotId = String(record.attributes.voting_ballot_id ?? "").trim();
return ( return (
<Dialog <Dialog
open={open} open={open}
title="Finalize provider ballot" title={votingBallotId ? "Finalize Voting ballot" : "Finalize provider ballot"}
onClose={onClose} onClose={onClose}
closeDisabled={busy} closeDisabled={busy}
portal portal
@@ -71,7 +75,7 @@ export default function CommitteeBallotDialog({
<Button disabled={busy} onClick={onClose}>Cancel</Button> <Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button <Button
variant="primary" variant="primary"
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()} disabled={busy || (!votingBallotId && !providerBallotRef.trim()) || !approvalId.trim() || !changeReason.trim()}
onClick={() => void finalize()} onClick={() => void finalize()}
> >
{busy ? "Importing" : "Finalize"} {busy ? "Importing" : "Finalize"}
@@ -82,12 +86,13 @@ export default function CommitteeBallotDialog({
<div className="committee-record-form"> <div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="committee-dialog-note"> <p className="committee-dialog-note">
Provider <strong>{providerId}</strong> returns only the verified aggregate result, {votingBallotId
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN. ? <>Voting ballot <strong>{votingBallotId}</strong> will be closed and its aggregate result recorded in the Committee minutes.</>
: <>Provider <strong>{providerId}</strong> returns only the verified aggregate result, receipt hash and evidence.</>}
</p> </p>
<FormField label="Provider ballot reference"> {!votingBallotId ? <FormField label="Provider ballot reference">
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} /> <input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
</FormField> </FormField> : null}
<FormField label="Approval ID"> <FormField label="Approval ID">
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} /> <input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
</FormField> </FormField>
@@ -377,7 +377,10 @@ function voteSummary(record: CommitteeRecord): string {
} }
function isProviderBallotReady(record: CommitteeRecord): boolean { function isProviderBallotReady(record: CommitteeRecord): boolean {
return record.state === "open" && Boolean(String(record.attributes.provider_id ?? "").trim()); return record.state === "open" && Boolean(
String(record.attributes.voting_ballot_id ?? "").trim()
|| String(record.attributes.provider_id ?? "").trim()
);
} }
function statusTone(state: string): "active" | "inactive" | "warning" { function statusTone(state: string): "active" | "inactive" | "warning" {
@@ -32,6 +32,7 @@ type Draft = {
approvalId: string; approvalId: string;
evidenceId: string; evidenceId: string;
providerId: string; providerId: string;
votingBallotId: string;
contentRecordId: string; contentRecordId: string;
decisionId: string; decisionId: string;
}; };
@@ -206,6 +207,9 @@ export default function CommitteeRecordDialog({
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} /> <input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField> </FormField>
</div> </div>
<FormField label="Voting ballot ID (optional)">
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
</FormField>
<FormField label="Choices (comma separated)"> <FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} /> <input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField> </FormField>
@@ -310,6 +314,7 @@ function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | n
approvalId: text(approval.object_id), approvalId: text(approval.object_id),
evidenceId: text(firstMapping(record?.evidence).evidence_id), evidenceId: text(firstMapping(record?.evidence).evidence_id),
providerId: text(attributes.provider_id), providerId: text(attributes.provider_id),
votingBallotId: text(attributes.voting_ballot_id),
contentRecordId: text(content.object_id), contentRecordId: text(content.object_id),
decisionId: text(decision.object_id) decisionId: text(decision.object_id)
}; };
@@ -373,6 +378,7 @@ function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft:
eligible_count: Number(draft.eligibleCount), eligible_count: Number(draft.eligibleCount),
cast_count: Number(draft.castCount), cast_count: Number(draft.castCount),
...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}), ...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}),
...(draft.votingBallotId.trim() ? { voting_ballot_id: draft.votingBallotId.trim() } : {}),
...(draft.state === "closed" ? { ...(draft.state === "closed" ? {
counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])), counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])),
quorum_met: draft.quorumMet, quorum_met: draft.quorumMet,