Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions src/api/consolidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
resolve_consolidation_proposal,
reverse_consolidation_execution,
)
from .proposals import ProposalActorRequest, ProposalReviewRequest
except ImportError:
from api.proposals import ProposalActorRequest, ProposalReviewRequest
from db.consolidation_queries import (
apply_consolidation_proposal,
generate_consolidation_proposals,
Expand All @@ -31,63 +33,66 @@ class GenerationRequest(BaseModel):
minimum_score: float = Field(default=0.5, ge=0, le=1)


class ReviewRequest(BaseModel):
state: Literal["accepted", "rejected"]
reviewed_by: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


class ActorRequest(BaseModel):
actor: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


@router.post("/proposals/generate", status_code=status.HTTP_201_CREATED)
async def generate(request: GenerationRequest) -> dict:
proposals = generate_consolidation_proposals(limit=request.limit, minimum_score=request.minimum_score)
return {"created": len(proposals), "proposals": proposals}
try:
proposals = generate_consolidation_proposals(
limit=request.limit, minimum_score=request.minimum_score
)
return {"created": len(proposals), "proposals": proposals}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/proposals")
async def list_proposals(
state: Literal["pending", "accepted", "rejected", "superseded"] = "pending",
limit: int = Query(default=100, ge=1, le=500),
) -> list[dict]:
return list_consolidation_proposals(state=state, limit=limit)
try:
return list_consolidation_proposals(state=state, limit=limit)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/proposals/{proposal_id}/review")
async def review(proposal_id: UUID, request: ReviewRequest) -> dict:
async def review(proposal_id: UUID, request: ProposalReviewRequest) -> dict:
try:
proposal = resolve_consolidation_proposal(
proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if proposal is None:
raise HTTPException(status_code=404, detail="Pending consolidation proposal not found")
return proposal


@router.post("/proposals/{proposal_id}/apply")
async def apply(proposal_id: UUID, request: ActorRequest) -> dict:
async def apply(proposal_id: UUID, request: ProposalActorRequest) -> dict:
try:
execution = apply_consolidation_proposal(proposal_id, applied_by=request.actor)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if execution is None:
raise HTTPException(status_code=404, detail="Consolidation proposal not found")
return execution


@router.post("/executions/{execution_id}/reverse")
async def reverse(execution_id: UUID, request: ActorRequest) -> dict:
async def reverse(execution_id: UUID, request: ProposalActorRequest) -> dict:
try:
execution = reverse_consolidation_execution(
execution_id, reversed_by=request.actor, note=request.note
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if execution is None:
raise HTTPException(status_code=404, detail="Consolidation execution not found")
return execution
27 changes: 7 additions & 20 deletions src/api/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
reverse_lifecycle_execution,
)
from ..lifecycle.execution import LifecycleExecutionError
from .proposals import ProposalActorRequest, ProposalReviewRequest
except ImportError: # Support legacy execution with src/ directly on sys.path.
from api.proposals import ProposalActorRequest, ProposalReviewRequest
from db.lifecycle_queries import (
apply_lifecycle_proposal,
generate_lifecycle_proposals,
Expand All @@ -35,21 +37,6 @@ class ProposalGenerationRequest(BaseModel):
minimum_score: float = Field(default=0.25, ge=0, le=1)


class ProposalReviewRequest(BaseModel):
state: Literal["accepted", "rejected"]
reviewed_by: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


class ProposalApplyRequest(BaseModel):
applied_by: str = Field(min_length=1, max_length=200)


class ProposalReverseRequest(BaseModel):
reversed_by: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


@router.post("/proposals/generate", status_code=status.HTTP_201_CREATED)
async def generate_proposals(request: ProposalGenerationRequest) -> dict:
try:
Expand Down Expand Up @@ -77,7 +64,7 @@ async def review_proposal(proposal_id: UUID, request: ProposalReviewRequest) ->
proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if proposal is None:
Expand All @@ -86,9 +73,9 @@ async def review_proposal(proposal_id: UUID, request: ProposalReviewRequest) ->


@router.post("/proposals/{proposal_id}/apply")
async def apply_proposal(proposal_id: UUID, request: ProposalApplyRequest) -> dict:
async def apply_proposal(proposal_id: UUID, request: ProposalActorRequest) -> dict:
try:
execution = apply_lifecycle_proposal(proposal_id, applied_by=request.applied_by)
execution = apply_lifecycle_proposal(proposal_id, applied_by=request.actor)
except LifecycleExecutionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
Expand All @@ -99,10 +86,10 @@ async def apply_proposal(proposal_id: UUID, request: ProposalApplyRequest) -> di


@router.post("/executions/{execution_id}/reverse")
async def reverse_execution(execution_id: UUID, request: ProposalReverseRequest) -> dict:
async def reverse_execution(execution_id: UUID, request: ProposalActorRequest) -> dict:
try:
execution = reverse_lifecycle_execution(
execution_id, reversed_by=request.reversed_by, note=request.note
execution_id, reversed_by=request.actor, note=request.note
)
except LifecycleExecutionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
Expand Down
35 changes: 35 additions & 0 deletions src/api/proposals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Shared REST contracts for human-reviewed proposal workflows."""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator


ProposalDecision = Literal["accepted", "rejected"]


class ProposalReviewRequest(BaseModel):
"""Explicit human decision for a pending proposal."""

model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

state: ProposalDecision
reviewed_by: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)

@model_validator(mode="after")
def require_rejection_note(self) -> "ProposalReviewRequest":
if self.state == "rejected" and not self.note:
raise ValueError("rejected proposals require a review note")
return self


class ProposalActorRequest(BaseModel):
"""Actor attribution for applying or reversing an approved proposal."""

model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

actor: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)
46 changes: 26 additions & 20 deletions src/api/pruning.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
review_pruning_proposal,
)
from ..pruning.execution import PruningConflict
from .proposals import ProposalActorRequest, ProposalReviewRequest
except ImportError:
from api.proposals import ProposalActorRequest, ProposalReviewRequest
from db.pruning_queries import (
apply_pruning_proposal,
generate_pruning_proposals,
Expand All @@ -32,60 +34,64 @@ class GenerateRequest(BaseModel):
limit: int = Field(default=500, ge=1, le=2000)


class ReviewRequest(BaseModel):
state: Literal["accepted", "rejected"]
reviewed_by: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


class ActorRequest(BaseModel):
actor: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=4000)


@router.post("/proposals/generate", status_code=status.HTTP_201_CREATED)
async def generate(request: GenerateRequest) -> dict:
proposals = generate_pruning_proposals(limit=request.limit)
return {"created": len(proposals), "proposals": proposals}
try:
proposals = generate_pruning_proposals(limit=request.limit)
return {"created": len(proposals), "proposals": proposals}
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.get("/proposals")
async def list_proposals(
state: Literal["pending", "accepted", "rejected", "applied", "reversed", "stale"] = "pending",
limit: int = Query(default=100, ge=1, le=500),
) -> list[dict]:
return list_pruning_proposals(state=state, limit=limit)
try:
return list_pruning_proposals(state=state, limit=limit)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc


@router.post("/proposals/{proposal_id}/review")
async def review(proposal_id: UUID, request: ReviewRequest) -> dict:
proposal = review_pruning_proposal(
proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note
)
async def review(proposal_id: UUID, request: ProposalReviewRequest) -> dict:
try:
proposal = review_pruning_proposal(
proposal_id, state=request.state, reviewed_by=request.reviewed_by, note=request.note
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if proposal is None:
raise HTTPException(status_code=404, detail="Pending pruning proposal not found")
return proposal


@router.post("/proposals/{proposal_id}/apply")
async def apply(proposal_id: UUID, request: ActorRequest) -> dict:
async def apply(proposal_id: UUID, request: ProposalActorRequest) -> dict:
try:
tombstone = apply_pruning_proposal(proposal_id, applied_by=request.actor)
except (PruningConflict, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if tombstone is None:
raise HTTPException(status_code=404, detail="Pruning proposal not found")
return tombstone


@router.post("/tombstones/{tombstone_id}/restore")
async def restore(tombstone_id: UUID, request: ActorRequest) -> dict:
async def restore(tombstone_id: UUID, request: ProposalActorRequest) -> dict:
try:
tombstone = restore_tombstone(
tombstone_id, reversed_by=request.actor, note=request.note
)
except (PruningConflict, ValueError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if tombstone is None:
raise HTTPException(status_code=404, detail="Tombstone not found")
return tombstone
44 changes: 44 additions & 0 deletions tests/test_proposal_api_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

import pytest
from pydantic import ValidationError

from api.proposals import ProposalActorRequest, ProposalReviewRequest


def test_acceptance_allows_optional_note() -> None:
request = ProposalReviewRequest(state="accepted", reviewed_by="operator")

assert request.state == "accepted"
assert request.reviewed_by == "operator"
assert request.note is None


def test_rejection_requires_explanation() -> None:
with pytest.raises(ValidationError, match="rejected proposals require a review note"):
ProposalReviewRequest(state="rejected", reviewed_by="operator")


def test_review_contract_strips_actor_and_note_whitespace() -> None:
request = ProposalReviewRequest(
state="rejected",
reviewed_by=" operator ",
note=" stale evidence ",
)

assert request.reviewed_by == "operator"
assert request.note == "stale evidence"


def test_review_contract_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError):
ProposalReviewRequest(
state="accepted",
reviewed_by="operator",
unexpected=True,
)


def test_actor_contract_requires_explicit_attribution() -> None:
with pytest.raises(ValidationError):
ProposalActorRequest(actor=" ")
Loading