feat(review): add multi-owner contracts and receipts
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
parent
52aefe39e4
commit
598f6418e7
13 changed files with 1401 additions and 9 deletions
|
|
@ -6,7 +6,6 @@ from pathlib import Path
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -14,9 +13,12 @@ from api.database import get_session
|
|||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
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.review_contracts import aggregate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
|
|
@ -154,6 +156,25 @@ async def resolve_decision_action(
|
|||
if decision.status == DecisionStatus.resolved:
|
||||
raise HTTPException(status_code=409, detail="Decision already resolved")
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
decision.status = DecisionStatus.resolved
|
||||
decision.decision_type = DecisionType.made
|
||||
decision.rationale = body.rationale
|
||||
|
|
|
|||
190
api/routers/review_contracts.py
Normal file
190
api/routers/review_contracts.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.schemas.review_contract import (
|
||||
ReviewAggregateRead,
|
||||
ReviewContractProject,
|
||||
ReviewContractRead,
|
||||
ReviewReceiptRead,
|
||||
ReviewReceiptSubmit,
|
||||
)
|
||||
from api.services.review_contracts import (
|
||||
active_contract,
|
||||
aggregate,
|
||||
digest_document,
|
||||
normalize_contract,
|
||||
utc_now,
|
||||
validate_receipt,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/review-contracts", tags=["review-contracts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projections",
|
||||
response_model=ReviewContractRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def project_contract(
|
||||
body: ReviewContractProject,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewContract:
|
||||
document, contract_digest = normalize_contract(body.contract)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ReviewContract).where(
|
||||
ReviewContract.contract_digest == contract_digest
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if (
|
||||
existing.source_repo != body.source.repo
|
||||
or existing.source_path != body.source.path
|
||||
or existing.source_revision != body.source.revision
|
||||
):
|
||||
raise HTTPException(
|
||||
409, "contract digest is already projected from a different source"
|
||||
)
|
||||
return existing
|
||||
contract_key = document["contract_key"]
|
||||
await session.execute(
|
||||
update(ReviewContract)
|
||||
.where(
|
||||
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
|
||||
)
|
||||
.values(active=False)
|
||||
)
|
||||
contract = ReviewContract(
|
||||
contract_key=contract_key,
|
||||
schema_version="review-contract/v1",
|
||||
contract_digest=contract_digest,
|
||||
source_repo=body.source.repo,
|
||||
source_path=body.source.path,
|
||||
source_revision=body.source.revision,
|
||||
document=document,
|
||||
active=True,
|
||||
required_for_decision=body.required_for_decision,
|
||||
decision_id=body.decision_id,
|
||||
workplan_id=body.workplan_id,
|
||||
task_id=body.task_id,
|
||||
projected_at=utc_now(),
|
||||
)
|
||||
session.add(contract)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
409, "source revision is already projected with different content"
|
||||
) from exc
|
||||
await session.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ReviewContractRead])
|
||||
async def list_contracts(
|
||||
contract_key: str | None = Query(None),
|
||||
active: bool | None = Query(None),
|
||||
decision_id: uuid.UUID | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ReviewContract]:
|
||||
query = select(ReviewContract).order_by(ReviewContract.projected_at.desc())
|
||||
if contract_key:
|
||||
query = query.where(ReviewContract.contract_key == contract_key)
|
||||
if active is not None:
|
||||
query = query.where(ReviewContract.active == active)
|
||||
if decision_id:
|
||||
query = query.where(ReviewContract.decision_id == decision_id)
|
||||
return list((await session.execute(query)).scalars())
|
||||
|
||||
|
||||
@router.get("/{contract_key}", response_model=ReviewContractRead)
|
||||
async def get_active_contract(
|
||||
contract_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewContract:
|
||||
return await active_contract(session, contract_key)
|
||||
|
||||
|
||||
@router.get("/{contract_key}/aggregate", response_model=ReviewAggregateRead)
|
||||
async def get_aggregate(
|
||||
contract_key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewAggregateRead:
|
||||
return await aggregate(session, await active_contract(session, contract_key))
|
||||
|
||||
|
||||
@router.get("/{contract_key}/receipts", response_model=list[ReviewReceiptRead])
|
||||
async def list_receipts(
|
||||
contract_key: str,
|
||||
include_stale: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ReviewReceipt]:
|
||||
contract = await active_contract(session, contract_key)
|
||||
query = (
|
||||
select(ReviewReceipt)
|
||||
.join(ReviewContract)
|
||||
.where(
|
||||
ReviewContract.contract_key == contract_key
|
||||
if include_stale
|
||||
else ReviewReceipt.contract_id == contract.id
|
||||
)
|
||||
.order_by(ReviewReceipt.submitted_at)
|
||||
)
|
||||
return list((await session.execute(query)).scalars())
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{contract_key}/receipts",
|
||||
response_model=ReviewReceiptRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def submit_receipt(
|
||||
contract_key: str,
|
||||
body: ReviewReceiptSubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ReviewReceipt:
|
||||
contract = await active_contract(session, contract_key)
|
||||
document = validate_receipt(contract, body)
|
||||
receipt_digest = digest_document(document)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(ReviewReceipt).where(ReviewReceipt.receipt_digest == receipt_digest)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
receipt = ReviewReceipt(
|
||||
contract_id=contract.id,
|
||||
owner_id=body.owner_id,
|
||||
actor=body.actor,
|
||||
disposition=body.disposition,
|
||||
contract_digest=body.contract_digest,
|
||||
receipt_digest=receipt_digest,
|
||||
artifact_hashes=body.artifact_hashes,
|
||||
assertion_ids=body.assertion_ids,
|
||||
checks=body.checks,
|
||||
note=body.note,
|
||||
source_repo=body.source.repo,
|
||||
source_path=body.source.path,
|
||||
source_revision=body.source.revision,
|
||||
submitted_at=utc_now(),
|
||||
document=document,
|
||||
)
|
||||
session.add(receipt)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
409, "receipt source revision already contains different evidence"
|
||||
) from exc
|
||||
await session.refresh(receipt)
|
||||
return receipt
|
||||
Loading…
Add table
Add a link
Reference in a new issue