state-hub/api/models/decision.py

121 lines
5.1 KiB
Python
Raw Normal View History

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"
)