feat(review): add multi-owner contracts and receipts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-22 20:57:51 +02:00
parent 52aefe39e4
commit 598f6418e7
13 changed files with 1401 additions and 9 deletions

View file

@ -23,6 +23,7 @@ from api.routers import reconciliation
from api.routers import execution
from api.routers import fabric
from api.routers import legacy_meter
from api.routers import review_contracts
class ETagMiddleware(BaseHTTPMiddleware):
@ -134,6 +135,7 @@ app.include_router(reconciliation.router)
app.include_router(execution.router)
app.include_router(fabric.router)
app.include_router(legacy_meter.router)
app.include_router(review_contracts.router)
app.include_router(state.router)
app.include_router(ops_runs.router)
app.include_router(policy.router)

View file

@ -42,6 +42,7 @@ from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGr
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.models.write_idempotency_key import WriteIdempotencyKey
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.models.suggestion import (
Suggestion,
SuggestionNote,
@ -83,5 +84,6 @@ __all__ = [
"LegacyInterface", "LegacyInterfaceUsageBucket",
"WriteIdempotencyKey",
"WorkRecordIdentifierAlias",
"ReviewContract", "ReviewReceipt",
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
]

View file

@ -0,0 +1,128 @@
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.models.base import Base, TimestampMixin, new_uuid7
class ReviewContract(Base, TimestampMixin):
"""Replaceable projection of one authoritative review-contract revision."""
__tablename__ = "review_contracts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
schema_version: Mapped[str] = mapped_column(String(40), nullable=False)
contract_digest: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True
)
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, index=True
)
required_for_decision: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
decision_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("decisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
index=True,
)
projected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
receipts: Mapped[list["ReviewReceipt"]] = relationship(
"ReviewReceipt", back_populates="contract", lazy="selectin"
)
__table_args__ = (
UniqueConstraint(
"contract_key",
"source_repo",
"source_path",
"source_revision",
name="uq_review_contract_source_revision",
),
Index("ix_review_contract_key_active", "contract_key", "active"),
)
class ReviewReceipt(Base, TimestampMixin):
"""Immutable evidence submitted against an exact contract digest."""
__tablename__ = "review_receipts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=new_uuid7
)
contract_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("review_contracts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
actor: Mapped[str] = mapped_column(String(160), nullable=False)
disposition: Mapped[str] = mapped_column(String(32), nullable=False)
contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
source_path: Mapped[str] = mapped_column(Text, nullable=False)
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
submitted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
contract: Mapped[ReviewContract] = relationship(
"ReviewContract", back_populates="receipts"
)
__table_args__ = (
UniqueConstraint(
"source_repo",
"source_path",
"source_revision",
name="uq_review_receipt_source_revision",
),
Index(
"ix_review_receipt_contract_owner_time",
"contract_id",
"owner_id",
"submitted_at",
),
)

View file

@ -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

View 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

View file

@ -0,0 +1,96 @@
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class SourceRef(BaseModel):
repo: str = Field(min_length=1, max_length=100)
path: str = Field(min_length=1, max_length=1000)
revision: str = Field(pattern=r"^[0-9a-f]{40,64}$")
class ReviewContractProject(BaseModel):
contract: dict[str, Any]
source: SourceRef
decision_id: uuid.UUID | None = None
workplan_id: uuid.UUID | None = None
task_id: uuid.UUID | None = None
required_for_decision: bool = False
class ReviewContractRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
contract_key: str
schema_version: str
contract_digest: str
source_repo: str
source_path: str
source_revision: str
document: dict[str, Any]
active: bool
required_for_decision: bool
decision_id: uuid.UUID | None
workplan_id: uuid.UUID | None
task_id: uuid.UUID | None
projected_at: datetime
created_at: datetime
updated_at: datetime
class ReviewReceiptSubmit(BaseModel):
owner_id: str = Field(min_length=1, max_length=160)
actor: str = Field(min_length=2, max_length=160)
disposition: Literal["approve", "request_changes"]
contract_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
artifact_hashes: dict[str, str] = Field(default_factory=dict)
assertion_ids: list[str] = Field(default_factory=list)
checks: list[dict[str, Any]] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=2000)
source: SourceRef
class ReviewReceiptRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
contract_id: uuid.UUID
owner_id: str
actor: str
disposition: str
contract_digest: str
receipt_digest: str
artifact_hashes: dict[str, str]
assertion_ids: list[str]
checks: list[dict[str, Any]]
note: str | None
source_repo: str
source_path: str
source_revision: str
submitted_at: datetime
created_at: datetime
class OwnerReviewState(BaseModel):
owner_id: str
status: Literal["missing", "approved", "request_changes", "stale"]
receipt_id: uuid.UUID | None = None
receipt_digest: str | None = None
submitted_at: datetime | None = None
class GateReviewState(BaseModel):
gate_id: str
policy: Literal["all_required"]
owners: list[str]
satisfied: bool
class ReviewAggregateRead(BaseModel):
contract_key: str
contract_digest: str
satisfied: bool
authorizes_execution: Literal[False] = False
owners: list[OwnerReviewState]
gates: list[GateReviewState]

