from __future__ import annotations import hashlib import json from datetime import datetime, timezone from typing import Any from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from api.models.review_contract import ReviewContract, ReviewReceipt from api.schemas.review_contract import ( GateReviewState, OwnerReviewState, ReviewAggregateRead, ) def canonical_json(value: Any) -> bytes: return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=True ).encode() def digest_document(value: Any) -> str: return hashlib.sha256(canonical_json(value)).hexdigest() def _assertion_id(text: str) -> str: return "assertion:" + hashlib.sha256(text.encode()).hexdigest()[:20] def normalize_contract(raw: dict[str, Any]) -> tuple[dict[str, Any], str]: """Validate v1 or adapt the WP-0024 prototype without executing its checks.""" digest = digest_document(raw) if raw.get("interface") == "railiance.owner-review" and raw.get("version") == 1: owners_raw = raw.get("owners") task_owners = raw.get("task_owners") hashes = raw.get("artifact_sha256") if ( not isinstance(owners_raw, dict) or not owners_raw or not isinstance(task_owners, dict) or not task_owners ): raise HTTPException( 422, "legacy contract requires non-empty owners and task_owners" ) if not isinstance(hashes, dict): raise HTTPException(422, "legacy contract requires artifact_sha256") owners = [] for owner_id, scope in owners_raw.items(): artifacts = scope.get("artifacts") or [] assertions = scope.get("assertions") or [] checks = scope.get("checks") or [] if ( not artifacts or not assertions or not checks or any(path not in hashes for path in artifacts) ): raise HTTPException(422, f"{owner_id}: incomplete legacy owner scope") owners.append( { "id": owner_id, "artifact_ids": artifacts, "assertions": [ {"id": _assertion_id(text), "statement": text} for text in assertions ], "check_ids": checks, } ) owner_ids = {item["id"] for item in owners} gates = [] for gate_id, required in task_owners.items(): if ( not required or len(required) != len(set(required)) or not set(required) <= owner_ids ): raise HTTPException(422, f"{gate_id}: invalid legacy owner set") gates.append({"id": gate_id, "policy": "all_required", "owners": required}) normalized = { "schema_version": "review-contract/v1", "contract_key": raw.get("workplan_id"), "subject": {"kind": "workplan", "id": raw.get("workplan_id")}, "owners": owners, "gates": gates, "artifacts": { path: {"algorithm": "sha256", "digest": value} for path, value in hashes.items() }, "allowed_dispositions": [ item.replace("-", "_") for item in (raw.get("decisions") or []) ], "legacy": { "interface": raw["interface"], "version": 1, "source_digest": digest, }, } return validate_v1(normalized), digest if raw.get("schema_version") != "review-contract/v1": raise HTTPException(422, "schema_version must be review-contract/v1") return validate_v1(raw), digest def validate_v1(doc: dict[str, Any]) -> dict[str, Any]: contract_key, subject = doc.get("contract_key"), doc.get("subject") owners, gates, artifacts = doc.get("owners"), doc.get("gates"), doc.get("artifacts") dispositions = doc.get("allowed_dispositions") if not isinstance(contract_key, str) or not contract_key: raise HTTPException(422, "contract_key is required") if ( not isinstance(subject, dict) or not subject.get("kind") or not subject.get("id") ): raise HTTPException(422, "typed subject kind and id are required") if ( not isinstance(owners, list) or not owners or not isinstance(gates, list) or not gates ): raise HTTPException(422, "non-empty owners and gates are required") if not isinstance(artifacts, dict) or not artifacts: raise HTTPException(422, "revision-pinned artifacts are required") if ( set(dispositions or []) != {"approve", "request_changes"} or len(dispositions) != 2 ): raise HTTPException( 422, "allowed_dispositions must contain approve and request_changes only" ) owner_ids: set[str] = set() for owner in owners: if not isinstance(owner, dict): raise HTTPException(422, "owner entries must be objects") owner_id = owner.get("id") artifact_ids = owner.get("artifact_ids") or [] assertions = owner.get("assertions") or [] checks = owner.get("check_ids") or [] assertion_ids = [ item.get("id") for item in assertions if isinstance(item, dict) ] if ( not owner_id or owner_id in owner_ids or not artifact_ids or not checks or not assertion_ids ): raise HTTPException( 422, "each owner needs a unique id and non-empty artifact/assertion/check scope", ) if len(assertion_ids) != len(set(assertion_ids)) or any( path not in artifacts for path in artifact_ids ): raise HTTPException(422, f"{owner_id}: invalid assertion or artifact scope") owner_ids.add(owner_id) for artifact_id, artifact in artifacts.items(): if ( not isinstance(artifact, dict) or artifact.get("algorithm") != "sha256" or not _is_sha256(artifact.get("digest")) ): raise HTTPException( 422, f"{artifact_id}: only sha256 artifacts are supported" ) gate_ids: set[str] = set() for gate in gates: if not isinstance(gate, dict): raise HTTPException(422, "gate entries must be objects") required = gate.get("owners") or [] if ( gate.get("policy") != "all_required" or not gate.get("id") or gate["id"] in gate_ids ): raise HTTPException( 422, "v1 gates require unique ids and all_required policy" ) if ( not required or len(required) != len(set(required)) or not set(required) <= owner_ids ): raise HTTPException(422, f"{gate.get('id')}: gate owner set is invalid") gate_ids.add(gate["id"]) return doc def _is_sha256(value: Any) -> bool: return ( isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value) ) def owner_scope(contract: ReviewContract, owner_id: str) -> dict[str, Any]: for owner in contract.document["owners"]: if owner["id"] == owner_id: return owner raise HTTPException(422, f"unknown owner: {owner_id}") def validate_receipt(contract: ReviewContract, body: Any) -> dict[str, Any]: if body.contract_digest != contract.contract_digest: raise HTTPException( 409, "contract digest mismatch; re-review the active contract" ) scope = owner_scope(contract, body.owner_id) if body.disposition == "request_changes": if not body.note or not body.note.strip(): raise HTTPException(422, "request_changes requires a note") else: expected_artifacts = { key: contract.document["artifacts"][key]["digest"] for key in scope["artifact_ids"] } if body.artifact_hashes != expected_artifacts: raise HTTPException( 422, "approval artifact hashes do not exactly match the owner scope" ) expected_assertions = {item["id"] for item in scope["assertions"]} if set(body.assertion_ids) != expected_assertions or len( body.assertion_ids ) != len(expected_assertions): raise HTTPException( 422, "approval assertions do not exactly match the owner scope" ) expected_checks = set(scope["check_ids"]) actual_checks = { item.get("id") for item in body.checks if isinstance(item, dict) } if actual_checks != expected_checks or len(body.checks) != len(expected_checks): raise HTTPException( 422, "approval checks do not exactly match the owner scope" ) if any( item.get("passed") is not True or item.get("read_only") is not True for item in body.checks ): raise HTTPException( 422, "every approval check must be passed and explicitly read_only" ) return { "owner_id": body.owner_id, "actor": body.actor, "disposition": body.disposition, "contract_digest": body.contract_digest, "artifact_hashes": body.artifact_hashes, "assertion_ids": body.assertion_ids, "checks": body.checks, "note": body.note, "source": body.source.model_dump(), } async def active_contract(session: AsyncSession, contract_key: str) -> ReviewContract: result = await session.execute( select(ReviewContract).where( ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True) ) ) contract = result.scalar_one_or_none() if contract is None: raise HTTPException(404, f"active review contract not found: {contract_key}") return contract async def aggregate( session: AsyncSession, contract: ReviewContract ) -> ReviewAggregateRead: rows = await session.execute( select(ReviewReceipt) .where(ReviewReceipt.contract_id == contract.id) .order_by(ReviewReceipt.submitted_at.desc(), ReviewReceipt.id.desc()) ) latest: dict[str, ReviewReceipt] = {} for receipt in rows.scalars(): latest.setdefault(receipt.owner_id, receipt) prior = await session.execute( select(ReviewReceipt.owner_id) .join(ReviewContract) .where( ReviewContract.contract_key == contract.contract_key, ReviewContract.id != contract.id, ) .distinct() ) stale_owners = set(prior.scalars()) owner_states, statuses = [], {} for owner in contract.document["owners"]: owner_id, receipt = owner["id"], latest.get(owner["id"]) state = ( receipt.disposition.replace("approve", "approved") if receipt else ("stale" if owner_id in stale_owners else "missing") ) statuses[owner_id] = state owner_states.append( OwnerReviewState( owner_id=owner_id, status=state, receipt_id=receipt.id if receipt else None, receipt_digest=receipt.receipt_digest if receipt else None, submitted_at=receipt.submitted_at if receipt else None, ) ) gates = [ GateReviewState( gate_id=gate["id"], policy="all_required", owners=gate["owners"], satisfied=all(statuses[owner] == "approved" for owner in gate["owners"]), ) for gate in contract.document["gates"] ] return ReviewAggregateRead( contract_key=contract.contract_key, contract_digest=contract.contract_digest, satisfied=all(gate.satisfied for gate in gates), authorizes_execution=False, owners=owner_states, gates=gates, ) def utc_now() -> datetime: return datetime.now(timezone.utc)