import enum import uuid from datetime import datetime from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, Index, String, Text, text from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from api.models.base import Base, TimestampMixin, new_uuid class DecisionType(str, enum.Enum): made = "made" pending = "pending" class DecisionStatus(str, enum.Enum): open = "open" resolved = "resolved" escalated = "escalated" superseded = "superseded" class DecisionClass(str, enum.Enum): """Gate-house decision classes (GH-DEC-2026-026). NULL on a row means legacy (as C0).""" C0 = "C0" C1 = "C1" C2 = "C2" C3 = "C3" # reserved by GH-DEC-2026-026; stored values allowed, the API refuses it class ResolutionSource(str, enum.Enum): text = "text" informed_decision = "informed_decision" # Classes whose resolution needs informed-decision evidence (STATE-WP-0101, design section 3.1). # C3 is reserved and refused by the API; the database treats it fail-closed like C2. EVIDENCE_CLASSES = frozenset({DecisionClass.C1, DecisionClass.C2, DecisionClass.C3}) APPROVAL_CLASSES = frozenset({DecisionClass.C2, DecisionClass.C3}) CLASS_RANK = {None: 0, DecisionClass.C0: 0, DecisionClass.C1: 1, DecisionClass.C2: 2, DecisionClass.C3: 3} # Shared with the Alembic migration (STATE-WP-0101-T02); keep the two in step. CK_DECISION_CLASS = "decision_class IS NULL OR decision_class IN ('C0','C1','C2','C3')" CK_RESOLUTION_SOURCE = ( "resolution_source IS NULL OR resolution_source IN ('text','informed_decision')" ) CK_CLASS_EVIDENCE = ( "NOT (status = 'resolved' AND decision_class IN ('C1','C2','C3')) OR " "(resolution_source = 'informed_decision' AND memo_id IS NOT NULL " "AND resolution_evidence IS NOT NULL)" ) CK_CLASS_APPROVAL = ( "NOT (status = 'resolved' AND decision_class IN ('C2','C3')) OR approval_id IS NOT NULL" ) class Decision(Base, TimestampMixin): __tablename__ = "decisions" __table_args__ = ( CheckConstraint( "topic_id IS NOT NULL OR workplan_id IS NOT NULL", name="ck_decisions_topic_or_workplan", ), CheckConstraint(CK_DECISION_CLASS, name="ck_decisions_decision_class"), CheckConstraint(CK_RESOLUTION_SOURCE, name="ck_decisions_resolution_source"), CheckConstraint(CK_CLASS_EVIDENCE, name="ck_decisions_class_evidence"), CheckConstraint(CK_CLASS_APPROVAL, name="ck_decisions_class_approval"), Index( "uq_decisions_memo_id", "memo_id", unique=True, postgresql_where=text("memo_id IS NOT NULL"), ), ) id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=new_uuid ) topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True ) workplan_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), nullable=True, index=True, ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) decision_type: Mapped[DecisionType] = mapped_column( Enum(DecisionType), nullable=False, default=DecisionType.pending ) status: Mapped[DecisionStatus] = mapped_column( Enum(DecisionStatus), nullable=False, default=DecisionStatus.open ) rationale: Mapped[str | None] = mapped_column(Text, nullable=True) decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True) superseded_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True ) # STATE-WP-0101: decision class and informed-decision evidence links. decision_class: Mapped[str | None] = mapped_column(String(2), nullable=True, index=True) memo_id: Mapped[str | None] = mapped_column(Text, nullable=True) approval_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) resolution_source: Mapped[str | None] = mapped_column(String(32), nullable=True) resolution_evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True) evidence_refs: Mapped[list] = mapped_column( JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb") ) topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821 workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821 progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 "ProgressEvent", back_populates="decision", lazy="selectin" )