View file

@ -0,0 +1,336 @@
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)

View file

@ -18,6 +18,7 @@ import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@ -571,6 +572,73 @@ def cmd_status(_args: argparse.Namespace) -> None:
print(f" [{deadline}] {d['title']}")
def _load_json_file(path: str) -> dict:
target = Path(path)
try:
value = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"ERROR: invalid JSON file {target}: {exc}")
sys.exit(2)
if not isinstance(value, dict):
print(f"ERROR: {target} must contain a JSON object")
sys.exit(2)
return value
def _git_source(path: str, repo_slug: str | None = None) -> tuple[Path, dict]:
target = Path(path).resolve()
try:
root = Path(subprocess.check_output(
["git", "-C", str(target.parent), "rev-parse", "--show-toplevel"],
text=True,
).strip())
revision = subprocess.check_output(
["git", "-C", str(root), "rev-parse", "HEAD"], text=True
).strip()
relative = target.relative_to(root).as_posix()
except (subprocess.CalledProcessError, ValueError) as exc:
print(f"ERROR: {target} must be inside a Git repository: {exc}")
sys.exit(2)
return target, {"repo": repo_slug or root.name, "path": relative, "revision": revision}
def cmd_review_project(args: argparse.Namespace) -> None:
"""Project a file-authoritative contract wrapper through the direct API."""
target, source = _git_source(args.file, args.source_repo)
contract = _load_json_file(str(target))
payload = contract if "contract" in contract and "source" in contract else {
"contract": contract,
"source": source,
"decision_id": args.decision_id,
"workplan_id": args.workplan_id,
"task_id": args.task_id,
"required_for_decision": args.required_for_decision,
}
print(json.dumps(
_api_post("/review-contracts/projections", payload),
indent=2,
))
def cmd_review_submit(args: argparse.Namespace) -> None:
"""Submit a file-authoritative receipt against an active contract."""
key = urllib.parse.quote(args.contract_key, safe="")
target, source = _git_source(args.file, args.source_repo)
receipt = _load_json_file(str(target))
if "source" not in receipt:
receipt["source"] = source
print(json.dumps(
_api_post(f"/review-contracts/{key}/receipts", receipt),
indent=2,
))
def cmd_review_status(args: argparse.Namespace) -> None:
"""Print the derived owner and gate matrix; never an execution authorization."""
key = urllib.parse.quote(args.contract_key, safe="")
print(json.dumps(_api_get(f"/review-contracts/{key}/aggregate"), indent=2))
def _outbox_store(args):
from api.edge.outbox import OutboxStore, default_outbox_path
@ -816,6 +884,26 @@ def main() -> None:
# status
sub.add_parser("status", help="Show State Hub health and summary totals")
# review — file-backed multi-owner review projection
review = sub.add_parser("review", help="Project and inspect multi-owner review evidence")
review_sub = review.add_subparsers(dest="review_command", required=True)
review_project = review_sub.add_parser("project", help="Project an authoritative contract wrapper")
review_project.add_argument("file")
review_project.add_argument("--source-repo", default=None)
review_project.add_argument("--decision-id", default=None)
review_project.add_argument("--workplan-id", default=None)
review_project.add_argument("--task-id", default=None)
review_project.add_argument("--required-for-decision", action="store_true")
review_project.set_defaults(func=cmd_review_project)
review_submit = review_sub.add_parser("submit", help="Submit an authoritative receipt file")
review_submit.add_argument("contract_key")
review_submit.add_argument("file")
review_submit.add_argument("--source-repo", default=None)
review_submit.set_defaults(func=cmd_review_submit)
review_status = review_sub.add_parser("status", help="Print owner and gate status")
review_status.add_argument("contract_key")
review_status.set_defaults(func=cmd_review_status)
# dev up — files-first local hub (CUST-WP-0054-T07)
dev = sub.add_parser("dev", help="Local dev-hub commands")
dev_sub = dev.add_subparsers(dest="dev_command", required=True)

