feat: implement committee decision workspace
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
+99
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user