diff --git a/api/main.py b/api/main.py index b3963bc..73ae3c9 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/api/models/__init__.py b/api/models/__init__.py index f285fc4..097ab2f 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -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", ] diff --git a/api/models/review_contract.py b/api/models/review_contract.py new file mode 100644 index 0000000..392235e --- /dev/null +++ b/api/models/review_contract.py @@ -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", + ), + ) diff --git a/api/routers/decisions.py b/api/routers/decisions.py index 9e777b7..069e9a0 100644 --- a/api/routers/decisions.py +++ b/api/routers/decisions.py @@ -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 diff --git a/api/routers/review_contracts.py b/api/routers/review_contracts.py new file mode 100644 index 0000000..19d2bd8 --- /dev/null +++ b/api/routers/review_contracts.py @@ -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 diff --git a/api/schemas/review_contract.py b/api/schemas/review_contract.py new file mode 100644 index 0000000..c5556fd --- /dev/null +++ b/api/schemas/review_contract.py @@ -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] diff --git a/api/services/review_contracts.py b/api/services/review_contracts.py new file mode 100644 index 0000000..0db02be --- /dev/null +++ b/api/services/review_contracts.py @@ -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) diff --git a/custodian_cli.py b/custodian_cli.py index 2a791a3..56b7fe6 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -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) diff --git a/docs/review-contracts-v1.md b/docs/review-contracts-v1.md new file mode 100644 index 0000000..b87aa6f --- /dev/null +++ b/docs/review-contracts-v1.md @@ -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. diff --git a/migrations/versions/c9e5a1b3d7f2_review_contracts_and_receipts.py b/migrations/versions/c9e5a1b3d7f2_review_contracts_and_receipts.py new file mode 100644 index 0000000..6c9a5ea --- /dev/null +++ b/migrations/versions/c9e5a1b3d7f2_review_contracts_and_receipts.py @@ -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") diff --git a/tests/test_review_contracts.py b/tests/test_review_contracts.py new file mode 100644 index 0000000..9a0743f --- /dev/null +++ b/tests/test_review_contracts.py @@ -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:") diff --git a/tests/test_work_record_identifier_migration.py b/tests/test_work_record_identifier_migration.py index 3179b21..8d525c0 100644 --- a/tests/test_work_record_identifier_migration.py +++ b/tests/test_work_record_identifier_migration.py @@ -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) diff --git a/workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md b/workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md index a6b6e07..9e33288 100644 --- a/workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md +++ b/workplans/STATE-WP-0082-multi-owner-review-contracts-and-receipts.md @@ -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.