from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime, timezone from sqlalchemy.orm import Session from govoplan_committee.backend.db.models import ( CommitteeDecisionProjection, CommitteeWorkspaceEvent, CommitteeWorkspaceRevision, ) from govoplan_core.core.dsar import ( DsarErasureActionRef, DsarExecutionResultRef, DsarRecordRef, DsarSubjectRef, dsar_capability_name, ) COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee") _MAX_RECORDS = 5_000 _CONFLICT = object() @dataclass(frozen=True, slots=True) class _SubjectSelectors: actor_ids: tuple[str, ...] object_kind: str | None object_id: str | None decision_id: str | None class CommitteeDsarProvider: provider_id = "committee" module_id = "committee" def search_subject( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, ) -> Sequence[DsarRecordRef]: db = _session(session) selectors = _subject_selectors(subject) if selectors is None: return () records: list[DsarRecordRef] = [] revisions = db.query(CommitteeWorkspaceRevision).filter( CommitteeWorkspaceRevision.tenant_id == tenant_id, CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids), ) events = db.query(CommitteeWorkspaceEvent).filter( CommitteeWorkspaceEvent.tenant_id == tenant_id, CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids), ) decisions = db.query(CommitteeDecisionProjection).filter( CommitteeDecisionProjection.tenant_id == tenant_id, CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids), ) if selectors.object_kind: revisions = revisions.filter( CommitteeWorkspaceRevision.object_kind == selectors.object_kind ) events = events.filter( CommitteeWorkspaceEvent.object_kind == selectors.object_kind ) if selectors.object_id: revisions = revisions.filter( CommitteeWorkspaceRevision.object_id == selectors.object_id ) events = events.filter( CommitteeWorkspaceEvent.object_id == selectors.object_id ) if selectors.object_kind == "meeting": decisions = decisions.filter( CommitteeDecisionProjection.meeting_id == selectors.object_id ) elif selectors.object_kind in {"agenda", "agenda_item"}: decisions = decisions.filter( CommitteeDecisionProjection.agenda_item_id == selectors.object_id ) elif selectors.object_kind == "decision": decisions = decisions.filter( CommitteeDecisionProjection.decision_id == selectors.object_id ) if selectors.decision_id: decisions = decisions.filter( CommitteeDecisionProjection.decision_id == selectors.decision_id ) records.extend( _revision_attribution(row) for row in _limited( revisions, CommitteeWorkspaceRevision.recorded_at, CommitteeWorkspaceRevision.id, label="workspace revision attribution", ) ) records.extend( _event_attribution(row) for row in _limited( events, CommitteeWorkspaceEvent.occurred_at, CommitteeWorkspaceEvent.id, label="workspace event attribution", ) ) records.extend( _decision_attribution(row) for row in _limited( decisions, CommitteeDecisionProjection.recorded_at, CommitteeDecisionProjection.id, label="Decision projection attribution", ) ) if len(records) > _MAX_RECORDS: raise ValueError( "Committee DSAR combined result limit exceeded; narrow the selectors." ) return tuple( sorted(records, key=lambda item: (item.resource_type, item.resource_id)) ) def plan_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, records: Sequence[DsarRecordRef], ) -> Sequence[DsarErasureActionRef]: del tenant_id _session(session) if _subject_selectors(subject) is None: raise ValueError("Committee DSAR subject selectors conflict.") actions: list[DsarErasureActionRef] = [] for record in records: _validate_record(record) actions.append( DsarErasureActionRef( action_id=( f"committee:retain:{record.resource_type}:{record.resource_id}" ), provider_id=self.provider_id, module_id=self.module_id, kind="retain", resource_type=record.resource_type, resource_id=record.resource_id, title=f"Retain {record.title}", rationale=record.retention_reason or "Committee attribution is immutable evidence.", executable=False, ) ) return tuple(actions) def execute_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, actions: Sequence[DsarErasureActionRef], request_id: str, ) -> Sequence[DsarExecutionResultRef]: del tenant_id _session(session) if _subject_selectors(subject) is None: raise ValueError("Committee DSAR subject selectors conflict.") results: list[DsarExecutionResultRef] = [] for action in actions: _validate_action(action) if action.executable or action.kind != "retain": raise ValueError("Committee DSAR publishes retain-only actions.") results.append( DsarExecutionResultRef( action_id=action.action_id, status="blocked", summary=( "Committee workspace, event, and Decision attribution remains " "immutable institutional evidence." ), evidence={"request_id": request_id}, ) ) return tuple(results) def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: references = subject.external_references values = { "account_id": _coalesce( subject.account_id, references.get("committee.account"), references.get("access.account"), ), "membership_id": _coalesce( subject.membership_id, references.get("committee.membership"), references.get("tenancy.membership"), ), "identity_id": _coalesce( subject.identity_id, references.get("committee.identity"), references.get("identity.id"), ), "actor_id": _coalesce( references.get("committee.actor"), references.get("committee.changed_by"), ), "object_kind": _coalesce( references.get("committee.object_kind"), references.get("committee.kind"), ), "object_id": _coalesce( references.get("committee.object"), references.get("committee.object_id"), ), "decision_id": _coalesce( references.get("committee.decision"), references.get("committee.decision_id"), ), } if any(value is _CONFLICT for value in values.values()): return None actor_ids = tuple( dict.fromkeys( value for value in ( _optional_string(values["account_id"]), _prefixed("account", values["account_id"]), _optional_string(values["membership_id"]), _prefixed("membership", values["membership_id"]), _optional_string(values["identity_id"]), _prefixed("identity", values["identity_id"]), ) if value ) ) direct_actor = _optional_string(values["actor_id"]) if direct_actor: if actor_ids and direct_actor not in actor_ids: return None if not actor_ids: actor_ids = (direct_actor,) if not actor_ids: return None return _SubjectSelectors( actor_ids=actor_ids, object_kind=_optional_string(values["object_kind"]), object_id=_optional_string(values["object_id"]), decision_id=_optional_string(values["decision_id"]), ) def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef: return DsarRecordRef( provider_id="committee", module_id="committee", resource_type="committee_workspace_actor_attribution", resource_id=row.id, category="committee_workspace_accountability", title="Committee workspace revision attribution", data={ "object_kind": row.object_kind, "object_id": row.object_id, "revision": row.revision, "parent_kind": row.parent_kind, "parent_id": row.parent_id, "state": row.state, "recorded_at": _iso(row.recorded_at), "superseded_at": _iso(row.superseded_at), "activity": "recorded_workspace_revision", }, observed_at=_aware(row.recorded_at), immutable_evidence=True, retention_reason="Committee workspace attribution is immutable evidence.", ) def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef: return DsarRecordRef( provider_id="committee", module_id="committee", resource_type="committee_event_actor_attribution", resource_id=row.id, category="committee_workspace_accountability", title="Committee lifecycle event attribution", data={ "object_kind": row.object_kind, "object_id": row.object_id, "object_revision": row.object_revision, "event_id": row.event_id, "event_type": row.event_type, "occurred_at": _iso(row.occurred_at), }, observed_at=_aware(row.occurred_at), immutable_evidence=True, retention_reason="Committee event attribution is immutable evidence.", ) def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef: return DsarRecordRef( provider_id="committee", module_id="committee", resource_type="committee_decision_actor_attribution", resource_id=row.id, category="committee_decision_accountability", title="Committee fallback Decision attribution", data={ "decision_id": row.decision_id, "revision": row.revision, "meeting_id": row.meeting_id, "agenda_item_id": row.agenda_item_id, "state": row.state, "recorded_at": _iso(row.recorded_at), "superseded_at": _iso(row.superseded_at), "activity": "recorded_decision_projection", }, observed_at=_aware(row.recorded_at), immutable_evidence=True, retention_reason=( "Committee fallback Decision attribution is immutable evidence." ), ) def _limited(query, first, second, *, label: str): rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all() if len(rows) > _MAX_RECORDS: raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.") return rows def _coalesce(*values: str | None) -> str | None | object: normalized = {str(value).strip() for value in values if str(value or "").strip()} if len(normalized) > 1: return _CONFLICT return next(iter(normalized), None) def _optional_string(value: object) -> str | None: return value if isinstance(value, str) and value else None def _prefixed(prefix: str, value: object) -> str | None: normalized = _optional_string(value) return f"{prefix}:{normalized}" if normalized else None def _iso(value: datetime | None) -> str | None: aware = _aware(value) return aware.isoformat() if aware else None def _aware(value: datetime | None) -> datetime | None: if value is None or value.tzinfo is not None: return value return value.replace(tzinfo=timezone.utc) def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Committee DSAR requires a SQLAlchemy Session.") return value _RESOURCE_TYPES = { "committee_workspace_actor_attribution", "committee_event_actor_attribution", "committee_decision_actor_attribution", } def _validate_record(record: DsarRecordRef) -> None: if record.provider_id != "committee" or record.module_id != "committee": raise ValueError("Committee DSAR cannot plan a foreign provider record.") if record.resource_type not in _RESOURCE_TYPES or not record.resource_id: raise ValueError("Committee DSAR record identity is invalid.") def _validate_action(action: DsarErasureActionRef) -> None: if action.provider_id != "committee" or action.module_id != "committee": raise ValueError("Committee DSAR cannot execute a foreign provider action.") if not action.action_id.startswith("committee:retain:"): raise ValueError("Committee DSAR action identity is invalid.") __all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]