View file

@ -0,0 +1,77 @@
# Review contracts and receipts v1
State Hub exposes a replaceable projection of repository-authoritative review
contracts and immutable receipt files. This is coordination evidence, not an
authorization system: a satisfied aggregate always says that it does not
authorize execution.
## Ownership and extraction boundary
- The contract-owning repository owns the contract file and its full Git
revision.
- Each reviewing repository owns its receipt file and full Git revision.
- State Hub validates, indexes, derives owner/gate state, and can be rebuilt
from those files. Agent messages may link to record IDs but are not receipt
storage.
- State Hub never executes a check named by a consumer. Receipt producers run
their own closed, read-only check interfaces and submit only their results.
- Actor strings are coordination identities in v1, not authenticated proof.
Authenticated actor-to-owner delegation belongs in the extracted hub-core
service.
All timestamps stored or emitted by State Hub are canonical UTC. Local calendar
and time rendering is an I/O or UI concern.
## Contract
POST /review-contracts/projections accepts a wrapper containing:
- source: repository, relative path, and a full Git revision;
- optional decision, workplan, and task UUID links;
- required_for_decision, which makes a linked decision wait for the gate;
- contract: a review-contract/v1 document.
The contract document has a stable contract key, typed subject, the two allowed
dispositions approve and request_changes, SHA-256 artifacts, owner scopes with
stable assertion and named check IDs, and gates. Version 1 supports only the
all_required gate policy.
Projecting a new digest under the same contract key makes the prior revision
inactive. Its receipts remain immutable and are reported as stale until the
owner submits evidence against the active digest. The bounded
railiance.owner-review version 1 adapter derives stable assertion IDs and
preserves that prototype's canonical source digest.
## Receipt
POST /review-contracts/{contract_key}/receipts accepts an authoritative receipt
document. An approval must exactly cover the owner's artifacts, assertion IDs,
and named checks. Every check must say both passed true and read_only true.
request_changes requires a note and becomes the owner's latest blocking
disposition. Reposting byte-equivalent evidence is idempotent.
The service assigns submitted_at in UTC and a SHA-256 receipt digest. Receipts
cannot be patched or deleted through this API.
Direct client examples:
statehub review project interfaces/review.json
statehub review submit EXAMPLE-REVIEW-1 receipt.json
statehub review status EXAMPLE-REVIEW-1
The CLI derives the repository-relative path and full current Git revision, so
those projection fields do not need to be embedded in the authoritative file.
Use --source-repo only when the repository directory name is not its canonical
slug.
If an active contract is linked to a decision with required_for_decision true,
the legacy decision resolve action returns 409 until every v1 all_required gate
is satisfied. Decisions without such a contract retain their existing
behavior.
## Rollback and rebuild
The Alembic downgrade drops only the two projection tables. It does not rewrite
or delete existing decisions. Rebuild by replaying contract projection files
in revision order, then their receipt files. Repository files remain the
recovery source throughout.

