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
120 lines
5.1 KiB
Python
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"
|
|
)
|