feat: implement committee decision workspace

This commit is contained in:
2026-08-01 17:48:25 +02:00
parent 232329ac52
commit 758b59ca03
28 changed files with 4909 additions and 32 deletions
+6
View File
@@ -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.
+37 -3
View File
@@ -4,16 +4,34 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`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.<provider>` 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`.
+58 -8
View File
@@ -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.<provider>`. 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.
+1 -1
View File
@@ -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" }
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import datetime
import re
from typing import Protocol, runtime_checkable
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalReference,
)
from govoplan_committee.backend.workspace import (
CommitteeWorkspaceError,
CommitteeWorkspaceRecord,
get_workspace_object,
record_workspace_object,
)
CAPABILITY_COMMITTEE_BALLOT_FINALIZER = "committee.ballot_finalizer"
_PROVIDER_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,79}$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
def ballot_adapter_capability(provider_id: str) -> str:
value = str(provider_id or "").strip()
if not _PROVIDER_RE.fullmatch(value):
raise CommitteeWorkspaceError("Committee ballot provider id is invalid.")
return f"committee.ballot_adapter.{value}"
@dataclass(frozen=True, slots=True)
class BallotFinalizationRequest:
tenant_id: str
vote_id: str
provider_id: str
provider_ballot_ref: str
choices: tuple[str, ...]
eligible_count: int
requested_at: datetime
idempotency_key: str
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.tenant_id.strip() or not self.vote_id.strip():
raise CommitteeWorkspaceError(
"Committee ballot tenant and vote identifiers are required."
)
if not self.provider_ballot_ref.strip():
raise CommitteeWorkspaceError(
"Committee provider ballot reference is required."
)
if len(self.choices) < 2 or len(self.choices) != len(set(self.choices)):
raise CommitteeWorkspaceError(
"Committee ballot choices must be unique."
)
if self.eligible_count < 0:
raise CommitteeWorkspaceError(
"Committee ballot eligible count cannot be negative."
)
if self.requested_at.tzinfo is None:
raise CommitteeWorkspaceError(
"Committee ballot requested_at must include a timezone."
)
if not self.idempotency_key.strip():
raise CommitteeWorkspaceError(
"Committee ballot idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class BallotFinalizationResult:
provider_id: str
provider_ballot_ref: str
counts: Mapping[str, int]
cast_count: int
quorum_met: bool
receipt_ref: str
result_sha256: str
evidence: tuple[EvidenceReference, ...]
def __post_init__(self) -> None:
ballot_adapter_capability(self.provider_id)
if not self.provider_ballot_ref.strip() or not self.receipt_ref.strip():
raise CommitteeWorkspaceError(
"Committee ballot provider and receipt references are required."
)
if not _SHA256_RE.fullmatch(self.result_sha256):
raise CommitteeWorkspaceError(
"Committee ballot result hash must be a lowercase SHA-256 digest."
)
if self.cast_count < 0 or any(
not isinstance(value, int) or value < 0 for value in self.counts.values()
):
raise CommitteeWorkspaceError(
"Committee ballot counts cannot be negative."
)
if not self.evidence:
raise CommitteeWorkspaceError(
"Committee ballot finalization requires provider evidence."
)
@runtime_checkable
class CommitteeBallotAdapter(Protocol):
def finalize_ballot(
self,
session: object,
principal: object,
*,
request: BallotFinalizationRequest,
) -> BallotFinalizationResult: ...
class CommitteeBallotFinalizer:
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def finalize(
self,
session: Session,
principal: object,
*,
vote_id: str,
provider_id: str,
provider_ballot_ref: str,
approval_ref: InstitutionalReference,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CommitteeWorkspaceRecord:
current = get_workspace_object(
session,
principal,
object_kind="vote",
object_id=vote_id,
)
if current is None:
raise LookupError("Committee vote not found.")
if current.state != "open":
raise CommitteeWorkspaceError(
"Only an open Committee vote can be finalized by a ballot provider."
)
if current.revision != expected_revision:
raise CommitteeWorkspaceError(
"Committee revision conflict: the expected revision is stale."
)
configured_provider = str(current.attributes.get("provider_id") or "").strip()
if configured_provider and configured_provider != provider_id:
raise CommitteeWorkspaceError(
"Committee vote is bound to another ballot provider."
)
choices = tuple(str(item) for item in current.attributes.get("choices", ()))
eligible_count = int(current.attributes.get("eligible_count") or 0)
request = BallotFinalizationRequest(
tenant_id=current.tenant_id,
vote_id=current.object_id,
provider_id=provider_id,
provider_ballot_ref=provider_ballot_ref,
choices=choices,
eligible_count=eligible_count,
requested_at=recorded_at,
idempotency_key=idempotency_key,
)
adapter = _capability(
self._registry,
ballot_adapter_capability(provider_id),
)
if not isinstance(adapter, CommitteeBallotAdapter):
raise CommitteeWorkspaceError(
f"Committee ballot provider is unavailable: {provider_id}."
)
result = adapter.finalize_ballot(
session,
principal,
request=request,
)
_validate_result(result, request=request)
counts = {choice: int(result.counts.get(choice, 0)) for choice in choices}
next_record = replace(
current,
revision=current.revision + 1,
state="closed",
recorded_at=recorded_at,
change_reason=change_reason,
attributes={
**dict(current.attributes),
"provider_id": provider_id,
"provider_ballot_ref": provider_ballot_ref,
"provider_receipt_ref": result.receipt_ref,
"provider_result_sha256": result.result_sha256,
"counts": counts,
"cast_count": result.cast_count,
"quorum_met": result.quorum_met,
"approval_ref": approval_ref.to_dict(),
},
evidence=_merge_evidence(current.evidence, result.evidence),
)
return record_workspace_object(
session,
principal,
record=next_record,
expected_revision=expected_revision,
idempotency_key=idempotency_key,
_provider_finalization=True,
)
def _validate_result(
result: BallotFinalizationResult,
*,
request: BallotFinalizationRequest,
) -> None:
if (
result.provider_id != request.provider_id
or result.provider_ballot_ref != request.provider_ballot_ref
):
raise CommitteeWorkspaceError(
"Committee ballot provider returned a result for another ballot."
)
if set(result.counts) != set(request.choices):
raise CommitteeWorkspaceError(
"Committee ballot provider result must cover exactly the configured choices."
)
if sum(result.counts.values()) != result.cast_count:
raise CommitteeWorkspaceError(
"Committee ballot provider counts do not match cast_count."
)
if result.cast_count > request.eligible_count:
raise CommitteeWorkspaceError(
"Committee ballot provider cast_count exceeds eligible_count."
)
if any(item.tenant_id != request.tenant_id for item in result.evidence):
raise CommitteeWorkspaceError(
"Committee ballot provider evidence cannot cross tenants."
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _merge_evidence(
existing: tuple[EvidenceReference, ...],
incoming: tuple[EvidenceReference, ...],
) -> tuple[EvidenceReference, ...]:
merged: list[EvidenceReference] = []
seen: set[tuple[str, str, str, str, str]] = set()
for item in (*existing, *incoming):
key = (
item.kind,
item.owner_module,
item.evidence_id,
item.tenant_id,
item.version,
)
if key in seen:
continue
seen.add(key)
merged.append(item)
return tuple(merged)
__all__ = [
"CAPABILITY_COMMITTEE_BALLOT_FINALIZER",
"BallotFinalizationRequest",
"BallotFinalizationResult",
"CommitteeBallotAdapter",
"CommitteeBallotFinalizer",
"ballot_adapter_capability",
]
@@ -0,0 +1,13 @@
"""Committee database models."""
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
__all__ = [
"CommitteeDecisionProjection",
"CommitteeWorkspaceEvent",
"CommitteeWorkspaceRevision",
]
+132
View File
@@ -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",
]
@@ -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",
]
+297 -15
View File
@@ -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,
)
@@ -0,0 +1 @@
"""Committee Alembic revisions."""
@@ -0,0 +1 @@
"""Committee migration revisions."""
@@ -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")
+229
View File
@@ -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"]
+45
View File
@@ -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",
]
+980
View File
@@ -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",
]
+240
View File
@@ -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()
+22 -5
View File
@@ -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__":
+42
View File
@@ -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()
+597
View File
@@ -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()
+28
View File
@@ -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
}
}
}
+115
View File
@@ -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<string, unknown>;
context?: Record<string, unknown> | null;
evidence: Array<Record<string, unknown>>;
record_refs: Array<Record<string, unknown>>;
};
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<CommitteeRecordList> {
return apiFetch<CommitteeRecordList>(
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<CommitteeRecord> {
return apiFetch<CommitteeRecord>(
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<CommitteeRecord> {
const providerId = String(record.attributes.provider_id ?? "").trim();
return apiFetch<CommitteeRecord>(
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()
})
}
);
}
@@ -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 (
<Dialog
open={open}
title="Finalize provider ballot"
onClose={onClose}
closeDisabled={busy}
portal
className="committee-ballot-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !providerBallotRef.trim() || !approvalId.trim() || !changeReason.trim()}
onClick={() => void finalize()}
>
{busy ? "Importing" : "Finalize"}
</Button>
</>
}
>
<div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="committee-dialog-note">
Provider <strong>{providerId}</strong> returns only the verified aggregate result,
receipt hash and evidence. Individual secret ballots are not stored in GovOPlaN.
</p>
<FormField label="Provider ballot reference">
<input value={providerBallotRef} disabled={busy} onChange={(event) => setProviderBallotRef(event.target.value)} />
</FormField>
<FormField label="Approval ID">
<input value={approvalId} disabled={busy} onChange={(event) => setApprovalId(event.target.value)} />
</FormField>
<FormField label="Change reason">
<input value={changeReason} maxLength={1000} disabled={busy} onChange={(event) => setChangeReason(event.target.value)} />
</FormField>
</div>
</Dialog>
);
}
@@ -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<CommitteeRecord[]>([]);
const [meetings, setMeetings] = useState<CommitteeRecord[]>([]);
const [agendaItems, setAgendaItems] = useState<CommitteeRecord[]>([]);
const [votes, setVotes] = useState<CommitteeRecord[]>([]);
const [minutes, setMinutes] = useState<CommitteeRecord[]>([]);
const [bodyId, setBodyId] = useState("");
const [meetingId, setMeetingId] = useState("");
const [agendaId, setAgendaId] = useState("");
const [editor, setEditor] = useState<EditorTarget | null>(null);
const [ballotRecord, setBallotRecord] = useState<CommitteeRecord | null>(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 (
<main className="committee-page">
<div className="committee-shell">
<div className="committee-toolbar">
<form onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
<Search size={16} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
</form>
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
{canWrite ? (
<Button variant="primary" onClick={() => setEditor({ kind: "body" })}>
<Plus size={16} aria-hidden="true" />
New body
</Button>
) : null}
</div>
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
<div className="committee-workspace">
<section className="committee-panel committee-body-panel" aria-label="Committee bodies">
<PanelHeading title="Bodies" count={bodies.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={bodies} selectedId={bodyId} onSelect={setBodyId} />
</PageScrollViewport>
{selectedBody && canWrite ? (
<div className="committee-panel-actions">
{canReviseCommitteeRecord(selectedBody) ? <IconButton label="Edit body" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "body", record: selectedBody })} /> : null}
<Button variant="primary" onClick={() => setEditor({ kind: "meeting", parentId: selectedBody.object_id })}>
<CalendarPlus size={16} aria-hidden="true" />
New meeting
</Button>
</div>
) : null}
</section>
<section className="committee-panel committee-meeting-panel" aria-label="Meetings">
<PanelHeading title="Meetings" count={meetings.length} />
<PageScrollViewport className="committee-panel-scroll">
<RecordList records={meetings} selectedId={meetingId} onSelect={setMeetingId} secondary={meetingSecondary} />
</PageScrollViewport>
</section>
<section className="committee-detail" aria-label="Meeting workspace">
{!selectedMeeting ? (
<div className="committee-empty">Select or create a meeting.</div>
) : (
<>
<div className="committee-detail-heading">
<div>
<span>{meetingTime}</span>
<h1>{selectedMeeting.title}</h1>
</div>
<StatusBadge status={statusTone(selectedMeeting.state)} label={humanize(selectedMeeting.state)} />
{canWrite && canReviseCommitteeRecord(selectedMeeting) ? <IconButton label="Edit meeting" icon={<Pencil size={16} />} onClick={() => setEditor({ kind: "meeting", record: selectedMeeting })} /> : null}
</div>
<PageScrollViewport className="committee-detail-scroll">
<WorkspaceSection
title="Agenda"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "agenda_item", parentId: selectedMeeting.object_id })}>
<ListPlus size={16} aria-hidden="true" />
Add item
</Button>
) : null}
>
<div className="committee-agenda-list">
{agendaItems.map((item) => (
<div key={item.object_id} className={item.object_id === agendaId ? "is-selected" : ""}>
<button type="button" onClick={() => setAgendaId(item.object_id)}>
<span className="committee-agenda-position">{String(item.attributes.position ?? "-")}</span>
<span><strong>{item.title}</strong><small>{humanize(item.state)}</small></span>
</button>
{canWrite && canReviseCommitteeRecord(item) ? <IconButton label="Edit agenda item" icon={<Pencil size={15} />} onClick={() => setEditor({ kind: "agenda_item", record: item })} /> : null}
</div>
))}
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null}
</div>
</WorkspaceSection>
{selectedAgenda ? (
<WorkspaceSection
title={`Votes - ${selectedAgenda.title}`}
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "vote", parentId: selectedAgenda.object_id })}>
<Vote size={16} aria-hidden="true" />
Add vote
</Button>
) : null}
>
<RecordRows
records={votes}
canEdit={(record) => canWrite && canReviseCommitteeRecord(record)}
onEdit={(record) => setEditor({ kind: "vote", record })}
detail={voteSummary}
secondaryAction={(record) => canFinalizeBallot && isProviderBallotReady(record) ? (
<IconButton
label={`Finalize ${record.title} from ballot provider`}
icon={<ShieldCheck size={15} />}
onClick={() => setBallotRecord(record)}
/>
) : null}
/>
</WorkspaceSection>
) : null}
<WorkspaceSection
title="Minutes"
action={canWrite ? (
<Button variant="ghost" onClick={() => setEditor({ kind: "minute", parentId: selectedMeeting.object_id })}>
<FilePlus2 size={16} aria-hidden="true" />
Add minutes
</Button>
) : null}
>
<RecordRows records={minutes} canEdit={(record) => canWrite && canReviseCommitteeRecord(record)} onEdit={(record) => setEditor({ kind: "minute", record })} detail={(record) => `Record ${String((record.attributes.content_ref as Record<string, unknown> | undefined)?.object_id ?? "-")}`} />
</WorkspaceSection>
</PageScrollViewport>
</>
)}
</section>
</div>
</div>
{editor ? (
<CommitteeRecordDialog
settings={settings}
tenantId={tenantId}
kind={editor.kind}
parentId={editor.parentId}
record={editor.record}
open
onClose={() => setEditor(null)}
onSaved={() => void refresh()}
/>
) : null}
{ballotRecord ? (
<CommitteeBallotDialog
settings={settings}
record={ballotRecord}
open
onClose={() => setBallotRecord(null)}
onSaved={() => void refresh()}
/>
) : null}
</main>
);
}
function PanelHeading({ title, count }: { title: string; count: number }) {
return <div className="committee-panel-heading"><h2>{title}</h2><span>{count}</span></div>;
}
function RecordList({ records, selectedId, onSelect, secondary }: {
records: CommitteeRecord[];
selectedId: string;
onSelect: (id: string) => void;
secondary?: (record: CommitteeRecord) => string;
}) {
if (records.length === 0) return <div className="committee-empty compact">No records.</div>;
return <div className="committee-record-list">{records.map((record) => (
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
<strong>{record.title}</strong>
<span>{secondary?.(record) ?? humanize(record.state)}</span>
</button>
))}</div>;
}
function WorkspaceSection({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
return <section className="committee-workspace-section"><div><h2>{title}</h2>{action}</div>{children}</section>;
}
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 <div className="committee-empty compact">No records.</div>;
return <div className="committee-record-rows">{records.map((record) => (
<div key={record.object_id}>
<span><strong>{record.title}</strong><small>{detail(record)}</small></span>
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
<span className="committee-row-actions">
{secondaryAction?.(record)}
{canEdit(record) ? <IconButton label={`Edit ${record.title}`} icon={<Pencil size={15} />} onClick={() => onEdit(record)} /> : null}
</span>
</div>
))}</div>;
}
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());
}
@@ -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<string, string>;
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<Draft>(() => 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 (
<Dialog
open={open}
title={`${record ? "Edit" : "New"} ${labelForKind(kind)}`}
onClose={onClose}
closeDisabled={busy}
portal
className="committee-record-dialog"
footer={
<>
<Button disabled={busy} onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={busy || !draft.title.trim() || !draft.changeReason.trim()}
onClick={() => void save()}
>
{busy ? "Saving" : "Save"}
</Button>
</>
}
>
<div className="committee-record-form">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<div className="committee-form-grid">
<FormField label="Title">
<input
value={draft.title}
maxLength={500}
disabled={busy}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
</FormField>
<FormField label="State">
<select
value={draft.state}
disabled={busy}
onChange={(event) => setDraft({ ...draft, state: event.target.value })}
>
{stateOptions.map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select>
</FormField>
</div>
{kind === "body" ? (
<FormField label="Responsible organization unit ID">
<input
value={draft.organizationUnitId}
disabled={busy}
onChange={(event) => setDraft({ ...draft, organizationUnitId: event.target.value })}
/>
</FormField>
) : null}
{kind === "meeting" ? (
<div className="committee-form-grid">
<FormField label="Starts">
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
</FormField>
<FormField label="Ends">
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
</FormField>
</div>
) : null}
{kind === "agenda_item" ? (
<>
<div className="committee-form-grid committee-form-grid-three">
<FormField label="Position">
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
</FormField>
<FormField label="Subject type">
<select value={draft.subjectKind} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectKind: event.target.value })}>
<option value="case">Case</option>
<option value="service">Service</option>
<option value="work_item">Work item</option>
<option value="record">Record</option>
</select>
</FormField>
<FormField label="Subject ID">
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
</FormField>
</div>
{draft.state === "decided" ? (
<FormField label="Formal Decision ID">
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
</FormField>
) : null}
</>
) : null}
{kind === "vote" ? (
<>
<div className="committee-form-grid committee-form-grid-three">
<FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
<option value="recorded">Recorded</option>
<option value="public">Public</option>
<option value="secret">Secret</option>
</select>
</FormField>
<FormField label="Eligible voters">
<input type="number" min="0" value={draft.eligibleCount} disabled={busy} onChange={(event) => setDraft({ ...draft, eligibleCount: event.target.value })} />
</FormField>
<FormField label="Ballot provider (optional)">
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField>
</div>
<FormField label="Choices (comma separated)">
<input value={draft.choices} disabled={busy} onChange={(event) => setDraft({ ...draft, choices: event.target.value })} />
</FormField>
{draft.state === "closed" ? (
<div className="committee-vote-result-fields">
<div className="committee-form-grid">
<FormField label="Votes cast">
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
</FormField>
<FormField label="Quorum">
<select value={draft.quorumMet ? "met" : "not-met"} disabled={busy} onChange={(event) => setDraft({ ...draft, quorumMet: event.target.value === "met" })}>
<option value="met">Met</option>
<option value="not-met">Not met</option>
</select>
</FormField>
</div>
<div className="committee-count-grid">
{choices.map((choice) => (
<FormField key={choice} label={`${choice} votes`}>
<input
type="number"
min="0"
value={draft.counts[choice] ?? "0"}
disabled={busy}
onChange={(event) => setDraft({
...draft,
counts: { ...draft.counts, [choice]: event.target.value }
})}
/>
</FormField>
))}
</div>
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
</div>
) : null}
</>
) : null}
{kind === "minute" ? (
<>
<FormField label="Minutes record ID">
<input value={draft.contentRecordId} disabled={busy} onChange={(event) => setDraft({ ...draft, contentRecordId: event.target.value })} />
</FormField>
{draft.state === "accepted" || draft.state === "corrected" ? (
<EvidenceFields draft={draft} busy={busy} setDraft={setDraft} />
) : null}
</>
) : null}
<FormField label="Change reason">
<input
value={draft.changeReason}
maxLength={1000}
disabled={busy}
onChange={(event) => setDraft({ ...draft, changeReason: event.target.value })}
/>
</FormField>
</div>
</Dialog>
);
}
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return (
<div className="committee-form-grid">
<FormField label="Approval ID">
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
</FormField>
<FormField label="Evidence record ID">
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
</FormField>
</div>
);
}
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<string, unknown> {
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function firstMapping(value: unknown): Record<string, unknown> {
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());
}
+60
View File
@@ -0,0 +1,60 @@
import type { CommitteeObjectKind, CommitteeRecord } from "../../api/committee";
export const COMMITTEE_STATES: Record<CommitteeObjectKind, string[]> = {
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<CommitteeObjectKind, Record<string, string[]>> = {
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;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, committeeModule } from "./module";
export * from "./api/committee";
+32
View File
@@ -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;
+326
View File
@@ -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;
}
}