Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
336 lines
12 KiB
Python
336 lines
12 KiB
Python
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)
|