From 758b59ca03c9f881f6aad1607a6b88667d5d0c09 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:48:25 +0200 Subject: [PATCH] feat: implement committee decision workspace --- AGENTS.md | 6 + README.md | 40 +- docs/COMMITTEE_DOMAIN_BOUNDARY.md | 66 +- pyproject.toml | 2 +- src/govoplan_committee/backend/ballots.py | 281 +++++ src/govoplan_committee/backend/db/__init__.py | 13 + src/govoplan_committee/backend/db/models.py | 132 +++ .../backend/decision_path.py | 333 ++++++ src/govoplan_committee/backend/manifest.py | 312 +++++- .../backend/migrations/__init__.py | 1 + .../backend/migrations/versions/__init__.py | 1 + .../d8b9f0a1c2e3_v018_committee_workspace.py | 99 ++ src/govoplan_committee/backend/router.py | 229 ++++ src/govoplan_committee/backend/schemas.py | 45 + src/govoplan_committee/backend/workspace.py | 980 ++++++++++++++++++ tests/test_decision_path.py | 240 +++++ tests/test_manifest.py | 27 +- tests/test_migrations.py | 42 + tests/test_workspace.py | 597 +++++++++++ webui/package.json | 28 + webui/src/api/committee.ts | 115 ++ .../committee/CommitteeBallotDialog.tsx | 100 ++ .../src/features/committee/CommitteePage.tsx | 401 +++++++ .../committee/CommitteeRecordDialog.tsx | 431 ++++++++ webui/src/features/committee/lifecycle.ts | 60 ++ webui/src/index.ts | 2 + webui/src/module.ts | 32 + webui/src/styles/committee.css | 326 ++++++ 28 files changed, 4909 insertions(+), 32 deletions(-) create mode 100644 src/govoplan_committee/backend/ballots.py create mode 100644 src/govoplan_committee/backend/db/__init__.py create mode 100644 src/govoplan_committee/backend/db/models.py create mode 100644 src/govoplan_committee/backend/decision_path.py create mode 100644 src/govoplan_committee/backend/migrations/__init__.py create mode 100644 src/govoplan_committee/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_committee/backend/migrations/versions/d8b9f0a1c2e3_v018_committee_workspace.py create mode 100644 src/govoplan_committee/backend/router.py create mode 100644 src/govoplan_committee/backend/schemas.py create mode 100644 src/govoplan_committee/backend/workspace.py create mode 100644 tests/test_decision_path.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_workspace.py create mode 100644 webui/package.json create mode 100644 webui/src/api/committee.ts create mode 100644 webui/src/features/committee/CommitteeBallotDialog.tsx create mode 100644 webui/src/features/committee/CommitteePage.tsx create mode 100644 webui/src/features/committee/CommitteeRecordDialog.tsx create mode 100644 webui/src/features/committee/lifecycle.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/committee.css diff --git a/AGENTS.md b/AGENTS.md index 2581b67..31607a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # GovOPlaN Committee Codex Guide +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Committee internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + ## Scope This repository owns the GovOPlaN Committee platform module seed. diff --git a/README.md b/README.md index 32e55be..0e9cbe8 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,34 @@ **Repository type:** module (domain). -`govoplan-committee` is the GovOPlaN platform module seed for committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks. +`govoplan-committee` is the GovOPlaN module for committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks. -This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation. +The backend now exposes versioned `committee.workspace` and +`committee.decision_path` capabilities. The workspace persists immutable, +OCC-guarded revisions for bodies, meetings, agenda items, governed vote +results, and minutes, with replay-safe lifecycle events. The decision path +combines that committee context with an effective Mandate, +including organization, function, and jurisdiction coverage, with approval, +legal bases, evidence, reasoning, and effects to create the shared +formal Decision contract. Mandate resolution and Decision persistence remain +optional providers. If Decisions is absent, Committee retains a bounded, +protected local Decision projection so the outcome remains reconstructable. +The `/committee` WebUI provides the body, meeting, agenda, vote, and minutes +workspace using the same immutable revisions and lifecycle guards as the API. + +`committee.ballot_finalizer` delegates provider-bound external or secret +ballots to `committee.ballot_adapter.` capabilities. Committee +validates the exact choices, eligible/cast totals, tenant evidence, receipt, +and result hash, then stores only the verified aggregate. Individual ballots +never enter Committee persistence, and a provider-bound vote cannot be closed +through the generic workspace endpoint. ## Initial Ownership - committee bodies - meeting agendas - minutes -- decision records +- deliberation/vote context and formal decision references - votes - follow-up assignments @@ -24,6 +42,8 @@ This module does not own: - generic task execution - document storage - calendar event storage +- the generic formal decision lifecycle; a future Decisions provider owns + authority, facts/rules, reasoning, effects, review, correction, and revocation Detailed boundary notes are in [docs/COMMITTEE_DOMAIN_BOUNDARY.md](docs/COMMITTEE_DOMAIN_BOUNDARY.md). @@ -54,6 +74,20 @@ cd /mnt/DATA/git/govoplan-committee PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests ``` +## API And Recovery + +`/api/v1/committee/workspace/{body|meeting|agenda_item|vote|minute}` supports +bounded list/read/write and immutable history. Writes require an idempotency +key and expected revision after creation. Protected local Decision projections +use a separate read permission. Database restore is the semantic recovery unit; +linked Calendar, Files, Records, Tasks, Approvals, and Decisions objects retain +their own recovery responsibility. + +`POST /api/v1/committee/workspace/vote/{vote_id}/finalize-provider` is the +separate effect boundary for an installed ballot adapter. It requires the +`committee:ballot:finalize` permission and preserves provider receipt/hash and +evidence without exposing or persisting individual votes. + ## Gitea Workflow Issue templates are installed under `.gitea/`, and the shared label taxonomy is copied to `docs/gitea-labels.json` with the module label `module/committee`. diff --git a/docs/COMMITTEE_DOMAIN_BOUNDARY.md b/docs/COMMITTEE_DOMAIN_BOUNDARY.md index 4686ec7..3479372 100644 --- a/docs/COMMITTEE_DOMAIN_BOUNDARY.md +++ b/docs/COMMITTEE_DOMAIN_BOUNDARY.md @@ -9,7 +9,7 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes, - committee bodies - meeting agendas - minutes -- decision records +- deliberation and vote context plus references to formal decision records - votes - follow-up assignments @@ -18,6 +18,9 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes, - generic task execution - document storage - calendar event storage +- generic approval gates +- the cross-domain formal decision lifecycle, including authority, facts, + applicable rules, reasoning, effects, review, correction, and revocation ## Integration Candidates @@ -28,19 +31,66 @@ Committee, board, council, and senate workflows for meetings, agendas, minutes, - workflow - approvals -## Seed State +## Current Persistent Backend Slice -The current repository state is intentionally small: +The current repository state is intentionally bounded: - module manifest and entry point - tenant-level permission definitions - manager and viewer role templates -- documentation topic describing the module boundary +- documentation topic and architecture/evidence declaration +- `committee.workspace` and `committee.decision_path` interfaces and capabilities +- tenant-scoped body, meeting, agenda-item, vote-result, and minute persistence +- immutable revisions, OCC, replay-safe lifecycle events, migrations, uninstall + guards, API routes, and tenant summary counts +- a governed assembler for one formal committee outcome +- a protected local Decision projection when the optional Decisions provider is + absent +- a three-pane `/committee` workspace for bodies, meetings, agendas, governed + vote results, and minutes +- a provider-neutral ballot-finalization contract for external and secret + ballots that retains aggregate evidence rather than individual ballots - Gitea issue workflow templates -- manifest contract test +- manifest and decision reconstruction contract tests -No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear. +The decision path accepts or resolves one effective +Mandate covering the deciding unit, function, and jurisdiction; requires +approval, fact evidence, versioned legal bases, operative +result, and reasoning, and emits the shared formal Decision contract. If a +Decision registry is installed it records there; otherwise the result is +retained in the Committee-owned fallback projection and is available only +through the protected-read permission. -## First Implementation Slice +The workspace records the result of a governed vote rather than becoming a +general remote-balloting system. Local closure requires unique choices, +eligible/cast counts, matching result counts, an explicit quorum result, an +Approval reference, and evidence. Decided agenda items require a formal +Decision reference, and meetings cannot close while agenda items remain +unfinished. Accepted or corrected minutes require a Records reference, +Approval, and evidence. -Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references. +A provider-bound vote is finalized only through +`committee.ballot_adapter.`. The adapter receives tenant, vote, +choices, eligible count, external ballot reference, request time, and +idempotency key. Its result must cover exactly the configured choices, sum to +the cast count, stay within eligibility, carry same-tenant evidence, and supply +a lowercase SHA-256 result digest plus provider receipt. Committee persists +that aggregate and does not persist voter choices or provider credentials. + +Database restore is the module's semantic recovery unit. Calendar events, +documents, records, tasks, approvals, and externally conducted votes remain +recoverable through their owning providers and are linked by stable references. + +## Decision Reconstruction Proof + +`tests/test_decision_path.py` proves effective-time authority, organization, +function, and jurisdiction coverage, approval, legal basis/evidence versions, requested effects, +information governance, responsible actor/automation assurance, and a protected +reconstruction payload. A vote remains +an approval reference and is not made indistinguishable from the formal +institutional outcome. + +`tests/test_workspace.py` proves parent and lifecycle constraints, immutable +history, replay and stale-write rejection, tenant isolation, committed-only +events, vote/quorum evidence, adapter-only provider closure, aggregate-only +secret-ballot persistence, minutes, and the local Decision projection. diff --git a/pyproject.toml b/pyproject.toml index 4770cfa..4fb379d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-committee" version = "0.1.8" -description = "GovOPlaN Committee platform module seed." +description = "GovOPlaN committee governance and formal-decision integration module." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } diff --git a/src/govoplan_committee/backend/ballots.py b/src/govoplan_committee/backend/ballots.py new file mode 100644 index 0000000..aeb45cd --- /dev/null +++ b/src/govoplan_committee/backend/ballots.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, replace +from datetime import datetime +import re +from typing import Protocol, runtime_checkable + +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + EvidenceReference, + InstitutionalReference, +) +from govoplan_committee.backend.workspace import ( + CommitteeWorkspaceError, + CommitteeWorkspaceRecord, + get_workspace_object, + record_workspace_object, +) + + +CAPABILITY_COMMITTEE_BALLOT_FINALIZER = "committee.ballot_finalizer" +_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,79}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def ballot_adapter_capability(provider_id: str) -> str: + value = str(provider_id or "").strip() + if not _PROVIDER_RE.fullmatch(value): + raise CommitteeWorkspaceError("Committee ballot provider id is invalid.") + return f"committee.ballot_adapter.{value}" + + +@dataclass(frozen=True, slots=True) +class BallotFinalizationRequest: + tenant_id: str + vote_id: str + provider_id: str + provider_ballot_ref: str + choices: tuple[str, ...] + eligible_count: int + requested_at: datetime + idempotency_key: str + + def __post_init__(self) -> None: + ballot_adapter_capability(self.provider_id) + if not self.tenant_id.strip() or not self.vote_id.strip(): + raise CommitteeWorkspaceError( + "Committee ballot tenant and vote identifiers are required." + ) + if not self.provider_ballot_ref.strip(): + raise CommitteeWorkspaceError( + "Committee provider ballot reference is required." + ) + if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)): + raise CommitteeWorkspaceError( + "Committee ballot choices must be unique." + ) + if self.eligible_count < 0: + raise CommitteeWorkspaceError( + "Committee ballot eligible count cannot be negative." + ) + if self.requested_at.tzinfo is None: + raise CommitteeWorkspaceError( + "Committee ballot requested_at must include a timezone." + ) + if not self.idempotency_key.strip(): + raise CommitteeWorkspaceError( + "Committee ballot idempotency key is required." + ) + + +@dataclass(frozen=True, slots=True) +class BallotFinalizationResult: + provider_id: str + provider_ballot_ref: str + counts: Mapping[str, int] + cast_count: int + quorum_met: bool + receipt_ref: str + result_sha256: str + evidence: tuple[EvidenceReference, ...] + + def __post_init__(self) -> None: + ballot_adapter_capability(self.provider_id) + if not self.provider_ballot_ref.strip() or not self.receipt_ref.strip(): + raise CommitteeWorkspaceError( + "Committee ballot provider and receipt references are required." + ) + if not _SHA256_RE.fullmatch(self.result_sha256): + raise CommitteeWorkspaceError( + "Committee ballot result hash must be a lowercase SHA-256 digest." + ) + if self.cast_count < 0 or any( + not isinstance(value, int) or value < 0 for value in self.counts.values() + ): + raise CommitteeWorkspaceError( + "Committee ballot counts cannot be negative." + ) + if not self.evidence: + raise CommitteeWorkspaceError( + "Committee ballot finalization requires provider evidence." + ) + + +@runtime_checkable +class CommitteeBallotAdapter(Protocol): + def finalize_ballot( + self, + session: object, + principal: object, + *, + request: BallotFinalizationRequest, + ) -> BallotFinalizationResult: ... + + +class CommitteeBallotFinalizer: + def __init__(self, registry: object | None = None) -> None: + self._registry = registry + + def finalize( + self, + session: Session, + principal: object, + *, + vote_id: str, + provider_id: str, + provider_ballot_ref: str, + approval_ref: InstitutionalReference, + expected_revision: int, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + ) -> CommitteeWorkspaceRecord: + current = get_workspace_object( + session, + principal, + object_kind="vote", + object_id=vote_id, + ) + if current is None: + raise LookupError("Committee vote not found.") + if current.state != "open": + raise CommitteeWorkspaceError( + "Only an open Committee vote can be finalized by a ballot provider." + ) + if current.revision != expected_revision: + raise CommitteeWorkspaceError( + "Committee revision conflict: the expected revision is stale." + ) + configured_provider = str(current.attributes.get("provider_id") or "").strip() + if configured_provider and configured_provider != provider_id: + raise CommitteeWorkspaceError( + "Committee vote is bound to another ballot provider." + ) + choices = tuple(str(item) for item in current.attributes.get("choices", ())) + eligible_count = int(current.attributes.get("eligible_count") or 0) + request = BallotFinalizationRequest( + tenant_id=current.tenant_id, + vote_id=current.object_id, + provider_id=provider_id, + provider_ballot_ref=provider_ballot_ref, + choices=choices, + eligible_count=eligible_count, + requested_at=recorded_at, + idempotency_key=idempotency_key, + ) + adapter = _capability( + self._registry, + ballot_adapter_capability(provider_id), + ) + if not isinstance(adapter, CommitteeBallotAdapter): + raise CommitteeWorkspaceError( + f"Committee ballot provider is unavailable: {provider_id}." + ) + result = adapter.finalize_ballot( + session, + principal, + request=request, + ) + _validate_result(result, request=request) + counts = {choice: int(result.counts.get(choice, 0)) for choice in choices} + next_record = replace( + current, + revision=current.revision + 1, + state="closed", + recorded_at=recorded_at, + change_reason=change_reason, + attributes={ + **dict(current.attributes), + "provider_id": provider_id, + "provider_ballot_ref": provider_ballot_ref, + "provider_receipt_ref": result.receipt_ref, + "provider_result_sha256": result.result_sha256, + "counts": counts, + "cast_count": result.cast_count, + "quorum_met": result.quorum_met, + "approval_ref": approval_ref.to_dict(), + }, + evidence=_merge_evidence(current.evidence, result.evidence), + ) + return record_workspace_object( + session, + principal, + record=next_record, + expected_revision=expected_revision, + idempotency_key=idempotency_key, + _provider_finalization=True, + ) + + +def _validate_result( + result: BallotFinalizationResult, + *, + request: BallotFinalizationRequest, +) -> None: + if ( + result.provider_id != request.provider_id + or result.provider_ballot_ref != request.provider_ballot_ref + ): + raise CommitteeWorkspaceError( + "Committee ballot provider returned a result for another ballot." + ) + if set(result.counts) != set(request.choices): + raise CommitteeWorkspaceError( + "Committee ballot provider result must cover exactly the configured choices." + ) + if sum(result.counts.values()) != result.cast_count: + raise CommitteeWorkspaceError( + "Committee ballot provider counts do not match cast_count." + ) + if result.cast_count > request.eligible_count: + raise CommitteeWorkspaceError( + "Committee ballot provider cast_count exceeds eligible_count." + ) + if any(item.tenant_id != request.tenant_id for item in result.evidence): + raise CommitteeWorkspaceError( + "Committee ballot provider evidence cannot cross tenants." + ) + + +def _capability(registry: object | None, name: str) -> object | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +def _merge_evidence( + existing: tuple[EvidenceReference, ...], + incoming: tuple[EvidenceReference, ...], +) -> tuple[EvidenceReference, ...]: + merged: list[EvidenceReference] = [] + seen: set[tuple[str, str, str, str, str]] = set() + for item in (*existing, *incoming): + key = ( + item.kind, + item.owner_module, + item.evidence_id, + item.tenant_id, + item.version, + ) + if key in seen: + continue + seen.add(key) + merged.append(item) + return tuple(merged) + + +__all__ = [ + "CAPABILITY_COMMITTEE_BALLOT_FINALIZER", + "BallotFinalizationRequest", + "BallotFinalizationResult", + "CommitteeBallotAdapter", + "CommitteeBallotFinalizer", + "ballot_adapter_capability", +] diff --git a/src/govoplan_committee/backend/db/__init__.py b/src/govoplan_committee/backend/db/__init__.py new file mode 100644 index 0000000..59350ad --- /dev/null +++ b/src/govoplan_committee/backend/db/__init__.py @@ -0,0 +1,13 @@ +"""Committee database models.""" + +from govoplan_committee.backend.db.models import ( + CommitteeDecisionProjection, + CommitteeWorkspaceEvent, + CommitteeWorkspaceRevision, +) + +__all__ = [ + "CommitteeDecisionProjection", + "CommitteeWorkspaceEvent", + "CommitteeWorkspaceRevision", +] diff --git a/src/govoplan_committee/backend/db/models.py b/src/govoplan_committee/backend/db/models.py new file mode 100644 index 0000000..e831ac1 --- /dev/null +++ b/src/govoplan_committee/backend/db/models.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, 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 CommitteeWorkspaceRevision(Base, TimestampMixin): + __tablename__ = "committee_workspace_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + "revision", + name="uq_committee_workspace_revision", + ), + Index( + "ix_committee_workspace_current", + "tenant_id", + "object_kind", + "object_id", + "superseded_at", + ), + Index( + "ix_committee_workspace_parent", + "tenant_id", + "parent_kind", + "parent_id", + "object_kind", + "state", + ), + ) + + 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) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("committee_workspace_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + parent_kind: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + parent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + state: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + 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) + changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +class CommitteeWorkspaceEvent(Base, TimestampMixin): + __tablename__ = "committee_workspace_events" + __table_args__ = ( + UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"), + UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"), + Index( + "ix_committee_workspace_event_object", + "tenant_id", + "object_kind", + "object_id", + "occurred_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) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + object_revision: Mapped[int] = mapped_column(Integer, nullable=False) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + occurred_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) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class CommitteeDecisionProjection(Base, TimestampMixin): + __tablename__ = "committee_decision_projections" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "decision_id", + "revision", + name="uq_committee_decision_projection_revision", + ), + Index( + "ix_committee_decision_projection_current", + "tenant_id", + "decision_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) + decision_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[str] = mapped_column(String(120), nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("committee_decision_projections.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + meeting_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + agenda_item_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + state: Mapped[str] = mapped_column(String(30), nullable=False, 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) + changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +__all__ = [ + "CommitteeDecisionProjection", + "CommitteeWorkspaceEvent", + "CommitteeWorkspaceRevision", +] diff --git a/src/govoplan_committee/backend/decision_path.py b/src/govoplan_committee/backend/decision_path.py new file mode 100644 index 0000000..5a838ac --- /dev/null +++ b/src/govoplan_committee/backend/decision_path.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, + ActorRepresentationReference, + DecisionAssuranceLevel, + DecisionEffectReference, + DecisionRegistry, + EvidenceReference, + FormalDecision, + GovernedContextEnvelope, + InformationGovernanceReference, + InstitutionalContextError, + InstitutionalReference, + LegalBasisReference, + MandateDefinition, + MandateResolution, + MandateResolutionRequest, + MandateResolver, + TemporalRevision, + resolve_mandate_candidates, +) +from govoplan_committee.backend.workspace import CAPABILITY_COMMITTEE_WORKSPACE + + +CAPABILITY_COMMITTEE_DECISION_PATH = "committee.decision_path" + + +@dataclass(frozen=True, slots=True) +class CommitteeDecisionProposal: + tenant_id: str + decision_id: str + revision: str + effective_at: datetime + meeting_ref: str + agenda_item_ref: str + decision_type: str + subject_refs: tuple[InstitutionalReference, ...] + organization_unit_ref: InstitutionalReference + function_ref: InstitutionalReference + actor: ActorRepresentationReference + approval_refs: tuple[InstitutionalReference, ...] + fact_evidence: tuple[EvidenceReference, ...] + legal_bases: tuple[LegalBasisReference, ...] + operative_result: str + reasoning: str + case_ref: InstitutionalReference | None = None + jurisdiction_refs: tuple[InstitutionalReference, ...] = () + party_refs: tuple[InstitutionalReference, ...] = () + record_refs: tuple[InstitutionalReference, ...] = () + conditions: tuple[str, ...] = () + requested_effects: tuple[DecisionEffectReference, ...] = () + remedy_refs: tuple[str, ...] = () + review_refs: tuple[str, ...] = () + information_governance: InformationGovernanceReference | None = None + assurance_level: DecisionAssuranceLevel = "human" + automation_preparation_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.effective_at.tzinfo is None or self.effective_at.utcoffset() is None: + raise InstitutionalContextError( + "Committee decision effective_at must include a timezone." + ) + if not self.meeting_ref.strip() or not self.agenda_item_ref.strip(): + raise InstitutionalContextError( + "Committee meeting and agenda item references are required." + ) + if not self.subject_refs: + raise InstitutionalContextError( + "Committee decisions require at least one subject." + ) + if not self.approval_refs: + raise InstitutionalContextError( + "Committee decisions require an accepted approval reference." + ) + if not self.fact_evidence or not self.legal_bases: + raise InstitutionalContextError( + "Committee decisions require fact evidence and legal basis versions." + ) + if not self.operative_result.strip() or not self.reasoning.strip(): + raise InstitutionalContextError( + "Committee decisions require an operative result and reasoning." + ) + references = ( + *self.subject_refs, + self.organization_unit_ref, + self.function_ref, + *self.approval_refs, + self.case_ref, + *self.jurisdiction_refs, + *self.party_refs, + *self.record_refs, + ) + if any( + item is not None and item.tenant_id != self.tenant_id + for item in references + ): + raise InstitutionalContextError( + "Committee decision references cannot cross tenants." + ) + if self.organization_unit_ref.kind != "organization_unit": + raise InstitutionalContextError( + "Committee decision organization reference must identify an organization unit." + ) + if self.function_ref.kind != "function": + raise InstitutionalContextError( + "Committee decision function reference must identify a function." + ) + if any(item.kind != "approval" for item in self.approval_refs): + raise InstitutionalContextError( + "Committee decision approval references must have approval kind." + ) + if any(item.kind != "jurisdiction" for item in self.jurisdiction_refs): + raise InstitutionalContextError( + "Committee decision jurisdiction references must have jurisdiction kind." + ) + + +@dataclass(frozen=True, slots=True) +class CommitteeDecisionPathResult: + decision: FormalDecision + mandate: MandateDefinition + persisted_by_decision_registry: bool + persisted_by_committee_projection: bool = False + + def reconstruction_payload(self) -> dict[str, object]: + return { + "decision": self.decision.to_dict(include_protected=True), + "mandate_ref": self.mandate.reference.to_dict(), + "mandate_revision": self.mandate.temporal.to_dict(), + "persisted_by_decision_registry": self.persisted_by_decision_registry, + "persisted_by_committee_projection": self.persisted_by_committee_projection, + } + + +class CommitteeDecisionPath: + """Build one reconstructable formal outcome without owning Decision storage.""" + + def __init__(self, registry: object | None = None) -> None: + self._registry = registry + + def decide( + self, + session: object, + principal: object, + *, + proposal: CommitteeDecisionProposal, + mandate_resolution: MandateResolution | None = None, + observed_effects: tuple[DecisionEffectReference, ...] = (), + expected_revision: str | None = None, + ) -> CommitteeDecisionPathResult: + resolution = mandate_resolution or self._resolve_mandate( + session, + principal, + proposal=proposal, + ) + mandate = _accepted_mandate(proposal, resolution) + decision_registry = _capability( + self._registry, + CAPABILITY_DECISION_REGISTRY, + ) + decision_ref = InstitutionalReference( + kind="decision", + owner_module=( + "decisions" + if isinstance(decision_registry, DecisionRegistry) + else "committee" + ), + object_id=proposal.decision_id, + tenant_id=proposal.tenant_id, + version=proposal.revision, + valid_at=proposal.effective_at, + ) + temporal = TemporalRevision( + revision=proposal.revision, + valid_from=proposal.effective_at, + recorded_at=proposal.effective_at, + change_reason="Committee decision accepted", + ) + evidence = tuple( + dict.fromkeys((*proposal.fact_evidence, *mandate.evidence, *resolution.evidence)) + ) + legal_bases = tuple( + dict.fromkeys((*proposal.legal_bases, *mandate.legal_bases)) + ) + authority_context = GovernedContextEnvelope( + tenant_id=proposal.tenant_id, + temporal=temporal, + actor=proposal.actor, + organization_unit_ref=proposal.organization_unit_ref, + function_ref=proposal.function_ref, + mandate_ref=mandate.reference, + jurisdiction_refs=proposal.jurisdiction_refs, + case_ref=proposal.case_ref, + party_refs=proposal.party_refs, + approval_refs=proposal.approval_refs, + decision_ref=decision_ref, + record_refs=proposal.record_refs, + legal_bases=legal_bases, + evidence=evidence, + information_governance=proposal.information_governance, + ) + decision = FormalDecision( + reference=decision_ref, + temporal=temporal, + decision_type=proposal.decision_type, + subject_refs=proposal.subject_refs, + state="decided", + authority_context=authority_context, + fact_evidence=evidence, + legal_bases=legal_bases, + assurance_level=proposal.assurance_level, + automation_preparation_refs=proposal.automation_preparation_refs, + operative_result=proposal.operative_result, + reasoning=proposal.reasoning, + conditions=proposal.conditions, + requested_effects=proposal.requested_effects, + observed_effects=observed_effects, + publication_refs=( + f"committee-meeting:{proposal.meeting_ref}", + f"committee-agenda-item:{proposal.agenda_item_ref}", + ), + remedy_refs=proposal.remedy_refs, + review_refs=proposal.review_refs, + ) + persisted = False + projected = False + if isinstance(decision_registry, DecisionRegistry): + decision = decision_registry.record_decision( + session, + principal, + decision=decision, + expected_revision=expected_revision, + ) + persisted = True + else: + workspace = _capability(self._registry, CAPABILITY_COMMITTEE_WORKSPACE) + if workspace is not None and hasattr(workspace, "record_local_decision"): + decision = workspace.record_local_decision( + session, + principal, + decision=decision, + meeting_id=proposal.meeting_ref, + agenda_item_id=proposal.agenda_item_ref, + expected_revision=expected_revision, + ) + projected = True + return CommitteeDecisionPathResult( + decision=decision, + mandate=mandate, + persisted_by_decision_registry=persisted, + persisted_by_committee_projection=projected, + ) + + def _resolve_mandate( + self, + session: object, + principal: object, + *, + proposal: CommitteeDecisionProposal, + ) -> MandateResolution: + resolver = _capability(self._registry, CAPABILITY_MANDATE_RESOLVER) + if not isinstance(resolver, MandateResolver): + raise InstitutionalContextError( + "Committee decision requires a Mandate resolver or an explicit governed resolution." + ) + return resolver.resolve_mandate( + session, + principal, + request=_mandate_request(proposal), + ) + + +def _accepted_mandate( + proposal: CommitteeDecisionProposal, + resolution: MandateResolution, +) -> MandateDefinition: + if not resolution.competent: + raise InstitutionalContextError( + resolution.explanation or "The acting function is not competent to decide." + ) + if resolution.conflict_refs: + raise InstitutionalContextError( + "Mandate resolution has unresolved conflicts: " + + ", ".join(resolution.conflict_refs) + ) + verified = resolve_mandate_candidates( + _mandate_request(proposal), + resolution.mandates, + ) + if not verified.competent: + raise InstitutionalContextError( + verified.explanation + or "Committee decision requires exactly one effective active mandate." + ) + return verified.mandates[0] + + +def _mandate_request( + proposal: CommitteeDecisionProposal, +) -> MandateResolutionRequest: + return MandateResolutionRequest( + tenant_id=proposal.tenant_id, + effective_at=proposal.effective_at, + task_type="committee.formal_decision", + authority_type=proposal.decision_type, + organization_unit_ref=proposal.organization_unit_ref, + function_ref=proposal.function_ref, + jurisdiction_refs=proposal.jurisdiction_refs, + ) + + +def _capability(registry: object | None, name: str) -> object | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +__all__ = [ + "CAPABILITY_COMMITTEE_DECISION_PATH", + "CommitteeDecisionPath", + "CommitteeDecisionPathResult", + "CommitteeDecisionProposal", +] diff --git a/src/govoplan_committee/backend/manifest.py b/src/govoplan_committee/backend/manifest.py index 81cb8be..aada86c 100644 --- a/src/govoplan_committee/backend/manifest.py +++ b/src/govoplan_committee/backend/manifest.py @@ -1,23 +1,149 @@ from __future__ import annotations -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate +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, + ModuleInterfaceRequirement, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.views import ViewSurface +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, +) +from govoplan_core.core.provider_governance import ( + ModuleArchitectureDeclaration, + ModuleArchitectureDocumentation, + ModuleMaturityEvidence, +) +from govoplan_committee.backend.decision_path import ( + CAPABILITY_COMMITTEE_DECISION_PATH, + CommitteeDecisionPath, +) +from govoplan_committee.backend.ballots import ( + CAPABILITY_COMMITTEE_BALLOT_FINALIZER, + CommitteeBallotFinalizer, +) +from govoplan_committee.backend.db import models as committee_models +from govoplan_committee.backend.workspace import ( + CAPABILITY_COMMITTEE_WORKSPACE, + SqlCommitteeWorkspace, +) +from govoplan_core.db.base import Base MODULE_ID = "committee" MODULE_NAME = "Committee" MODULE_VERSION = "0.1.8" READ_SCOPE = "committee:workspace:read" WRITE_SCOPE = "committee:workspace:write" +BALLOT_SCOPE = "committee:ballot:finalize" ADMIN_SCOPE = "committee:workspace:admin" +PROTECTED_READ_SCOPE = "committee:decision:protected_read" OPTIONAL_DEPENDENCIES = ( "calendar", "docs", "files", + "mandates", + "decisions", "tasks", "workflow_engine", "approvals", ) +ARCHITECTURE = ModuleArchitectureDeclaration( + layer="communication_participation", + kind="domain", + maturity="vertical_slice", + evidence=( + ModuleMaturityEvidence( + kind="test", + reference="tests/test_decision_path.py", + summary="Proves effective mandate, approval, evidence, effect, and reconstruction semantics for a committee decision.", + ), + ModuleMaturityEvidence( + kind="documentation", + reference="docs/COMMITTEE_DOMAIN_BOUNDARY.md", + summary="Defines the Committee/Decision/Mandate ownership boundary.", + ), + ), + known_limits=( + "The Committee WebUI covers the governed body, meeting, agenda, vote, and minute workspace; domain-specific deliberation panels can extend this surface.", + "External and secret ballots are delegated to provider adapters. Committee stores only verified aggregates, provider receipts, hashes, and evidence rather than individual ballots.", + "Formal Decision persistence and Mandate resolution remain optional provider capabilities; the local projection is a bounded fallback.", + ), + owned_concepts=( + "committee bodies", + "meeting and agenda context", + "deliberation and vote context", + ), + non_owned_concepts=( + "formal decision lifecycle", + "mandate and jurisdiction lifecycle", + "generic approvals", + ), + reference_packages=("product.service-to-decision",), + documentation=ModuleArchitectureDocumentation( + security=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",), + operations=("docs/COMMITTEE_DOMAIN_BOUNDARY.md",), + ), +) + + +def _decision_path(context: ModuleContext) -> CommitteeDecisionPath: + return CommitteeDecisionPath(context.registry) + + +def _workspace(context: ModuleContext) -> SqlCommitteeWorkspace: + del context + return SqlCommitteeWorkspace() + + +def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer: + return CommitteeBallotFinalizer(context.registry) + + +def _router(context: ModuleContext): + from govoplan_committee.backend.router import configure_registry, router + + configure_registry(context.registry) + return router + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + current = session.query(committee_models.CommitteeWorkspaceRevision).filter( + committee_models.CommitteeWorkspaceRevision.tenant_id == tenant_id, + committee_models.CommitteeWorkspaceRevision.superseded_at.is_(None), + ) + return { + "committee_bodies": current.filter( + committee_models.CommitteeWorkspaceRevision.object_kind == "body", + committee_models.CommitteeWorkspaceRevision.state == "active", + ).count(), + "committee_meetings": current.filter( + committee_models.CommitteeWorkspaceRevision.object_kind == "meeting", + committee_models.CommitteeWorkspaceRevision.state.in_(("scheduled", "open")), + ).count(), + } + def _permission(scope: str, label: str, description: str) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) @@ -34,9 +160,31 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio PERMISSIONS = ( - _permission(READ_SCOPE, "View committee workspace", "Read committee records, configuration, and workflow context."), - _permission(WRITE_SCOPE, "Manage committee workspace", "Create and update committee records and workflow state."), - _permission(ADMIN_SCOPE, "Administer committee workspace", "Configure committee policies, templates, and tenant-level administration."), + _permission( + READ_SCOPE, + "View committee workspace", + "Read committee records, configuration, and workflow context.", + ), + _permission( + WRITE_SCOPE, + "Manage committee workspace", + "Create and update committee records and workflow state.", + ), + _permission( + ADMIN_SCOPE, + "Administer committee workspace", + "Configure committee policies, templates, and tenant-level administration.", + ), + _permission( + BALLOT_SCOPE, + "Finalize provider ballots", + "Import a verifiable aggregate result from an installed external or secret ballot provider.", + ), + _permission( + PROTECTED_READ_SCOPE, + "Read protected committee decisions", + "Read reasoning and protected institutional context from a Committee-owned fallback Decision projection.", + ), ) ROLE_TEMPLATES = ( @@ -44,7 +192,7 @@ ROLE_TEMPLATES = ( slug="committee_manager", name="Committee manager", description="Manage committee records and workflow state.", - permissions=(READ_SCOPE, WRITE_SCOPE), + permissions=(READ_SCOPE, WRITE_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE), ), RoleTemplate( slug="committee_viewer", @@ -58,15 +206,20 @@ DOCUMENTATION = ( DocumentationTopic( id=f"{MODULE_ID}.module-boundary", title=f"{MODULE_NAME} module boundary", - summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, decisions, voting, and follow-up tasks.", + summary="Committee, board, council, and senate workflows for meetings, agendas, minutes, deliberation, voting, formal decision references, and follow-up tasks.", body=( - "This repository is currently a platform module seed. It registers the domain boundary, " - "permission surface, role templates, and documentation metadata before runtime APIs, " - "database models, migrations, and WebUI routes are introduced." + "The persistent workspace keeps immutable bodies, meetings, agenda items, governed " + "vote results, minutes, and lifecycle events. The decision path constructs a " + "reconstructable formal Decision from that context, an effective Mandate resolution, " + "approval, versioned legal bases, evidence, reasoning, and requested or observed " + "effects. Committee does not own generic Mandate or Decision persistence; optional " + "providers resolve and record those objects when installed, while a protected local " + "projection preserves the bounded fallback. Provider-bound ballots are finalized " + "through adapter capabilities without retaining individual ballots." ), layer="available", - documentation_types=("admin",), - audience=("operator", "module_admin", "product_owner"), + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner"), order=100, related_modules=OPTIONAL_DEPENDENCIES, links=( @@ -78,8 +231,19 @@ DOCUMENTATION = ( ), metadata={ "seed": True, - "domain_objects": ['committee bodies', 'meeting agendas', 'minutes', 'decision records', 'votes', 'follow-up assignments'], - "first_slice": "Define committee body, meeting, agenda item, decision, vote, minute, and follow-up task references.", + "domain_objects": [ + "committee bodies", + "meeting agendas", + "minutes", + "deliberation and vote context", + "formal decision references", + "votes", + "follow-up assignments", + ], + "first_slice": "Persist committee body, meeting, agenda item, governed vote result, minute, and formal Decision references.", + "does_not_own": [ + "generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle" + ], }, ), ) @@ -90,10 +254,128 @@ manifest = ModuleManifest( version=MODULE_VERSION, dependencies=("access",), optional_dependencies=OPTIONAL_DEPENDENCIES, - required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + optional_capabilities=( + CAPABILITY_MANDATE_RESOLVER, + CAPABILITY_DECISION_REGISTRY, + ), + route_factory=_router, + nav_items=( + NavItem( + path="/committee", + label="Committee", + icon="gavel", + required_any=(READ_SCOPE,), + order=38, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/committee-webui", + routes=( + FrontendRoute( + path="/committee", + component="CommitteePage", + required_any=(READ_SCOPE,), + order=38, + ), + ), + nav_items=( + NavItem( + path="/committee", + label="Committee", + icon="gavel", + required_any=(READ_SCOPE,), + order=38, + ), + ), + view_surfaces=( + ViewSurface( + id="committee.navigation", + module_id=MODULE_ID, + kind="navigation", + label="Committee navigation", + order=10, + ), + ViewSurface( + id="committee.workspace", + module_id=MODULE_ID, + kind="route", + label="Committee workspace", + order=20, + ), + ), + ), + provides_interfaces=( + ModuleInterfaceProvider( + name="committee.decision_path", + version="0.1.0", + ), + ModuleInterfaceProvider( + name="committee.workspace", + version="0.1.0", + ), + ModuleInterfaceProvider( + name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER, + version="0.1.0", + ), + ), + requires_interfaces=( + ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="decisions.reconstruction", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ), + capability_factories={ + CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path, + CAPABILITY_COMMITTEE_WORKSPACE: _workspace, + CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer, + }, + capability_documentation={ + CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation( + label="Committee decision path", + summary="Builds a formal, evidence-backed Decision from governed committee context.", + contract_version="0.1.0", + ), + CAPABILITY_COMMITTEE_WORKSPACE: CapabilityDocumentation( + label="Committee workspace", + summary="Persists versioned bodies, meetings, agenda items, vote results, minutes, and fallback Decision projections.", + contract_version="0.1.0", + ), + CAPABILITY_COMMITTEE_BALLOT_FINALIZER: CapabilityDocumentation( + label="Committee ballot finalizer", + summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.", + 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( + committee_models.CommitteeDecisionProjection, + committee_models.CommitteeWorkspaceEvent, + committee_models.CommitteeWorkspaceRevision, + label="Committee", + ), + retirement_notes="Destructive retirement requires a database snapshot and removes Committee workspace revisions, lifecycle events, and local Decision projections.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + committee_models.CommitteeWorkspaceRevision, + committee_models.CommitteeWorkspaceEvent, + committee_models.CommitteeDecisionProjection, + label="Committee", + ), + ), + tenant_summary_providers=(_tenant_summary,), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, documentation=DOCUMENTATION, + architecture=ARCHITECTURE, ) diff --git a/src/govoplan_committee/backend/migrations/__init__.py b/src/govoplan_committee/backend/migrations/__init__.py new file mode 100644 index 0000000..75405af --- /dev/null +++ b/src/govoplan_committee/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Committee Alembic revisions.""" diff --git a/src/govoplan_committee/backend/migrations/versions/__init__.py b/src/govoplan_committee/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..d9250a0 --- /dev/null +++ b/src/govoplan_committee/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Committee migration revisions.""" diff --git a/src/govoplan_committee/backend/migrations/versions/d8b9f0a1c2e3_v018_committee_workspace.py b/src/govoplan_committee/backend/migrations/versions/d8b9f0a1c2e3_v018_committee_workspace.py new file mode 100644 index 0000000..9b9dc6e --- /dev/null +++ b/src/govoplan_committee/backend/migrations/versions/d8b9f0a1c2e3_v018_committee_workspace.py @@ -0,0 +1,99 @@ +"""v0.1.8 Committee workspace baseline. + +Revision ID: d8b9f0a1c2e3 +Revises: None +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d8b9f0a1c2e3" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "committee_workspace_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("parent_kind", sa.String(length=30), nullable=True), + sa.Column("parent_id", sa.String(length=255), nullable=True), + sa.Column("state", sa.String(length=30), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("search_text", sa.Text(), nullable=False), + 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("changed_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"], ["committee_workspace_revisions.id"], name=op.f("fk_committee_workspace_revisions_previous_revision_id_committee_workspace_revisions"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_workspace_revisions")), + sa.UniqueConstraint("tenant_id", "object_kind", "object_id", "revision", name="uq_committee_workspace_revision"), + ) + for column in ("tenant_id", "object_kind", "object_id", "previous_revision_id", "parent_kind", "parent_id", "state", "recorded_at", "superseded_at", "changed_by"): + op.create_index(op.f(f"ix_committee_workspace_revisions_{column}"), "committee_workspace_revisions", [column], unique=False) + op.create_index("ix_committee_workspace_current", "committee_workspace_revisions", ["tenant_id", "object_kind", "object_id", "superseded_at"], unique=False) + op.create_index("ix_committee_workspace_parent", "committee_workspace_revisions", ["tenant_id", "parent_kind", "parent_id", "object_kind", "state"], unique=False) + + op.create_table( + "committee_workspace_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("object_revision", sa.Integer(), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("event_type", sa.String(length=120), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + 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_committee_workspace_events")), + sa.UniqueConstraint("tenant_id", "event_id", name="uq_committee_workspace_event"), + sa.UniqueConstraint("tenant_id", "idempotency_key", name="uq_committee_workspace_idempotency"), + ) + for column in ("tenant_id", "object_kind", "object_id", "event_id", "event_type", "occurred_at", "actor_id"): + op.create_index(op.f(f"ix_committee_workspace_events_{column}"), "committee_workspace_events", [column], unique=False) + op.create_index("ix_committee_workspace_event_object", "committee_workspace_events", ["tenant_id", "object_kind", "object_id", "occurred_at"], unique=False) + + op.create_table( + "committee_decision_projections", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("decision_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.String(length=120), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("meeting_id", sa.String(length=255), nullable=False), + sa.Column("agenda_item_id", sa.String(length=255), nullable=False), + sa.Column("state", sa.String(length=30), nullable=False), + 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("changed_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"], ["committee_decision_projections.id"], name=op.f("fk_committee_decision_projections_previous_revision_id_committee_decision_projections"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_committee_decision_projections")), + sa.UniqueConstraint("tenant_id", "decision_id", "revision", name="uq_committee_decision_projection_revision"), + ) + for column in ("tenant_id", "decision_id", "previous_revision_id", "meeting_id", "agenda_item_id", "state", "recorded_at", "superseded_at", "changed_by"): + op.create_index(op.f(f"ix_committee_decision_projections_{column}"), "committee_decision_projections", [column], unique=False) + op.create_index("ix_committee_decision_projection_current", "committee_decision_projections", ["tenant_id", "decision_id", "superseded_at"], unique=False) + + +def downgrade() -> None: + op.drop_table("committee_decision_projections") + op.drop_table("committee_workspace_events") + op.drop_table("committee_workspace_revisions") diff --git a/src/govoplan_committee/backend/router.py b/src/govoplan_committee/backend/router.py new file mode 100644 index 0000000..0db31bb --- /dev/null +++ b/src/govoplan_committee/backend/router.py @@ -0,0 +1,229 @@ +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.core.institutional import ( + InstitutionalContextError, + InstitutionalReference, +) +from govoplan_core.db.session import get_session +from govoplan_committee.backend.ballots import CommitteeBallotFinalizer +from govoplan_committee.backend.manifest import ( + BALLOT_SCOPE, + PROTECTED_READ_SCOPE, + READ_SCOPE, + WRITE_SCOPE, +) +from govoplan_committee.backend.schemas import ( + CommitteeBallotFinalizeRequest, + CommitteeWorkspaceHistoryResponse, + CommitteeWorkspaceListResponse, + CommitteeWorkspaceWriteRequest, +) +from govoplan_committee.backend.workspace import ( + CommitteeWorkspaceError, + CommitteeWorkspaceRecord, + get_local_decision, + get_workspace_object, + list_workspace_objects, + record_workspace_object, + workspace_history, +) + + +router = APIRouter(prefix="/committee", tags=["committee"]) +_registry: object | None = None + + +def configure_registry(registry: object | None) -> None: + global _registry + _registry = 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) + lowered = message.casefold() + code = 409 if any(word in lowered for word in ("conflict", "stale", "already")) else 400 + return HTTPException(status_code=code, detail=message) + + +@router.get("/decision-projections/{decision_id}", response_model=dict[str, Any]) +def api_get_local_decision( + decision_id: str, + revision: str | None = Query(default=None, max_length=120), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, PROTECTED_READ_SCOPE) + item = get_local_decision( + session, + principal, + decision_id=decision_id, + revision=revision, + ) + if item is None: + raise HTTPException(status_code=404, detail="Committee Decision projection not found") + return item.to_dict(include_protected=True) + + +@router.get("/workspace/{object_kind}", response_model=CommitteeWorkspaceListResponse) +def api_list_workspace( + object_kind: str, + parent_id: str | None = Query(default=None, max_length=255), + state: list[str] | None = Query(default=None), + query: str = "", + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> CommitteeWorkspaceListResponse: + _require(principal, READ_SCOPE) + try: + records, total = list_workspace_objects( + session, + principal, + object_kind=object_kind, + parent_id=parent_id, + states=state, + query=query, + offset=offset, + limit=limit, + ) + except CommitteeWorkspaceError as exc: + raise _error(exc) from exc + return CommitteeWorkspaceListResponse( + records=[item.to_dict() for item in records], + total=total, + offset=offset, + limit=limit, + ) + + +@router.post( + "/workspace/{object_kind}", + response_model=dict[str, Any], + status_code=status.HTTP_201_CREATED, +) +def api_record_workspace( + object_kind: str, + payload: CommitteeWorkspaceWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + raw = dict(payload.record) + if str(raw.get("object_kind") or "") != object_kind: + raise HTTPException( + status_code=400, + detail="Committee object kind in path and payload must match.", + ) + try: + item = record_workspace_object( + session, + principal, + record=CommitteeWorkspaceRecord.from_mapping(raw), + idempotency_key=payload.idempotency_key, + expected_revision=payload.expected_revision, + ) + session.commit() + except (CommitteeWorkspaceError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + +@router.post( + "/workspace/vote/{vote_id}/finalize-provider", + response_model=dict[str, Any], +) +def api_finalize_provider_vote( + vote_id: str, + payload: CommitteeBallotFinalizeRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, BALLOT_SCOPE) + try: + item = CommitteeBallotFinalizer(_registry).finalize( + session, + principal, + vote_id=vote_id, + provider_id=payload.provider_id, + provider_ballot_ref=payload.provider_ballot_ref, + approval_ref=InstitutionalReference.from_mapping(payload.approval_ref), + expected_revision=payload.expected_revision, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + ) + session.commit() + except LookupError as exc: + session.rollback() + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (CommitteeWorkspaceError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + +@router.get("/workspace/{object_kind}/{object_id}", response_model=dict[str, Any]) +def api_get_workspace( + object_kind: str, + object_id: str, + revision: int | None = Query(default=None, ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, READ_SCOPE) + try: + item = get_workspace_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + revision=revision, + ) + except CommitteeWorkspaceError as exc: + raise _error(exc) from exc + if item is None: + raise HTTPException(status_code=404, detail="Committee object not found") + return item.to_dict() + + +@router.get( + "/workspace/{object_kind}/{object_id}/history", + response_model=CommitteeWorkspaceHistoryResponse, +) +def api_workspace_history( + object_kind: str, + object_id: str, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> CommitteeWorkspaceHistoryResponse: + _require(principal, READ_SCOPE) + try: + revisions = workspace_history( + session, + principal, + object_kind=object_kind, + object_id=object_id, + limit=limit, + ) + except CommitteeWorkspaceError as exc: + raise _error(exc) from exc + return CommitteeWorkspaceHistoryResponse( + revisions=[item.to_dict() for item in revisions] + ) + + +__all__ = ["configure_registry", "router"] diff --git a/src/govoplan_committee/backend/schemas.py b/src/govoplan_committee/backend/schemas.py new file mode 100644 index 0000000..89d4cac --- /dev/null +++ b/src/govoplan_committee/backend/schemas.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class CommitteeWorkspaceWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + record: dict[str, Any] + idempotency_key: str = Field(min_length=1, max_length=255) + expected_revision: int | None = Field(default=None, ge=1) + + +class CommitteeWorkspaceListResponse(BaseModel): + records: list[dict[str, Any]] + total: int + offset: int + limit: int + + +class CommitteeWorkspaceHistoryResponse(BaseModel): + revisions: list[dict[str, Any]] + + +class CommitteeBallotFinalizeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider_id: str = Field(min_length=1, max_length=80) + provider_ballot_ref: str = Field(min_length=1, max_length=255) + approval_ref: dict[str, Any] + expected_revision: int = Field(ge=1) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1_000) + idempotency_key: str = Field(min_length=1, max_length=255) + + +__all__ = [ + "CommitteeWorkspaceHistoryResponse", + "CommitteeWorkspaceListResponse", + "CommitteeWorkspaceWriteRequest", + "CommitteeBallotFinalizeRequest", +] diff --git a/src/govoplan_committee/backend/workspace.py b/src/govoplan_committee/backend/workspace.py new file mode 100644 index 0000000..a1157aa --- /dev/null +++ b/src/govoplan_committee/backend/workspace.py @@ -0,0 +1,980 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime +import hashlib +import json +from typing import Any, Literal +import uuid + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.core.institutional import ( + EvidenceReference, + FormalDecision, + GovernedContextEnvelope, + InstitutionalContextError, + InstitutionalReference, +) +from govoplan_committee.backend.db.models import ( + CommitteeDecisionProjection, + CommitteeWorkspaceEvent, + CommitteeWorkspaceRevision, +) + + +CAPABILITY_COMMITTEE_WORKSPACE = "committee.workspace" +CommitteeObjectKind = Literal["body", "meeting", "agenda_item", "vote", "minute"] + +_PARENT_KIND: dict[str, str | None] = { + "body": None, + "meeting": "body", + "agenda_item": "meeting", + "vote": "agenda_item", + "minute": "meeting", +} +_STATES: dict[str, frozenset[str]] = { + "body": frozenset({"draft", "active", "suspended", "retired"}), + "meeting": frozenset({"draft", "scheduled", "open", "closed", "cancelled"}), + "agenda_item": frozenset({"draft", "scheduled", "deliberating", "decided", "withdrawn"}), + "vote": frozenset({"draft", "open", "closed", "cancelled"}), + "minute": frozenset({"draft", "proposed", "accepted", "corrected"}), +} +_TRANSITIONS: dict[str, dict[str, frozenset[str]]] = { + "body": { + "draft": frozenset({"draft", "active", "retired"}), + "active": frozenset({"active", "suspended", "retired"}), + "suspended": frozenset({"active", "suspended", "retired"}), + "retired": frozenset(), + }, + "meeting": { + "draft": frozenset({"draft", "scheduled", "cancelled"}), + "scheduled": frozenset({"scheduled", "open", "cancelled"}), + "open": frozenset({"open", "closed", "cancelled"}), + "closed": frozenset(), + "cancelled": frozenset(), + }, + "agenda_item": { + "draft": frozenset({"draft", "scheduled", "withdrawn"}), + "scheduled": frozenset({"scheduled", "deliberating", "withdrawn"}), + "deliberating": frozenset({"deliberating", "decided", "withdrawn"}), + "decided": frozenset(), + "withdrawn": frozenset(), + }, + "vote": { + "draft": frozenset({"draft", "open", "cancelled"}), + "open": frozenset({"open", "closed", "cancelled"}), + "closed": frozenset(), + "cancelled": frozenset(), + }, + "minute": { + "draft": frozenset({"draft", "proposed"}), + "proposed": frozenset({"proposed", "accepted"}), + "accepted": frozenset({"corrected"}), + "corrected": frozenset({"corrected"}), + }, +} + + +class CommitteeWorkspaceError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class CommitteeWorkspaceRecord: + tenant_id: str + object_kind: CommitteeObjectKind + object_id: str + revision: int + state: str + title: str + recorded_at: datetime + change_reason: str + parent_id: str | None = None + attributes: Mapping[str, Any] = field(default_factory=dict) + context: GovernedContextEnvelope | None = None + evidence: tuple[EvidenceReference, ...] = () + record_refs: tuple[InstitutionalReference, ...] = () + + def __post_init__(self) -> None: + if self.object_kind not in _PARENT_KIND: + raise CommitteeWorkspaceError("Unsupported Committee object kind.") + _identifier(self.tenant_id, "Committee tenant id", maximum=36) + _identifier(self.object_id, "Committee object id", maximum=255) + if self.revision < 1: + raise CommitteeWorkspaceError( + "Committee object revision must be positive." + ) + if self.state not in _STATES[self.object_kind]: + raise CommitteeWorkspaceError( + f"Unsupported {self.object_kind} state: {self.state!r}." + ) + _required_text(self.title, "Committee object title", maximum=500) + _required_text(self.change_reason, "Committee change reason", maximum=1_000) + _require_aware(self.recorded_at, "Committee recorded_at") + expected_parent = _PARENT_KIND[self.object_kind] + if expected_parent is None and self.parent_id is not None: + raise CommitteeWorkspaceError( + "Committee bodies cannot have a workspace parent." + ) + if expected_parent is not None: + _identifier( + self.parent_id or "", + f"Committee {expected_parent} parent id", + maximum=255, + ) + if self.context is not None and self.context.tenant_id != self.tenant_id: + raise CommitteeWorkspaceError( + "Committee institutional context belongs to another tenant." + ) + if any(item.tenant_id != self.tenant_id for item in self.evidence): + raise CommitteeWorkspaceError( + "Committee evidence cannot cross tenants." + ) + if any( + item.tenant_id != self.tenant_id or item.kind != "record" + for item in self.record_refs + ): + raise CommitteeWorkspaceError( + "Committee record references must be same-tenant record references." + ) + _validate_attributes(self) + + def to_dict(self) -> dict[str, Any]: + return { + "tenant_id": self.tenant_id, + "object_kind": self.object_kind, + "object_id": self.object_id, + "revision": self.revision, + "state": self.state, + "title": self.title, + "parent_id": self.parent_id, + "recorded_at": self.recorded_at.isoformat(), + "change_reason": self.change_reason, + "attributes": _json_value(self.attributes), + "context": self.context.to_dict() if self.context else None, + "evidence": [item.to_dict() for item in self.evidence], + "record_refs": [item.to_dict() for item in self.record_refs], + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "CommitteeWorkspaceRecord": + context = value.get("context") + attributes = value.get("attributes") or {} + if not isinstance(attributes, Mapping): + raise CommitteeWorkspaceError( + "Committee attributes must be an object." + ) + return cls( + tenant_id=_required_text(value.get("tenant_id"), "Committee tenant id", maximum=36), + object_kind=_object_kind(value.get("object_kind")), + object_id=_required_text(value.get("object_id"), "Committee object id", maximum=255), + revision=_positive_int(value.get("revision"), "Committee revision"), + state=_required_text(value.get("state"), "Committee state", maximum=30), + title=_required_text(value.get("title"), "Committee title", maximum=500), + parent_id=_optional_text(value.get("parent_id"), maximum=255), + recorded_at=_datetime(value.get("recorded_at"), "Committee recorded_at"), + change_reason=_required_text(value.get("change_reason"), "Committee change reason", maximum=1_000), + attributes=dict(attributes), + context=( + GovernedContextEnvelope.from_mapping(context) + if isinstance(context, Mapping) + else None + ), + evidence=tuple( + EvidenceReference.from_mapping(item) + for item in _mapping_items(value.get("evidence"), "Committee evidence") + ), + record_refs=tuple( + InstitutionalReference.from_mapping(item) + for item in _mapping_items(value.get("record_refs"), "Committee record references") + ), + ) + + +def record_workspace_object( + session: Session, + principal: object, + *, + record: CommitteeWorkspaceRecord, + idempotency_key: str, + expected_revision: int | None = None, + _provider_finalization: bool = False, +) -> CommitteeWorkspaceRecord: + tenant_id = _principal_tenant(principal) + if record.tenant_id != tenant_id: + raise CommitteeWorkspaceError( + "Committee workspace records cannot cross tenants." + ) + clean_key = _required_text( + idempotency_key, + "Committee idempotency key", + maximum=255, + ) + request_sha256 = _sha256( + { + "record": record.to_dict(), + "expected_revision": expected_revision, + } + ) + replay = _replay( + session, + tenant_id=tenant_id, + idempotency_key=clean_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + current = _current_row( + session, + tenant_id=tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + lock=True, + ) + if current is None: + if expected_revision is not None or record.revision != 1: + raise CommitteeWorkspaceError( + "A new Committee object must start at revision 1 without an expected revision." + ) + else: + if expected_revision != current.revision: + raise CommitteeWorkspaceError( + "Committee revision conflict: the expected revision is stale." + ) + if record.revision != current.revision + 1: + raise CommitteeWorkspaceError( + "Committee object revisions must be consecutive." + ) + if record.parent_id != current.parent_id: + raise CommitteeWorkspaceError( + "A Committee object's parent cannot change across revisions." + ) + if record.state not in _TRANSITIONS[record.object_kind][current.state]: + raise CommitteeWorkspaceError( + f"Committee {record.object_kind} transition {current.state!r} to " + f"{record.state!r} is not allowed." + ) + current_payload = current.payload if isinstance(current.payload, Mapping) else {} + current_attributes = current_payload.get("attributes") + provider_id = ( + str(current_attributes.get("provider_id") or "").strip() + if isinstance(current_attributes, Mapping) + else "" + ) + if ( + record.object_kind == "vote" + and current.state == "open" + and record.state == "closed" + and provider_id + and not _provider_finalization + ): + raise CommitteeWorkspaceError( + "A provider-bound Committee vote must be finalized through its ballot adapter." + ) + current.superseded_at = record.recorded_at + _validate_parent(session, record) + _validate_related_state(session, record) + row = CommitteeWorkspaceRevision( + tenant_id=tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + revision=record.revision, + previous_revision_id=current.id if current is not None else None, + parent_kind=_PARENT_KIND[record.object_kind], + parent_id=record.parent_id, + state=record.state, + title=record.title, + search_text=f"{record.title} {record.object_id} {record.state}".casefold(), + recorded_at=record.recorded_at, + payload=record.to_dict(), + changed_by=_principal_actor(principal), + ) + event_id = str(uuid.uuid4()) + operation = "created" if current is None else "updated" + event = CommitteeWorkspaceEvent( + tenant_id=tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + object_revision=record.revision, + event_id=event_id, + event_type=f"committee.{record.object_kind}.{operation}", + occurred_at=record.recorded_at, + actor_id=_principal_actor(principal), + idempotency_key=clean_key, + request_sha256=request_sha256, + payload={ + "state": record.state, + "revision": record.revision, + "parent_id": record.parent_id, + "change_reason": record.change_reason, + }, + ) + session.add_all((row, event)) + session.flush() + emit_platform_event( + session, + PlatformEvent( + event_id=event_id, + type=event.event_type, + module_id="committee", + payload=dict(event.payload), + occurred_at=record.recorded_at, + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=tenant_id), + resource=EventObjectRef( + type=f"committee_{record.object_kind}", + id=record.object_id, + label=record.title, + ), + classification="internal", + institutional_context=record.context, + ), + ) + return _workspace_from_row(row) + + +def get_workspace_object( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + revision: int | None = None, +) -> CommitteeWorkspaceRecord | None: + tenant_id = _principal_tenant(principal) + kind = _object_kind(object_kind) + query = session.query(CommitteeWorkspaceRevision).filter( + CommitteeWorkspaceRevision.tenant_id == tenant_id, + CommitteeWorkspaceRevision.object_kind == kind, + CommitteeWorkspaceRevision.object_id == object_id, + ) + if revision is None: + query = query.filter(CommitteeWorkspaceRevision.superseded_at.is_(None)) + else: + query = query.filter(CommitteeWorkspaceRevision.revision == revision) + row = query.order_by(CommitteeWorkspaceRevision.revision.desc()).first() + return _workspace_from_row(row) if row is not None else None + + +def list_workspace_objects( + session: Session, + principal: object, + *, + object_kind: str, + parent_id: str | None = None, + states: Sequence[str] | None = None, + query: str = "", + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[CommitteeWorkspaceRecord, ...], int]: + tenant_id = _principal_tenant(principal) + kind = _object_kind(object_kind) + if offset < 0 or not 1 <= limit <= 200: + raise CommitteeWorkspaceError( + "Committee list offset must be non-negative and limit between 1 and 200." + ) + statement = session.query(CommitteeWorkspaceRevision).filter( + CommitteeWorkspaceRevision.tenant_id == tenant_id, + CommitteeWorkspaceRevision.object_kind == kind, + CommitteeWorkspaceRevision.superseded_at.is_(None), + ) + if parent_id is not None: + statement = statement.filter( + CommitteeWorkspaceRevision.parent_id == parent_id + ) + if states: + statement = statement.filter( + CommitteeWorkspaceRevision.state.in_(tuple(states)) + ) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter( + CommitteeWorkspaceRevision.search_text.contains(clean_query) + ) + total = int(statement.with_entities(func.count()).scalar() or 0) + rows = ( + statement.order_by( + CommitteeWorkspaceRevision.recorded_at.desc(), + CommitteeWorkspaceRevision.object_id.asc(), + ) + .offset(offset) + .limit(limit) + .all() + ) + return tuple(_workspace_from_row(row) for row in rows), total + + +def workspace_history( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + limit: int = 100, +) -> tuple[CommitteeWorkspaceRecord, ...]: + if not 1 <= limit <= 200: + raise CommitteeWorkspaceError( + "Committee history limit must be between 1 and 200." + ) + rows = ( + session.query(CommitteeWorkspaceRevision) + .filter( + CommitteeWorkspaceRevision.tenant_id == _principal_tenant(principal), + CommitteeWorkspaceRevision.object_kind == _object_kind(object_kind), + CommitteeWorkspaceRevision.object_id == object_id, + ) + .order_by(CommitteeWorkspaceRevision.revision.desc()) + .limit(limit) + .all() + ) + return tuple(_workspace_from_row(row) for row in rows) + + +class SqlCommitteeWorkspace: + def record(self, session: object, principal: object, *, record: CommitteeWorkspaceRecord, idempotency_key: str, expected_revision: int | None = None) -> CommitteeWorkspaceRecord: + return record_workspace_object(_session(session), principal, record=record, idempotency_key=idempotency_key, expected_revision=expected_revision) + + def get(self, session: object, principal: object, *, object_kind: str, object_id: str, revision: int | None = None) -> CommitteeWorkspaceRecord | None: + return get_workspace_object(_session(session), principal, object_kind=object_kind, object_id=object_id, revision=revision) + + def record_local_decision( + self, + session: object, + principal: object, + *, + decision: FormalDecision, + meeting_id: str, + agenda_item_id: str, + expected_revision: str | None = None, + ) -> FormalDecision: + return record_local_decision( + _session(session), + principal, + decision=decision, + meeting_id=meeting_id, + agenda_item_id=agenda_item_id, + expected_revision=expected_revision, + ) + + def get_local_decision(self, session: object, principal: object, *, decision_id: str, revision: str | None = None) -> FormalDecision | None: + return get_local_decision(_session(session), principal, decision_id=decision_id, revision=revision) + + +def record_local_decision( + session: Session, + principal: object, + *, + decision: FormalDecision, + meeting_id: str, + agenda_item_id: str, + expected_revision: str | None = None, +) -> FormalDecision: + tenant_id = _principal_tenant(principal) + if decision.reference.owner_module != "committee": + raise CommitteeWorkspaceError( + "Only a Committee-owned fallback Decision may use the local projection." + ) + if decision.reference.tenant_id != tenant_id: + raise CommitteeWorkspaceError( + "Committee Decision projections cannot cross tenants." + ) + for kind, object_id in (("meeting", meeting_id), ("agenda_item", agenda_item_id)): + if get_workspace_object( + session, + principal, + object_kind=kind, + object_id=object_id, + ) is None: + raise CommitteeWorkspaceError( + f"Committee Decision projection requires an existing {kind.replace('_', ' ')}." + ) + payload = decision.to_dict(include_protected=True) + replay = ( + session.query(CommitteeDecisionProjection) + .filter( + CommitteeDecisionProjection.tenant_id == tenant_id, + CommitteeDecisionProjection.decision_id == decision.reference.object_id, + CommitteeDecisionProjection.revision == decision.temporal.revision, + ) + .one_or_none() + ) + if replay is not None: + if replay.payload != payload: + raise CommitteeWorkspaceError( + "A different Committee Decision already uses this revision." + ) + return FormalDecision.from_mapping(replay.payload) + current = ( + session.query(CommitteeDecisionProjection) + .filter( + CommitteeDecisionProjection.tenant_id == tenant_id, + CommitteeDecisionProjection.decision_id == decision.reference.object_id, + CommitteeDecisionProjection.superseded_at.is_(None), + ) + .with_for_update() + .one_or_none() + ) + if current is None: + if expected_revision is not None: + raise CommitteeWorkspaceError( + "Committee Decision revision conflict: no current projection exists." + ) + else: + if expected_revision != current.revision: + raise CommitteeWorkspaceError( + "Committee Decision revision conflict: the expected revision is stale." + ) + current.superseded_at = decision.temporal.recorded_at + if decision.temporal.recorded_at is None: + raise CommitteeWorkspaceError( + "Committee Decision projection requires recorded_at." + ) + row = CommitteeDecisionProjection( + tenant_id=tenant_id, + decision_id=decision.reference.object_id, + revision=decision.temporal.revision, + previous_revision_id=current.id if current is not None else None, + meeting_id=meeting_id, + agenda_item_id=agenda_item_id, + state=decision.state, + recorded_at=decision.temporal.recorded_at, + payload=payload, + changed_by=_principal_actor(principal), + ) + session.add(row) + session.flush() + emit_platform_event( + session, + PlatformEvent( + type="committee.decision.projected", + module_id="committee", + payload={ + "revision": decision.temporal.revision, + "state": decision.state, + "meeting_id": meeting_id, + "agenda_item_id": agenda_item_id, + }, + occurred_at=decision.temporal.recorded_at, + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=tenant_id), + resource=EventObjectRef(type="decision", id=decision.reference.object_id), + classification="restricted", + institutional_context=decision.authority_context, + ), + ) + return FormalDecision.from_mapping(row.payload) + + +def get_local_decision( + session: Session, + principal: object, + *, + decision_id: str, + revision: str | None = None, +) -> FormalDecision | None: + query = session.query(CommitteeDecisionProjection).filter( + CommitteeDecisionProjection.tenant_id == _principal_tenant(principal), + CommitteeDecisionProjection.decision_id == decision_id, + ) + if revision is None: + query = query.filter(CommitteeDecisionProjection.superseded_at.is_(None)) + else: + query = query.filter(CommitteeDecisionProjection.revision == revision) + row = query.order_by(CommitteeDecisionProjection.recorded_at.desc()).first() + return FormalDecision.from_mapping(row.payload) if row is not None else None + + +def _validate_parent(session: Session, record: CommitteeWorkspaceRecord) -> None: + parent_kind = _PARENT_KIND[record.object_kind] + if parent_kind is None: + return + parent = _current_row( + session, + tenant_id=record.tenant_id, + object_kind=parent_kind, + object_id=record.parent_id or "", + lock=False, + ) + if parent is None: + raise CommitteeWorkspaceError( + f"Committee {record.object_kind} requires an existing {parent_kind.replace('_', ' ')}." + ) + if parent.state in {"retired", "cancelled", "withdrawn"}: + raise CommitteeWorkspaceError( + "Committee objects cannot be added below a terminal parent." + ) + + +def _validate_related_state(session: Session, record: CommitteeWorkspaceRecord) -> None: + if record.object_kind == "meeting" and record.state == "closed": + unfinished = ( + session.query(CommitteeWorkspaceRevision.id) + .filter( + CommitteeWorkspaceRevision.tenant_id == record.tenant_id, + CommitteeWorkspaceRevision.object_kind == "agenda_item", + CommitteeWorkspaceRevision.parent_id == record.object_id, + CommitteeWorkspaceRevision.superseded_at.is_(None), + ~CommitteeWorkspaceRevision.state.in_(("decided", "withdrawn")), + ) + .first() + ) + if unfinished is not None: + raise CommitteeWorkspaceError( + "A meeting cannot close while agenda items remain unfinished." + ) + if record.object_kind == "agenda_item" and record.state == "decided": + decision_ref = _reference( + record.attributes.get("decision_ref"), + "decision", + record.tenant_id, + required=True, + ) + if decision_ref is None: + raise CommitteeWorkspaceError( + "A decided agenda item requires a formal Decision reference." + ) + + +def _validate_attributes(record: CommitteeWorkspaceRecord) -> None: + attributes = record.attributes + if len(attributes) > 100: + raise CommitteeWorkspaceError( + "Committee attributes are limited to 100 entries." + ) + if record.object_kind == "body": + _reference( + attributes.get("organization_unit_ref"), + "organization_unit", + record.tenant_id, + required=True, + ) + _references( + attributes.get("function_refs"), + "function", + record.tenant_id, + ) + quorum = attributes.get("quorum") + if quorum is not None and not isinstance(quorum, Mapping): + raise CommitteeWorkspaceError("Committee body quorum must be an object.") + elif record.object_kind == "meeting": + starts_at = _datetime(attributes.get("starts_at"), "Meeting starts_at") + ends_at = _datetime(attributes.get("ends_at"), "Meeting ends_at") + if ends_at <= starts_at: + raise CommitteeWorkspaceError("Meeting ends_at must follow starts_at.") + elif record.object_kind == "agenda_item": + if _positive_int(attributes.get("position"), "Agenda position") < 1: + raise CommitteeWorkspaceError("Agenda position must be positive.") + subjects = _references( + attributes.get("subject_refs"), + None, + record.tenant_id, + ) + if not subjects: + raise CommitteeWorkspaceError( + "Committee agenda items require at least one subject reference." + ) + if record.state == "decided": + _reference( + attributes.get("decision_ref"), + "decision", + record.tenant_id, + required=True, + ) + elif record.object_kind == "vote": + method = str(attributes.get("method") or "recorded") + if method not in {"recorded", "public", "secret"}: + raise CommitteeWorkspaceError("Unsupported Committee vote method.") + choices = tuple( + str(item).strip() + for item in _sequence(attributes.get("choices"), "Vote choices") + if str(item).strip() + ) + if len(choices) < 2 or len(choices) != len(set(choices)): + raise CommitteeWorkspaceError( + "Committee votes require at least two unique choices." + ) + eligible = _non_negative_int( + attributes.get("eligible_count"), + "Vote eligible_count", + ) + cast = _non_negative_int(attributes.get("cast_count"), "Vote cast_count") + if cast > eligible: + raise CommitteeWorkspaceError( + "Vote cast_count cannot exceed eligible_count." + ) + if record.state == "closed": + counts = attributes.get("counts") + if not isinstance(counts, Mapping): + raise CommitteeWorkspaceError( + "A closed vote requires result counts." + ) + clean_counts = {str(key): _non_negative_int(value, "Vote count") for key, value in counts.items()} + if set(clean_counts) - set(choices) or sum(clean_counts.values()) != cast: + raise CommitteeWorkspaceError( + "Closed vote counts must match the configured choices and cast_count." + ) + if not isinstance(attributes.get("quorum_met"), bool): + raise CommitteeWorkspaceError( + "A closed vote requires an explicit quorum_met result." + ) + _reference( + attributes.get("approval_ref"), + "approval", + record.tenant_id, + required=True, + ) + if not record.evidence: + raise CommitteeWorkspaceError( + "A closed vote requires result evidence." + ) + elif record.object_kind == "minute": + _reference( + attributes.get("content_ref"), + "record", + record.tenant_id, + required=True, + ) + if record.state in {"accepted", "corrected"}: + _reference( + attributes.get("approval_ref"), + "approval", + record.tenant_id, + required=True, + ) + if not record.evidence: + raise CommitteeWorkspaceError( + "Accepted or corrected minutes require evidence." + ) + + +def _replay(session: Session, *, tenant_id: str, idempotency_key: str, request_sha256: str) -> CommitteeWorkspaceRecord | None: + row = session.query(CommitteeWorkspaceEvent).filter( + CommitteeWorkspaceEvent.tenant_id == tenant_id, + CommitteeWorkspaceEvent.idempotency_key == idempotency_key, + ).one_or_none() + if row is None: + return None + if row.request_sha256 != request_sha256: + raise CommitteeWorkspaceError( + "Committee idempotency conflict: the key was used for another request." + ) + revision = session.query(CommitteeWorkspaceRevision).filter( + CommitteeWorkspaceRevision.tenant_id == tenant_id, + CommitteeWorkspaceRevision.object_kind == row.object_kind, + CommitteeWorkspaceRevision.object_id == row.object_id, + CommitteeWorkspaceRevision.revision == row.object_revision, + ).one() + return _workspace_from_row(revision) + + +def _current_row(session: Session, *, tenant_id: str, object_kind: str, object_id: str, lock: bool) -> CommitteeWorkspaceRevision | None: + query = session.query(CommitteeWorkspaceRevision).filter( + CommitteeWorkspaceRevision.tenant_id == tenant_id, + CommitteeWorkspaceRevision.object_kind == object_kind, + CommitteeWorkspaceRevision.object_id == object_id, + CommitteeWorkspaceRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _workspace_from_row(row: CommitteeWorkspaceRevision) -> CommitteeWorkspaceRecord: + payload = dict(row.payload) + payload["revision"] = row.revision + payload["state"] = row.state + payload["recorded_at"] = _datetime_text(row.recorded_at) + return CommitteeWorkspaceRecord.from_mapping(payload) + + +def _object_kind(value: object) -> CommitteeObjectKind: + result = str(value or "").strip() + if result not in _PARENT_KIND: + raise CommitteeWorkspaceError("Unsupported Committee object kind.") + return result # type: ignore[return-value] + + +def _reference(value: object, kind: str | None, tenant_id: str, *, required: bool) -> InstitutionalReference | None: + if value is None: + if required: + raise CommitteeWorkspaceError( + f"Committee {kind or 'institutional'} reference is required." + ) + return None + if not isinstance(value, Mapping): + raise CommitteeWorkspaceError("Committee reference must be an object.") + result = InstitutionalReference.from_mapping(value) + if result.tenant_id != tenant_id or (kind is not None and result.kind != kind): + raise CommitteeWorkspaceError( + "Committee reference has the wrong tenant or kind." + ) + return result + + +def _references(value: object, kind: str | None, tenant_id: str) -> tuple[InstitutionalReference, ...]: + return tuple( + item + for item in ( + _reference(candidate, kind, tenant_id, required=True) + for candidate in _sequence(value, "Committee references") + ) + if item is not None + ) + + +def _mapping_items(value: object, label: str) -> tuple[Mapping[str, object], ...]: + items = _sequence(value, label) + if any(not isinstance(item, Mapping) for item in items): + raise CommitteeWorkspaceError(f"{label} must contain objects.") + return tuple(items) # type: ignore[return-value] + + +def _sequence(value: object, label: str) -> tuple[object, ...]: + if value is None: + return () + if not isinstance(value, (list, tuple)): + raise CommitteeWorkspaceError(f"{label} must be a list.") + return tuple(value) + + +def _datetime(value: object, label: str) -> datetime: + if isinstance(value, datetime): + result = value + else: + try: + result = datetime.fromisoformat(str(value or "").replace("Z", "+00:00")) + except ValueError as exc: + raise CommitteeWorkspaceError(f"{label} is invalid.") from exc + _require_aware(result, label) + return result + + +def _require_aware(value: datetime, label: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise CommitteeWorkspaceError(f"{label} must include a timezone.") + + +def _positive_int(value: object, label: str) -> int: + result = _non_negative_int(value, label) + if result < 1: + raise CommitteeWorkspaceError(f"{label} must be positive.") + return result + + +def _non_negative_int(value: object, label: str) -> int: + if isinstance(value, bool): + raise CommitteeWorkspaceError(f"{label} must be an integer.") + try: + result = int(value) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise CommitteeWorkspaceError(f"{label} must be an integer.") from exc + if result < 0: + raise CommitteeWorkspaceError(f"{label} cannot be negative.") + return result + + +def _identifier(value: object, label: str, *, maximum: int) -> str: + result = _required_text(value, label, maximum=maximum) + if any(character.isspace() for character in result): + raise CommitteeWorkspaceError(f"{label} cannot contain whitespace.") + return result + + +def _required_text(value: object, label: str, *, maximum: int) -> str: + result = str(value or "").strip() + if not result: + raise CommitteeWorkspaceError(f"{label} is required.") + if len(result) > maximum: + raise CommitteeWorkspaceError(f"{label} is limited to {maximum} characters.") + return result + + +def _optional_text(value: object, *, maximum: int) -> str | None: + result = str(value or "").strip() + if not result: + return None + if len(result) > maximum: + raise CommitteeWorkspaceError(f"Text is limited to {maximum} characters.") + return result + + +def _sha256(value: object) -> str: + return hashlib.sha256( + json.dumps(_json_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + ).hexdigest() + + +def _json_value(value: object) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "to_dict"): + return _json_value(value.to_dict()) + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + return value + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError( + "Committee operations require a tenant-bound principal." + ) + return tenant_id + + +def _principal_actor(principal: object) -> str | None: + user = getattr(principal, "user", None) + for value in ( + getattr(user, "id", None), + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + ): + candidate = str(value or "").strip() + if candidate: + return candidate + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise InstitutionalContextError( + "Committee workspace requires a database session." + ) + return value # type: ignore[return-value] + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +__all__ = [ + "CAPABILITY_COMMITTEE_WORKSPACE", + "CommitteeObjectKind", + "CommitteeWorkspaceError", + "CommitteeWorkspaceRecord", + "SqlCommitteeWorkspace", + "get_local_decision", + "get_workspace_object", + "list_workspace_objects", + "record_local_decision", + "record_workspace_object", + "workspace_history", +] diff --git a/tests/test_decision_path.py b/tests/test_decision_path.py new file mode 100644 index 0000000..e4a3f49 --- /dev/null +++ b/tests/test_decision_path.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime, timedelta + +from govoplan_core.core.institutional import ( + CAPABILITY_DECISION_REGISTRY, + CAPABILITY_MANDATE_RESOLVER, + ActorRepresentationReference, + DecisionEffectReference, + EvidenceReference, + InformationGovernanceReference, + InstitutionalContextError, + InstitutionalReference, + LegalBasisReference, + MandateDefinition, + MandateResolution, + TemporalRevision, +) +from govoplan_committee.backend.decision_path import ( + CommitteeDecisionPath, + CommitteeDecisionProposal, +) + + +NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + + +def reference(kind: str, object_id: str) -> InstitutionalReference: + return InstitutionalReference( + kind=kind, # type: ignore[arg-type] + owner_module=( + "committee" + if kind in {"decision", "record"} + else "organizations" + if kind in {"organization_unit", "function", "jurisdiction"} + else "approvals" + if kind == "approval" + else "cases" + ), + object_id=object_id, + tenant_id="tenant-1", + valid_at=NOW, + ) + + +def mandate() -> MandateDefinition: + return MandateDefinition( + reference=InstitutionalReference( + kind="mandate", + owner_module="committee", + object_id="mandate-1", + tenant_id="tenant-1", + version="7", + valid_at=NOW, + ), + temporal=TemporalRevision( + revision="7", + valid_from=NOW - timedelta(days=30), + valid_to=NOW + timedelta(days=30), + recorded_at=NOW - timedelta(days=40), + ), + task_types=("committee.formal_decision",), + authority_types=("committee.resolution",), + organization_unit_refs=(reference("organization_unit", "board-1"),), + function_refs=(reference("function", "chair"),), + jurisdiction_refs=(reference("jurisdiction", "city-1"),), + legal_bases=(legal_basis(),), + evidence=(evidence("mandate-evidence"),), + ) + + +def legal_basis() -> LegalBasisReference: + return LegalBasisReference( + kind="statute", + authority="Example council", + reference="rules:committee:12", + version="2026-01", + effective_from=NOW - timedelta(days=100), + ) + + +def evidence(evidence_id: str) -> EvidenceReference: + return EvidenceReference( + kind="record", + owner_module="committee", + evidence_id=evidence_id, + tenant_id="tenant-1", + version="1", + checksum="sha256:example", + captured_at=NOW, + ) + + +def proposal() -> CommitteeDecisionProposal: + return CommitteeDecisionProposal( + tenant_id="tenant-1", + decision_id="decision-1", + revision="1", + effective_at=NOW, + meeting_ref="meeting-4", + agenda_item_ref="item-7", + decision_type="committee.resolution", + subject_refs=(reference("case", "case-1"),), + organization_unit_ref=reference("organization_unit", "board-1"), + function_ref=reference("function", "chair"), + actor=ActorRepresentationReference( + tenant_id="tenant-1", + account_id="account-1", + identity_id="identity-1", + represented_function_ref=reference("function", "chair"), + mandate_ref=mandate().reference, + ), + approval_refs=(reference("approval", "vote-approval-1"),), + fact_evidence=(evidence("minutes-4"),), + legal_bases=(legal_basis(),), + operative_result="The proposal is accepted.", + reasoning="The submitted evidence satisfies the applicable rule.", + case_ref=reference("case", "case-1"), + jurisdiction_refs=(reference("jurisdiction", "city-1"),), + record_refs=(reference("record", "minutes-4"),), + requested_effects=( + DecisionEffectReference( + effect_key="postbox.notify_parties", + state="requested", + resource_refs=("postbox:case-1",), + ), + ), + review_refs=("review:administrative-court",), + information_governance=InformationGovernanceReference( + classification="restricted", + purposes=("formal_decision",), + legal_basis_refs=("rules:committee:12@2026-01",), + disclosure_state="partly_disclosable", + ), + assurance_level="human_reviewed_automation", + automation_preparation_refs=("dataflow:recommendation-1",), + ) + + +class FakeDecisionRegistry: + def __init__(self) -> None: + self.recorded = None + + def get_decision(self, session, principal, *, reference): + return self.recorded + + def record_decision(self, session, principal, *, decision, expected_revision=None): + self.recorded = decision + return decision + + +class FakeMandateResolver: + def __init__(self, resolution: MandateResolution) -> None: + self.resolution = resolution + self.request = None + + def resolve_mandate(self, session, principal, *, request): + self.request = request + return self.resolution + + +class FakeRegistry: + def __init__(self, capabilities: dict[str, object]) -> None: + self.capabilities = capabilities + + def has_capability(self, name: str) -> bool: + return name in self.capabilities + + def capability(self, name: str) -> object: + return self.capabilities[name] + + +class CommitteeDecisionPathTests(unittest.TestCase): + def test_decision_reconstructs_authority_approval_evidence_and_effects(self) -> None: + registry = FakeDecisionRegistry() + resolution = MandateResolution( + competent=True, + mandates=(mandate(),), + explanation="Chair is competent for this agenda item.", + ) + mandate_resolver = FakeMandateResolver(resolution) + path = CommitteeDecisionPath( + FakeRegistry( + { + CAPABILITY_MANDATE_RESOLVER: mandate_resolver, + CAPABILITY_DECISION_REGISTRY: registry, + } + ) + ) + + result = path.decide(None, None, proposal=proposal()) + payload = result.reconstruction_payload() + + self.assertTrue(result.persisted_by_decision_registry) + self.assertIs(registry.recorded, result.decision) + self.assertEqual("mandate-1", payload["mandate_ref"]["object_id"]) + self.assertEqual( + "vote-approval-1", + payload["decision"]["authority_context"]["approval_refs"][0]["object_id"], + ) + self.assertEqual( + "postbox.notify_parties", + payload["decision"]["requested_effects"][0]["effect_key"], + ) + self.assertEqual( + "The submitted evidence satisfies the applicable rule.", + payload["decision"]["reasoning"], + ) + self.assertEqual( + "city-1", mandate_resolver.request.jurisdiction_refs[0].object_id + ) + self.assertEqual( + "human_reviewed_automation", + payload["decision"]["assurance_level"], + ) + self.assertEqual( + ["dataflow:recommendation-1"], + payload["decision"]["automation_preparation_refs"], + ) + + def test_decision_rejects_ambiguous_or_ineffective_mandate(self) -> None: + accepted = mandate() + with self.assertRaisesRegex( + InstitutionalContextError, + "exactly one effective active mandate", + ): + CommitteeDecisionPath().decide( + None, + None, + proposal=proposal(), + mandate_resolution=MandateResolution( + competent=True, + mandates=(accepted, accepted), + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index b91e186..5d3d686 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,7 +2,14 @@ from __future__ import annotations import unittest -from govoplan_committee.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest +from govoplan_committee.backend.manifest import ( + ADMIN_SCOPE, + BALLOT_SCOPE, + PROTECTED_READ_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + get_manifest, +) class ManifestSeedTests(unittest.TestCase): @@ -12,12 +19,22 @@ class ManifestSeedTests(unittest.TestCase): self.assertEqual(manifest.id, "committee") self.assertEqual(manifest.name, "Committee") self.assertEqual(manifest.dependencies, ("access",)) - self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}) + self.assertEqual( + {permission.scope for permission in manifest.permissions}, + {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, BALLOT_SCOPE, PROTECTED_READ_SCOPE}, + ) self.assertEqual({role.slug for role in manifest.role_templates}, {"committee_manager", "committee_viewer"}) self.assertTrue(manifest.documentation) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.migration_spec) - self.assertIsNone(manifest.frontend) + self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.migration_spec) + self.assertIsNotNone(manifest.frontend) + self.assertEqual("@govoplan/committee-webui", manifest.frontend.package_name) + self.assertEqual("/committee", manifest.frontend.routes[0].path) + self.assertEqual( + {"committee.navigation", "committee.workspace"}, + {surface.id for surface in manifest.frontend.view_surfaces}, + ) + self.assertIn("committee.workspace", manifest.capability_factories) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..b76f49e --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,42 @@ +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_committee.backend.manifest import get_manifest +from govoplan_core.db.migrations import migrate_database + + +class CommitteeMigrationTests(unittest.TestCase): + def test_fresh_migration_creates_committee_workspace_and_head(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-committee-migration-") as directory: + url = f"sqlite:///{Path(directory) / 'committee.db'}" + migrate_database( + database_url=url, + enabled_modules=("committee",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + self.assertTrue( + { + "committee_decision_projections", + "committee_workspace_events", + "committee_workspace_revisions", + }.issubset(inspect(engine).get_table_names()) + ) + with engine.connect() as connection: + self.assertIn( + "d8b9f0a1c2e3", + set(MigrationContext.configure(connection).get_current_heads()), + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..e5aa6d3 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.events import EventBus, event_bus_context +from govoplan_core.core.institutional import ( + ActorRepresentationReference, + EvidenceReference, + InformationGovernanceReference, + InstitutionalReference, + LegalBasisReference, + MandateDefinition, + MandateResolution, + TemporalRevision, +) +from govoplan_committee.backend.db.models import ( + CommitteeDecisionProjection, + CommitteeWorkspaceEvent, + CommitteeWorkspaceRevision, +) +from govoplan_committee.backend.ballots import ( + BallotFinalizationResult, + CommitteeBallotFinalizer, + ballot_adapter_capability, +) +from govoplan_committee.backend.decision_path import ( + CommitteeDecisionPath, + CommitteeDecisionProposal, +) +from govoplan_committee.backend.workspace import ( + CommitteeWorkspaceError, + CommitteeWorkspaceRecord, + SqlCommitteeWorkspace, + get_local_decision, + list_workspace_objects, + record_workspace_object, + workspace_history, +) + + +NOW = datetime(2026, 8, 1, 13, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +class BallotAdapter: + def finalize_ballot(self, session, principal, *, request): + return BallotFinalizationResult( + provider_id=request.provider_id, + provider_ballot_ref=request.provider_ballot_ref, + counts={"yes": 3, "no": 1}, + cast_count=4, + quorum_met=True, + receipt_ref="receipt:secret-ballot-1", + result_sha256="a" * 64, + evidence=(evidence("secret-ballot-result"),), + ) + + +class BallotRegistry: + def __init__(self) -> None: + self.name = ballot_adapter_capability("secure-vote") + self.adapter = BallotAdapter() + + def has_capability(self, name: str) -> bool: + return name == self.name + + def capability(self, name: str) -> object: + if name != self.name: + raise KeyError(name) + return self.adapter + + +def ref(kind: str, object_id: str, owner: str) -> InstitutionalReference: + return InstitutionalReference( + kind=kind, # type: ignore[arg-type] + owner_module=owner, + object_id=object_id, + tenant_id="tenant-1", + version="1", + valid_at=NOW, + ) + + +def evidence(evidence_id: str) -> EvidenceReference: + return EvidenceReference( + kind="record", + owner_module="records", + evidence_id=evidence_id, + tenant_id="tenant-1", + version="1", + captured_at=NOW, + ) + + +def workspace_record( + kind: str, + object_id: str, + *, + state: str, + attributes: dict[str, object], + parent_id: str | None = None, + revision: int = 1, + evidence_refs: tuple[EvidenceReference, ...] = (), +) -> CommitteeWorkspaceRecord: + return CommitteeWorkspaceRecord( + tenant_id="tenant-1", + object_kind=kind, # type: ignore[arg-type] + object_id=object_id, + revision=revision, + state=state, + title=f"{kind.replace('_', ' ').title()} {object_id}", + parent_id=parent_id, + recorded_at=NOW + timedelta(minutes=revision - 1), + change_reason="Governed Committee update.", + attributes=attributes, + evidence=evidence_refs, + ) + + +def mandate() -> MandateDefinition: + return MandateDefinition( + reference=ref("mandate", "mandate-1", "mandates"), + temporal=TemporalRevision( + revision="1", + valid_from=NOW - timedelta(days=1), + recorded_at=NOW - timedelta(days=2), + ), + task_types=("committee.formal_decision",), + authority_types=("committee.resolution",), + organization_unit_refs=(ref("organization_unit", "board-1", "organizations"),), + function_refs=(ref("function", "chair", "organizations"),), + jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),), + legal_bases=(legal_basis(),), + evidence=(evidence("mandate-proof"),), + ) + + +def legal_basis() -> LegalBasisReference: + return LegalBasisReference( + kind="statute", + authority="Example council", + reference="committee-rules:12", + version="2026-01", + ) + + +def proposal() -> CommitteeDecisionProposal: + function_ref = ref("function", "chair", "organizations") + return CommitteeDecisionProposal( + tenant_id="tenant-1", + decision_id="decision-1", + revision="1", + effective_at=NOW, + meeting_ref="meeting-1", + agenda_item_ref="agenda-1", + decision_type="committee.resolution", + subject_refs=(ref("case", "case-1", "cases"),), + organization_unit_ref=ref("organization_unit", "board-1", "organizations"), + function_ref=function_ref, + actor=ActorRepresentationReference( + tenant_id="tenant-1", + account_id="account-1", + identity_id="identity-1", + represented_function_ref=function_ref, + mandate_ref=mandate().reference, + ), + approval_refs=(ref("approval", "vote-1", "approvals"),), + fact_evidence=(evidence("vote-result-1"),), + legal_bases=(legal_basis(),), + operative_result="The application is approved.", + reasoning="The evidence and vote meet the applicable rules.", + jurisdiction_refs=(ref("jurisdiction", "city-1", "organizations"),), + information_governance=InformationGovernanceReference( + classification="restricted", + purposes=("formal_decision",), + ), + ) + + +class CommitteeWorkspaceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + CommitteeWorkspaceRevision.__table__, + CommitteeWorkspaceEvent.__table__, + CommitteeDecisionProjection.__table__, + ): + table.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_full_workspace_lifecycle_and_local_decision_projection(self) -> None: + bus = EventBus() + events = [] + bus.subscribe("*", events.append) + with event_bus_context(bus): + body = workspace_record( + "body", + "body-1", + state="active", + attributes={ + "organization_unit_ref": ref( + "organization_unit", + "board-1", + "organizations", + ).to_dict(), + "function_refs": [ + ref("function", "chair", "organizations").to_dict() + ], + "quorum": {"minimum_count": 3}, + }, + ) + record_workspace_object( + self.session, + self.principal, + record=body, + idempotency_key="body-create", + ) + meeting = workspace_record( + "meeting", + "meeting-1", + state="scheduled", + parent_id="body-1", + attributes={ + "starts_at": NOW.isoformat(), + "ends_at": (NOW + timedelta(hours=2)).isoformat(), + "location": "Council chamber", + }, + ) + record_workspace_object( + self.session, + self.principal, + record=meeting, + idempotency_key="meeting-create", + ) + agenda = workspace_record( + "agenda_item", + "agenda-1", + state="scheduled", + parent_id="meeting-1", + attributes={ + "position": 1, + "subject_refs": [ref("case", "case-1", "cases").to_dict()], + }, + ) + record_workspace_object( + self.session, + self.principal, + record=agenda, + idempotency_key="agenda-create", + ) + deliberating = replace( + agenda, + revision=2, + state="deliberating", + recorded_at=NOW + timedelta(minutes=1), + ) + record_workspace_object( + self.session, + self.principal, + record=deliberating, + expected_revision=1, + idempotency_key="agenda-deliberating", + ) + vote = workspace_record( + "vote", + "vote-1", + state="open", + parent_id="agenda-1", + attributes={ + "method": "recorded", + "choices": ["yes", "no", "abstain"], + "eligible_count": 5, + "cast_count": 0, + }, + ) + record_workspace_object( + self.session, + self.principal, + record=vote, + idempotency_key="vote-open", + ) + closed_vote = replace( + vote, + revision=2, + state="closed", + recorded_at=NOW + timedelta(minutes=1), + attributes={ + **dict(vote.attributes), + "cast_count": 5, + "counts": {"yes": 4, "no": 1, "abstain": 0}, + "quorum_met": True, + "approval_ref": ref( + "approval", + "vote-1", + "approvals", + ).to_dict(), + }, + evidence=(evidence("vote-result-1"),), + ) + record_workspace_object( + self.session, + self.principal, + record=closed_vote, + expected_revision=1, + idempotency_key="vote-close", + ) + + decision = CommitteeDecisionPath().decide( + self.session, + self.principal, + proposal=proposal(), + mandate_resolution=MandateResolution( + competent=True, + mandates=(mandate(),), + ), + ).decision + workspace = SqlCommitteeWorkspace() + projected = workspace.record_local_decision( + self.session, + self.principal, + decision=decision, + meeting_id="meeting-1", + agenda_item_id="agenda-1", + ) + decided = replace( + deliberating, + revision=3, + state="decided", + recorded_at=NOW + timedelta(minutes=2), + attributes={ + **dict(deliberating.attributes), + "decision_ref": projected.reference.to_dict(), + }, + ) + record_workspace_object( + self.session, + self.principal, + record=decided, + expected_revision=2, + idempotency_key="agenda-decide", + ) + open_meeting = replace( + meeting, + revision=2, + state="open", + recorded_at=NOW + timedelta(minutes=1), + ) + record_workspace_object( + self.session, + self.principal, + record=open_meeting, + expected_revision=1, + idempotency_key="meeting-open", + ) + closed_meeting = replace( + open_meeting, + revision=3, + state="closed", + recorded_at=NOW + timedelta(hours=2), + ) + record_workspace_object( + self.session, + self.principal, + record=closed_meeting, + expected_revision=2, + idempotency_key="meeting-close", + ) + minute = workspace_record( + "minute", + "minute-1", + state="accepted", + parent_id="meeting-1", + attributes={ + "content_ref": ref("record", "minutes-1", "records").to_dict(), + "approval_ref": ref("approval", "minutes-ok", "approvals").to_dict(), + }, + evidence_refs=(evidence("minutes-signature"),), + ) + record_workspace_object( + self.session, + self.principal, + record=minute, + idempotency_key="minute-accept", + ) + self.assertEqual([], events) + self.session.commit() + + self.assertEqual("decision-1", get_local_decision( + self.session, + self.principal, + decision_id="decision-1", + ).reference.object_id) + agenda_history = workspace_history( + self.session, + self.principal, + object_kind="agenda_item", + object_id="agenda-1", + ) + self.assertEqual([3, 2, 1], [item.revision for item in agenda_history]) + meetings, total = list_workspace_objects( + self.session, + self.principal, + object_kind="meeting", + states=("closed",), + ) + self.assertEqual(1, total) + self.assertEqual("meeting-1", meetings[0].object_id) + self.assertIn("committee.decision.projected", [item.type for item in events]) + + def test_parent_state_occ_replay_and_tenant_boundaries_fail_closed(self) -> None: + body = workspace_record( + "body", + "body-1", + state="active", + attributes={ + "organization_unit_ref": ref( + "organization_unit", + "board-1", + "organizations", + ).to_dict(), + }, + ) + first = record_workspace_object( + self.session, + self.principal, + record=body, + idempotency_key="body-1", + ) + replay = record_workspace_object( + self.session, + self.principal, + record=body, + idempotency_key="body-1", + ) + self.assertEqual(first, replay) + + with self.assertRaisesRegex(CommitteeWorkspaceError, "idempotency conflict"): + record_workspace_object( + self.session, + self.principal, + record=replace(body, title="Changed"), + idempotency_key="body-1", + ) + with self.assertRaisesRegex(CommitteeWorkspaceError, "existing body"): + record_workspace_object( + self.session, + self.principal, + record=workspace_record( + "meeting", + "meeting-orphan", + state="scheduled", + parent_id="missing", + attributes={ + "starts_at": NOW.isoformat(), + "ends_at": (NOW + timedelta(hours=1)).isoformat(), + }, + ), + idempotency_key="meeting-orphan", + ) + with self.assertRaisesRegex(CommitteeWorkspaceError, "stale"): + record_workspace_object( + self.session, + self.principal, + record=replace(body, revision=2), + expected_revision=99, + idempotency_key="body-stale", + ) + with self.assertRaisesRegex(CommitteeWorkspaceError, "cross tenants"): + record_workspace_object( + self.session, + Principal(tenant_id="tenant-2"), + record=body, + idempotency_key="cross-tenant", + ) + + def test_provider_ballot_finalization_persists_only_aggregate_evidence(self) -> None: + records = ( + workspace_record( + "body", + "body-ballot", + state="active", + attributes={ + "organization_unit_ref": ref( + "organization_unit", + "board-1", + "organizations", + ).to_dict(), + "function_refs": [], + }, + ), + workspace_record( + "meeting", + "meeting-ballot", + state="open", + parent_id="body-ballot", + attributes={ + "starts_at": NOW.isoformat(), + "ends_at": (NOW + timedelta(hours=2)).isoformat(), + }, + ), + workspace_record( + "agenda_item", + "agenda-ballot", + state="deliberating", + parent_id="meeting-ballot", + attributes={ + "position": 1, + "subject_refs": [ref("case", "case-1", "cases").to_dict()], + }, + ), + workspace_record( + "vote", + "vote-provider", + state="open", + parent_id="agenda-ballot", + attributes={ + "method": "secret", + "provider_id": "secure-vote", + "choices": ["yes", "no"], + "eligible_count": 5, + "cast_count": 0, + }, + ), + ) + for index, item in enumerate(records): + record_workspace_object( + self.session, + self.principal, + record=item, + idempotency_key=f"ballot-setup-{index}", + ) + self.session.commit() + + with self.assertRaisesRegex(CommitteeWorkspaceError, "ballot adapter"): + record_workspace_object( + self.session, + self.principal, + record=replace( + records[-1], + revision=2, + state="closed", + recorded_at=NOW + timedelta(minutes=4), + evidence=(evidence("manual-result"),), + attributes={ + **records[-1].attributes, + "counts": {"yes": 3, "no": 1}, + "cast_count": 4, + "quorum_met": True, + "provider_receipt_ref": "forged-receipt", + "provider_result_sha256": "b" * 64, + "approval_ref": ref( + "approval", "vote-approval", "approvals" + ).to_dict(), + }, + ), + expected_revision=1, + idempotency_key="ballot-manual-close", + ) + + closed = CommitteeBallotFinalizer(BallotRegistry()).finalize( + self.session, + self.principal, + vote_id="vote-provider", + provider_id="secure-vote", + provider_ballot_ref="external-ballot-7", + approval_ref=ref("approval", "vote-approval", "approvals"), + expected_revision=1, + recorded_at=NOW + timedelta(minutes=5), + change_reason="Imported verified secret ballot aggregate.", + idempotency_key="ballot-finalize-1", + ) + self.session.commit() + + self.assertEqual("closed", closed.state) + self.assertEqual({"yes": 3, "no": 1}, closed.attributes["counts"]) + self.assertEqual("a" * 64, closed.attributes["provider_result_sha256"]) + self.assertNotIn("ballots", closed.attributes) + self.assertEqual("secret-ballot-result", closed.evidence[0].evidence_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7249e00 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/committee-webui", + "version": "0.1.8", + "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/committee.css": "./src/styles/committee.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/committee.ts b/webui/src/api/committee.ts new file mode 100644 index 0000000..20d9c42 --- /dev/null +++ b/webui/src/api/committee.ts @@ -0,0 +1,115 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type CommitteeObjectKind = "body" | "meeting" | "agenda_item" | "vote" | "minute"; + +export type CommitteeRecord = { + tenant_id: string; + object_kind: CommitteeObjectKind; + object_id: string; + revision: number; + state: string; + title: string; + parent_id?: string | null; + recorded_at: string; + change_reason: string; + attributes: Record; + context?: Record | null; + evidence: Array>; + record_refs: Array>; +}; + +export type CommitteeRecordList = { + records: CommitteeRecord[]; + total: number; + offset: number; + limit: number; +}; + +export function listCommitteeRecords( + settings: ApiSettings, + kind: CommitteeObjectKind, + options: { + parentId?: string; + query?: string; + states?: string[]; + limit?: number; + } = {}, + signal?: AbortSignal +): Promise { + return apiFetch( + settings, + apiPath(`/api/v1/committee/workspace/${kind}`, { + parent_id: options.parentId, + query: options.query, + state: options.states, + limit: options.limit ?? 200 + }), + { signal } + ); +} + +export function saveCommitteeRecord( + settings: ApiSettings, + record: CommitteeRecord, + expectedRevision?: number +): Promise { + return apiFetch( + settings, + `/api/v1/committee/workspace/${record.object_kind}`, + { + method: "POST", + body: JSON.stringify({ + record, + idempotency_key: crypto.randomUUID(), + expected_revision: expectedRevision + }) + } + ); +} + +export function committeeRecordHistory( + settings: ApiSettings, + record: CommitteeRecord, + signal?: AbortSignal +): Promise<{ revisions: CommitteeRecord[] }> { + return apiFetch( + settings, + `/api/v1/committee/workspace/${record.object_kind}/${encodeURIComponent(record.object_id)}/history`, + { signal } + ); +} + +export function finalizeProviderBallot( + settings: ApiSettings, + record: CommitteeRecord, + input: { + providerBallotRef: string; + approvalId: string; + changeReason: string; + } +): Promise { + const providerId = String(record.attributes.provider_id ?? "").trim(); + return apiFetch( + settings, + `/api/v1/committee/workspace/vote/${encodeURIComponent(record.object_id)}/finalize-provider`, + { + method: "POST", + body: JSON.stringify({ + provider_id: providerId, + provider_ballot_ref: input.providerBallotRef.trim(), + approval_ref: { + kind: "approval", + owner_module: "approvals", + object_id: input.approvalId.trim(), + tenant_id: record.tenant_id, + version: "1" + }, + expected_revision: record.revision, + recorded_at: new Date().toISOString(), + change_reason: input.changeReason.trim(), + idempotency_key: crypto.randomUUID() + }) + } + ); +} diff --git a/webui/src/features/committee/CommitteeBallotDialog.tsx b/webui/src/features/committee/CommitteeBallotDialog.tsx new file mode 100644 index 0000000..416bbfd --- /dev/null +++ b/webui/src/features/committee/CommitteeBallotDialog.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { + Button, + Dialog, + DismissibleAlert, + FormField, + type ApiSettings +} from "@govoplan/core-webui"; +import { + finalizeProviderBallot, + type CommitteeRecord +} from "../../api/committee"; + + +export default function CommitteeBallotDialog({ + settings, + record, + open, + onClose, + onSaved +}: { + settings: ApiSettings; + record: CommitteeRecord; + open: boolean; + onClose: () => void; + onSaved: (record: CommitteeRecord) => void; +}) { + const [providerBallotRef, setProviderBallotRef] = useState(""); + const [approvalId, setApprovalId] = useState(""); + const [changeReason, setChangeReason] = useState("Imported verified ballot aggregate."); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!open) return; + setProviderBallotRef(""); + setApprovalId(""); + setChangeReason("Imported verified ballot aggregate."); + setError(""); + }, [open, record.object_id]); + + async function finalize() { + setBusy(true); + setError(""); + try { + const saved = await finalizeProviderBallot(settings, record, { + providerBallotRef, + approvalId, + changeReason + }); + onSaved(saved); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Ballot result could not be imported."); + } finally { + setBusy(false); + } + } + + const providerId = String(record.attributes.provider_id ?? ""); + return ( + + + + + } + > +
+ {error ? {error} : null} +