View file

@ -0,0 +1,151 @@
"""add multi-owner review contract projections and immutable receipts
Revision ID: c9e5a1b3d7f2
Revises: b8d4f0a2c6e1
Create Date: 2026-08-22
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "c9e5a1b3d7f2"
down_revision = "b8d4f0a2c6e1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"review_contracts",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("contract_key", sa.String(160), nullable=False),
sa.Column("schema_version", sa.String(40), nullable=False),
sa.Column("contract_digest", sa.String(64), nullable=False, unique=True),
sa.Column("source_repo", sa.String(100), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("source_revision", sa.String(64), nullable=False),
sa.Column("document", JSONB(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column(
"required_for_decision",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column(
"decision_id",
UUID(as_uuid=True),
sa.ForeignKey("decisions.id", ondelete="RESTRICT"),
nullable=True,
),
sa.Column(
"workplan_id",
UUID(as_uuid=True),
sa.ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
),
sa.Column(
"task_id",
UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
nullable=True,
),
sa.Column("projected_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"contract_key",
"source_repo",
"source_path",
"source_revision",
name="uq_review_contract_source_revision",
),
)
op.create_index(
"ix_review_contracts_contract_key", "review_contracts", ["contract_key"]
)
op.create_index("ix_review_contracts_active", "review_contracts", ["active"])
op.create_index(
"ix_review_contract_key_active", "review_contracts", ["contract_key", "active"]
)
op.create_index(
"ix_review_contracts_decision_id", "review_contracts", ["decision_id"]
)
op.create_index(
"ix_review_contracts_workplan_id", "review_contracts", ["workplan_id"]
)
op.create_index("ix_review_contracts_task_id", "review_contracts", ["task_id"])
op.create_table(
"review_receipts",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column(
"contract_id",
UUID(as_uuid=True),
sa.ForeignKey("review_contracts.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("owner_id", sa.String(160), nullable=False),
sa.Column("actor", sa.String(160), nullable=False),
sa.Column("disposition", sa.String(32), nullable=False),
sa.Column("contract_digest", sa.String(64), nullable=False),
sa.Column("receipt_digest", sa.String(64), nullable=False, unique=True),
sa.Column("artifact_hashes", JSONB(), nullable=False),
sa.Column("assertion_ids", JSONB(), nullable=False),
sa.Column("checks", JSONB(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("source_repo", sa.String(100), nullable=False),
sa.Column("source_path", sa.Text(), nullable=False),
sa.Column("source_revision", sa.String(64), nullable=False),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("document", JSONB(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"source_repo",
"source_path",
"source_revision",
name="uq_review_receipt_source_revision",
),
)
op.create_index(
"ix_review_receipts_contract_id", "review_receipts", ["contract_id"]
)
op.create_index("ix_review_receipts_owner_id", "review_receipts", ["owner_id"])
op.create_index(
"ix_review_receipts_contract_digest", "review_receipts", ["contract_digest"]
)
op.create_index(
"ix_review_receipts_submitted_at", "review_receipts", ["submitted_at"]
)
op.create_index(
"ix_review_receipt_contract_owner_time",
"review_receipts",
["contract_id", "owner_id", "submitted_at"],
)
def downgrade() -> None:
op.drop_table("review_receipts")
op.drop_table("review_contracts")

View file

@ -0,0 +1,264 @@
import hashlib
import json
import pytest
from tests.conftest import (
create_test_domain,
create_test_repo,
create_test_topic,
create_test_workplan,
)
def _digest(value):
return hashlib.sha256(
json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
).hexdigest()
def _contract(artifact_digest="a" * 64):
return {
"schema_version": "review-contract/v1",
"contract_key": "TEST-REVIEW-1",
"subject": {"kind": "task", "id": "TEST-WP-1-T01"},
"allowed_dispositions": ["approve", "request_changes"],
"artifacts": {
"docs/runbook.md": {"algorithm": "sha256", "digest": artifact_digest}
},
"owners": [
{
"id": "railiance-infra",
"artifact_ids": ["docs/runbook.md"],
"assertions": [
{
"id": "safe-boundary",
"statement": "No live action is authorized.",
}
],
"check_ids": ["runbook-lint"],
}
],
"gates": [
{
"id": "TEST-WP-1-T01",
"policy": "all_required",
"owners": ["railiance-infra"],
}
],
}
def _source(repo, path, revision):
return {"repo": repo, "path": path, "revision": revision}
def _approval(contract, revision="2" * 40):
return {
"owner_id": "railiance-infra",
"actor": "operator:test",
"disposition": "approve",
"contract_digest": _digest(contract),
"artifact_hashes": {
"docs/runbook.md": contract["artifacts"]["docs/runbook.md"]["digest"]
},
"assertion_ids": ["safe-boundary"],
"checks": [{"id": "runbook-lint", "passed": True, "read_only": True}],
"source": _source("railiance-infra", "reviews/receipt.json", revision),
}
@pytest.mark.asyncio
async def test_review_round_trip_and_idempotency(client):
contract = _contract()
projected = await client.post(
"/review-contracts/projections",
json={
"contract": contract,
"source": _source("test-owner", "interfaces/review.json", "1" * 40),
},
)
assert projected.status_code == 201, projected.text
assert projected.json()["contract_digest"] == _digest(contract)
missing = await client.get("/review-contracts/TEST-REVIEW-1/aggregate")
assert missing.json()["satisfied"] is False
assert missing.json()["authorizes_execution"] is False
assert missing.json()["owners"][0]["status"] == "missing"
approval = _approval(contract)
first = await client.post("/review-contracts/TEST-REVIEW-1/receipts", json=approval)
second = await client.post(
"/review-contracts/TEST-REVIEW-1/receipts", json=approval
)
assert first.status_code == second.status_code == 201
assert first.json()["id"] == second.json()["id"]
assert first.json()["submitted_at"].endswith("+00:00") or first.json()[
"submitted_at"
].endswith("Z")
result = (await client.get("/review-contracts/TEST-REVIEW-1/aggregate")).json()
assert result["satisfied"] is True
assert result["owners"][0]["status"] == "approved"
@pytest.mark.asyncio
async def test_receipt_rejects_wrong_owner_and_artifact_drift(client):
contract = _contract()
await client.post(
"/review-contracts/projections",
json={
"contract": contract,
"source": _source("test-owner", "interfaces/review.json", "1" * 40),
},
)
wrong_owner = _approval(contract)
wrong_owner["owner_id"] = "somebody-else"
assert (
await client.post("/review-contracts/TEST-REVIEW-1/receipts", json=wrong_owner)
).status_code == 422
drift = _approval(contract)
drift["artifact_hashes"]["docs/runbook.md"] = "f" * 64
assert (
await client.post("/review-contracts/TEST-REVIEW-1/receipts", json=drift)
).status_code == 422
@pytest.mark.asyncio
async def test_new_contract_revision_marks_prior_receipt_stale(client):
old = _contract()
await client.post(
"/review-contracts/projections",
json={
"contract": old,
"source": _source("test-owner", "interfaces/review.json", "1" * 40),
},
)
await client.post("/review-contracts/TEST-REVIEW-1/receipts", json=_approval(old))
new = _contract("b" * 64)
replaced = await client.post(
"/review-contracts/projections",
json={
"contract": new,
"source": _source("test-owner", "interfaces/review.json", "3" * 40),
},
)
assert replaced.status_code == 201, replaced.text
state = (await client.get("/review-contracts/TEST-REVIEW-1/aggregate")).json()
assert state["owners"][0]["status"] == "stale"
assert state["satisfied"] is False
@pytest.mark.asyncio
async def test_request_changes_supersedes_approval(client):
contract = _contract()
await client.post(
"/review-contracts/projections",
json={
"contract": contract,
"source": _source("test-owner", "interfaces/review.json", "1" * 40),
},
)
await client.post(
"/review-contracts/TEST-REVIEW-1/receipts", json=_approval(contract)
)
change = {
"owner_id": "railiance-infra",
"actor": "operator:test",
"disposition": "request_changes",
"contract_digest": _digest(contract),
"note": "Clarify the recovery owner.",
"source": _source("railiance-infra", "reviews/request-changes.json", "4" * 40),
}
assert (
await client.post("/review-contracts/TEST-REVIEW-1/receipts", json=change)
).status_code == 201
state = (await client.get("/review-contracts/TEST-REVIEW-1/aggregate")).json()
assert state["owners"][0]["status"] == "request_changes"
assert state["satisfied"] is False
@pytest.mark.asyncio
async def test_required_review_blocks_legacy_decision_resolution(client):
domain = await create_test_domain(client)
topic = await create_test_topic(client, domain_slug=domain["slug"])
repo = await create_test_repo(client, domain_slug=domain["slug"])
workplan = await create_test_workplan(client, repo["id"], topic["id"])
decision = (
await client.post(
"/decisions/",
json={"title": "Run attended procedure", "workplan_id": workplan["id"]},
)
).json()
contract = _contract()
projected = await client.post(
"/review-contracts/projections",
json={
"contract": contract,
"source": _source("test-owner", "interfaces/review.json", "1" * 40),
"decision_id": decision["id"],
"required_for_decision": True,
},
)
assert projected.status_code == 201, projected.text
blocked = await client.post(
f"/decisions/{decision['id']}/resolve",
json={
"rationale": "Proceed",
"decided_by": "operator:test",
"write_log": False,
},
)
assert blocked.status_code == 409
await client.post(
"/review-contracts/TEST-REVIEW-1/receipts", json=_approval(contract)
)
resolved = await client.post(
f"/decisions/{decision['id']}/resolve",
json={
"rationale": "Reviewed",
"decided_by": "operator:test",
"write_log": False,
},
)
assert resolved.status_code == 200
@pytest.mark.asyncio
async def test_legacy_wp0024_contract_adapter(client):
legacy = {
"interface": "railiance.owner-review",
"version": 1,
"workplan_id": "RAILIANCE-WP-0024",
"decisions": ["approve", "request-changes"],
"task_owners": {"RAILIANCE-WP-0024-T03": ["railiance-infra"]},
"owners": {
"railiance-infra": {
"artifacts": ["docs/runbook.md"],
"assertions": ["No live execution is authorized."],
"checks": ["node-reboot-preflight"],
}
},
"artifact_sha256": {"docs/runbook.md": "a" * 64},
}
result = await client.post(
"/review-contracts/projections",
json={
"contract": legacy,
"source": _source(
"railiance-platform",
"interfaces/RAILIANCE-WP-0024-owner-reviews.json",
"5" * 40,
),
},
)
assert result.status_code == 201, result.text
body = result.json()
assert body["contract_key"] == "RAILIANCE-WP-0024"
assert body["contract_digest"] == _digest(legacy)
assert body["document"]["owners"][0]["assertions"][0]["id"].startswith("assertion:")

View file

@ -185,7 +185,7 @@ def test_every_work_record_foreign_key_cascades_on_update():
for foreign_key in table.foreign_keys
if foreign_key.target_fullname in {"workplans.id", "tasks.id"}
]
assert len(references) == 20
assert len(references) == 22
assert all(foreign_key.onupdate == "CASCADE" for foreign_key in references)

View file

@ -4,7 +4,7 @@ type: workplan
title: "Multi-owner review contracts and receipts"
domain: infotech
repo: state-hub
status: active
status: finished
owner: codex
topic_slug: multi-owner-review-contracts
created: "2026-08-22"
@ -36,7 +36,7 @@ separable for later movement to `hub-core`.
```task
id: STATE-WP-0082-T01
status: progress
status: done
priority: high
state_hub_task_id: "a6511423-78e0-51c6-8c7d-b91609e331ff"
```
@ -51,11 +51,15 @@ Done when the boundary preserves existing simple decisions, keeps repository
files authoritative, treats State Hub as projection, and states explicitly
that a satisfied procedure review is not an execution authorization.
Implemented in `docs/review-contracts-v1.md` and the
`review-contract/v1` validator. The bounded WP-0024 adapter preserves the
prototype source digest and derives stable assertion ids.
## Add projection persistence
```task
id: STATE-WP-0082-T02
status: todo
status: done
priority: high
state_hub_task_id: "e2ac4ad6-91d9-5b4e-a0fb-7106c731e348"
```
@ -69,11 +73,15 @@ and optional links to a decision, workplan, or task.
Done when upgrade/downgrade works and existing decision rows and routes require
no data rewrite.
Implemented as separate `review_contracts` and append-only
`review_receipts` tables in migration `c9e5a1b3d7f2`. A clean
upgrade/downgrade/upgrade round trip passed.
## Implement validation and aggregation APIs
```task
id: STATE-WP-0082-T03
status: todo
status: done
priority: high
state_hub_task_id: "48682d0c-e69f-5486-a007-472dea959994"
```
@ -88,11 +96,15 @@ For version 1, support the prototype's `all_required` policy only. A latest
valid `request_changes` blocks its owner's gates; contract changes make prior
receipts stale rather than mutating or deleting them.
Projection, receipt, exact listing, and aggregate routes enforce the v1
contract. Linked required reviews now prevent legacy decision resolution from
bypassing an unsatisfied gate.
## Provide a direct client and legacy adapter
```task
id: STATE-WP-0082-T04
status: todo
status: done
priority: medium
state_hub_task_id: "9ab5b022-25d0-556c-bca0-222c414bc18f"
```
@ -105,11 +117,15 @@ migrate without making State Hub execute its repository-specific checks.
Done when callers no longer encode receipts as opaque agent-message bodies and
message transport can carry stable contract/receipt references instead.
Implemented `statehub review project|submit|status`. The client derives the
repository path and full revision from Git. Legacy message receipts remain
historical notifications rather than canonical evidence.
## Pilot with railiance-infra
```task
id: STATE-WP-0082-T05
status: todo
status: done
priority: high
state_hub_task_id: "90af0001-17c6-5cbb-9d08-64947211f956"
```
@ -123,11 +139,19 @@ The pilot must prove approval, request-changes supersession, artifact-change
staleness, duplicate idempotency, wrong-owner rejection, canonical UTC receipt
time, and rebuild from repository files.
Projected contract `01a02ac9-9e90-74c0-bdb2-a8604d32b542` from
`railiance-platform@a557208a4a33520c39f749dcc26e6985386a96d4`.
Projected railiance-infra receipt
`01a02aca-02dd-7b16-93f2-7ab455976059` from
`railiance-infra@d85237aee8080201ddacb2d4f34a15b6fc91609b`.
Its owner state is approved while both gates remain unsatisfied and
`authorizes_execution` remains false.
## Verify compatibility and extraction readiness
```task
id: STATE-WP-0082-T06
status: todo
status: done
priority: medium
state_hub_task_id: "c8486c31-d36b-5517-be2f-7efc715dfd79"
```
@ -138,6 +162,19 @@ future `hub-core` extraction boundary. Record any unimplemented authenticated
actor-to-owner authorization as a live residual rather than implying that a
caller-supplied owner string is authority.
Focused lint and 13 affected tests pass. The full suite reached 639 passing
tests; its sole foreign-key inventory failure was updated for the two new
CASCADE links and the affected suites then passed. Authenticated actor-to-owner
delegation is live residual intake
`01a02aca-2dfc-7e57-bebf-f5e970d7b403`.
## Residual handoff
Authenticated actor-to-owner delegation and authorization proof are tracked by
State Hub intake `01a02aca-2dfc-7e57-bebf-f5e970d7b403` (`origin:
residual`, `origin_ref: STATE-WP-0082`). Until that work is promoted, actor
strings are coordination identities only.
## Acceptance
- Existing single-decider APIs remain backward compatible.