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
123 lines
4.2 KiB
Python
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
|