state-hub/api/schemas/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

123 lines
4.2 KiB
Python

import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from api.models.decision import DecisionClass, DecisionStatus, DecisionType
from api.schemas.compat import LegacyWorkstreamIdBodyMixin, OptionalWorkplanIdCompatMixin
from pydantic import AliasChoices, Field
EvidenceRefType = Literal[
"presentation", "audit_record", "ccr", "workplan_task", "adr", "commit", "document"
]
# Written only through POST /decisions/{id}/resolve-evidence (STATE-WP-0101).
EVIDENCE_LINK_FIELDS = ("memo_id", "approval_id", "resolution_source", "resolution_evidence")
class EvidenceRef(BaseModel):
"""Descriptive reference (design section 3.2). Resolves nothing; closed type vocabulary."""
model_config = ConfigDict(extra="forbid")
type: EvidenceRefType
ref: str = Field(min_length=1, max_length=512)
digest: str | None = Field(default=None, max_length=128)
def _refuse_link_fields(data: Any) -> Any:
if isinstance(data, dict):
present = sorted(k for k in EVIDENCE_LINK_FIELDS if k in data)
if present:
raise ValueError(
f"{', '.join(present)} can only be set through "
"POST /decisions/{id}/resolve-evidence"
)
return data
class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel):
id: uuid.UUID | None = None
topic_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
title: str
description: str | None = None
decision_type: DecisionType = DecisionType.pending
status: DecisionStatus = DecisionStatus.open
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
decision_class: DecisionClass | None = None
evidence_refs: list[EvidenceRef] = Field(default_factory=list, max_length=64)
@model_validator(mode="before")
@classmethod
def no_link_fields(cls, data: Any) -> Any:
return _refuse_link_fields(data)
@model_validator(mode="after")
def topic_or_workplan_required(self) -> "DecisionCreate":
if self.topic_id is None and self.workplan_id is None:
raise ValueError("At least one of topic_id or workplan_id must be set")
return self
class DecisionResolve(BaseModel):
rationale: str
decided_by: str
write_log: bool = True # append to DECISIONS.md in the registered project directory
class DecisionUpdate(BaseModel):
title: str | None = None
description: str | None = None
decision_type: DecisionType | None = None
status: DecisionStatus | None = None
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
decision_class: DecisionClass | None = None
evidence_refs: list[EvidenceRef] | None = Field(default=None, max_length=64)
@model_validator(mode="before")
@classmethod
def no_link_fields(cls, data: Any) -> Any:
return _refuse_link_fields(data)
class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID | None = None
title: str
description: str | None = None
decision_type: DecisionType
status: DecisionStatus
rationale: str | None = None
decided_by: str | None = None
decided_at: datetime | None = None
deadline: datetime | None = None
escalation_note: str | None = None
superseded_by: uuid.UUID | None = None
decision_class: DecisionClass | None = None
memo_id: str | None = None
approval_id: uuid.UUID | None = None
resolution_source: str | None = None
resolution_evidence: dict | None = None
evidence_refs: list[dict] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@field_validator("evidence_refs", mode="before")
@classmethod
def _refs_default(cls, value: Any) -> Any:
return [] if value is None else value