state-hub/api/models/decision.py
tegwick 0b473c1bda
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m3s
STATE-WP-0101-T02/T03: decision classes, evidence refusals, write-back route
T02: additive migration d5e6f7a8b9c0 (decision_class, memo_id,
approval_id, resolution_source, resolution_evidence, evidence_refs,
CHECKs, partial unique memo_id); typed evidence_refs; 409
evidence_required for text resolve, resolving PATCH fields and resolved
create on C1/C2; raise-only class; C3 refused as reserved.

T03: POST /decisions/{id}/resolve-evidence (link/resolve), projected
token verified by TokenReview with audience
state-hub-decision-writeback and an exact subject allowlist; outcome
record v1 validation, 8 KiB bound, secret scan; idempotent on
(decision_id, memo_id). Off by default (DECISION_WRITEBACK_ENABLED).
Tests use a fake reviewer; full suite 1286 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-11-01 04:01:48 +01:00

120 lines
5.1 KiB
Python

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