STATE-WP-0101-T02/T03: decision classes, evidence refusals, write-back route
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

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
This commit is contained in:
tegwick 2026-11-01 04:01:48 +01:00
parent b86bd17cef
commit 0b473c1bda
8 changed files with 1225 additions and 39 deletions

View file

@ -42,6 +42,16 @@ class Settings(BaseSettings):
repository_rename_preflight_secret: str | None = None
repository_rename_preflight_ttl_seconds: int = 900
repository_rename_forge_timeout_seconds: float = 10.0
# STATE-WP-0101-T03: authenticated decision write-back route
# (POST /decisions/{id}/resolve-evidence). Off by default; enabling it needs
# the API ServiceAccount bound to system:auth-delegator (founder-run, cluster
# RBAC) so TokenReview works. Subjects: comma-separated exact allowlist.
decision_writeback_enabled: bool = False
decision_writeback_audience: str = "state-hub-decision-writeback"
decision_writeback_subjects: str = "system:serviceaccount:state-hub:state-hub"
kubernetes_api_url: str = "https://kubernetes.default.svc"
kubernetes_serviceaccount_dir: str = "/var/run/secrets/kubernetes.io/serviceaccount"
token_review_timeout_seconds: float = 5.0
settings = Settings()

View file

@ -2,8 +2,8 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
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
@ -21,6 +21,41 @@ class DecisionStatus(str, enum.Enum):
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__ = (
@ -28,6 +63,16 @@ class Decision(Base, TimestampMixin):
"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(
@ -58,6 +103,15 @@ class Decision(Base, TimestampMixin):
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

View file

@ -7,15 +7,30 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.events import EventEnvelope, publish_event
from api.models.decision import Decision, DecisionStatus, DecisionType
from api.models.decision import (
APPROVAL_CLASSES,
CLASS_RANK,
EVIDENCE_CLASSES,
Decision,
DecisionClass,
DecisionStatus,
DecisionType,
ResolutionSource,
)
from api.models.progress_event import ProgressEvent
from api.models.review_contract import ReviewContract
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param
from api.services.decision_writeback import (
DecisionEvidenceWrite,
require_writeback_caller,
stored_outcome,
)
from api.services.review_contracts import aggregate
logger = logging.getLogger(__name__)
@ -40,6 +55,82 @@ def _needs_escalation(body: DecisionCreate) -> str | None:
return None
# STATE-WP-0101: fields that resolve or describe a resolution. On C1/C2 they are
# written only by POST /decisions/{id}/resolve-evidence.
_RESOLVING_PATCH_FIELDS = ("decided_by", "decided_at", "rationale")
def _class_of(decision: Decision) -> DecisionClass | None:
return DecisionClass(decision.decision_class) if decision.decision_class else None
def _refuse_reserved(value: DecisionClass | None) -> None:
if value == DecisionClass.C3:
raise HTTPException(
status_code=422,
detail={
"error": "decision_class_reserved",
"message": "C3 is reserved and not usable (gate-house GH-DEC-2026-026)",
},
)
def _evidence_required(decision_class: DecisionClass, action: str) -> HTTPException:
return HTTPException(
status_code=409,
detail={
"error": "evidence_required",
"message": (
f"{decision_class.value} decisions are resolved only by informed-decision "
f"evidence; {action} is refused"
),
"decision_class": decision_class.value,
"resolve_with": "POST /decisions/{id}/resolve-evidence",
},
)
async def _require_review_contracts(session: AsyncSession, decision: Decision) -> None:
review_rows = await session.execute(
select(ReviewContract).where(
ReviewContract.decision_id == decision.id,
ReviewContract.active.is_(True),
ReviewContract.required_for_decision.is_(True),
)
)
for contract in review_rows.scalars():
review_state = await aggregate(session, contract)
if not review_state.satisfied:
raise HTTPException(
status_code=409,
detail={
"message": "required multi-owner review is not satisfied",
"contract_key": contract.contract_key,
"contract_digest": contract.contract_digest,
},
)
def _publish_resolved(decision: Decision, decided_by: str, rationale: str | None) -> None:
subject = "org.statehub.decision.resolved"
envelope = EventEnvelope.new(
subject,
attributes={
"decision_id": str(decision.id),
"title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None,
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
"decided_by": decided_by,
"rationale_snippet": (rationale or "")[:240],
"decision_class": decision.decision_class,
"memo_id": decision.memo_id,
"approval_id": str(decision.approval_id) if decision.approval_id else None,
"resolution_source": decision.resolution_source,
},
)
asyncio.create_task(publish_event(subject, envelope))
@router.get("/", response_model=list[DecisionRead])
async def list_decisions(
request: Request,
@ -49,6 +140,7 @@ async def list_decisions(
workstream_id: uuid.UUID | None = None,
status: DecisionStatus | None = None,
decision_type: DecisionType | None = None,
decision_class: DecisionClass | None = None,
session: AsyncSession = Depends(get_session),
) -> list[Decision]:
if workstream_id is not None and workplan_id is None:
@ -70,6 +162,8 @@ async def list_decisions(
q = q.where(Decision.status == status)
if decision_type:
q = q.where(Decision.decision_type == decision_type)
if decision_class:
q = q.where(Decision.decision_class == decision_class.value)
q = q.order_by(Decision.created_at)
result = await session.execute(q)
return list(result.scalars().all())
@ -91,7 +185,11 @@ async def create_decision(
route="/decisions/",
replacement_ref="POST /decisions/ with workplan_id",
)
_refuse_reserved(body.decision_class)
if body.decision_class in EVIDENCE_CLASSES and body.status == DecisionStatus.resolved:
raise _evidence_required(body.decision_class, "creating it already resolved")
data = body.model_dump()
data["decision_class"] = body.decision_class.value if body.decision_class else None
note = _needs_escalation(body)
if note:
data["escalation_note"] = note
@ -123,7 +221,52 @@ async def update_decision(
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
for field, value in body.model_dump(exclude_unset=True).items():
fields = body.model_dump(exclude_unset=True)
current = _class_of(decision)
effective = current
if "decision_class" in fields:
new = body.decision_class
_refuse_reserved(new)
if CLASS_RANK[new] < CLASS_RANK[current] or (new is None and current is not None):
raise HTTPException(
status_code=409,
detail={
"error": "decision_class_lowering",
"message": "a decision class may be raised, never lowered",
"decision_class": current.value if current else None,
},
)
if new != current and decision.status in (
DecisionStatus.resolved, DecisionStatus.superseded
):
raise HTTPException(
status_code=409,
detail={
"error": "decision_class_frozen",
"message": "the class of a resolved or superseded decision cannot change",
},
)
effective = new
fields["decision_class"] = new.value if new else None
if effective in EVIDENCE_CLASSES:
if fields.get("status") == DecisionStatus.resolved:
raise _evidence_required(effective, "setting status=resolved by PATCH")
touched = [f for f in _RESOLVING_PATCH_FIELDS if f in fields]
if touched:
raise _evidence_required(effective, f"setting {', '.join(touched)} by PATCH")
if (
decision.status == DecisionStatus.resolved
and "status" in fields
and fields["status"] not in (DecisionStatus.resolved, DecisionStatus.superseded)
):
raise HTTPException(
status_code=409,
detail={
"error": "evidence_bound",
"message": "an evidence-resolved decision is not reopened by PATCH",
},
)
for field, value in fields.items():
setattr(decision, field, value)
await session.commit()
await session.refresh(decision)
@ -155,31 +298,18 @@ async def resolve_decision_action(
raise HTTPException(status_code=404, detail="Decision not found")
if decision.status == DecisionStatus.resolved:
raise HTTPException(status_code=409, detail="Decision already resolved")
decision_class = _class_of(decision)
if decision_class in EVIDENCE_CLASSES:
raise _evidence_required(decision_class, "text resolution")
review_rows = await session.execute(
select(ReviewContract).where(
ReviewContract.decision_id == decision.id,
ReviewContract.active.is_(True),
ReviewContract.required_for_decision.is_(True),
)
)
for contract in review_rows.scalars():
review_state = await aggregate(session, contract)
if not review_state.satisfied:
raise HTTPException(
status_code=409,
detail={
"message": "required multi-owner review is not satisfied",
"contract_key": contract.contract_key,
"contract_digest": contract.contract_digest,
},
)
await _require_review_contracts(session, decision)
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.rationale = body.rationale
decision.decided_by = body.decided_by
decision.decided_at = datetime.now(tz=timezone.utc)
decision.resolution_source = ResolutionSource.text.value
await session.commit()
await session.refresh(decision)
@ -198,23 +328,152 @@ async def resolve_decision_action(
if body.write_log:
await _write_project_log(decision, body.rationale, body.decided_by, session)
subject = "org.statehub.decision.resolved"
envelope = EventEnvelope.new(
subject,
attributes={
"decision_id": str(decision.id),
"title": decision.title,
"topic_id": str(decision.topic_id) if decision.topic_id else None,
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
"decided_by": body.decided_by,
"rationale_snippet": (body.rationale or "")[:240],
},
)
asyncio.create_task(publish_event(subject, envelope))
_publish_resolved(decision, body.decided_by, body.rationale)
return decision
def _conflict(error: str, message: str, **extra) -> HTTPException:
return HTTPException(status_code=409, detail={"error": error, "message": message, **extra})
@router.post("/{decision_id}/resolve-evidence", response_model=DecisionRead)
async def resolve_decision_with_evidence(
decision_id: uuid.UUID,
body: DecisionEvidenceWrite,
caller: str = Depends(require_writeback_caller),
session: AsyncSession = Depends(get_session),
) -> Decision:
"""Link a memo to, or resolve, a C1/C2 decision with informed-decision evidence.
STATE-WP-0101-T03, design sections 5.1-5.3. Only the verified write-back
subject reaches this body. Idempotent on (decision_id, memo_id).
"""
decision = await session.get(Decision, decision_id)
if decision is None:
raise HTTPException(status_code=404, detail="Decision not found")
decision_class = _class_of(decision)
if decision_class not in (DecisionClass.C1, DecisionClass.C2):
raise _conflict(
"evidence_not_applicable",
"evidence write-back applies to C1 and C2 decisions only",
decision_class=decision_class.value if decision_class else None,
)
if decision.status == DecisionStatus.superseded:
raise _conflict("decision_superseded", "the decision was superseded")
if decision.memo_id is not None and decision.memo_id != body.memo_id:
raise _conflict(
"memo_conflict", "the decision is linked to another memo", memo_id=decision.memo_id
)
if decision.memo_id is None:
other = await session.execute(
select(Decision.id).where(Decision.memo_id == body.memo_id, Decision.id != decision.id)
)
if other.scalar_one_or_none() is not None:
raise _conflict("memo_already_linked", "the memo answers another decision")
outcome = body.outcome
if outcome is not None:
if outcome.hub_decision_id != decision.id:
raise HTTPException(status_code=422, detail={"error": "hub_decision_mismatch"})
if outcome.memo_id != body.memo_id:
raise HTTPException(status_code=422, detail={"error": "memo_mismatch"})
approvals = {
a for a in (
body.approval_id,
outcome.approval_id if outcome else None,
decision.approval_id,
) if a is not None
}
if len(approvals) > 1:
raise _conflict(
"approval_conflict",
"approval_id differs from the one linked",
approval_id=str(decision.approval_id) if decision.approval_id else None,
)
approval_id = next(iter(approvals), None)
if decision.status == DecisionStatus.resolved:
# Same memo (checked above): a repeated link or resolve is a no-op.
return decision
if body.mode == "link":
changed = decision.memo_id is None or (
approval_id is not None and decision.approval_id is None
)
decision.memo_id = body.memo_id
decision.approval_id = approval_id
if changed:
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise _conflict("memo_already_linked", "the memo answers another decision")
await session.refresh(decision)
return decision
assert outcome is not None # guaranteed by DecisionEvidenceWrite
if decision_class in APPROVAL_CLASSES and (
approval_id is None or not outcome.approval_entry_id
):
raise HTTPException(
status_code=422,
detail={
"error": "approval_required",
"message": "C2 resolution needs approval_id and approval_entry_id",
},
)
record = stored_outcome(outcome)
await _require_review_contracts(session, decision)
decided_by = (outcome.display_name or f"{outcome.subject.iss}#{outcome.subject.sub}")[:100]
verdict = "accepted" if outcome.verb == "accept" else "declined"
rationale = (
f"{verdict} in informed-decision, memo {body.memo_id} revision {outcome.memo_revision}"
+ (f", option {outcome.option}" if outcome.option else "")
+ (f"\n\n{outcome.note}" if outcome.note else "")
)
decision.status = DecisionStatus.resolved
decision.decision_type = DecisionType.made
decision.memo_id = body.memo_id
decision.approval_id = approval_id
decision.rationale = rationale
decision.decided_by = decided_by
decision.decided_at = outcome.decided_at
decision.resolution_source = ResolutionSource.informed_decision.value
decision.resolution_evidence = record
session.add(
ProgressEvent(
topic_id=decision.topic_id,
workplan_id=decision.workplan_id,
decision_id=decision.id,
event_type="decision_resolved",
summary=f"Decision resolved ({verdict}, informed-decision): {decision.title}",
author=decided_by,
detail={
"resolution_source": ResolutionSource.informed_decision.value,
"decision_class": decision_class.value,
"memo_id": body.memo_id,
"memo_revision": outcome.memo_revision,
"verb": outcome.verb,
"subject": record["subject"],
"view_hash": outcome.view_hash,
"presentation_id": outcome.presentation_id,
"approval_id": str(approval_id) if approval_id else None,
"attested_via": caller,
},
)
)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise _conflict("memo_already_linked", "the memo answers another decision")
await session.refresh(decision)
_publish_resolved(decision, decided_by, rationale)
return decision
async def _write_project_log(
decision: Decision, rationale: str, decided_by: str, session: AsyncSession
) -> None:

View file

@ -1,13 +1,42 @@
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from api.models.decision import DecisionStatus, DecisionType
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
@ -24,6 +53,13 @@ class DecisionCreate(LegacyWorkstreamIdBodyMixin, BaseModel):
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":
@ -49,6 +85,13 @@ class DecisionUpdate(BaseModel):
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):
@ -65,5 +108,16 @@ class DecisionRead(OptionalWorkplanIdCompatMixin, BaseModel):
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

View file

@ -0,0 +1,236 @@
"""Authenticated decision write-back (STATE-WP-0101-T03).
Design: docs/decision-services-alignment-answer.md sections 5.2 and 5.3; founder
decisions 1 and 2 of 2026-11-01 (section 8.1). The dispatch worker pulls decided
outcomes from informed-decision and posts them here. Exactly one caller is
admitted: a Kubernetes projected ServiceAccount token with the dedicated
audience, verified by TokenReview, whose subject is on an exact allowlist.
The hub records the human subject as attested by informed-decision through the
authenticated worker; it does not authenticate the human itself.
"""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Literal, Protocol
import httpx
from fastapi import Depends, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from api.config import settings
from api.services.informed_decision_contract import ContractError, scan_secrets
OUTCOME_KIND = "informed-decision.decision-outcome"
MAX_OUTCOME_BYTES = 8192
MAX_NOTE_BYTES = 2048
_MEMO_ID_PATTERN = r"^[a-z0-9][a-z0-9-]{2,62}$"
_VIEW_HASH_PATTERN = r"^sha256:[0-9a-f]{64}$"
# ---------------------------------------------------------------------------
# Outcome record, informed-decision.decision-outcome v1 (design section 5.2)
# ---------------------------------------------------------------------------
class OutcomeSubject(BaseModel):
model_config = ConfigDict(extra="forbid")
iss: str = Field(min_length=1, max_length=512)
sub: str = Field(min_length=1, max_length=255)
class OutcomeAssurance(BaseModel):
model_config = ConfigDict(extra="forbid")
acr: str | None = Field(default=None, max_length=128)
aal: str | None = Field(default=None, max_length=32)
auth_time: datetime
@model_validator(mode="after")
def _level_present(self) -> "OutcomeAssurance":
if not self.acr and not self.aal:
raise ValueError("assurance needs acr or aal")
if self.auth_time.tzinfo is None:
raise ValueError("auth_time must carry a timezone")
return self
class DecisionOutcome(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["informed-decision.decision-outcome"]
version: Literal[1]
hub_decision_id: uuid.UUID
memo_id: str = Field(pattern=_MEMO_ID_PATTERN)
memo_revision: int = Field(ge=0)
verb: Literal["accept", "decline"]
option: str | None = Field(default=None, max_length=64)
subject: OutcomeSubject
assurance: OutcomeAssurance
decided_at: datetime
view_hash: str = Field(pattern=_VIEW_HASH_PATTERN)
presentation_id: str = Field(min_length=1, max_length=128)
approval_id: uuid.UUID | None = None
approval_entry_id: str | None = Field(default=None, max_length=128)
audit_ref: str | None = Field(default=None, max_length=256)
display_name: str | None = Field(default=None, max_length=100)
note: str | None = None
@field_validator("decided_at")
@classmethod
def _utc(cls, value: datetime) -> datetime:
if value.tzinfo is None:
raise ValueError("decided_at must carry a timezone")
return value
@field_validator("note")
@classmethod
def _bounded_note(cls, value: str | None) -> str | None:
if value is not None and len(value.encode("utf-8")) > MAX_NOTE_BYTES:
raise ValueError(f"note exceeds {MAX_NOTE_BYTES} bytes")
return value
class DecisionEvidenceWrite(BaseModel):
"""Body of POST /decisions/{id}/resolve-evidence."""
model_config = ConfigDict(extra="forbid")
mode: Literal["link", "resolve"]
memo_id: str = Field(pattern=_MEMO_ID_PATTERN)
approval_id: uuid.UUID | None = None
outcome: DecisionOutcome | None = None
@model_validator(mode="after")
def _mode_shape(self) -> "DecisionEvidenceWrite":
if self.mode == "resolve" and self.outcome is None:
raise ValueError("resolve mode requires an outcome record")
if self.mode == "link" and self.outcome is not None:
raise ValueError("link mode carries no outcome record")
return self
def stored_outcome(outcome: DecisionOutcome) -> dict:
"""The record as received (unset optional fields omitted), bounded and secret-scanned."""
record = outcome.model_dump(mode="json", exclude_unset=True)
encoded = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
if len(encoded.encode("utf-8")) > MAX_OUTCOME_BYTES:
raise HTTPException(
status_code=422,
detail={"error": "outcome_too_large", "limit_bytes": MAX_OUTCOME_BYTES},
)
try:
scan_secrets(record)
except ContractError:
# The matched text is never echoed.
raise HTTPException(status_code=422, detail={"error": "secret_marker"}) from None
return record
# ---------------------------------------------------------------------------
# Caller verification (design section 5.3)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class TokenReviewResult:
authenticated: bool
username: str | None = None
audiences: tuple[str, ...] = ()
class TokenReviewer(Protocol):
async def review(self, token: str, audience: str) -> TokenReviewResult: ...
class TokenReviewUnavailable(RuntimeError):
pass
class KubernetesTokenReviewer:
"""TokenReview against the cluster API with the API pod's own ServiceAccount.
Needs the API ServiceAccount bound to ``system:auth-delegator`` (founder-run
cluster RBAC). Any transport or API failure raises TokenReviewUnavailable;
the route then refuses (fail closed).
"""
def __init__(
self,
api_url: str | None = None,
serviceaccount_dir: str | None = None,
timeout: float | None = None,
) -> None:
self.api_url = (api_url or settings.kubernetes_api_url).rstrip("/")
self.sa_dir = Path(serviceaccount_dir or settings.kubernetes_serviceaccount_dir)
self.timeout = timeout if timeout is not None else settings.token_review_timeout_seconds
async def review(self, token: str, audience: str) -> TokenReviewResult:
try:
own_token = (self.sa_dir / "token").read_text().strip()
ca = str(self.sa_dir / "ca.crt")
async with httpx.AsyncClient(verify=ca, timeout=self.timeout) as client:
response = await client.post(
f"{self.api_url}/apis/authentication.k8s.io/v1/tokenreviews",
headers={"Authorization": f"Bearer {own_token}"},
json={
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"spec": {"token": token, "audiences": [audience]},
},
)
except (OSError, httpx.HTTPError) as exc:
raise TokenReviewUnavailable(type(exc).__name__) from None
if response.status_code != 201 and response.status_code != 200:
raise TokenReviewUnavailable(f"tokenreview_http_{response.status_code}")
status = (response.json() or {}).get("status") or {}
return TokenReviewResult(
authenticated=bool(status.get("authenticated")),
username=(status.get("user") or {}).get("username"),
audiences=tuple(status.get("audiences") or ()),
)
def get_token_reviewer() -> TokenReviewer:
"""FastAPI dependency; tests override it with a fake reviewer."""
return KubernetesTokenReviewer()
def allowed_subjects() -> frozenset[str]:
return frozenset(
s.strip() for s in settings.decision_writeback_subjects.split(",") if s.strip()
)
async def require_writeback_caller(
request: Request, reviewer: TokenReviewer = Depends(get_token_reviewer)
) -> str:
"""Return the verified caller subject or refuse. Feature flag first, then token."""
if not settings.decision_writeback_enabled:
raise HTTPException(status_code=503, detail={"error": "decision_writeback_disabled"})
header = request.headers.get("authorization", "")
scheme, _, token = header.partition(" ")
if scheme.lower() != "bearer" or not token.strip():
raise HTTPException(
status_code=401,
detail={"error": "token_required"},
headers={"WWW-Authenticate": "Bearer"},
)
audience = settings.decision_writeback_audience
try:
result = await reviewer.review(token.strip(), audience)
except TokenReviewUnavailable:
raise HTTPException(
status_code=503, detail={"error": "token_review_unavailable"}
) from None
if not result.authenticated or audience not in result.audiences:
raise HTTPException(
status_code=401,
detail={"error": "token_invalid"},
headers={"WWW-Authenticate": "Bearer"},
)
if result.username not in allowed_subjects():
raise HTTPException(status_code=403, detail={"error": "subject_not_allowed"})
return result.username