commit c176c5cfcb75a04a6e8c49f1b2e9ae614e381661 Author: Albrecht Degering Date: Sat Aug 1 20:57:25 2026 +0200 Implement governed voting module diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9112ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +node_modules/ +dist/ +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d200e24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# GovOPlaN Voting Codex Guide + +## Scope + +This module owns governed ballot definitions, frozen electorates, vote +receipts, tallying, certification, challenge, and annulment. Committee owns +deliberation, Poll owns informal preferences, Decisions owns the resulting +institutional decision, and Encryption owns key custody. Do not claim ballot +secrecy or legal certification unless an explicit provider supplies and proves +that assurance profile. + +## Documentation Contract + +- Update manifest-driven user/admin documentation with behavior changes. +- Keep Gitea issues as the delivery source of truth. +- Keep recorded, confidential, secret, and externally certified assurance + profiles explicit and fail closed when a required provider is unavailable. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab18d35 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# GovOPlaN Voting + +`govoplan-voting` owns governed ballot definitions, frozen electorate +snapshots, ballot casting and replacement, deterministic tallying, result +certification, challenges, annulment, and provider evidence. + +The native `recorded` assurance profile is auditable but not secret: active +ballots are stored so that a result can be reconstructed. Confidential, +secret, and externally certified profiles fail closed unless an explicit +`voting.provider.` capability is installed. Providers return only +aggregate results and evidence; raw secret ballots and provider credentials +do not enter the Voting database. + +Committee and other modules retain their deliberation or business context and +refer to Voting ballots through the `voting.ballots` capability. Informal +preference and availability collection remains in `govoplan-poll`. + +See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations, +security, recovery, and integration details. diff --git a/docs/VOTING_DOMAIN.md b/docs/VOTING_DOMAIN.md new file mode 100644 index 0000000..c6f243c --- /dev/null +++ b/docs/VOTING_DOMAIN.md @@ -0,0 +1,80 @@ +# Voting domain, assurance, and operations + +## Ownership boundary + +Voting owns the protocol and authoritative state of a governed ballot: + +- immutable ballot definitions after opening +- frozen electorate entries, weights, and provenance +- eligibility enforcement, vote replacement, and receipts +- deterministic tally, quorum, threshold, closure, and certification +- challenge, annulment, command replay, and lifecycle evidence +- provider-neutral handoff for confidential, secret, or externally certified + voting + +Committee owns the body, meeting, agenda, deliberation, and minute context. It +stores a Voting ballot reference and a verified aggregate result for meeting +records. Poll owns informal preference, response, and availability collection. +Decisions owns the resulting formal institutional outcome. Encryption and +Identity Trust own cryptographic key and device primitives rather than the +voting ceremony. + +## Assurance profiles + +The native `recorded` profile stores each current vote with its elector, +selection, weight, generation, and receipt hash. Superseded votes remain +available for authorized reconstruction. It is auditable but **not secret**. + +The `confidential`, `secret`, and `external_certified` profiles require a +provider exposed as `voting.provider.`. Voting refuses to open the +ballot when that provider is unavailable. The provider keeps raw ballots and +credentials inside its own assurance boundary and returns aggregate counts, +weighted counts, a result hash, and evidence. GovOPlaN does not claim that a +provider or deployment satisfies legal or certification requirements merely +because the adapter contract is implemented. + +## Lifecycle and concurrency + +Ballots move through `draft -> open -> closed -> certified`. A closed or +certified result can be challenged. An administrator can annul an invalid +ballot from any non-annulled lifecycle state. Opening freezes SHA-256 hashes of +the definition and electorate. Each state change creates a new immutable +revision and lifecycle event. Mutating commands require an expected revision +and an idempotency key; a key replay returns the first outcome and a key reused +with another payload is rejected. + +Recorded voters can replace a vote only when the frozen definition allows it. +The previous generation is retained and excluded from the active tally. +Lifecycle events contain the receipt hash and generation, not the selection. + +## Operations and recovery + +Back up these tables as one consistency unit: + +- `voting_ballot_revisions` +- `voting_cast_records` +- `voting_lifecycle_events` +- `voting_command_replays` + +A restore is valid only when current revisions are unique per tenant and +ballot, cast generations are unique per elector, every active cast references +the current frozen definition hash, result hashes can be recomputed, and event +sequences are contiguous. Provider-backed restores also require the external +provider evidence and ballot reference to remain resolvable. Do not reopen a +restored external ballot merely to compensate for missing provider state. + +Before destructive retirement, stop new casts, retain a verified database +snapshot, export result/certification evidence under the applicable retention +policy, and reconcile any Committee or Decision references. Horizontal nodes +must use the shared database; no authoritative Voting state is stored on a +node-local filesystem. + +## Security checks + +- tenant predicates apply to every ballot, cast, event, and replay lookup +- the authenticated account must match the frozen elector subject +- native casting fails closed for non-recorded assurance profiles +- raw selections are never returned by list, detail, result, or history APIs +- provider result keys must exactly match frozen options +- external results require evidence and cannot exceed the frozen electorate +- certification and annulment use separate permissions diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b630b2b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-voting" +version = "0.1.14" +description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_voting = ["py.typed"] + +[project.entry-points."govoplan.modules"] +"voting" = "govoplan_voting.backend.manifest:get_manifest" diff --git a/src/govoplan_voting/__init__.py b/src/govoplan_voting/__init__.py new file mode 100644 index 0000000..ab65d24 --- /dev/null +++ b/src/govoplan_voting/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Voting module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_voting/backend/__init__.py b/src/govoplan_voting/backend/__init__.py new file mode 100644 index 0000000..1cf885b --- /dev/null +++ b/src/govoplan_voting/backend/__init__.py @@ -0,0 +1 @@ +"""Voting backend.""" diff --git a/src/govoplan_voting/backend/db/__init__.py b/src/govoplan_voting/backend/db/__init__.py new file mode 100644 index 0000000..d201ae2 --- /dev/null +++ b/src/govoplan_voting/backend/db/__init__.py @@ -0,0 +1,13 @@ +from govoplan_voting.backend.db.models import ( + VotingBallotRevision, + VotingCastRecord, + VotingCommandReplay, + VotingLifecycleEvent, +) + +__all__ = [ + "VotingBallotRevision", + "VotingCastRecord", + "VotingCommandReplay", + "VotingLifecycleEvent", +] diff --git a/src/govoplan_voting/backend/db/models.py b/src/govoplan_voting/backend/db/models.py new file mode 100644 index 0000000..395a110 --- /dev/null +++ b/src/govoplan_voting/backend/db/models.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import ( + DateTime, + ForeignKey, + Index, + Integer, + JSON, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class VotingBallotRevision(Base, TimestampMixin): + __tablename__ = "voting_ballot_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "ballot_id", "revision", name="uq_voting_ballot_revision" + ), + Index("ix_voting_ballot_current", "tenant_id", "ballot_id", "superseded_at"), + Index("ix_voting_ballot_catalogue", "tenant_id", "state", "recorded_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("voting_ballot_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + state: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + assurance_profile: Mapped[str] = mapped_column( + String(40), nullable=False, index=True + ) + method: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + definition_sha256: Mapped[str | None] = mapped_column( + String(64), nullable=True, index=True + ) + electorate_sha256: Mapped[str | None] = mapped_column( + String(64), nullable=True, index=True + ) + recorded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + superseded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + + +class VotingCastRecord(Base, TimestampMixin): + __tablename__ = "voting_cast_records" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "ballot_id", + "elector_id", + "generation", + name="uq_voting_cast_generation", + ), + UniqueConstraint( + "tenant_id", + "ballot_id", + "idempotency_key", + name="uq_voting_cast_idempotency", + ), + Index( + "ix_voting_cast_active", + "tenant_id", + "ballot_id", + "elector_id", + "superseded_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + elector_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + generation: Mapped[int] = mapped_column(Integer, nullable=False) + selections: Mapped[list[str]] = mapped_column(JSON, nullable=False) + weight: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + cast_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + superseded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +class VotingLifecycleEvent(Base, TimestampMixin): + __tablename__ = "voting_lifecycle_events" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "ballot_id", "sequence", name="uq_voting_event_sequence" + ), + Index("ix_voting_event_history", "tenant_id", "ballot_id", "sequence"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + event_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + recorded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict) + + +class VotingCommandReplay(Base, TimestampMixin): + __tablename__ = "voting_command_replays" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "operation", "idempotency_key", name="uq_voting_command_replay" + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + operation: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + + +__all__ = [ + "VotingBallotRevision", + "VotingCastRecord", + "VotingCommandReplay", + "VotingLifecycleEvent", +] diff --git a/src/govoplan_voting/backend/manifest.py b/src/govoplan_voting/backend/manifest.py new file mode 100644 index 0000000..d26f216 --- /dev/null +++ b/src/govoplan_voting/backend/manifest.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationLink, + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.views import ViewSurface +from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS +from govoplan_core.db.base import Base +from govoplan_voting.backend.db import models as voting_models +from govoplan_voting.backend.service import SqlVotingBallots + + +MODULE_ID = "voting" +MODULE_NAME = "Voting" +MODULE_VERSION = "0.1.14" +READ_SCOPE = "voting:ballot:read" +MANAGE_SCOPE = "voting:ballot:manage" +CAST_SCOPE = "voting:ballot:cast" +CERTIFY_SCOPE = "voting:ballot:certify" +ADMIN_SCOPE = "voting:ballot:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category=MODULE_NAME, + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +def _router(context: ModuleContext): + from govoplan_voting.backend.router import configure_registry, router + + configure_registry(context.registry) + return router + + +def _ballots(context: ModuleContext) -> SqlVotingBallots: + return SqlVotingBallots(context.registry) + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + current = session.query(voting_models.VotingBallotRevision).filter( + voting_models.VotingBallotRevision.tenant_id == tenant_id, + voting_models.VotingBallotRevision.superseded_at.is_(None), + ) + return { + "voting_ballots": current.count(), + "voting_open_ballots": current.filter( + voting_models.VotingBallotRevision.state == "open" + ).count(), + } + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + dependencies=("access",), + optional_dependencies=( + "committee", + "decisions", + "encryption", + "forms", + "identity_trust", + "policy", + "reporting", + "workflow_engine", + ), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + provides_interfaces=( + ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"), + ), + permissions=( + _permission( + READ_SCOPE, + "View ballots", + "Read ballot definitions, status, aggregate results, and history.", + ), + _permission( + MANAGE_SCOPE, + "Manage ballots", + "Create, revise, open, and close governed ballots.", + ), + _permission( + CAST_SCOPE, + "Cast votes", + "Cast or replace the current principal's eligible vote.", + ), + _permission( + CERTIFY_SCOPE, + "Certify ballots", + "Certify, challenge, and reconcile governed ballot results.", + ), + _permission( + ADMIN_SCOPE, + "Administer voting", + "Annul ballots and configure Voting assurance providers.", + ), + ), + role_templates=( + RoleTemplate( + slug="voting_officer", + name="Voting officer", + description="Manage and certify governed ballots.", + permissions=(READ_SCOPE, MANAGE_SCOPE, CAST_SCOPE, CERTIFY_SCOPE), + ), + RoleTemplate( + slug="voter", + name="Voter", + description="Read eligible ballots and cast a vote.", + permissions=(READ_SCOPE, CAST_SCOPE), + ), + RoleTemplate( + slug="voting_admin", + name="Voting administrator", + description="Administer Voting and annul invalid ballots.", + permissions=( + READ_SCOPE, + MANAGE_SCOPE, + CAST_SCOPE, + CERTIFY_SCOPE, + ADMIN_SCOPE, + ), + ), + ), + route_factory=_router, + nav_items=( + NavItem( + path="/voting", + label="Voting", + icon="vote", + required_any=(READ_SCOPE,), + order=39, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/voting-webui", + routes=( + FrontendRoute( + path="/voting", + component="VotingPage", + required_any=(READ_SCOPE,), + order=39, + ), + ), + nav_items=( + NavItem( + path="/voting", + label="Voting", + icon="vote", + required_any=(READ_SCOPE,), + order=39, + ), + ), + view_surfaces=( + ViewSurface( + id="voting.navigation", + module_id=MODULE_ID, + kind="navigation", + label="Voting navigation", + order=10, + ), + ViewSurface( + id="voting.catalogue", + module_id=MODULE_ID, + kind="route", + label="Voting ballot catalogue", + order=20, + ), + ViewSurface( + id="voting.ballot", + module_id=MODULE_ID, + kind="section", + label="Voting ballot workspace", + parent_id="voting.catalogue", + order=30, + ), + ), + ), + capability_factories={CAPABILITY_VOTING_BALLOTS: _ballots}, + capability_documentation={ + CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation( + label="Governed ballots", + summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.", + contract_version="0.1.0", + ) + }, + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + voting_models.VotingCommandReplay, + voting_models.VotingLifecycleEvent, + voting_models.VotingCastRecord, + voting_models.VotingBallotRevision, + label=MODULE_NAME, + ), + retirement_notes="Destructive retirement requires a verified snapshot and removes ballot definitions, recorded votes, receipts, results, and certification history.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + voting_models.VotingBallotRevision, + voting_models.VotingCastRecord, + voting_models.VotingLifecycleEvent, + voting_models.VotingCommandReplay, + label=MODULE_NAME, + ), + ), + tenant_summary_providers=(_tenant_summary,), + documentation=( + DocumentationTopic( + id="voting.assurance", + title="Voting assurance and certification", + summary="Operate recorded ballots and provider-backed confidential or secret voting without conflating the assurance profiles.", + body=( + "Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. " + "Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. " + "Closure, certification, challenge, and annulment remain separate auditable transitions." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner", "auditor"), + links=( + DocumentationLink( + label="Voting domain and assurance boundary", + href="govoplan-voting/docs/VOTING_DOMAIN.md", + kind="repository", + ), + ), + ), + ), + architecture=declared_module_architecture( + layer="communication_participation", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/VOTING_DOMAIN.md", + test_ref="tests/test_voting.py", + known_limits=( + "The native profile is recorded and reconstructable, not cryptographically secret.", + "Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.", + "Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.", + ), + supported_authority_modes=("native_authoritative", "external_authoritative"), + owned_concepts=( + "ballot definition", + "frozen electorate", + "vote receipt", + "tally", + "ballot certification", + "ballot challenge and annulment", + ), + non_owned_concepts=( + "committee deliberation", + "informal poll response", + "formal institutional decision", + "cryptographic key custody", + ), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/VOTING_DOMAIN.md",), + recovery_docs=("docs/VOTING_DOMAIN.md",), + security_docs=("docs/VOTING_DOMAIN.md",), + operations_docs=("docs/VOTING_DOMAIN.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_voting/backend/migrations/__init__.py b/src/govoplan_voting/backend/migrations/__init__.py new file mode 100644 index 0000000..2ad1264 --- /dev/null +++ b/src/govoplan_voting/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Voting migrations.""" diff --git a/src/govoplan_voting/backend/migrations/versions/7a8b9c0d1e2f_v0114_voting_baseline.py b/src/govoplan_voting/backend/migrations/versions/7a8b9c0d1e2f_v0114_voting_baseline.py new file mode 100644 index 0000000..19db767 --- /dev/null +++ b/src/govoplan_voting/backend/migrations/versions/7a8b9c0d1e2f_v0114_voting_baseline.py @@ -0,0 +1,196 @@ +"""v0.1.14 Voting baseline. + +Revision ID: 7a8b9c0d1e2f +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "7a8b9c0d1e2f" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "voting_ballot_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("ballot_id", sa.String(length=36), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("state", sa.String(length=30), nullable=False), + sa.Column("assurance_profile", sa.String(length=40), nullable=False), + sa.Column("method", sa.String(length=40), nullable=False), + sa.Column("definition_sha256", sa.String(length=64), nullable=True), + sa.Column("electorate_sha256", sa.String(length=64), nullable=True), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["previous_revision_id"], + ["voting_ballot_revisions.id"], + name=op.f( + "fk_voting_ballot_revisions_previous_revision_id_voting_ballot_revisions" + ), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_ballot_revisions")), + sa.UniqueConstraint( + "tenant_id", "ballot_id", "revision", name="uq_voting_ballot_revision" + ), + ) + for column in ( + "tenant_id", + "ballot_id", + "previous_revision_id", + "state", + "assurance_profile", + "method", + "definition_sha256", + "electorate_sha256", + "recorded_at", + "superseded_at", + "created_by", + ): + op.create_index( + op.f(f"ix_voting_ballot_revisions_{column}"), + "voting_ballot_revisions", + [column], + unique=False, + ) + op.create_index( + "ix_voting_ballot_current", + "voting_ballot_revisions", + ["tenant_id", "ballot_id", "superseded_at"], + unique=False, + ) + op.create_index( + "ix_voting_ballot_catalogue", + "voting_ballot_revisions", + ["tenant_id", "state", "recorded_at"], + unique=False, + ) + + op.create_table( + "voting_cast_records", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("ballot_id", sa.String(length=36), nullable=False), + sa.Column("definition_sha256", sa.String(length=64), nullable=False), + sa.Column("elector_id", sa.String(length=255), nullable=False), + sa.Column("generation", sa.Integer(), nullable=False), + sa.Column("selections", sa.JSON(), nullable=False), + sa.Column("weight", sa.Integer(), nullable=False), + sa.Column("cast_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("receipt_sha256", sa.String(length=64), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_cast_records")), + sa.UniqueConstraint( + "tenant_id", + "ballot_id", + "elector_id", + "generation", + name="uq_voting_cast_generation", + ), + sa.UniqueConstraint( + "tenant_id", + "ballot_id", + "idempotency_key", + name="uq_voting_cast_idempotency", + ), + ) + for column in ( + "tenant_id", + "ballot_id", + "elector_id", + "cast_at", + "superseded_at", + "receipt_sha256", + "actor_id", + ): + op.create_index( + op.f(f"ix_voting_cast_records_{column}"), + "voting_cast_records", + [column], + unique=False, + ) + op.create_index( + "ix_voting_cast_active", + "voting_cast_records", + ["tenant_id", "ballot_id", "elector_id", "superseded_at"], + unique=False, + ) + + op.create_table( + "voting_lifecycle_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("ballot_id", sa.String(length=36), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(length=60), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_lifecycle_events")), + sa.UniqueConstraint( + "tenant_id", "ballot_id", "sequence", name="uq_voting_event_sequence" + ), + ) + for column in ("tenant_id", "ballot_id", "event_type", "recorded_at", "actor_id"): + op.create_index( + op.f(f"ix_voting_lifecycle_events_{column}"), + "voting_lifecycle_events", + [column], + unique=False, + ) + op.create_index( + "ix_voting_event_history", + "voting_lifecycle_events", + ["tenant_id", "ballot_id", "sequence"], + unique=False, + ) + + op.create_table( + "voting_command_replays", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("operation", sa.String(length=60), nullable=False), + sa.Column("idempotency_key", sa.String(length=160), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("response", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_command_replays")), + sa.UniqueConstraint( + "tenant_id", "operation", "idempotency_key", name="uq_voting_command_replay" + ), + ) + for column in ("tenant_id", "operation"): + op.create_index( + op.f(f"ix_voting_command_replays_{column}"), + "voting_command_replays", + [column], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("voting_command_replays") + op.drop_table("voting_lifecycle_events") + op.drop_table("voting_cast_records") + op.drop_table("voting_ballot_revisions") diff --git a/src/govoplan_voting/backend/migrations/versions/__init__.py b/src/govoplan_voting/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..5da69eb --- /dev/null +++ b/src/govoplan_voting/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Voting migration revisions.""" diff --git a/src/govoplan_voting/backend/router.py b/src/govoplan_voting/backend/router.py new file mode 100644 index 0000000..a6abba1 --- /dev/null +++ b/src/govoplan_voting/backend/router.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.db.session import get_session +from govoplan_voting.backend.schemas import ( + VotingCastRequest, + VotingCertificationRequest, + VotingListResponse, + VotingReasonedTransitionRequest, + VotingTransitionRequest, + VotingWriteRequest, +) +from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError + + +router = APIRouter(prefix="/voting", tags=["voting"]) +_registry: object | None = None + + +def configure_registry(registry: object | None) -> None: + global _registry + _registry = registry + + +def _service() -> SqlVotingBallots: + return SqlVotingBallots(_registry) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + if isinstance(exc, LookupError): + return HTTPException(status_code=404, detail=message) + return HTTPException( + status_code=409 + if "conflict" in message.lower() or "idempotency" in message.lower() + else 400, + detail=message, + ) + + +@router.get("", response_model=VotingListResponse) +def api_list_ballots( + ballot_state: str | None = Query(default=None, alias="state"), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> VotingListResponse: + from govoplan_voting.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + return VotingListResponse( + ballots=list( + _service().list_ballots(session, principal, state=ballot_state, limit=limit) + ) + ) + + +@router.get("/{ballot_id}", response_model=dict[str, Any]) +def api_get_ballot( + ballot_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + item = _service().get_ballot(session, principal, ballot_id=ballot_id) + if item is None: + raise HTTPException(status_code=404, detail="Voting ballot not found") + return dict(item) + + +@router.get("/{ballot_id}/history", response_model=list[dict[str, Any]]) +def api_get_ballot_history( + ballot_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> list[dict[str, Any]]: + from govoplan_voting.backend.manifest import READ_SCOPE + + _require(principal, READ_SCOPE) + if _service().get_ballot(session, principal, ballot_id=ballot_id) is None: + raise HTTPException(status_code=404, detail="Voting ballot not found") + return [ + dict(item) + for item in _service().history(session, principal, ballot_id=ballot_id) + ] + + +@router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) +def api_create_ballot( + payload: VotingWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import MANAGE_SCOPE + + _require(principal, MANAGE_SCOPE) + try: + result = _service().create_ballot( + session, + principal, + command=payload.ballot.to_command(), + idempotency_key=payload.idempotency_key, + ) + session.commit() + item = _service().get_ballot(session, principal, ballot_id=result.id) + return dict(item or {}) + except (VotingStoreError, LookupError) as exc: + session.rollback() + raise _error(exc) from exc + + +@router.put("/{ballot_id}", response_model=dict[str, Any]) +def api_update_ballot( + ballot_id: str, + payload: VotingWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import MANAGE_SCOPE + + _require(principal, MANAGE_SCOPE) + if payload.expected_revision is None: + raise HTTPException(status_code=400, detail="expected_revision is required") + try: + result = _service().update_draft( + session, + principal, + ballot_id=ballot_id, + command=payload.ballot.to_command(), + expected_revision=payload.expected_revision, + idempotency_key=payload.idempotency_key, + ) + session.commit() + return dict( + _service().get_ballot(session, principal, ballot_id=result.id) or {} + ) + except (VotingStoreError, LookupError) as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/{ballot_id}/open", response_model=dict[str, Any]) +def api_open_ballot( + ballot_id: str, + payload: VotingTransitionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import MANAGE_SCOPE + + _require(principal, MANAGE_SCOPE) + return _transition( + session, + principal, + ballot_id=ballot_id, + callback=lambda: _service().open_ballot( + session, + principal, + ballot_id=ballot_id, + expected_revision=payload.expected_revision, + idempotency_key=payload.idempotency_key, + ), + ) + + +@router.post("/{ballot_id}/cast", response_model=dict[str, Any]) +def api_cast_ballot( + ballot_id: str, + payload: VotingCastRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import CAST_SCOPE + + _require(principal, CAST_SCOPE) + try: + result = _service().cast_ballot( + session, principal, ballot_id=ballot_id, command=payload.to_command() + ) + session.commit() + return { + "ballot_id": result.ballot_id, + "revision": result.revision, + "receipt_sha256": result.receipt_sha256, + "cast_at": result.cast_at, + "replaced_previous": result.replaced_previous, + "replayed": result.replayed, + } + except (VotingStoreError, LookupError) as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/{ballot_id}/close", response_model=dict[str, Any]) +def api_close_ballot( + ballot_id: str, + payload: VotingTransitionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import MANAGE_SCOPE + + _require(principal, MANAGE_SCOPE) + try: + result = _service().close_ballot( + session, + principal, + ballot_id=ballot_id, + expected_revision=payload.expected_revision, + idempotency_key=payload.idempotency_key, + ) + session.commit() + return { + "ballot": dict( + _service().get_ballot(session, principal, ballot_id=ballot_id) or {} + ), + "result": { + "counts": dict(result.counts), + "weighted_counts": dict(result.weighted_counts), + "cast_count": result.cast_count, + "cast_weight": result.cast_weight, + "eligible_count": result.eligible_count, + "eligible_weight": result.eligible_weight, + "quorum_met": result.quorum_met, + "threshold_met": result.threshold_met, + "winning_options": list(result.winning_options), + "result_sha256": result.result_sha256, + "evidence": list(result.evidence), + }, + } + except (VotingStoreError, LookupError) as exc: + session.rollback() + raise _error(exc) from exc + + +@router.post("/{ballot_id}/certify", response_model=dict[str, Any]) +def api_certify_ballot( + ballot_id: str, + payload: VotingCertificationRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import CERTIFY_SCOPE + + _require(principal, CERTIFY_SCOPE) + return _transition( + session, + principal, + ballot_id=ballot_id, + callback=lambda: _service().certify_ballot( + session, + principal, + ballot_id=ballot_id, + expected_revision=payload.expected_revision, + evidence=payload.evidence, + idempotency_key=payload.idempotency_key, + ), + ) + + +@router.post("/{ballot_id}/challenge", response_model=dict[str, Any]) +def api_challenge_ballot( + ballot_id: str, + payload: VotingReasonedTransitionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import CERTIFY_SCOPE + + _require(principal, CERTIFY_SCOPE) + return _transition( + session, + principal, + ballot_id=ballot_id, + callback=lambda: _service().challenge_ballot( + session, + principal, + ballot_id=ballot_id, + expected_revision=payload.expected_revision, + reason=payload.reason, + idempotency_key=payload.idempotency_key, + ), + ) + + +@router.post("/{ballot_id}/annul", response_model=dict[str, Any]) +def api_annul_ballot( + ballot_id: str, + payload: VotingReasonedTransitionRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + from govoplan_voting.backend.manifest import ADMIN_SCOPE + + _require(principal, ADMIN_SCOPE) + return _transition( + session, + principal, + ballot_id=ballot_id, + callback=lambda: _service().annul_ballot( + session, + principal, + ballot_id=ballot_id, + expected_revision=payload.expected_revision, + reason=payload.reason, + idempotency_key=payload.idempotency_key, + ), + ) + + +def _transition( + session: Session, + principal: ApiPrincipal, + *, + ballot_id: str, + callback, +) -> dict[str, Any]: + try: + callback() + session.commit() + return dict( + _service().get_ballot(session, principal, ballot_id=ballot_id) or {} + ) + except (VotingStoreError, LookupError) as exc: + session.rollback() + raise _error(exc) from exc + + +__all__ = ["configure_registry", "router"] diff --git a/src/govoplan_voting/backend/schemas.py b/src/govoplan_voting/backend/schemas.py new file mode 100644 index 0000000..7202a85 --- /dev/null +++ b/src/govoplan_voting/backend/schemas.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from govoplan_core.core.voting import ( + VotingBallotCreateCommand, + VotingCastCommand, + VotingElector, + VotingOption, +) + + +class VotingOptionInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str = Field(min_length=1, max_length=120) + label: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) + + +class VotingElectorInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + subject_id: str = Field(min_length=1, max_length=255) + label: str | None = Field(default=None, max_length=255) + weight: int = Field(default=1, ge=1, le=1_000_000) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class VotingBallotInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=10_000) + method: Literal["single_choice", "approval", "yes_no_abstain"] = "single_choice" + assurance_profile: Literal[ + "recorded", "confidential", "secret", "external_certified" + ] = "recorded" + options: list[VotingOptionInput] = Field(min_length=2, max_length=200) + electorate: list[VotingElectorInput] = Field(min_length=1, max_length=100_000) + context_module: str | None = Field(default=None, max_length=120) + context_resource_type: str | None = Field(default=None, max_length=120) + context_resource_id: str | None = Field(default=None, max_length=255) + quorum_weight: int = Field(default=0, ge=0) + threshold_numerator: int = Field(default=1, ge=1) + threshold_denominator: int = Field(default=2, ge=1) + allow_replacement: bool = True + opens_at: datetime | None = None + closes_at: datetime | None = None + provider_id: str | None = Field(default=None, max_length=80) + provider_ballot_ref: str | None = Field(default=None, max_length=255) + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_threshold(self) -> "VotingBallotInput": + if self.threshold_numerator > self.threshold_denominator: + raise ValueError("threshold_numerator cannot exceed threshold_denominator") + return self + + def to_command(self) -> VotingBallotCreateCommand: + return VotingBallotCreateCommand( + title=self.title, + description=self.description, + method=self.method, + assurance_profile=self.assurance_profile, + options=tuple( + VotingOption( + key=item.key, + label=item.label, + description=item.description, + ) + for item in self.options + ), + electorate=tuple( + VotingElector( + subject_id=item.subject_id, + label=item.label, + weight=item.weight, + provenance=item.provenance, + ) + for item in self.electorate + ), + context_module=self.context_module, + context_resource_type=self.context_resource_type, + context_resource_id=self.context_resource_id, + quorum_weight=self.quorum_weight, + threshold_numerator=self.threshold_numerator, + threshold_denominator=self.threshold_denominator, + allow_replacement=self.allow_replacement, + opens_at=self.opens_at, + closes_at=self.closes_at, + provider_id=self.provider_id, + provider_ballot_ref=self.provider_ballot_ref, + metadata=self.metadata, + ) + + +class VotingWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + ballot: VotingBallotInput + expected_revision: int | None = Field(default=None, ge=1) + idempotency_key: str = Field(min_length=1, max_length=160) + + +class VotingTransitionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + idempotency_key: str = Field(min_length=1, max_length=160) + + +class VotingCastRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + selections: list[str] = Field(min_length=1, max_length=200) + idempotency_key: str = Field(min_length=1, max_length=160) + + def to_command(self) -> VotingCastCommand: + return VotingCastCommand( + selections=tuple(self.selections), + idempotency_key=self.idempotency_key, + ) + + +class VotingCertificationRequest(VotingTransitionRequest): + evidence: list[dict[str, Any]] = Field(default_factory=list, max_length=500) + + +class VotingReasonedTransitionRequest(VotingTransitionRequest): + reason: str = Field(min_length=1, max_length=4000) + + +class VotingListResponse(BaseModel): + ballots: list[dict[str, Any]] + + +__all__ = [ + "VotingBallotInput", + "VotingCastRequest", + "VotingCertificationRequest", + "VotingListResponse", + "VotingReasonedTransitionRequest", + "VotingTransitionRequest", + "VotingWriteRequest", +] diff --git a/src/govoplan_voting/backend/service.py b/src/govoplan_voting/backend/service.py new file mode 100644 index 0000000..dff1769 --- /dev/null +++ b/src/govoplan_voting/backend/service.py @@ -0,0 +1,1261 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import asdict +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.core.voting import ( + ExternalVotingFinalizationRequest, + ExternalVotingProvider, + VOTING_ASSURANCE_RECORDED, + VotingBallotCreateCommand, + VotingBallotRef, + VotingCastCommand, + VotingReceipt, + VotingResult, + voting_provider_capability, +) +from govoplan_voting.backend.db.models import ( + VotingBallotRevision, + VotingCastRecord, + VotingCommandReplay, + VotingLifecycleEvent, +) + + +ALLOWED_ASSURANCE_PROFILES = frozenset( + {"recorded", "confidential", "secret", "external_certified"} +) +ALLOWED_METHODS = frozenset({"single_choice", "approval", "yes_no_abstain"}) +TERMINAL_STATES = frozenset({"certified", "annulled"}) + + +class VotingStoreError(ValueError): + pass + + +class SqlVotingBallots: + def __init__(self, registry: object | None = None) -> None: + self._registry = registry + + def create_ballot( + self, + session: object, + principal: object, + *, + command: VotingBallotCreateCommand, + idempotency_key: str, + ) -> VotingBallotRef: + typed_session = _session(session) + tenant_id = _principal_tenant(principal) + payload = _payload_from_command(command) + _validate_draft(payload) + request = {"command": payload} + replay = _read_replay( + typed_session, + tenant_id=tenant_id, + operation="create", + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _ref_from_mapping(replay) + ballot_id = str(uuid.uuid4()) + now = _now() + row = VotingBallotRevision( + tenant_id=tenant_id, + ballot_id=ballot_id, + revision=1, + state="draft", + assurance_profile=str(payload["assurance_profile"]), + method=str(payload["method"]), + definition_sha256=None, + electorate_sha256=None, + recorded_at=now, + payload=payload, + created_by=_principal_actor(principal), + ) + typed_session.add(row) + typed_session.flush() + _record_event( + typed_session, + row=row, + event_type="ballot.created", + principal=principal, + payload={"revision": 1}, + ) + result = _ref(row) + _write_replay( + typed_session, + tenant_id=tenant_id, + operation="create", + idempotency_key=idempotency_key, + request=request, + response=_ref_mapping(result), + ) + return result + + def update_draft( + self, + session: object, + principal: object, + *, + ballot_id: str, + command: VotingBallotCreateCommand, + expected_revision: int, + idempotency_key: str, + ) -> VotingBallotRef: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + payload = _payload_from_command(command) + _validate_draft(payload) + request = { + "ballot_id": ballot_id, + "expected_revision": expected_revision, + "command": payload, + } + operation = f"update:{ballot_id}" + replay = _read_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _ref_from_mapping(replay) + _expect(current, revision=expected_revision, states={"draft"}) + revised = _revise( + typed_session, + current=current, + principal=principal, + state="draft", + payload=payload, + event_type="ballot.updated", + event_payload={"previous_revision": current.revision}, + ) + result = _ref(revised) + _write_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + response=_ref_mapping(result), + ) + return result + + def get_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + ) -> Mapping[str, object] | None: + typed_session = _session(session) + tenant_id = _principal_tenant(principal) + row = ( + typed_session.query(VotingBallotRevision) + .filter( + VotingBallotRevision.tenant_id == tenant_id, + VotingBallotRevision.ballot_id == ballot_id, + VotingBallotRevision.superseded_at.is_(None), + ) + .one_or_none() + ) + return _ballot_mapping(row) if row is not None else None + + def list_ballots( + self, + session: object, + principal: object, + *, + state: str | None = None, + limit: int = 100, + ) -> tuple[Mapping[str, object], ...]: + typed_session = _session(session) + tenant_id = _principal_tenant(principal) + if not 1 <= limit <= 200: + raise VotingStoreError("Voting list limit must be between 1 and 200.") + query = typed_session.query(VotingBallotRevision).filter( + VotingBallotRevision.tenant_id == tenant_id, + VotingBallotRevision.superseded_at.is_(None), + ) + if state: + query = query.filter(VotingBallotRevision.state == state) + rows = ( + query.order_by(VotingBallotRevision.recorded_at.desc()).limit(limit).all() + ) + return tuple(_ballot_mapping(row) for row in rows) + + def history( + self, + session: object, + principal: object, + *, + ballot_id: str, + ) -> tuple[Mapping[str, object], ...]: + typed_session = _session(session) + tenant_id = _principal_tenant(principal) + rows = ( + typed_session.query(VotingLifecycleEvent) + .filter( + VotingLifecycleEvent.tenant_id == tenant_id, + VotingLifecycleEvent.ballot_id == ballot_id, + ) + .order_by(VotingLifecycleEvent.sequence.asc()) + .all() + ) + return tuple( + { + "sequence": row.sequence, + "event_type": row.event_type, + "recorded_at": _datetime_text(row.recorded_at), + "actor_id": row.actor_id, + "payload": dict(row.payload), + } + for row in rows + ) + + def open_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + idempotency_key: str, + ) -> VotingBallotRef: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + request = {"ballot_id": ballot_id, "expected_revision": expected_revision} + operation = f"open:{ballot_id}" + replay = _read_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _ref_from_mapping(replay) + _expect(current, revision=expected_revision, states={"draft"}) + payload = dict(current.payload) + _validate_draft(payload) + profile = str(payload["assurance_profile"]) + if profile != VOTING_ASSURANCE_RECORDED: + provider_id = str(payload.get("provider_id") or "").strip() + if ( + not provider_id + or _capability(self._registry, voting_provider_capability(provider_id)) + is None + ): + raise VotingStoreError( + "The selected Voting assurance profile requires an available external provider." + ) + definition_hash, electorate_hash = _frozen_hashes(payload) + payload["definition_sha256"] = definition_hash + payload["electorate_sha256"] = electorate_hash + revised = _revise( + typed_session, + current=current, + principal=principal, + state="open", + payload=payload, + definition_sha256=definition_hash, + electorate_sha256=electorate_hash, + event_type="ballot.opened", + event_payload={ + "revision": current.revision + 1, + "definition_sha256": definition_hash, + "electorate_sha256": electorate_hash, + }, + ) + result = _ref(revised) + _write_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + response=_ref_mapping(result), + ) + return result + + def cast_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + command: VotingCastCommand, + ) -> VotingReceipt: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + if current.state != "open": + raise VotingStoreError("Only an open Voting ballot accepts votes.") + if current.assurance_profile != VOTING_ASSURANCE_RECORDED: + raise VotingStoreError( + "Votes for this assurance profile must be cast through its external provider." + ) + _validate_window(current.payload) + actor_id = _principal_actor(principal) + elector_id = str(command.elector_id or actor_id or "").strip() + if not elector_id or not actor_id: + raise VotingStoreError("Voting requires an authenticated elector identity.") + if elector_id != actor_id: + raise VotingStoreError( + "A Voting principal cannot cast a ballot for another elector." + ) + electorate = { + str(item["subject_id"]): item + for item in list(current.payload.get("electorate") or []) + } + elector = electorate.get(elector_id) + if elector is None: + raise VotingStoreError( + "The current principal is not in the frozen electorate." + ) + selections = tuple( + str(item).strip() for item in command.selections if str(item).strip() + ) + _validate_selections(current.payload, selections) + idempotency_key = _idempotency(command.idempotency_key) + replay = ( + typed_session.query(VotingCastRecord) + .filter( + VotingCastRecord.tenant_id == current.tenant_id, + VotingCastRecord.ballot_id == ballot_id, + VotingCastRecord.idempotency_key == idempotency_key, + ) + .one_or_none() + ) + if replay is not None: + if ( + replay.elector_id != elector_id + or tuple(replay.selections) != selections + ): + raise VotingStoreError( + "Voting idempotency key was reused for another vote." + ) + return VotingReceipt( + ballot_id=ballot_id, + revision=current.revision, + receipt_sha256=replay.receipt_sha256, + cast_at=_aware(replay.cast_at), + replaced_previous=replay.generation > 1, + replayed=True, + ) + previous = ( + typed_session.query(VotingCastRecord) + .filter( + VotingCastRecord.tenant_id == current.tenant_id, + VotingCastRecord.ballot_id == ballot_id, + VotingCastRecord.elector_id == elector_id, + VotingCastRecord.superseded_at.is_(None), + ) + .with_for_update() + .one_or_none() + ) + if previous is not None and not bool(current.payload.get("allow_replacement")): + raise VotingStoreError( + "This Voting ballot does not allow replacing a cast vote." + ) + cast_at = _now() + generation = (previous.generation + 1) if previous is not None else 1 + if previous is not None: + previous.superseded_at = cast_at + receipt_hash = _sha256( + { + "tenant_id": current.tenant_id, + "ballot_id": ballot_id, + "definition_sha256": current.definition_sha256, + "elector_id": elector_id, + "generation": generation, + "selections": selections, + "cast_at": cast_at.isoformat(), + "idempotency_key": idempotency_key, + } + ) + typed_session.add( + VotingCastRecord( + tenant_id=current.tenant_id, + ballot_id=ballot_id, + definition_sha256=str(current.definition_sha256), + elector_id=elector_id, + generation=generation, + selections=list(selections), + weight=int(elector.get("weight") or 1), + cast_at=cast_at, + idempotency_key=idempotency_key, + receipt_sha256=receipt_hash, + actor_id=actor_id, + ) + ) + typed_session.flush() + _record_event( + typed_session, + row=current, + event_type="ballot.vote_cast", + principal=principal, + payload={ + "receipt_sha256": receipt_hash, + "elector_id": elector_id, + "generation": generation, + "replaced_previous": previous is not None, + }, + ) + return VotingReceipt( + ballot_id=ballot_id, + revision=current.revision, + receipt_sha256=receipt_hash, + cast_at=cast_at, + replaced_previous=previous is not None, + ) + + def close_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + idempotency_key: str, + ) -> VotingResult: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + request = {"ballot_id": ballot_id, "expected_revision": expected_revision} + operation = f"close:{ballot_id}" + replay = _read_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _result_from_mapping(replay) + _expect(current, revision=expected_revision, states={"open"}) + if current.assurance_profile == VOTING_ASSURANCE_RECORDED: + result = self._tally_recorded(typed_session, current=current) + else: + result = self._finalize_external( + typed_session, + principal, + current=current, + idempotency_key=idempotency_key, + ) + normalized = VotingResult( + ballot_id=ballot_id, + revision=current.revision + 1, + counts=result.counts, + weighted_counts=result.weighted_counts, + cast_count=result.cast_count, + cast_weight=result.cast_weight, + eligible_count=result.eligible_count, + eligible_weight=result.eligible_weight, + quorum_met=result.quorum_met, + threshold_met=result.threshold_met, + winning_options=result.winning_options, + result_sha256=result.result_sha256, + evidence=result.evidence, + ) + payload = dict(current.payload) + payload["result"] = _result_mapping(normalized) + revised = _revise( + typed_session, + current=current, + principal=principal, + state="closed", + payload=payload, + event_type="ballot.closed", + event_payload={ + "result_sha256": result.result_sha256, + "cast_count": result.cast_count, + "quorum_met": result.quorum_met, + "threshold_met": result.threshold_met, + }, + ) + if revised.revision != normalized.revision: + raise VotingStoreError( + "Voting result revision did not match the persisted ballot revision." + ) + _write_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + response=_result_mapping(normalized), + ) + return normalized + + def certify_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + evidence: Sequence[Mapping[str, object]], + idempotency_key: str, + ) -> VotingBallotRef: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + evidence_items = [dict(item) for item in evidence] + request = { + "ballot_id": ballot_id, + "expected_revision": expected_revision, + "evidence": evidence_items, + } + operation = f"certify:{ballot_id}" + replay = _read_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _ref_from_mapping(replay) + _expect(current, revision=expected_revision, states={"closed"}) + result = dict(current.payload.get("result") or {}) + if not result.get("result_sha256"): + raise VotingStoreError( + "A Voting ballot cannot be certified without a result hash." + ) + if ( + current.assurance_profile != VOTING_ASSURANCE_RECORDED + and not evidence_items + ): + raise VotingStoreError( + "External Voting certification requires provider evidence." + ) + certification = { + "certified_at": _now().isoformat(), + "certified_by": _principal_actor(principal), + "result_sha256": result["result_sha256"], + "evidence": evidence_items, + } + payload = dict(current.payload) + payload["certification"] = certification + revised = _revise( + typed_session, + current=current, + principal=principal, + state="certified", + payload=payload, + event_type="ballot.certified", + event_payload=certification, + ) + response = _ref_mapping(_ref(revised)) + _write_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + response=response, + ) + return _ref_from_mapping(response) + + def challenge_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + reason: str, + idempotency_key: str, + ) -> VotingBallotRef: + return self._reasoned_transition( + session, + principal, + ballot_id=ballot_id, + expected_revision=expected_revision, + reason=reason, + idempotency_key=idempotency_key, + target_state="challenged", + allowed_states={"closed", "certified"}, + ) + + def annul_ballot( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + reason: str, + idempotency_key: str, + ) -> VotingBallotRef: + return self._reasoned_transition( + session, + principal, + ballot_id=ballot_id, + expected_revision=expected_revision, + reason=reason, + idempotency_key=idempotency_key, + target_state="annulled", + allowed_states={"draft", "open", "closed", "certified", "challenged"}, + ) + + def _reasoned_transition( + self, + session: object, + principal: object, + *, + ballot_id: str, + expected_revision: int, + reason: str, + idempotency_key: str, + target_state: str, + allowed_states: set[str], + ) -> VotingBallotRef: + typed_session = _session(session) + current = _current(typed_session, principal, ballot_id=ballot_id, lock=True) + normalized_reason = str(reason or "").strip() + if not normalized_reason: + raise VotingStoreError(f"Voting {target_state} requires a reason.") + request = { + "ballot_id": ballot_id, + "expected_revision": expected_revision, + "reason": normalized_reason, + } + operation = f"{target_state}:{ballot_id}" + replay = _read_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + ) + if replay is not None: + return _ref_from_mapping(replay) + _expect(current, revision=expected_revision, states=allowed_states) + payload = dict(current.payload) + payload[f"{target_state}_reason"] = normalized_reason + revised = _revise( + typed_session, + current=current, + principal=principal, + state=target_state, + payload=payload, + event_type=f"ballot.{target_state}", + event_payload={"reason": normalized_reason}, + ) + response = _ref_mapping(_ref(revised)) + _write_replay( + typed_session, + tenant_id=current.tenant_id, + operation=operation, + idempotency_key=idempotency_key, + request=request, + response=response, + ) + return _ref_from_mapping(response) + + def _tally_recorded( + self, + session: Session, + *, + current: VotingBallotRevision, + ) -> VotingResult: + rows = ( + session.query(VotingCastRecord) + .filter( + VotingCastRecord.tenant_id == current.tenant_id, + VotingCastRecord.ballot_id == current.ballot_id, + VotingCastRecord.superseded_at.is_(None), + ) + .all() + ) + option_keys = [str(item["key"]) for item in current.payload["options"]] + counts = {key: 0 for key in option_keys} + weighted = {key: 0 for key in option_keys} + for row in rows: + for selection in row.selections: + counts[selection] += 1 + weighted[selection] += row.weight + eligible = list(current.payload["electorate"]) + eligible_weight = sum(int(item.get("weight") or 1) for item in eligible) + cast_weight = sum(row.weight for row in rows) + quorum_met = cast_weight >= int(current.payload.get("quorum_weight") or 0) + max_weight = max(weighted.values(), default=0) + winners = tuple( + key for key in option_keys if max_weight > 0 and weighted[key] == max_weight + ) + numerator = int(current.payload.get("threshold_numerator") or 1) + denominator = int(current.payload.get("threshold_denominator") or 2) + threshold_met = bool( + quorum_met + and winners + and max_weight * denominator >= max(cast_weight, 1) * numerator + ) + body = { + "ballot_id": current.ballot_id, + "counts": counts, + "weighted_counts": weighted, + "cast_count": len(rows), + "cast_weight": cast_weight, + "eligible_count": len(eligible), + "eligible_weight": eligible_weight, + "quorum_met": quorum_met, + "threshold_met": threshold_met, + "winning_options": winners, + "definition_sha256": current.definition_sha256, + "electorate_sha256": current.electorate_sha256, + } + return VotingResult( + ballot_id=current.ballot_id, + revision=current.revision + 1, + counts=counts, + weighted_counts=weighted, + cast_count=len(rows), + cast_weight=cast_weight, + eligible_count=len(eligible), + eligible_weight=eligible_weight, + quorum_met=quorum_met, + threshold_met=threshold_met, + winning_options=winners, + result_sha256=_sha256(body), + ) + + def _finalize_external( + self, + session: Session, + principal: object, + *, + current: VotingBallotRevision, + idempotency_key: str, + ) -> VotingResult: + provider_id = str(current.payload.get("provider_id") or "").strip() + provider_ref = str(current.payload.get("provider_ballot_ref") or "").strip() + provider = _capability(self._registry, voting_provider_capability(provider_id)) + if not isinstance(provider, ExternalVotingProvider): + raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.") + electorate = list(current.payload["electorate"]) + result = provider.finalize_ballot( + session, + principal, + request=ExternalVotingFinalizationRequest( + tenant_id=current.tenant_id, + ballot_id=current.ballot_id, + provider_ballot_ref=provider_ref, + definition_sha256=str(current.definition_sha256), + electorate_sha256=str(current.electorate_sha256), + options=tuple( + _option_from_mapping(item) for item in current.payload["options"] + ), + eligible_count=len(electorate), + eligible_weight=sum( + int(item.get("weight") or 1) for item in electorate + ), + requested_at=_now(), + idempotency_key=_idempotency(idempotency_key), + ), + ) + option_keys = {str(item["key"]) for item in current.payload["options"]} + if result.ballot_id != current.ballot_id: + raise VotingStoreError( + "Voting provider returned a result for another ballot." + ) + if ( + set(result.counts) != option_keys + or set(result.weighted_counts) != option_keys + ): + raise VotingStoreError( + "Voting provider result must cover exactly the frozen options." + ) + if sum(result.counts.values()) < result.cast_count: + raise VotingStoreError( + "Voting provider counts are inconsistent with cast_count." + ) + if result.cast_count > len(electorate): + raise VotingStoreError( + "Voting provider cast_count exceeds the frozen electorate." + ) + if not result.evidence: + raise VotingStoreError("Voting provider finalization requires evidence.") + return result + + +def _payload_from_command(command: VotingBallotCreateCommand) -> dict[str, Any]: + return { + "title": str(command.title or "").strip(), + "description": _optional_text(command.description), + "method": str(command.method or "").strip(), + "assurance_profile": str(command.assurance_profile or "").strip(), + "options": [asdict(item) for item in command.options], + "electorate": [ + { + "subject_id": item.subject_id, + "label": item.label, + "weight": item.weight, + "provenance": dict(item.provenance), + } + for item in command.electorate + ], + "context": { + "module": _optional_text(command.context_module), + "resource_type": _optional_text(command.context_resource_type), + "resource_id": _optional_text(command.context_resource_id), + }, + "quorum_weight": command.quorum_weight, + "threshold_numerator": command.threshold_numerator, + "threshold_denominator": command.threshold_denominator, + "allow_replacement": command.allow_replacement, + "opens_at": _datetime_text(command.opens_at), + "closes_at": _datetime_text(command.closes_at), + "provider_id": _optional_text(command.provider_id), + "provider_ballot_ref": _optional_text(command.provider_ballot_ref), + "metadata": dict(command.metadata), + "definition_sha256": None, + "electorate_sha256": None, + "result": None, + "certification": None, + } + + +def _validate_draft(payload: Mapping[str, Any]) -> None: + if not str(payload.get("title") or "").strip(): + raise VotingStoreError("Voting ballot title is required.") + method = str(payload.get("method") or "") + if method not in ALLOWED_METHODS: + raise VotingStoreError("Unsupported Voting method.") + assurance = str(payload.get("assurance_profile") or "") + if assurance not in ALLOWED_ASSURANCE_PROFILES: + raise VotingStoreError("Unsupported Voting assurance profile.") + options = list(payload.get("options") or []) + option_keys = [str(item.get("key") or "").strip() for item in options] + if ( + len(options) < 2 + or any(not key for key in option_keys) + or len(set(option_keys)) != len(option_keys) + ): + raise VotingStoreError( + "Voting ballots require at least two uniquely keyed options." + ) + electorate = list(payload.get("electorate") or []) + elector_ids = [str(item.get("subject_id") or "").strip() for item in electorate] + if ( + not electorate + or any(not item for item in elector_ids) + or len(set(elector_ids)) != len(elector_ids) + ): + raise VotingStoreError( + "Voting electorate entries must have unique subject ids." + ) + if any(int(item.get("weight") or 0) < 1 for item in electorate): + raise VotingStoreError("Voting electorate weights must be positive integers.") + eligible_weight = sum(int(item.get("weight") or 1) for item in electorate) + quorum = int(payload.get("quorum_weight") or 0) + if quorum < 0 or quorum > eligible_weight: + raise VotingStoreError("Voting quorum cannot exceed the electorate weight.") + numerator = int(payload.get("threshold_numerator") or 0) + denominator = int(payload.get("threshold_denominator") or 0) + if numerator < 1 or denominator < 1 or numerator > denominator: + raise VotingStoreError( + "Voting threshold must be a fraction between zero and one." + ) + opens_at = _parse_datetime(payload.get("opens_at")) + closes_at = _parse_datetime(payload.get("closes_at")) + if opens_at and closes_at and closes_at <= opens_at: + raise VotingStoreError("Voting close time must be after its open time.") + if assurance != VOTING_ASSURANCE_RECORDED: + if ( + not str(payload.get("provider_id") or "").strip() + or not str(payload.get("provider_ballot_ref") or "").strip() + ): + raise VotingStoreError( + "External Voting assurance requires provider id and ballot reference." + ) + + +def _validate_selections( + payload: Mapping[str, Any], selections: tuple[str, ...] +) -> None: + option_keys = {str(item["key"]) for item in payload["options"]} + if ( + not selections + or len(selections) != len(set(selections)) + or not set(selections) <= option_keys + ): + raise VotingStoreError( + "Voting selections must be unique configured option keys." + ) + method = str(payload["method"]) + if method in {"single_choice", "yes_no_abstain"} and len(selections) != 1: + raise VotingStoreError("This Voting method requires exactly one selection.") + + +def _validate_window(payload: Mapping[str, Any]) -> None: + now = _now() + opens_at = _parse_datetime(payload.get("opens_at")) + closes_at = _parse_datetime(payload.get("closes_at")) + if opens_at and now < opens_at: + raise VotingStoreError("Voting ballot has not opened yet.") + if closes_at and now >= closes_at: + raise VotingStoreError("Voting ballot has already reached its close time.") + + +def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]: + electorate = list(payload.get("electorate") or []) + definition = { + key: value + for key, value in payload.items() + if key + not in { + "electorate", + "electorate_sha256", + "definition_sha256", + "result", + "certification", + } + } + return _sha256(definition), _sha256(electorate) + + +def _current( + session: Session, + principal: object, + *, + ballot_id: str, + lock: bool, +) -> VotingBallotRevision: + tenant_id = _principal_tenant(principal) + query = session.query(VotingBallotRevision).filter( + VotingBallotRevision.tenant_id == tenant_id, + VotingBallotRevision.ballot_id == ballot_id, + VotingBallotRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + row = query.one_or_none() + if row is None: + raise LookupError("Voting ballot not found.") + return row + + +def _expect(row: VotingBallotRevision, *, revision: int, states: set[str]) -> None: + if row.revision != revision: + raise VotingStoreError( + "Voting revision conflict: the expected revision is stale." + ) + if row.state not in states: + raise VotingStoreError( + f"Voting transition is not allowed from state {row.state}." + ) + + +def _revise( + session: Session, + *, + current: VotingBallotRevision, + principal: object, + state: str, + payload: Mapping[str, Any], + event_type: str, + event_payload: Mapping[str, Any], + definition_sha256: str | None = None, + electorate_sha256: str | None = None, +) -> VotingBallotRevision: + now = _now() + current.superseded_at = now + row = VotingBallotRevision( + tenant_id=current.tenant_id, + ballot_id=current.ballot_id, + revision=current.revision + 1, + previous_revision_id=current.id, + state=state, + assurance_profile=str(payload["assurance_profile"]), + method=str(payload["method"]), + definition_sha256=definition_sha256 or current.definition_sha256, + electorate_sha256=electorate_sha256 or current.electorate_sha256, + recorded_at=now, + payload=dict(payload), + created_by=_principal_actor(principal), + ) + session.add(row) + session.flush() + _record_event( + session, + row=row, + event_type=event_type, + principal=principal, + payload=dict(event_payload), + ) + return row + + +def _record_event( + session: Session, + *, + row: VotingBallotRevision, + event_type: str, + principal: object, + payload: Mapping[str, Any], +) -> None: + sequence = ( + session.query(func.max(VotingLifecycleEvent.sequence)) + .filter( + VotingLifecycleEvent.tenant_id == row.tenant_id, + VotingLifecycleEvent.ballot_id == row.ballot_id, + ) + .scalar() + or 0 + ) + 1 + session.add( + VotingLifecycleEvent( + tenant_id=row.tenant_id, + ballot_id=row.ballot_id, + sequence=sequence, + event_type=event_type, + recorded_at=_now(), + actor_id=_principal_actor(principal), + payload=dict(payload), + ) + ) + session.flush() + + +def _read_replay( + session: Session, + *, + tenant_id: str, + operation: str, + idempotency_key: str, + request: Mapping[str, Any], +) -> Mapping[str, Any] | None: + key = _idempotency(idempotency_key) + row = ( + session.query(VotingCommandReplay) + .filter( + VotingCommandReplay.tenant_id == tenant_id, + VotingCommandReplay.operation == operation, + VotingCommandReplay.idempotency_key == key, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != _sha256(request): + raise VotingStoreError("Voting idempotency key was reused for another command.") + return dict(row.response) + + +def _write_replay( + session: Session, + *, + tenant_id: str, + operation: str, + idempotency_key: str, + request: Mapping[str, Any], + response: Mapping[str, Any], +) -> None: + session.add( + VotingCommandReplay( + tenant_id=tenant_id, + operation=operation, + idempotency_key=_idempotency(idempotency_key), + request_sha256=_sha256(request), + response=dict(response), + ) + ) + session.flush() + + +def _ballot_mapping(row: VotingBallotRevision) -> dict[str, object]: + return { + "id": row.ballot_id, + "revision": row.revision, + "state": row.state, + "assurance_profile": row.assurance_profile, + "method": row.method, + "definition_sha256": row.definition_sha256, + "electorate_sha256": row.electorate_sha256, + "recorded_at": _datetime_text(row.recorded_at), + **dict(row.payload), + } + + +def _ref(row: VotingBallotRevision) -> VotingBallotRef: + return VotingBallotRef( + id=row.ballot_id, + revision=row.revision, + state=row.state, + assurance_profile=row.assurance_profile, + definition_sha256=row.definition_sha256, + electorate_sha256=row.electorate_sha256, + ) + + +def _ref_mapping(value: VotingBallotRef) -> dict[str, object]: + return { + "id": value.id, + "revision": value.revision, + "state": value.state, + "assurance_profile": value.assurance_profile, + "definition_sha256": value.definition_sha256, + "electorate_sha256": value.electorate_sha256, + } + + +def _ref_from_mapping(value: Mapping[str, Any]) -> VotingBallotRef: + return VotingBallotRef( + id=str(value["id"]), + revision=int(value["revision"]), + state=str(value["state"]), + assurance_profile=str(value["assurance_profile"]), + definition_sha256=_optional_text(value.get("definition_sha256")), + electorate_sha256=_optional_text(value.get("electorate_sha256")), + ) + + +def _result_mapping(value: VotingResult) -> dict[str, object]: + return { + "ballot_id": value.ballot_id, + "revision": value.revision, + "counts": dict(value.counts), + "weighted_counts": dict(value.weighted_counts), + "cast_count": value.cast_count, + "cast_weight": value.cast_weight, + "eligible_count": value.eligible_count, + "eligible_weight": value.eligible_weight, + "quorum_met": value.quorum_met, + "threshold_met": value.threshold_met, + "winning_options": list(value.winning_options), + "result_sha256": value.result_sha256, + "evidence": [dict(item) for item in value.evidence], + } + + +def _result_from_mapping(value: Mapping[str, Any]) -> VotingResult: + return VotingResult( + ballot_id=str(value["ballot_id"]), + revision=int(value["revision"]), + counts={str(key): int(count) for key, count in dict(value["counts"]).items()}, + weighted_counts={ + str(key): int(count) + for key, count in dict(value["weighted_counts"]).items() + }, + cast_count=int(value["cast_count"]), + cast_weight=int(value["cast_weight"]), + eligible_count=int(value["eligible_count"]), + eligible_weight=int(value["eligible_weight"]), + quorum_met=bool(value["quorum_met"]), + threshold_met=bool(value["threshold_met"]), + winning_options=tuple(str(item) for item in value.get("winning_options") or ()), + result_sha256=str(value["result_sha256"]), + evidence=tuple(dict(item) for item in value.get("evidence") or ()), + ) + + +def _option_from_mapping(value: Mapping[str, Any]): + from govoplan_core.core.voting import VotingOption + + return VotingOption( + key=str(value["key"]), + label=str(value["label"]), + description=_optional_text(value.get("description")), + ) + + +def _sha256(value: object) -> str: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=_json_default, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _json_default(value: object) -> object: + if isinstance(value, datetime): + return _datetime_text(value) + if isinstance(value, tuple): + return list(value) + raise TypeError(f"Unsupported canonical Voting value: {type(value).__name__}") + + +def _idempotency(value: str) -> str: + normalized = str(value or "").strip() + if not normalized or len(normalized) > 160: + raise VotingStoreError( + "Voting idempotency key is required and limited to 160 characters." + ) + return normalized + + +def _principal_tenant(principal: object) -> str: + value = str(getattr(principal, "tenant_id", "") or "").strip() + if not value: + raise VotingStoreError("Voting operations require a tenant-bound principal.") + return value + + +def _principal_actor(principal: object) -> str | None: + for name in ("account_id", "user_id", "identity_id", "membership_id", "subject"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise VotingStoreError("Voting requires a database session.") + return value # type: ignore[return-value] + + +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 _optional_text(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _parse_datetime(value: object) -> datetime | None: + if value in (None, ""): + return None + parsed = ( + value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + ) + if parsed.tzinfo is None: + raise VotingStoreError("Voting date-times must include a timezone.") + return parsed + + +def _aware(value: datetime) -> datetime: + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + return _aware(value).isoformat() + + +def _now() -> datetime: + return datetime.now(UTC) + + +__all__ = ["SqlVotingBallots", "VotingStoreError"] diff --git a/src/govoplan_voting/py.typed b/src/govoplan_voting/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_voting/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..d080129 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_voting.backend.manifest import get_manifest + + +class VotingMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_voting_runtime_tables(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-voting-") as directory: + url = f"sqlite:///{Path(directory) / 'voting.db'}" + migrate_database( + database_url=url, + enabled_modules=("voting",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + tables = set(inspect(engine).get_table_names()) + self.assertTrue( + { + "voting_ballot_revisions", + "voting_cast_records", + "voting_lifecycle_events", + "voting_command_replays", + }.issubset(tables) + ) + with engine.connect() as connection: + self.assertIn( + "7a8b9c0d1e2f", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_voting.py b/tests/test_voting.py new file mode 100644 index 0000000..d2dcd34 --- /dev/null +++ b/tests/test_voting.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.voting import ( + VotingBallotCreateCommand, + VotingCastCommand, + VotingElector, + VotingOption, +) +from govoplan_core.db.base import Base +from govoplan_voting.backend.db.models import VotingCastRecord +from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError + + +@dataclass +class Principal: + tenant_id: str + account_id: str + + +def command(*, assurance: str = "recorded", provider_id: str | None = None): + return VotingBallotCreateCommand( + title="Budget choice", + method="single_choice", + assurance_profile=assurance, + options=(VotingOption("a", "Option A"), VotingOption("b", "Option B")), + electorate=( + VotingElector("alice", "Alice", 2, {"source": "mandate:1"}), + VotingElector("bob", "Bob", 1, {"source": "mandate:2"}), + ), + quorum_weight=2, + threshold_numerator=1, + threshold_denominator=2, + provider_id=provider_id, + provider_ballot_ref="provider-ballot-1" if provider_id else None, + ) + + +class VotingTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.Session = sessionmaker(bind=self.engine) + self.service = SqlVotingBallots() + self.manager = Principal("tenant-1", "manager") + + def tearDown(self) -> None: + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def create_open(self, session): + created = self.service.create_ballot( + session, + self.manager, + command=command(), + idempotency_key="create-1", + ) + opened = self.service.open_ballot( + session, + self.manager, + ballot_id=created.id, + expected_revision=created.revision, + idempotency_key="open-1", + ) + return opened + + def test_recorded_ballot_freezes_replaces_tallies_and_certifies(self) -> None: + with self.Session() as session: + opened = self.create_open(session) + detail = self.service.get_ballot(session, self.manager, ballot_id=opened.id) + self.assertEqual(64, len(str(detail["definition_sha256"]))) + self.assertEqual(64, len(str(detail["electorate_sha256"]))) + + first = self.service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand(("a",), "cast-alice-1"), + ) + replay = self.service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand(("a",), "cast-alice-1"), + ) + self.assertEqual(first.receipt_sha256, replay.receipt_sha256) + self.assertTrue(replay.replayed) + + replaced = self.service.cast_ballot( + session, + Principal("tenant-1", "alice"), + ballot_id=opened.id, + command=VotingCastCommand(("b",), "cast-alice-2"), + ) + self.assertTrue(replaced.replaced_previous) + self.service.cast_ballot( + session, + Principal("tenant-1", "bob"), + ballot_id=opened.id, + command=VotingCastCommand(("b",), "cast-bob-1"), + ) + result = self.service.close_ballot( + session, + self.manager, + ballot_id=opened.id, + expected_revision=opened.revision, + idempotency_key="close-1", + ) + self.assertEqual({"a": 0, "b": 2}, dict(result.counts)) + self.assertEqual({"a": 0, "b": 3}, dict(result.weighted_counts)) + self.assertTrue(result.quorum_met) + self.assertTrue(result.threshold_met) + certified = self.service.certify_ballot( + session, + self.manager, + ballot_id=opened.id, + expected_revision=result.revision, + evidence=(), + idempotency_key="certify-1", + ) + self.assertEqual("certified", certified.state) + events = self.service.history(session, self.manager, ballot_id=opened.id) + self.assertEqual( + [ + "ballot.created", + "ballot.opened", + "ballot.vote_cast", + "ballot.vote_cast", + "ballot.vote_cast", + "ballot.closed", + "ballot.certified", + ], + [item["event_type"] for item in events], + ) + active = ( + session.query(VotingCastRecord) + .filter( + VotingCastRecord.ballot_id == opened.id, + VotingCastRecord.superseded_at.is_(None), + ) + .count() + ) + self.assertEqual(2, active) + + def test_eligibility_tenant_and_revision_are_enforced(self) -> None: + with self.Session() as session: + opened = self.create_open(session) + with self.assertRaisesRegex(VotingStoreError, "frozen electorate"): + self.service.cast_ballot( + session, + Principal("tenant-1", "mallory"), + ballot_id=opened.id, + command=VotingCastCommand(("a",), "cast-mallory"), + ) + self.assertIsNone( + self.service.get_ballot( + session, + Principal("tenant-2", "manager"), + ballot_id=opened.id, + ) + ) + with self.assertRaisesRegex(VotingStoreError, "revision conflict"): + self.service.close_ballot( + session, + self.manager, + ballot_id=opened.id, + expected_revision=1, + idempotency_key="close-stale", + ) + + def test_secret_profile_fails_closed_without_provider(self) -> None: + with self.Session() as session: + created = self.service.create_ballot( + session, + self.manager, + command=command(assurance="secret", provider_id="certified"), + idempotency_key="create-secret", + ) + with self.assertRaisesRegex( + VotingStoreError, "requires an available external provider" + ): + self.service.open_ballot( + session, + self.manager, + ballot_id=created.id, + expected_revision=created.revision, + idempotency_key="open-secret", + ) + + def test_idempotency_key_cannot_change_command(self) -> None: + with self.Session() as session: + self.service.create_ballot( + session, + self.manager, + command=command(), + idempotency_key="create-replay", + ) + changed = replace(command(), title="Different") + with self.assertRaisesRegex(VotingStoreError, "idempotency key"): + self.service.create_ballot( + session, + self.manager, + command=changed, + idempotency_key="create-replay", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..613706d --- /dev/null +++ b/webui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@govoplan/voting-webui", + "version": "0.1.14", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/voting.css": "./src/styles/voting.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/voting.ts b/webui/src/api/voting.ts new file mode 100644 index 0000000..829ccba --- /dev/null +++ b/webui/src/api/voting.ts @@ -0,0 +1,101 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type VotingOption = { key: string; label: string; description?: string | null }; +export type VotingElector = { subject_id: string; label?: string | null; weight: number; provenance: Record }; +export type VotingBallot = { + id: string; + revision: number; + state: "draft" | "open" | "closed" | "certified" | "challenged" | "annulled"; + title: string; + description?: string | null; + method: "single_choice" | "approval" | "yes_no_abstain"; + assurance_profile: "recorded" | "confidential" | "secret" | "external_certified"; + options: VotingOption[]; + electorate: VotingElector[]; + context: { module?: string | null; resource_type?: string | null; resource_id?: string | null }; + quorum_weight: number; + threshold_numerator: number; + threshold_denominator: number; + allow_replacement: boolean; + opens_at?: string | null; + closes_at?: string | null; + provider_id?: string | null; + provider_ballot_ref?: string | null; + definition_sha256?: string | null; + electorate_sha256?: string | null; + result?: VotingResult | null; + certification?: Record | null; + metadata: Record; +}; +export type VotingResult = { + counts: Record; + weighted_counts: Record; + cast_count: number; + cast_weight: number; + eligible_count: number; + eligible_weight: number; + quorum_met: boolean; + threshold_met: boolean; + winning_options: string[]; + result_sha256: string; + evidence: Array>; +}; +export type VotingBallotDraft = Omit & { + context_module?: string | null; + context_resource_type?: string | null; + context_resource_id?: string | null; +}; +export type VotingEvent = { sequence: number; event_type: string; recorded_at: string; actor_id?: string | null; payload: Record }; + +export function listVotingBallots(settings: ApiSettings, state?: string, signal?: AbortSignal): Promise<{ ballots: VotingBallot[] }> { + return apiFetch(settings, apiPath("/api/v1/voting", { state, limit: 200 }), { signal }); +} + +export function getVotingBallot(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, { signal }); +} + +export function listVotingHistory(settings: ApiSettings, ballotId: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/history`, { signal }); +} + +export function createVotingBallot(settings: ApiSettings, ballot: VotingBallotDraft): Promise { + return apiFetch(settings, "/api/v1/voting", { + method: "POST", + body: JSON.stringify({ ballot, idempotency_key: crypto.randomUUID() }) + }); +} + +export function updateVotingBallot(settings: ApiSettings, ballotId: string, revision: number, ballot: VotingBallotDraft): Promise { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}`, { + method: "PUT", + body: JSON.stringify({ ballot, expected_revision: revision, idempotency_key: crypto.randomUUID() }) + }); +} + +export function transitionVotingBallot( + settings: ApiSettings, + ballot: VotingBallot, + action: "open" | "close" | "certify", + extra: Record = {} +): Promise { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, { + method: "POST", + body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), ...extra }) + }); +} + +export function reasonedVotingTransition(settings: ApiSettings, ballot: VotingBallot, action: "challenge" | "annul", reason: string): Promise { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballot.id)}/${action}`, { + method: "POST", + body: JSON.stringify({ expected_revision: ballot.revision, idempotency_key: crypto.randomUUID(), reason }) + }); +} + +export function castVotingBallot(settings: ApiSettings, ballotId: string, selections: string[]): Promise<{ receipt_sha256: string; replaced_previous: boolean; replayed: boolean }> { + return apiFetch(settings, `/api/v1/voting/${encodeURIComponent(ballotId)}/cast`, { + method: "POST", + body: JSON.stringify({ selections, idempotency_key: crypto.randomUUID() }) + }); +} diff --git a/webui/src/features/voting/VotingBallotDialog.tsx b/webui/src/features/voting/VotingBallotDialog.tsx new file mode 100644 index 0000000..f2364bb --- /dev/null +++ b/webui/src/features/voting/VotingBallotDialog.tsx @@ -0,0 +1,198 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + DismissibleAlert, + FormField, + IconButton, + ToggleSwitch, + type ApiSettings +} from "@govoplan/core-webui"; +import { + createVotingBallot, + updateVotingBallot, + type VotingBallot, + type VotingBallotDraft +} from "../../api/voting"; + + +export default function VotingBallotDialog({ + open, + settings, + ballot, + currentAccountId, + onClose, + onSaved +}: { + open: boolean; + settings: ApiSettings; + ballot: VotingBallot | null; + currentAccountId: string; + onClose: () => void; + onSaved: (ballot: VotingBallot) => void; +}) { + const [draft, setDraft] = useState(() => initialDraft(currentAccountId, ballot)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + setDraft(initialDraft(currentAccountId, ballot)); + setBusy(false); + setError(""); + }, [ballot, currentAccountId, open]); + + const valid = useMemo(() => Boolean( + draft.title.trim() + && draft.options.length >= 2 + && draft.options.every((item) => item.key.trim() && item.label.trim()) + && draft.electorate.length > 0 + && draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0) + && (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim())) + ), [draft]); + + async function save() { + if (!valid) return; + setBusy(true); + setError(""); + const payload: VotingBallotDraft = { + ...draft, + title: draft.title.trim(), + description: draft.description?.trim() || null, + options: draft.options.map((item) => ({ ...item, key: item.key.trim(), label: item.label.trim(), description: item.description?.trim() || null })), + electorate: draft.electorate.map((item) => ({ ...item, subject_id: item.subject_id.trim(), label: item.label?.trim() || null })), + provider_id: draft.assurance_profile === "recorded" ? null : draft.provider_id?.trim() || null, + provider_ballot_ref: draft.assurance_profile === "recorded" ? null : draft.provider_ballot_ref?.trim() || null + }; + try { + const saved = ballot + ? await updateVotingBallot(settings, ballot.id, ballot.revision, payload) + : await createVotingBallot(settings, payload); + onSaved(saved); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The ballot could not be saved."); + } finally { + setBusy(false); + } + } + + return ( + + + + + }> +
+ {error && {error}} +
+ setDraft({ ...draft, title: event.target.value })} /> + + + +