feat: implement committee decision workspace

This commit is contained in:
2026-08-01 17:48:25 +02:00
parent 232329ac52
commit 758b59ca03
28 changed files with 4909 additions and 32 deletions
@@ -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",
]