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
+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",
]