+ Provider {providerId} returns only the verified aggregate result, + receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN. +

+ + setProviderBallotRef(event.target.value)} /> + + + setApprovalId(event.target.value)} /> + + + setChangeReason(event.target.value)} /> + +
+
+ ); +} diff --git a/webui/src/features/committee/CommitteePage.tsx b/webui/src/features/committee/CommitteePage.tsx new file mode 100644 index 0000000..b8b3647 --- /dev/null +++ b/webui/src/features/committee/CommitteePage.tsx @@ -0,0 +1,401 @@ +import { + CalendarPlus, + FilePlus2, + ListPlus, + Pencil, + Plus, + RefreshCw, + Search, + ShieldCheck, + Vote +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Button, + DismissibleAlert, + IconButton, + LoadingIndicator, + PageScrollViewport, + StatusBadge, + hasScope, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + listCommitteeRecords, + type CommitteeObjectKind, + type CommitteeRecord +} from "../../api/committee"; +import CommitteeBallotDialog from "./CommitteeBallotDialog"; +import CommitteeRecordDialog from "./CommitteeRecordDialog"; +import { canReviseCommitteeRecord } from "./lifecycle"; + + +type EditorTarget = { + kind: CommitteeObjectKind; + parentId?: string | null; + record?: CommitteeRecord | null; +}; + +export default function CommitteePage({ settings, auth }: PlatformRouteContext) { + const tenantId = auth.active_tenant?.id ?? auth.tenant.id; + const canWrite = hasScope(auth, "committee:workspace:write"); + const canFinalizeBallot = hasScope(auth, "committee:ballot:finalize"); + const [query, setQuery] = useState(""); + const [bodies, setBodies] = useState([]); + const [meetings, setMeetings] = useState([]); + const [agendaItems, setAgendaItems] = useState([]); + const [votes, setVotes] = useState([]); + const [minutes, setMinutes] = useState([]); + const [bodyId, setBodyId] = useState(""); + const [meetingId, setMeetingId] = useState(""); + const [agendaId, setAgendaId] = useState(""); + const [editor, setEditor] = useState(null); + const [ballotRecord, setBallotRecord] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const selectedBody = bodies.find((item) => item.object_id === bodyId) ?? null; + const selectedMeeting = meetings.find((item) => item.object_id === meetingId) ?? null; + const selectedAgenda = agendaItems.find((item) => item.object_id === agendaId) ?? null; + + const loadBodies = useCallback(async (signal?: AbortSignal) => { + const response = await listCommitteeRecords( + settings, + "body", + { query: query.trim(), limit: 200 }, + signal + ); + setBodies(response.records); + setBodyId((current) => response.records.some((item) => item.object_id === current) + ? current + : response.records[0]?.object_id ?? ""); + }, [query, settings]); + + const refresh = useCallback(async () => { + setLoading(true); + setError(""); + try { + await loadBodies(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Committee workspace could not be loaded."); + } finally { + setLoading(false); + } + }, [loadBodies]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + loadBodies(controller.signal). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Committee bodies could not be loaded."); + } + }). + finally(() => setLoading(false)); + return () => controller.abort(); + }, [loadBodies]); + + useEffect(() => { + if (!bodyId) { + setMeetings([]); + setMeetingId(""); + return; + } + const controller = new AbortController(); + listCommitteeRecords(settings, "meeting", { parentId: bodyId }, controller.signal). + then((response) => { + setMeetings(response.records); + setMeetingId((current) => response.records.some((item) => item.object_id === current) + ? current + : response.records[0]?.object_id ?? ""); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Committee meetings could not be loaded."); + } + }); + return () => controller.abort(); + }, [bodyId, settings]); + + useEffect(() => { + if (!meetingId) { + setAgendaItems([]); + setMinutes([]); + setAgendaId(""); + return; + } + const controller = new AbortController(); + Promise.all([ + listCommitteeRecords(settings, "agenda_item", { parentId: meetingId }, controller.signal), + listCommitteeRecords(settings, "minute", { parentId: meetingId }, controller.signal) + ]). + then(([agenda, nextMinutes]) => { + const ordered = [...agenda.records].sort( + (left, right) => Number(left.attributes.position ?? 0) - Number(right.attributes.position ?? 0) + ); + setAgendaItems(ordered); + setMinutes(nextMinutes.records); + setAgendaId((current) => ordered.some((item) => item.object_id === current) + ? current + : ordered[0]?.object_id ?? ""); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Meeting details could not be loaded."); + } + }); + return () => controller.abort(); + }, [meetingId, settings]); + + useEffect(() => { + if (!agendaId) { + setVotes([]); + return; + } + const controller = new AbortController(); + listCommitteeRecords(settings, "vote", { parentId: agendaId }, controller.signal). + then((response) => setVotes(response.records)). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Votes could not be loaded."); + } + }); + return () => controller.abort(); + }, [agendaId, settings]); + + const meetingTime = useMemo( + () => selectedMeeting + ? `${formatDateTime(selectedMeeting.attributes.starts_at)} - ${formatTime(selectedMeeting.attributes.ends_at)}` + : "", + [selectedMeeting] + ); + + return ( +
+
+
+
{ event.preventDefault(); void refresh(); }} className="committee-search"> +
+ + {error ? {error} : null} + {loading && bodies.length === 0 ? : null} + +
+
+ + + + + {selectedBody && canWrite ? ( +
+ {canReviseCommitteeRecord(selectedBody) ? } onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null} + +
+ ) : null} +
+ +
+ + + + +
+ +
+ {!selectedMeeting ? ( +
Select or create a meeting.
+ ) : ( + <> +
+
+ {meetingTime} +

{selectedMeeting.title}

+
+ + {canWrite && canReviseCommitteeRecord(selectedMeeting) ? } onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null} +
+ + setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}> + + + {selectedAgenda ? ( + setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}> + + ) : null} + + setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}> + + + + )} +
+
+
+ + {editor ? ( + setEditor(null)} + onSaved={() => void refresh()} + /> + ) : null} + {ballotRecord ? ( + setBallotRecord(null)} + onSaved={() => void refresh()} + /> + ) : null} +
+ ); +} + +function PanelHeading({ title, count }: { title: string; count: number }) { + return

{title}

{count}
; +} + +function RecordList({ records, selectedId, onSelect, secondary }: { + records: CommitteeRecord[]; + selectedId: string; + onSelect: (id: string) => void; + secondary?: (record: CommitteeRecord) => string; +}) { + if (records.length === 0) return
No records.
; + return
{records.map((record) => ( + + ))}
; +} + +function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { + return

{title}

{action}
{children}
; +} + +function RecordRows({ records, canEdit, onEdit, detail, secondaryAction }: { + records: CommitteeRecord[]; + canEdit: (record: CommitteeRecord) => boolean; + onEdit: (record: CommitteeRecord) => void; + detail: (record: CommitteeRecord) => string; + secondaryAction?: (record: CommitteeRecord) => ReactNode; +}) { + if (records.length === 0) return
No records.
; + return
{records.map((record) => ( +
+ {record.title}{detail(record)} + + + {secondaryAction?.(record)} + {canEdit(record) ? } onClick={() => onEdit(record)} /> : null} + +
+ ))}
; +} + +function meetingSecondary(record: CommitteeRecord): string { + return `${formatDateTime(record.attributes.starts_at)} - ${humanize(record.state)}`; +} + +function voteSummary(record: CommitteeRecord): string { + const cast = Number(record.attributes.cast_count ?? 0); + const eligible = Number(record.attributes.eligible_count ?? 0); + return `${humanize(String(record.attributes.method ?? "recorded"))}, ${cast}/${eligible} cast`; +} + +function isProviderBallotReady(record: CommitteeRecord): boolean { + return record.state === "open" && Boolean(String(record.attributes.provider_id ?? "").trim()); +} + +function statusTone(state: string): "active" | "inactive" | "warning" { + if (["active", "open", "accepted", "decided", "closed"].includes(state)) return "active"; + if (["cancelled", "retired", "withdrawn"].includes(state)) return "inactive"; + return "warning"; +} + +function formatDateTime(value: unknown): string { + if (!value) return "Date not set"; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(String(value))); +} + +function formatTime(value: unknown): string { + if (!value) return "-"; + return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(new Date(String(value))); +} + +function humanize(value: string): string { + return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/webui/src/features/committee/CommitteeRecordDialog.tsx b/webui/src/features/committee/CommitteeRecordDialog.tsx new file mode 100644 index 0000000..846ff95 --- /dev/null +++ b/webui/src/features/committee/CommitteeRecordDialog.tsx @@ -0,0 +1,431 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + DismissibleAlert, + FormField, + type ApiSettings +} from "@govoplan/core-webui"; +import { + saveCommitteeRecord, + type CommitteeObjectKind, + type CommitteeRecord +} from "../../api/committee"; +import { COMMITTEE_STATES, committeeStateOptions } from "./lifecycle"; + +type Draft = { + title: string; + state: string; + changeReason: string; + organizationUnitId: string; + startsAt: string; + endsAt: string; + position: string; + subjectKind: string; + subjectId: string; + choices: string; + method: string; + eligibleCount: string; + castCount: string; + counts: Record; + quorumMet: boolean; + approvalId: string; + evidenceId: string; + providerId: string; + contentRecordId: string; + decisionId: string; +}; + +export default function CommitteeRecordDialog({ + settings, + tenantId, + kind, + parentId, + record, + open, + onClose, + onSaved +}: { + settings: ApiSettings; + tenantId: string; + kind: CommitteeObjectKind; + parentId?: string | null; + record?: CommitteeRecord | null; + open: boolean; + onClose: () => void; + onSaved: (record: CommitteeRecord) => void; +}) { + const [draft, setDraft] = useState(() => draftFromRecord(kind, record)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const choices = useMemo( + () => draft.choices.split(",").map((item) => item.trim()).filter(Boolean), + [draft.choices] + ); + const stateOptions = useMemo( + () => committeeStateOptions(kind, record), + [kind, record] + ); + + useEffect(() => { + if (!open) return; + setDraft(draftFromRecord(kind, record)); + setError(""); + }, [kind, open, record]); + + async function save() { + setBusy(true); + setError(""); + try { + const payload = recordFromDraft({ + tenantId, + kind, + parentId, + record, + draft, + choices + }); + const saved = await saveCommitteeRecord( + settings, + payload, + record?.revision + ); + onSaved(saved); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Committee record could not be saved."); + } finally { + setBusy(false); + } + } + + return ( + + + + + } + > +
+ {error ? {error} : null} +
+ + setDraft({ ...draft, title: event.target.value })} + /> + + + + +
+ + {kind === "body" ? ( + + setDraft({ ...draft, organizationUnitId: event.target.value })} + /> + + ) : null} + + {kind === "meeting" ? ( +
+ + setDraft({ ...draft, startsAt: event.target.value })} /> + + + setDraft({ ...draft, endsAt: event.target.value })} /> + +
+ ) : null} + + {kind === "agenda_item" ? ( + <> +
+ + setDraft({ ...draft, position: event.target.value })} /> + + + + + + setDraft({ ...draft, subjectId: event.target.value })} /> + +
+ {draft.state === "decided" ? ( + + setDraft({ ...draft, decisionId: event.target.value })} /> + + ) : null} + + ) : null} + + {kind === "vote" ? ( + <> +
+ + + + + setDraft({ ...draft, eligibleCount: event.target.value })} /> + + + setDraft({ ...draft, providerId: event.target.value })} /> + +
+ + setDraft({ ...draft, choices: event.target.value })} /> + + {draft.state === "closed" ? ( +
+
+ + setDraft({ ...draft, castCount: event.target.value })} /> + + + + +
+
+ {choices.map((choice) => ( + + setDraft({ + ...draft, + counts: { ...draft.counts, [choice]: event.target.value } + })} + /> + + ))} +
+ +
+ ) : null} + + ) : null} + + {kind === "minute" ? ( + <> + + setDraft({ ...draft, contentRecordId: event.target.value })} /> + + {draft.state === "accepted" || draft.state === "corrected" ? ( + + ) : null} + + ) : null} + + + setDraft({ ...draft, changeReason: event.target.value })} + /> + +
+
+ ); +} + +function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) { + return ( +
+ + setDraft({ ...draft, approvalId: event.target.value })} /> + + + setDraft({ ...draft, evidenceId: event.target.value })} /> + +
+ ); +} + +function draftFromRecord(kind: CommitteeObjectKind, record?: CommitteeRecord | null): Draft { + const attributes = record?.attributes ?? {}; + const startsAt = localDateTime(attributes.starts_at, 60); + const endsAt = localDateTime(attributes.ends_at, 120); + const subject = firstMapping(attributes.subject_refs); + const approval = mapping(attributes.approval_ref); + const content = mapping(attributes.content_ref); + const decision = mapping(attributes.decision_ref); + const organization = mapping(attributes.organization_unit_ref); + const counts = mapping(attributes.counts); + return { + title: record?.title ?? "", + state: record?.state ?? COMMITTEE_STATES[kind][0], + changeReason: record ? "" : `Created ${labelForKind(kind)}.`, + organizationUnitId: text(organization.object_id), + startsAt, + endsAt, + position: String(attributes.position ?? 1), + subjectKind: text(subject.kind) || "case", + subjectId: text(subject.object_id), + choices: array(attributes.choices).join(", ") || "yes, no, abstain", + method: text(attributes.method) || "recorded", + eligibleCount: String(attributes.eligible_count ?? 0), + castCount: String(attributes.cast_count ?? 0), + counts: Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, String(value)])), + quorumMet: attributes.quorum_met !== false, + approvalId: text(approval.object_id), + evidenceId: text(firstMapping(record?.evidence).evidence_id), + providerId: text(attributes.provider_id), + contentRecordId: text(content.object_id), + decisionId: text(decision.object_id) + }; +} + +function recordFromDraft({ tenantId, kind, parentId, record, draft, choices }: { + tenantId: string; + kind: CommitteeObjectKind; + parentId?: string | null; + record?: CommitteeRecord | null; + draft: Draft; + choices: string[]; +}): CommitteeRecord { + const attributes = attributesFromDraft(tenantId, kind, draft, choices); + const evidence = needsEvidence(kind, draft.state) && draft.evidenceId.trim() + ? [{ + kind: "record", + owner_module: "records", + evidence_id: draft.evidenceId.trim(), + tenant_id: tenantId, + version: "1", + captured_at: new Date().toISOString() + }] + : record?.evidence ?? []; + return { + tenant_id: tenantId, + object_kind: kind, + object_id: record?.object_id ?? crypto.randomUUID(), + revision: (record?.revision ?? 0) + 1, + state: draft.state, + title: draft.title.trim(), + parent_id: record?.parent_id ?? parentId ?? null, + recorded_at: new Date().toISOString(), + change_reason: draft.changeReason.trim(), + attributes, + context: record?.context ?? null, + evidence, + record_refs: record?.record_refs ?? [] + }; +} + +function attributesFromDraft(tenantId: string, kind: CommitteeObjectKind, draft: Draft, choices: string[]): Record { + if (kind === "body") return { + organization_unit_ref: reference("organization_unit", "organizations", draft.organizationUnitId, tenantId), + function_refs: [] + }; + if (kind === "meeting") return { + starts_at: new Date(draft.startsAt).toISOString(), + ends_at: new Date(draft.endsAt).toISOString() + }; + if (kind === "agenda_item") return { + position: Number(draft.position), + subject_refs: [reference(draft.subjectKind, ownerForKind(draft.subjectKind), draft.subjectId, tenantId)], + ...(draft.state === "decided" ? { + decision_ref: reference("decision", "decisions", draft.decisionId, tenantId) + } : {}) + }; + if (kind === "vote") return { + method: draft.method, + choices, + eligible_count: Number(draft.eligibleCount), + cast_count: Number(draft.castCount), + ...(draft.providerId.trim() ? { provider_id: draft.providerId.trim() } : {}), + ...(draft.state === "closed" ? { + counts: Object.fromEntries(choices.map((choice) => [choice, Number(draft.counts[choice] ?? 0)])), + quorum_met: draft.quorumMet, + approval_ref: reference("approval", "approvals", draft.approvalId, tenantId) + } : {}) + }; + return { + content_ref: reference("record", "records", draft.contentRecordId, tenantId), + ...(draft.state === "accepted" || draft.state === "corrected" ? { + approval_ref: reference("approval", "approvals", draft.approvalId, tenantId) + } : {}) + }; +} + +function reference(kind: string, owner: string, objectId: string, tenantId: string) { + return { kind, owner_module: owner, object_id: objectId.trim(), tenant_id: tenantId, version: "1" }; +} + +function ownerForKind(kind: string): string { + return { case: "cases", service: "services", work_item: "workflow_engine", record: "records" }[kind] ?? kind; +} + +function needsEvidence(kind: CommitteeObjectKind, state: string): boolean { + return (kind === "vote" && state === "closed") + || (kind === "minute" && ["accepted", "corrected"].includes(state)); +} + +function localDateTime(value: unknown, offsetMinutes: number): string { + const date = value ? new Date(String(value)) : new Date(Date.now() + offsetMinutes * 60_000); + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function mapping(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function firstMapping(value: unknown): Record { + return mapping(Array.isArray(value) ? value[0] : undefined); +} + +function array(value: unknown): string[] { + return Array.isArray(value) ? value.map(String) : []; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function labelForKind(kind: CommitteeObjectKind): string { + return humanize(kind); +} + +function humanize(value: string): string { + return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} diff --git a/webui/src/features/committee/lifecycle.ts b/webui/src/features/committee/lifecycle.ts new file mode 100644 index 0000000..0c98a53 --- /dev/null +++ b/webui/src/features/committee/lifecycle.ts @@ -0,0 +1,60 @@ +import type { CommitteeObjectKind, CommitteeRecord } from "../../api/committee"; + + +export const COMMITTEE_STATES: Record = { + body: ["draft", "active", "suspended", "retired"], + meeting: ["draft", "scheduled", "open", "closed", "cancelled"], + agenda_item: ["draft", "scheduled", "deliberating", "decided", "withdrawn"], + vote: ["draft", "open", "closed", "cancelled"], + minute: ["draft", "proposed", "accepted", "corrected"] +}; + +const TRANSITIONS: Record> = { + body: { + draft: ["draft", "active", "retired"], + active: ["active", "suspended", "retired"], + suspended: ["active", "suspended", "retired"], + retired: [] + }, + meeting: { + draft: ["draft", "scheduled", "cancelled"], + scheduled: ["scheduled", "open", "cancelled"], + open: ["open", "closed", "cancelled"], + closed: [], + cancelled: [] + }, + agenda_item: { + draft: ["draft", "scheduled", "withdrawn"], + scheduled: ["scheduled", "deliberating", "withdrawn"], + deliberating: ["deliberating", "decided", "withdrawn"], + decided: [], + withdrawn: [] + }, + vote: { + draft: ["draft", "open", "cancelled"], + open: ["open", "closed", "cancelled"], + closed: [], + cancelled: [] + }, + minute: { + draft: ["draft", "proposed"], + proposed: ["proposed", "accepted"], + accepted: ["corrected"], + corrected: ["corrected"] + } +}; + +export function committeeStateOptions( + kind: CommitteeObjectKind, + record?: CommitteeRecord | null +): string[] { + if (!record) return COMMITTEE_STATES[kind]; + const options = TRANSITIONS[kind][record.state] ?? []; + const providerBound = kind === "vote" && Boolean(String(record.attributes.provider_id ?? "").trim()); + const allowed = providerBound ? options.filter((state) => state !== "closed") : options; + return allowed.length > 0 ? allowed : [record.state]; +} + +export function canReviseCommitteeRecord(record: CommitteeRecord): boolean { + return (TRANSITIONS[record.object_kind][record.state] ?? []).length > 0; +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..90e92e1 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { default, committeeModule } from "./module"; +export * from "./api/committee"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..4f8d986 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,32 @@ +import { createElement, lazy } from "react"; +import type { PlatformWebModule } from "@govoplan/core-webui"; +import "./styles/committee.css"; + + +const CommitteePage = lazy(() => import("./features/committee/CommitteePage")); + +export const committeeModule: PlatformWebModule = { + id: "committee", + name: "Committee", + version: "0.1.8", + optionalDependencies: ["calendar", "files", "mandates", "decisions", "approvals"], + navItems: [ + { + to: "/committee", + label: "Committee", + iconName: "gavel", + anyOf: ["committee:workspace:read"], + order: 38 + } + ], + routes: [ + { + path: "/committee", + anyOf: ["committee:workspace:read"], + order: 38, + render: (context) => createElement(CommitteePage, context) + } + ] +}; + +export default committeeModule; diff --git a/webui/src/styles/committee.css b/webui/src/styles/committee.css new file mode 100644 index 0000000..37a7dc1 --- /dev/null +++ b/webui/src/styles/committee.css @@ -0,0 +1,326 @@ +.committee-page, +.committee-shell { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.committee-shell { + display: flex; + flex-direction: column; + background: var(--surface); +} + +.committee-toolbar { + display: flex; + align-items: center; + gap: 9px; + min-height: 58px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface-raised); +} + +.committee-search { + display: flex; + align-items: center; + gap: 8px; + width: min(460px, 100%); + margin-right: auto; +} + +.committee-search input { + min-width: 140px; + flex: 1; +} + +.committee-alert { + margin: 10px 16px 0; +} + +.committee-workspace { + display: grid; + grid-template-columns: minmax(220px, 0.7fr) minmax(260px, 0.9fr) minmax(420px, 2fr); + flex: 1; + min-height: 0; +} + +.committee-panel, +.committee-detail { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + border-right: 1px solid var(--border); +} + +.committee-detail { + border-right: 0; +} + +.committee-panel-heading, +.committee-detail-heading, +.committee-workspace-section > div:first-child { + display: flex; + align-items: center; + gap: 10px; +} + +.committee-panel-heading { + min-height: 48px; + padding: 8px 13px; + border-bottom: 1px solid var(--border); +} + +.committee-panel-heading h2, +.committee-workspace-section h2 { + margin: 0; + font-size: 0.92rem; + letter-spacing: 0; +} + +.committee-panel-heading span { + margin-left: auto; + color: var(--text-soft); + font-size: 0.78rem; +} + +.committee-panel-scroll, +.committee-detail-scroll { + flex: 1; + min-height: 0; +} + +.committee-record-list { + display: flex; + flex-direction: column; +} + +.committee-record-list > button { + display: flex; + min-height: 58px; + flex-direction: column; + gap: 3px; + padding: 9px 13px; + border: 0; + border-bottom: 1px solid var(--border); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.committee-record-list > button:hover, +.committee-record-list > button.is-selected, +.committee-agenda-list > div:hover, +.committee-agenda-list > div.is-selected { + background: var(--hover-bg); +} + +.committee-record-list span, +.committee-agenda-list small, +.committee-record-rows small, +.committee-detail-heading span { + color: var(--text-soft); + font-size: 0.77rem; +} + +.committee-panel-actions { + display: flex; + justify-content: flex-end; + gap: 7px; + padding: 9px; + border-top: 1px solid var(--border); +} + +.committee-detail-heading { + min-height: 70px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); +} + +.committee-detail-heading > div:first-child { + min-width: 0; + margin-right: auto; +} + +.committee-detail-heading h1 { + overflow: hidden; + margin: 3px 0 0; + font-size: 1.08rem; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.committee-workspace-section { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.committee-workspace-section > div:first-child { + min-height: 36px; + margin-bottom: 8px; +} + +.committee-workspace-section > div:first-child .btn { + margin-left: auto; +} + +.committee-agenda-list, +.committee-record-rows { + border-top: 1px solid var(--border); +} + +.committee-agenda-list > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + width: 100%; + min-height: 52px; + border-bottom: 1px solid var(--border); +} + +.committee-agenda-list > div > button:first-child { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-width: 0; + min-height: 51px; + padding: 6px 8px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.committee-agenda-list > div > button:first-child > span:nth-child(2), +.committee-record-rows > div > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.committee-agenda-position { + color: var(--text-soft); + font-size: 0.82rem; + text-align: center; +} + +.committee-record-rows > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + min-height: 52px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} + +.committee-row-actions { + display: flex; + flex-direction: row !important; + align-items: center; + gap: 4px !important; +} + +.committee-empty { + display: grid; + min-height: 180px; + place-items: center; + color: var(--text-soft); +} + +.committee-empty.compact { + min-height: 72px; +} + +.committee-record-dialog { + width: min(820px, calc(100vw - 32px)); + max-height: min(820px, calc(100vh - 32px)); +} + +.committee-ballot-dialog { + width: min(620px, calc(100vw - 32px)); +} + +.committee-dialog-note { + margin: 0; + color: var(--text-soft); + line-height: 1.45; +} + +.committee-record-form { + display: flex; + flex-direction: column; + gap: 13px; +} + +.committee-form-grid, +.committee-count-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.committee-form-grid-three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.committee-count-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +} + +.committee-vote-result-fields { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 4px; + border-top: 1px solid var(--border); +} + +@media (max-width: 1050px) { + .committee-workspace { + grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr); + } +} + +@media (max-width: 760px) { + .committee-toolbar { + align-items: stretch; + flex-wrap: wrap; + } + + .committee-search { + width: 100%; + } + + .committee-workspace { + grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr); + grid-template-rows: repeat(2, minmax(0, 1fr)); + } + + .committee-body-panel { + grid-column: 1; + grid-row: 1; + border-bottom: 1px solid var(--border); + } + + .committee-meeting-panel { + grid-column: 1; + grid-row: 2; + } + + .committee-detail { + grid-column: 2; + grid-row: 1 / span 2; + } + + .committee-form-grid, + .committee-form-grid-three { + grid-template-columns: 1fr; + } +}