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