fix(rc06): close publication facade audit gaps
This commit is contained in:
+27
@@ -17,6 +17,12 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"LOCK TABLE rc06_publication_sagas IN ACCESS EXCLUSIVE MODE"
|
||||
)
|
||||
)
|
||||
saga_count = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM rc06_publication_sagas")
|
||||
).scalar_one()
|
||||
@@ -141,10 +147,26 @@ def upgrade() -> None:
|
||||
"rc06_apply_challenge_uses",
|
||||
["tenant_id"],
|
||||
)
|
||||
with op.batch_alter_table("rc06_publication_sagas") as batch:
|
||||
batch.create_foreign_key(
|
||||
"fk_rc06_publication_saga_authority_transaction",
|
||||
"rc06_online_publication_transactions",
|
||||
["transaction_id"],
|
||||
["transaction_id"],
|
||||
ondelete="NO ACTION",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"LOCK TABLE rc06_publication_sagas, "
|
||||
"rc06_online_publication_transactions, rc06_apply_challenge_uses "
|
||||
"IN ACCESS EXCLUSIVE MODE"
|
||||
)
|
||||
)
|
||||
compensation_count = bind.execute(
|
||||
sa.text(
|
||||
"SELECT COUNT(*) FROM rc06_publication_sagas "
|
||||
@@ -162,6 +184,11 @@ def downgrade() -> None:
|
||||
"RC06 migration 0082 refuses to erase durable publication authority, "
|
||||
"receipt, countersign, or challenge-consumption evidence."
|
||||
)
|
||||
with op.batch_alter_table("rc06_publication_sagas") as batch:
|
||||
batch.drop_constraint(
|
||||
"fk_rc06_publication_saga_authority_transaction",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_rc06_apply_challenge_uses_tenant_id",
|
||||
table_name="rc06_apply_challenge_uses",
|
||||
|
||||
@@ -776,6 +776,7 @@ def _check_rc06_publication_facade_config(settings: ContractApiSettings) -> Runt
|
||||
if len(set(resolved_files.values())) != len(resolved_files):
|
||||
errors.append("facade trust and credential files must use distinct paths")
|
||||
bearer_path = resolved_files.get("facade bearer token")
|
||||
bearer: str | None = None
|
||||
if bearer_path is not None and bearer_path.is_file():
|
||||
try:
|
||||
bearer = bearer_path.read_text(encoding="utf-8").strip()
|
||||
@@ -792,6 +793,15 @@ def _check_rc06_publication_facade_config(settings: ContractApiSettings) -> Runt
|
||||
}
|
||||
if bearer in reused:
|
||||
errors.append("facade bearer token must be independent from other service credentials")
|
||||
kms_credential_path = resolved_files.get("KMS credential")
|
||||
if bearer is not None and kms_credential_path is not None and kms_credential_path.is_file():
|
||||
try:
|
||||
kms_credential = kms_credential_path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeError):
|
||||
errors.append("KMS credential cannot be read as UTF-8")
|
||||
else:
|
||||
if bearer == kms_credential:
|
||||
errors.append("facade bearer token must be independent from the KMS credential")
|
||||
public_key_paths = [
|
||||
resolved_files.get("approval root public key"),
|
||||
resolved_files.get("apply service public key"),
|
||||
@@ -829,9 +839,14 @@ def validate_rc06_publication_startup(settings: ContractApiSettings) -> None:
|
||||
|
||||
if not settings.rc06_publication_facade_enabled:
|
||||
return
|
||||
result = _check_rc06_publication_facade_config(settings)
|
||||
if result.status != "ok":
|
||||
raise RuntimeError(result.message)
|
||||
results = (
|
||||
_check_rc06_publication_config(settings),
|
||||
_check_rc06_publication_facade_config(settings),
|
||||
_check_reliable_evidence_signing_config(settings),
|
||||
)
|
||||
errors = [result.message for result in results if result.status != "ok"]
|
||||
if errors:
|
||||
raise RuntimeError(" ".join(errors))
|
||||
|
||||
|
||||
def _check_worker_config(settings: ContractApiSettings) -> RuntimeConfigCheck:
|
||||
|
||||
+9
-1
@@ -1738,7 +1738,15 @@ class Rc06PublicationSagaModel(Base):
|
||||
),
|
||||
)
|
||||
|
||||
transaction_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
transaction_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey(
|
||||
"rc06_online_publication_transactions.transaction_id",
|
||||
ondelete="NO ACTION",
|
||||
name="fk_rc06_publication_saga_authority_transaction",
|
||||
),
|
||||
primary_key=True,
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default=DEFAULT_TENANT_ID)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
+116
-12
@@ -10,7 +10,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import RLock
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
@@ -18,7 +18,7 @@ from uuid import uuid4
|
||||
import httpx
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from yuqei_sdk.review_routing import REVIEW_ROUTING_VERSION, review_prompt_routes
|
||||
|
||||
@@ -44,6 +44,7 @@ EXPECTED_ASSET_COUNT = 60
|
||||
EXPECTED_MODULE_COUNT = 14
|
||||
EXPECTED_RUNTIME_COUNT = 14
|
||||
EXPECTED_WRITE_COUNT = 74
|
||||
AUTHORITY_COMMIT_MARGIN = timedelta(seconds=5)
|
||||
_coordinator_lock = RLock()
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ class Rc06PrepareRequest(BaseModel):
|
||||
batch_manifest_raw_sha256: str = Field(pattern=HEX64)
|
||||
batch_manifest_payload_sha256: str = Field(pattern=HEX64)
|
||||
approval_receipt_sha256: str = Field(pattern=HEX64)
|
||||
authority_expires_at: datetime
|
||||
assets: list[dict[str, Any]] = Field(min_length=EXPECTED_ASSET_COUNT, max_length=EXPECTED_ASSET_COUNT)
|
||||
prompt_modules: list[dict[str, Any]] = Field(min_length=EXPECTED_MODULE_COUNT, max_length=EXPECTED_MODULE_COUNT)
|
||||
runtime_identities: list[dict[str, Any]] = Field(min_length=EXPECTED_RUNTIME_COUNT, max_length=EXPECTED_RUNTIME_COUNT)
|
||||
@@ -208,6 +210,24 @@ def _parse_time(value: Any) -> datetime | None:
|
||||
return result.replace(tzinfo=UTC) if result.tzinfo is None else result.astimezone(UTC)
|
||||
|
||||
|
||||
def _database_utc_now(session: Any) -> datetime:
|
||||
"""Read the participant database clock used by the commit transaction."""
|
||||
|
||||
bind = session.get_bind()
|
||||
# PostgreSQL CURRENT_TIMESTAMP is the transaction-start timestamp and is
|
||||
# therefore unsafe for a second expiry check in a long write transaction.
|
||||
# clock_timestamp() is the actual wall clock at each statement.
|
||||
value = (
|
||||
session.scalar(text("SELECT clock_timestamp()"))
|
||||
if bind.dialect.name == "postgresql"
|
||||
else session.scalar(select(func.current_timestamp()))
|
||||
)
|
||||
result = _parse_time(value)
|
||||
if result is None:
|
||||
raise Rc06PublicationError("Contract database clock is unavailable.")
|
||||
return result
|
||||
|
||||
|
||||
def _finish_asset(asset: dict[str, Any]) -> dict[str, Any]:
|
||||
asset["body_sha256"] = hashlib.sha256(asset["body_content"].encode("utf-8")).hexdigest()
|
||||
asset["input_schema_sha256"] = _sha(asset["input_schema"]) if asset["input_schema"] is not None else None
|
||||
@@ -310,6 +330,8 @@ class Rc06PublicationCoordinator:
|
||||
|
||||
def prepare(self, payload: Rc06PrepareRequest, *, tenant_id: str) -> Rc06SagaStatus:
|
||||
self._assert_current_deployment(payload)
|
||||
if payload.authority_expires_at.tzinfo is None or payload.authority_expires_at.utcoffset() is None:
|
||||
raise Rc06Conflict("RC06 authority expiry must include a timezone.")
|
||||
self._authorization.verify(payload, tenant_id=tenant_id)
|
||||
installed = contract_source_assets()
|
||||
supplied = sorted(payload.assets, key=lambda item: item.get("template_id", ""))
|
||||
@@ -321,10 +343,13 @@ class Rc06PublicationCoordinator:
|
||||
raise Rc06Conflict("The runtime Prompt identity inventory is incomplete or duplicated.")
|
||||
request_payload = payload.model_dump(mode="json")
|
||||
request_sha256 = _sha(request_payload)
|
||||
now = datetime.now(UTC)
|
||||
compensation_capability: str
|
||||
with _coordinator_lock, self._session_factory.begin() as session:
|
||||
self._lock_saga(session, payload.transaction_id)
|
||||
now = _database_utc_now(session)
|
||||
authority_expires_at = _parse_time(payload.authority_expires_at)
|
||||
if authority_expires_at is None or now + AUTHORITY_COMMIT_MARGIN >= authority_expires_at:
|
||||
raise Rc06Conflict("RC06 authority expired before participant prepare.")
|
||||
existing = session.scalar(select(Rc06PublicationSagaModel).where(
|
||||
Rc06PublicationSagaModel.tenant_id == tenant_id,
|
||||
Rc06PublicationSagaModel.idempotency_key == payload.idempotency_key
|
||||
@@ -437,9 +462,14 @@ class Rc06PublicationCoordinator:
|
||||
if row.state not in {"prepared", "ai_committed", "contract_committed"}:
|
||||
raise Rc06Conflict(f"RC06 saga cannot commit from {row.state}.")
|
||||
if row.state in {"prepared", "ai_committed"}:
|
||||
self._assert_current_deployment(
|
||||
Rc06PrepareRequest.model_validate(self._frozen_request(row))
|
||||
)
|
||||
frozen = Rc06PrepareRequest.model_validate(self._frozen_request(row))
|
||||
self._assert_current_deployment(frozen)
|
||||
with self._session_factory() as clock_session:
|
||||
if (
|
||||
_database_utc_now(clock_session) + AUTHORITY_COMMIT_MARGIN
|
||||
>= frozen.authority_expires_at.astimezone(UTC)
|
||||
):
|
||||
raise Rc06Conflict("RC06 authority expired before participant commit.")
|
||||
if row.state == "prepared":
|
||||
# The previous coordinator process may have crashed after the AI
|
||||
# database committed but before its local journal advanced. Ask
|
||||
@@ -632,13 +662,30 @@ class Rc06PublicationCoordinator:
|
||||
if saga.state != "ai_committed":
|
||||
raise Rc06Conflict(f"Contract participant cannot commit from {saga.state}.")
|
||||
request = self._frozen_request(saga)
|
||||
authority_expires_at = _parse_time(request["authority_expires_at"])
|
||||
contract_write_started_at = _database_utc_now(session)
|
||||
if (
|
||||
authority_expires_at is None
|
||||
or contract_write_started_at + AUTHORITY_COMMIT_MARGIN >= authority_expires_at
|
||||
):
|
||||
raise Rc06Conflict("RC06 authority expired before the Contract participant write.")
|
||||
ai_status = _decode(saga.ai_status_json, {})
|
||||
self._assert_ai_state(
|
||||
ai_status,
|
||||
transaction_id,
|
||||
request,
|
||||
expected_state="committed",
|
||||
)
|
||||
ai_committed_at = _parse_time(ai_status.get("committed_at"))
|
||||
if ai_committed_at is None or ai_committed_at >= authority_expires_at:
|
||||
raise Rc06Conflict("AI participant committed outside the RC06 authority window.")
|
||||
assets = {item["template_id"]: item for item in request["assets"]}
|
||||
items = session.scalars(select(Rc06TemplatePublicationItemModel).where(
|
||||
Rc06TemplatePublicationItemModel.transaction_id == transaction_id
|
||||
)).all()
|
||||
if len(items) != EXPECTED_ASSET_COUNT:
|
||||
raise Rc06PublicationError("Contract participant journal does not contain 60 items.")
|
||||
now = datetime.now(UTC)
|
||||
now = contract_write_started_at
|
||||
for item in sorted(items, key=lambda value: value.template_id):
|
||||
self._lock_template(session, saga.tenant_id, item.template_id)
|
||||
head = self._latest_event(session, saga.tenant_id, item.template_id)
|
||||
@@ -707,10 +754,35 @@ class Rc06PublicationCoordinator:
|
||||
item.online_content_sha256 = asset["content_sha256"]
|
||||
item.state = "applied"
|
||||
item.updated_at = now
|
||||
# Capture the exact participant readback at the irreversible
|
||||
# Contract boundary. Receipt construction may happen much later
|
||||
# (including after the short authority window expires), so it must
|
||||
# never invent a recovery-time commit/capture timestamp.
|
||||
asset_results = self._verify_contract_readback(session, saga, request)
|
||||
results = sorted(
|
||||
asset_results + ai_status["item_results"],
|
||||
key=lambda value: (value["kind"], value["item_id"]),
|
||||
)
|
||||
snapshots = ai_status["runtime_snapshots"]
|
||||
if len(results) != EXPECTED_WRITE_COUNT or len(snapshots) != EXPECTED_RUNTIME_COUNT:
|
||||
raise Rc06PublicationError("RC06 irreversible readback evidence is incomplete.")
|
||||
boundary_at = _database_utc_now(session)
|
||||
if boundary_at + AUTHORITY_COMMIT_MARGIN >= authority_expires_at:
|
||||
raise Rc06Conflict(
|
||||
"RC06 authority expired before the Contract participant commit boundary."
|
||||
)
|
||||
# This is the last instant at which all participant writes and the
|
||||
# complete readback have succeeded inside the Contract
|
||||
# transaction. Do not reuse the earlier object-write timestamp:
|
||||
# a long batch could otherwise claim an irreversible boundary
|
||||
# before the evidence was actually complete.
|
||||
saga.item_results_json = _encode(results)
|
||||
saga.runtime_snapshots_json = _encode(snapshots)
|
||||
saga.state = "contract_committed"
|
||||
saga.error_message = None
|
||||
saga.contract_committed_at = now
|
||||
saga.updated_at = now
|
||||
saga.contract_committed_at = boundary_at
|
||||
saga.committed_at = boundary_at
|
||||
saga.updated_at = boundary_at
|
||||
|
||||
def _finalize(self, transaction_id: str, ai_status: dict[str, Any]) -> None:
|
||||
with _coordinator_lock, self._session_factory.begin() as session:
|
||||
@@ -746,14 +818,23 @@ class Rc06PublicationCoordinator:
|
||||
results = sorted(asset_results + prompt_results, key=lambda item: (item["kind"], item["item_id"]))
|
||||
if len(results) != EXPECTED_WRITE_COUNT or any(item.get("status") != "applied_and_verified" for item in results):
|
||||
raise Rc06PublicationError("RC06 online apply readback is incomplete.")
|
||||
persisted_results = _decode(saga.item_results_json, [])
|
||||
persisted_snapshots = _decode(saga.runtime_snapshots_json, [])
|
||||
if (
|
||||
results != persisted_results
|
||||
or snapshots != persisted_snapshots
|
||||
or saga.committed_at is None
|
||||
or saga.contract_committed_at is None
|
||||
or saga.committed_at != saga.contract_committed_at
|
||||
):
|
||||
raise Rc06PublicationError(
|
||||
"RC06 irreversible participant evidence changed during finalize."
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
saga.item_results_json = _encode(results)
|
||||
saga.runtime_snapshots_json = _encode(snapshots)
|
||||
saga.ai_status_json = _encode(ai_status)
|
||||
saga.state = "committed"
|
||||
saga.compensation_capability = None
|
||||
saga.error_message = None
|
||||
saga.committed_at = now
|
||||
saga.updated_at = now
|
||||
|
||||
def _compensate_templates(self, transaction_id: str) -> None:
|
||||
@@ -1032,9 +1113,15 @@ class Rc06PublicationCoordinator:
|
||||
try:
|
||||
committed_at = _parse_time(payload.get("committed_at"))
|
||||
sealed_at = _parse_time(payload.get("sealed_at"))
|
||||
authority_expires_at = _parse_time(payload.get("authority_expires_at"))
|
||||
expected_authority_expires_at = _parse_time(
|
||||
request.get("authority_expires_at")
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
committed_at = None
|
||||
sealed_at = None
|
||||
authority_expires_at = None
|
||||
expected_authority_expires_at = None
|
||||
state_markers_valid = (
|
||||
expected_state == "committed"
|
||||
and payload.get("no_go") is True
|
||||
@@ -1055,6 +1142,9 @@ class Rc06PublicationCoordinator:
|
||||
or payload.get("batch_manifest_raw_sha256") != request["batch_manifest_raw_sha256"]
|
||||
or payload.get("batch_manifest_payload_sha256") != request["batch_manifest_payload_sha256"]
|
||||
or payload.get("approval_receipt_sha256") != request["approval_receipt_sha256"]
|
||||
or authority_expires_at is None
|
||||
or expected_authority_expires_at is None
|
||||
or authority_expires_at != expected_authority_expires_at
|
||||
or committed_at is None
|
||||
or not state_markers_valid
|
||||
or payload.get("write_count") != EXPECTED_MODULE_COUNT
|
||||
@@ -1175,6 +1265,19 @@ class Rc06PublicationCoordinator:
|
||||
row = self._require_saga(session, transaction_id)
|
||||
if row.state != "contract_committed":
|
||||
raise Rc06Conflict(f"RC06 saga changed state during AI seal: {row.state}")
|
||||
previous = _decode(row.ai_status_json, {})
|
||||
if (
|
||||
status.get("candidate_commit") != previous.get("candidate_commit")
|
||||
or status.get("release_image_digest")
|
||||
!= previous.get("release_image_digest")
|
||||
or _parse_time(status.get("committed_at"))
|
||||
!= _parse_time(previous.get("committed_at"))
|
||||
or status.get("item_results") != previous.get("item_results")
|
||||
or status.get("runtime_snapshots") != previous.get("runtime_snapshots")
|
||||
):
|
||||
raise Rc06PublicationError(
|
||||
"AI sealed evidence drifted from its persisted commit readback."
|
||||
)
|
||||
row.ai_status_json = _encode(status)
|
||||
row.compensation_capability = None
|
||||
row.error_message = None
|
||||
@@ -1294,6 +1397,7 @@ class Rc06PublicationCoordinator:
|
||||
"batch_manifest_raw_sha256": payload.batch_manifest_raw_sha256,
|
||||
"batch_manifest_payload_sha256": payload.batch_manifest_payload_sha256,
|
||||
"approval_receipt_sha256": payload.approval_receipt_sha256,
|
||||
"authority_expires_at": payload.authority_expires_at.isoformat(),
|
||||
"compensation_capability_sha256": hashlib.sha256(
|
||||
compensation_capability.encode("utf-8")
|
||||
).hexdigest(),
|
||||
|
||||
+150
-46
@@ -15,7 +15,7 @@ import json
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any, Mapping, Protocol
|
||||
@@ -23,7 +23,7 @@ from typing import Any, Mapping, Protocol
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .config import ContractApiSettings
|
||||
@@ -49,6 +49,7 @@ from .rc06_publication_protocol import (
|
||||
APPLY_STATUS_SCHEMA,
|
||||
FINALIZE_REQUEST_SCHEMA,
|
||||
FINALIZE_RESPONSE_SCHEMA,
|
||||
HEX64,
|
||||
KMS_ATTESTATION_PAYLOAD_SCHEMA,
|
||||
KMS_ATTESTATION_SCHEMA,
|
||||
ONLINE_ATTESTATION_DOMAIN,
|
||||
@@ -277,6 +278,21 @@ def _utc(value: datetime | None) -> datetime | None:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
|
||||
|
||||
def _database_wall_clock(session: Any) -> datetime:
|
||||
value = (
|
||||
session.scalar(text("SELECT clock_timestamp()"))
|
||||
if session.get_bind().dialect.name == "postgresql"
|
||||
else session.scalar(select(func.current_timestamp()))
|
||||
)
|
||||
result = _utc(value)
|
||||
if result is None:
|
||||
raise Rc06FacadeError("RC06 facade database clock is unavailable.")
|
||||
return result
|
||||
|
||||
|
||||
FINALIZE_COMMIT_MARGIN = timedelta(seconds=5)
|
||||
|
||||
|
||||
def _strict_json(raw: bytes, *, maximum: int) -> dict[str, Any]:
|
||||
if not raw or len(raw) > maximum:
|
||||
raise Rc06FacadeError("RC06 request body is empty or exceeds the configured limit.")
|
||||
@@ -569,6 +585,11 @@ class Rc06PublicationFacadeService:
|
||||
for item in material.manifest["runtime_prompt_dependencies"]["items"]
|
||||
]
|
||||
request = material.request
|
||||
authority_expires_at = min(
|
||||
parse_time(material.challenge["payload"]["expires_at"], "challenge expires_at"),
|
||||
parse_time(material.approval["payload"]["expires_at"], "approval expires_at"),
|
||||
parse_time(material.legal_summary["expires_at"], "legal review expires_at"),
|
||||
)
|
||||
return Rc06PrepareRequest(
|
||||
transaction_id=request["transaction_id"],
|
||||
idempotency_key=request["idempotency_key"],
|
||||
@@ -579,6 +600,7 @@ class Rc06PublicationFacadeService:
|
||||
batch_manifest_raw_sha256=request["batch_manifest_raw_sha256"],
|
||||
batch_manifest_payload_sha256=request["batch_manifest_payload_sha256"],
|
||||
approval_receipt_sha256=request["approval_receipt_sha256"],
|
||||
authority_expires_at=authority_expires_at,
|
||||
assets=material.manifest["asset_batch"]["items"],
|
||||
prompt_modules=material.manifest["prompt_module_batch"]["items"],
|
||||
runtime_identities=runtime,
|
||||
@@ -601,11 +623,15 @@ class Rc06PublicationFacadeService:
|
||||
"RC06 authority window expired before the participant saga was prepared."
|
||||
)
|
||||
status = self._coordinator.prepare(prepare, tenant_id=self._tenant_id)
|
||||
if status.state == "preparing" and authority_current:
|
||||
# Coordinator prepare is itself durable/idempotent. Resume the
|
||||
# phase split between its local journal and the AI participant.
|
||||
status = self._coordinator.prepare(prepare, tenant_id=self._tenant_id)
|
||||
self._record_saga_status(transaction_id, status)
|
||||
if status.state == "prepared":
|
||||
self._set_state(transaction_id, "saga_prepared")
|
||||
if not authority_current:
|
||||
if status.state in {"prepared", "ai_committed"}:
|
||||
if status.state in {"preparing", "prepared", "ai_committed"}:
|
||||
self._set_state(
|
||||
transaction_id,
|
||||
self._facade_state(status.state),
|
||||
@@ -696,8 +722,10 @@ class Rc06PublicationFacadeService:
|
||||
committed_at = _utc(status.committed_at)
|
||||
if committed_at is None:
|
||||
raise Rc06FacadeError("RC06 coordinator commit time is unavailable.")
|
||||
authority_expires_at = parse_time(
|
||||
material.challenge["payload"]["expires_at"], "challenge expires_at"
|
||||
authority_expires_at = min(
|
||||
parse_time(material.challenge["payload"]["expires_at"], "challenge expires_at"),
|
||||
parse_time(material.approval["payload"]["expires_at"], "approval expires_at"),
|
||||
parse_time(material.legal_summary["expires_at"], "legal review expires_at"),
|
||||
)
|
||||
approval_expires_at = parse_time(
|
||||
material.approval["payload"]["expires_at"], "approval expires_at"
|
||||
@@ -871,10 +899,9 @@ class Rc06PublicationFacadeService:
|
||||
def _ensure_runtime_receipt(self, transaction_id: str, payload: dict[str, Any]) -> None:
|
||||
row = self._load(transaction_id)
|
||||
persisted_payload = _decode(row.runtime_payload_json, None)
|
||||
if persisted_payload is not None:
|
||||
# captured_at is frozen by the first attempt; recovery must reuse it.
|
||||
payload = persisted_payload
|
||||
else:
|
||||
if persisted_payload is not None and persisted_payload != payload:
|
||||
raise Rc06FacadeConflict("Persisted runtime receipt payload drifted.")
|
||||
if persisted_payload is None:
|
||||
self._update(transaction_id, runtime_payload_json=_encode(payload))
|
||||
row = self._load(transaction_id)
|
||||
signature = _decode(row.runtime_primary_signature_json, None)
|
||||
@@ -998,14 +1025,18 @@ class Rc06PublicationFacadeService:
|
||||
if saga is not None:
|
||||
self._record_saga_status(transaction_id, saga)
|
||||
if saga.state in {"contract_committed", "committed"}:
|
||||
self._set_state(
|
||||
transaction_id,
|
||||
"contract_committed",
|
||||
contract_committed_at=saga.contract_committed_at,
|
||||
error="participant commit is durable; facade evidence is pending",
|
||||
)
|
||||
current = self._load(transaction_id)
|
||||
if current.contract_committed_at is None:
|
||||
self._set_state(
|
||||
transaction_id,
|
||||
"contract_committed",
|
||||
contract_committed_at=saga.contract_committed_at,
|
||||
error="participant commit is durable; facade evidence is pending",
|
||||
)
|
||||
if not all((
|
||||
current.failure_code,
|
||||
current.failure_at,
|
||||
current.failure_evidence_sha256,
|
||||
)):
|
||||
self._record_error(
|
||||
transaction_id,
|
||||
Rc06FacadeError(
|
||||
@@ -1018,6 +1049,19 @@ class Rc06PublicationFacadeService:
|
||||
self._facade_state(saga.state),
|
||||
error=saga.error,
|
||||
)
|
||||
elif saga.state == "compensation_failed":
|
||||
self._set_state(
|
||||
transaction_id,
|
||||
"compensation_failed",
|
||||
error=saga.error or "participant compensation outcome is unverified",
|
||||
)
|
||||
self._record_error(
|
||||
transaction_id,
|
||||
Rc06FacadeError(
|
||||
saga.error or "participant compensation outcome is unverified"
|
||||
),
|
||||
outcome="compensation_failed",
|
||||
)
|
||||
row = self._load(transaction_id)
|
||||
request = _decode(row.request_json, {})
|
||||
committed = row.state in {"online_committed_pending_countersign", "finalized"}
|
||||
@@ -1187,14 +1231,7 @@ class Rc06PublicationFacadeService:
|
||||
or before.compensated_from_state == "committed"
|
||||
)
|
||||
)
|
||||
ai_before_state = "committed" if ai_was_committed else (
|
||||
"prepared" if before is not None else "not_started"
|
||||
)
|
||||
contract_before_state = "prepared" if before is not None else "not_started"
|
||||
before_states = [
|
||||
_state_evidence("ai-platform", ai_before_state, 14 if ai_was_committed else 0, 14 if ai_was_committed else 0),
|
||||
_state_evidence("contract-platform", contract_before_state, 0, 0),
|
||||
]
|
||||
self._set_state(transaction_id, "compensating")
|
||||
if before is None:
|
||||
after = None
|
||||
@@ -1222,16 +1259,38 @@ class Rc06PublicationFacadeService:
|
||||
after.removed_item_count,
|
||||
after.unchanged_item_count,
|
||||
)
|
||||
if ai_was_committed:
|
||||
if after.compensated_from_state == "committed":
|
||||
# The participant's durable compensation evidence is the
|
||||
# authority for a lost-response recovery. The facade journal
|
||||
# can still say prepared even though AI committed immediately
|
||||
# before the process died.
|
||||
ai_was_committed = True
|
||||
if (
|
||||
after.compensated_from_state != "committed"
|
||||
or len(compensation_items) != 14
|
||||
len(compensation_items) != 14
|
||||
or len(runtime_items) != 14
|
||||
or not isinstance(after.compensation_evidence_sha256, str)
|
||||
or re.fullmatch(HEX64, after.compensation_evidence_sha256) is None
|
||||
):
|
||||
raise Rc06FacadeError("RC06 AI committed compensation evidence is incomplete.")
|
||||
elif compensation_items or runtime_items or after.compensated_from_state != "prepared":
|
||||
elif (
|
||||
ai_was_committed
|
||||
or compensation_items
|
||||
or runtime_items
|
||||
or after.compensated_from_state != "prepared"
|
||||
):
|
||||
raise Rc06FacadeError("RC06 prepared compensation falsely claims writes.")
|
||||
ai_before_state = "committed" if ai_was_committed else (
|
||||
"prepared" if before is not None else "not_started"
|
||||
)
|
||||
before_states = [
|
||||
_state_evidence(
|
||||
"ai-platform",
|
||||
ai_before_state,
|
||||
14 if ai_was_committed else 0,
|
||||
14 if ai_was_committed else 0,
|
||||
),
|
||||
_state_evidence("contract-platform", contract_before_state, 0, 0),
|
||||
]
|
||||
after_states = [
|
||||
_state_evidence(
|
||||
"ai-platform",
|
||||
@@ -1324,14 +1383,24 @@ class Rc06PublicationFacadeService:
|
||||
return self._finalize_response(row)
|
||||
if row.state != "online_committed_pending_countersign":
|
||||
raise Rc06FacadeConflict("RC06 transaction is not awaiting offline countersign.")
|
||||
countersigned_at = self._validate_apply_receipt(row, request["apply_receipt"])
|
||||
now = datetime.now(UTC)
|
||||
if now < countersigned_at:
|
||||
now = countersigned_at
|
||||
countersigned_at = self._validate_apply_receipt(
|
||||
row,
|
||||
request["apply_receipt"],
|
||||
now=now,
|
||||
)
|
||||
self._update_and_consume(
|
||||
transaction_id,
|
||||
outcome="finalized",
|
||||
consumed_at=now,
|
||||
countersigned_at=countersigned_at,
|
||||
authority_deadline=min(
|
||||
parse_time(
|
||||
_decode(row.request_json, {})["approval_receipt"]["payload"]["expires_at"],
|
||||
"approval expires_at",
|
||||
),
|
||||
_utc(row.legal_source_review_expires_at),
|
||||
),
|
||||
state="finalized",
|
||||
apply_receipt_json=_encode(request["apply_receipt"]),
|
||||
apply_receipt_sha256=request["apply_receipt_sha256"],
|
||||
@@ -1347,6 +1416,8 @@ class Rc06PublicationFacadeService:
|
||||
self,
|
||||
row: Rc06OnlinePublicationTransactionModel,
|
||||
receipt: Any,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> datetime:
|
||||
envelope = require_exact(receipt, "apply receipt", (
|
||||
"schema_version", "receipt_id", "payload", "signature",
|
||||
@@ -1388,11 +1459,18 @@ class Rc06PublicationFacadeService:
|
||||
countersigned_at = parse_time(payload["countersigned_at"], "countersigned_at")
|
||||
runtime_captured_at = parse_time(runtime["payload"]["captured_at"], "runtime captured_at")
|
||||
approval = frozen["approval_receipt"]
|
||||
approval_expires_at = parse_time(
|
||||
approval["payload"]["expires_at"], "approval expires_at"
|
||||
)
|
||||
legal_expires_at = _utc(row.legal_source_review_expires_at)
|
||||
if (
|
||||
authorized_at != runtime_captured_at
|
||||
or countersigned_at < authorized_at
|
||||
or countersigned_at >= parse_time(approval["payload"]["expires_at"], "approval expires_at")
|
||||
or countersigned_at >= _utc(row.legal_source_review_expires_at)
|
||||
or countersigned_at > now
|
||||
or now >= approval_expires_at
|
||||
or now >= legal_expires_at
|
||||
or countersigned_at >= approval_expires_at
|
||||
or countersigned_at >= legal_expires_at
|
||||
):
|
||||
raise Rc06FacadeError("Offline apply receipt is outside its authority window.")
|
||||
try:
|
||||
@@ -1510,6 +1588,8 @@ class Rc06PublicationFacadeService:
|
||||
*,
|
||||
outcome: str,
|
||||
consumed_at: datetime,
|
||||
authority_deadline: datetime | None = None,
|
||||
countersigned_at: datetime | None = None,
|
||||
**values: Any,
|
||||
) -> None:
|
||||
with _facade_lock, self._session_factory.begin() as session:
|
||||
@@ -1523,6 +1603,19 @@ class Rc06PublicationFacadeService:
|
||||
raise Rc06FacadeError("RC06 challenge consumption ledger is missing.")
|
||||
if use.outcome == "finalized" and outcome != "finalized":
|
||||
raise Rc06FacadeConflict("Finalized RC06 challenge outcome is immutable.")
|
||||
if authority_deadline is not None:
|
||||
database_now = _database_wall_clock(session)
|
||||
if countersigned_at is not None and countersigned_at > database_now:
|
||||
raise Rc06FacadeConflict(
|
||||
"RC06 countersign is later than the database commit clock."
|
||||
)
|
||||
if database_now + FINALIZE_COMMIT_MARGIN >= authority_deadline:
|
||||
raise Rc06FacadeConflict(
|
||||
"RC06 finalize crossed its approval or legal-review authority deadline."
|
||||
)
|
||||
consumed_at = database_now
|
||||
if "finalized_at" in values:
|
||||
values["finalized_at"] = database_now
|
||||
for key, value in values.items():
|
||||
if not hasattr(row, key):
|
||||
raise Rc06FacadeError(f"Unsupported RC06 durable field: {key}")
|
||||
@@ -1636,6 +1729,8 @@ class Rc06PublicationFacadeService:
|
||||
|
||||
@staticmethod
|
||||
def _facade_state(saga_state: str, *, committing: bool = False) -> str:
|
||||
if saga_state == "preparing":
|
||||
return "saga_prepared"
|
||||
if saga_state == "prepared":
|
||||
return "saga_committing" if committing else "saga_prepared"
|
||||
if saga_state == "ai_committed":
|
||||
@@ -1692,23 +1787,32 @@ class Rc06PublicationFacadeService:
|
||||
"""
|
||||
|
||||
identity = transaction_id if isinstance(transaction_id, str) else "invalid"
|
||||
with self._session_factory() as session:
|
||||
if session.get_bind().dialect.name != "postgresql":
|
||||
with _facade_lock:
|
||||
yield
|
||||
return
|
||||
key = int.from_bytes(
|
||||
hashlib.sha256(
|
||||
f"rc06-online-singleflight:{identity}".encode()
|
||||
).digest()[:8],
|
||||
"big",
|
||||
signed=True,
|
||||
)
|
||||
session.execute(text("SELECT pg_advisory_lock(:key)"), {"key": key})
|
||||
with self._session_factory() as probe:
|
||||
bind = probe.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
with _facade_lock:
|
||||
yield
|
||||
return
|
||||
key = int.from_bytes(
|
||||
hashlib.sha256(
|
||||
f"rc06-online-singleflight:{identity}".encode()
|
||||
).digest()[:8],
|
||||
"big",
|
||||
signed=True,
|
||||
)
|
||||
# Keep one physical connection checked out in DBAPI autocommit mode.
|
||||
# A session transaction held across AI/KMS calls would be vulnerable to
|
||||
# idle_in_transaction_session_timeout and could silently lose the lock.
|
||||
with bind.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
|
||||
connection.execute(text("SELECT pg_advisory_lock(:key)"), {"key": key})
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
session.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": key})
|
||||
unlocked = connection.execute(
|
||||
text("SELECT pg_advisory_unlock(:key)"), {"key": key}
|
||||
).scalar_one()
|
||||
if unlocked is not True:
|
||||
raise Rc06FacadeError("RC06 PostgreSQL singleflight lock ownership was lost.")
|
||||
|
||||
|
||||
def is_rc06_publication_facade_path(path: str) -> bool:
|
||||
|
||||
+549
-53
@@ -73,8 +73,58 @@ EXPECTED_ASSETS = 60
|
||||
EXPECTED_MODULES = 14
|
||||
EXPECTED_RUNTIME = 14
|
||||
EXPECTED_GOLDEN = 31
|
||||
EXPECTED_GOLDEN_GENERATION = 23
|
||||
EXPECTED_GOLDEN_CLAUSE = 8
|
||||
EXPECTED_DEFAULT_PROMPTS = 46
|
||||
REQUIRED_IMAGE_ROLES = (
|
||||
"contract-web", "contract-api", "ai-platform-api", "ai-worker",
|
||||
)
|
||||
RC05_SUMMARY_SCHEMA = "multiscene-evaluation-run.v1"
|
||||
RC05_PLAN_SCHEMA = "multiscene-evaluation-plan.v2"
|
||||
RC05_CANDIDATE_SCHEMA = "multiscene-candidate-binding.v1"
|
||||
RC05_RELEASE_CONTROLS_SCHEMA = "multiscene-release-controls.v2"
|
||||
EXPECTED_RC05_OPERATION_LIMITS = {
|
||||
"draft": 120,
|
||||
"semantic_review": 30,
|
||||
"repair_draft": 8,
|
||||
"repair_review": 8,
|
||||
"specialist_review": 106,
|
||||
"intake": 8,
|
||||
}
|
||||
EXPECTED_RC05_CANARY_CASE_IDS = (
|
||||
"base--TPL-AGENCY-DISTRIBUTION-001--normal",
|
||||
"base--TPL-DEMAND-LETTER-001--normal",
|
||||
"base--TPL-PURCHASE-GOODS-001--normal",
|
||||
"scene--TPL-AMENDMENT-GENERAL-001--opposite-adversarial",
|
||||
"specialist-trade_procurement-first",
|
||||
"intake-tpl-purchase-goods-001",
|
||||
)
|
||||
BLOCKED_CONTROLS = (
|
||||
"single_reviewer_batch_approval_receipt",
|
||||
"system_generated_item_apply_receipts_and_online_snapshots",
|
||||
"runtime_executable_prompt_snapshots_1_drafting_plus_13_review",
|
||||
)
|
||||
DISPOSITION_STATUSES = {
|
||||
"named_legal_approval", "scoped_internal_risk_acceptance", "unresolved",
|
||||
}
|
||||
EXISTING_DOMAIN_PROMPTS = {
|
||||
"contract_review_channel_franchise",
|
||||
"contract_review_employment_hr",
|
||||
"contract_review_investment_finance",
|
||||
"contract_review_real_estate_property",
|
||||
"contract_review_tech_saas_data_compliance",
|
||||
}
|
||||
DRAFTING_MODULE_ORDER = (
|
||||
"draft_intake",
|
||||
"template_match",
|
||||
"missing_facts_questions",
|
||||
"clause_assembly_plan",
|
||||
"draft_consistency_check",
|
||||
"candidate_revision_explanation",
|
||||
)
|
||||
LEGAL_PLACEHOLDERS = {"", "-", "n/a", "none", "pending", "unknown", "待确认", "待复核"}
|
||||
KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
STABLE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$")
|
||||
|
||||
|
||||
class Rc06ProtocolError(RuntimeError):
|
||||
@@ -162,9 +212,16 @@ def require_official_url(value: Any, label: str) -> str:
|
||||
text_value = require_text(value, label, maximum=2048)
|
||||
parsed = urlparse(text_value)
|
||||
host = (parsed.hostname or "").lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise Rc06ProtocolError(
|
||||
f"{label} must use a credential-free official HTTPS URL."
|
||||
) from exc
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not host
|
||||
or port not in {None, 443}
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or not (host == "gov.cn" or host.endswith(".gov.cn"))
|
||||
@@ -245,6 +302,8 @@ def _validate_asset(value: Any) -> dict[str, Any]:
|
||||
)
|
||||
row = require_exact(value, "asset", fields)
|
||||
template_id = require_text(row["template_id"], "asset template_id", maximum=128)
|
||||
if STABLE_ID.fullmatch(template_id) is None:
|
||||
raise Rc06ProtocolError("Asset template_id is not a stable identifier.")
|
||||
body = require_text(row["body_content"], f"asset {template_id} body", maximum=4_000_000)
|
||||
if sha256_bytes(body.encode("utf-8")) != require_sha(row["body_sha256"], "asset body hash"):
|
||||
raise Rc06ProtocolError(f"Asset body hash drift: {template_id}")
|
||||
@@ -291,6 +350,235 @@ def _validate_module(value: Any) -> dict[str, Any]:
|
||||
return row
|
||||
|
||||
|
||||
def _require_count(value: Any, label: str, *, maximum: int | None = None) -> int:
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or value < 0
|
||||
or (maximum is not None and value > maximum)
|
||||
):
|
||||
raise Rc06ProtocolError(f"{label} is invalid.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_candidate(value: Any) -> dict[str, Any]:
|
||||
candidate = require_exact(value, "candidate", (
|
||||
"commit", "tag", "source_manifest_sha256", "source_manifest_payload_sha256",
|
||||
"image_identities_sha256", "image_identity_set_sha256", "images",
|
||||
"rc05_plan_sha256", "rc05_plan_identity", "rc05_terminal_evidence",
|
||||
))
|
||||
commit = require_text(candidate["commit"], "candidate commit", maximum=64)
|
||||
if COMMIT.fullmatch(commit) is None:
|
||||
raise Rc06ProtocolError("Candidate commit is invalid.")
|
||||
tag = require_text(candidate["tag"], "candidate tag", maximum=128)
|
||||
if ".." in tag or "//" in tag:
|
||||
raise Rc06ProtocolError("Candidate tag is invalid.")
|
||||
source_raw = require_sha(candidate["source_manifest_sha256"], "source manifest raw hash")
|
||||
source_payload = require_sha(
|
||||
candidate["source_manifest_payload_sha256"], "source manifest payload hash"
|
||||
)
|
||||
image_raw = require_sha(candidate["image_identities_sha256"], "image identity raw hash")
|
||||
image_set = require_sha(candidate["image_identity_set_sha256"], "image identity set hash")
|
||||
plan_raw = require_sha(candidate["rc05_plan_sha256"], "RC05 plan raw hash")
|
||||
|
||||
images = candidate["images"]
|
||||
if not isinstance(images, list) or len(images) != len(REQUIRED_IMAGE_ROLES):
|
||||
raise Rc06ProtocolError("Candidate must identify all four release images.")
|
||||
roles: set[str] = set()
|
||||
digests: set[str] = set()
|
||||
normalized_images: list[dict[str, Any]] = []
|
||||
for number, value_row in enumerate(images):
|
||||
row = require_exact(value_row, f"candidate image {number}", (
|
||||
"role", "repository", "manifest_digest", "config_digest",
|
||||
))
|
||||
role = require_text(row["role"], "candidate image role", maximum=64)
|
||||
require_text(row["repository"], f"candidate image {role} repository", maximum=256)
|
||||
manifest_digest = require_text(
|
||||
row["manifest_digest"], f"candidate image {role} manifest digest", maximum=71
|
||||
)
|
||||
config_digest = require_text(
|
||||
row["config_digest"], f"candidate image {role} config digest", maximum=71
|
||||
)
|
||||
if IMAGE_DIGEST.fullmatch(manifest_digest) is None or IMAGE_DIGEST.fullmatch(config_digest) is None:
|
||||
raise Rc06ProtocolError("Candidate image digest is invalid.")
|
||||
if role in roles or manifest_digest in digests:
|
||||
raise Rc06ProtocolError("Candidate image roles or manifest digests are duplicated.")
|
||||
roles.add(role)
|
||||
digests.add(manifest_digest)
|
||||
normalized_images.append(dict(row))
|
||||
if roles != set(REQUIRED_IMAGE_ROLES):
|
||||
raise Rc06ProtocolError("Candidate image roles are incomplete.")
|
||||
normalized_images.sort(key=lambda item: REQUIRED_IMAGE_ROLES.index(item["role"]))
|
||||
if image_set != sha256_json(normalized_images):
|
||||
raise Rc06ProtocolError("Candidate image identity set hash drift.")
|
||||
|
||||
plan = require_exact(candidate["rc05_plan_identity"], "RC05 plan identity", (
|
||||
"schema_version", "model", "reasoning_effort", "timeout_seconds", "case_count",
|
||||
"drafting_case_count", "supplemental_case_count", "operation_limits",
|
||||
"total_provider_call_budget", "case_inventory_sha256", "source_pins_sha256",
|
||||
"candidate", "release_controls",
|
||||
))
|
||||
if (
|
||||
plan["schema_version"] != RC05_PLAN_SCHEMA
|
||||
or plan["model"] != "gpt-5.6-sol"
|
||||
or plan["reasoning_effort"] != "medium"
|
||||
or plan["timeout_seconds"] != 300
|
||||
or plan["case_count"] != 144
|
||||
or plan["drafting_case_count"] != 120
|
||||
or plan["supplemental_case_count"] != 24
|
||||
or plan["operation_limits"] != EXPECTED_RC05_OPERATION_LIMITS
|
||||
or plan["total_provider_call_budget"] != 280
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 plan identity is not the frozen 144-case plan.")
|
||||
require_sha(plan["case_inventory_sha256"], "RC05 case inventory hash")
|
||||
require_sha(plan["source_pins_sha256"], "RC05 source pins hash")
|
||||
plan_candidate = require_exact(plan["candidate"], "RC05 candidate binding", (
|
||||
"schema_version", "commit", "tag", "source_manifest", "image_identities",
|
||||
))
|
||||
source_binding = require_exact(plan_candidate["source_manifest"], "RC05 source binding", (
|
||||
"schema_version", "raw_sha256", "payload_sha256",
|
||||
))
|
||||
image_binding = require_exact(plan_candidate["image_identities"], "RC05 image binding", (
|
||||
"schema_version", "raw_sha256", "identity_set_sha256", "images",
|
||||
))
|
||||
if (
|
||||
plan_candidate["schema_version"] != RC05_CANDIDATE_SCHEMA
|
||||
or plan_candidate["commit"] != commit
|
||||
or plan_candidate["tag"] != tag
|
||||
or source_binding["schema_version"] != "yuqei.release-source-manifest.v1"
|
||||
or source_binding["raw_sha256"] != source_raw
|
||||
or source_binding["payload_sha256"] != source_payload
|
||||
or image_binding["schema_version"] not in {
|
||||
"yuqei.rc06-image-identities.v1", "yuqei.installable-release-manifest.v1"
|
||||
}
|
||||
or image_binding["raw_sha256"] != image_raw
|
||||
or image_binding["identity_set_sha256"] != image_set
|
||||
or image_binding["images"] != normalized_images
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 candidate binding drifted from the publication candidate.")
|
||||
controls = require_exact(plan["release_controls"], "RC05 release controls", (
|
||||
"schema_version", "canary_case_ids", "failure_classes", "expected_operations",
|
||||
))
|
||||
failure_classes = controls["failure_classes"]
|
||||
expected_operations = controls["expected_operations"]
|
||||
actual_operation_counts = {operation: 0 for operation in EXPECTED_RC05_OPERATION_LIMITS}
|
||||
if (
|
||||
not isinstance(failure_classes, dict)
|
||||
or len(failure_classes) != 144
|
||||
or any(
|
||||
not isinstance(key, str)
|
||||
or STABLE_ID.fullmatch(key) is None
|
||||
or not isinstance(value, str)
|
||||
or STABLE_ID.fullmatch(value) is None
|
||||
for key, value in failure_classes.items()
|
||||
)
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 failure-class inventory is incomplete.")
|
||||
if not isinstance(expected_operations, dict) or set(expected_operations) != set(failure_classes):
|
||||
raise Rc06ProtocolError("RC05 expected-operation inventory is incomplete.")
|
||||
for case_id, operations in expected_operations.items():
|
||||
if (
|
||||
not isinstance(case_id, str)
|
||||
or not case_id
|
||||
or not isinstance(operations, list)
|
||||
or not operations
|
||||
or len(operations) != len(set(operations))
|
||||
or any(operation not in actual_operation_counts for operation in operations)
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 expected-operation inventory is invalid.")
|
||||
for operation in operations:
|
||||
actual_operation_counts[operation] += 1
|
||||
if (
|
||||
controls["schema_version"] != RC05_RELEASE_CONTROLS_SCHEMA
|
||||
or controls["canary_case_ids"] != list(EXPECTED_RC05_CANARY_CASE_IDS)
|
||||
or actual_operation_counts != EXPECTED_RC05_OPERATION_LIMITS
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 release-control identity drift.")
|
||||
|
||||
terminal = require_exact(candidate["rc05_terminal_evidence"], "RC05 terminal evidence", (
|
||||
"summary_schema_version", "summary_raw_sha256", "ledger_identity_raw_sha256",
|
||||
"ledger_identity_sha256", "campaign_identity_raw_sha256", "campaign_identity_sha256",
|
||||
"plan_raw_sha256", "case_results_sha256", "operation_statistics_sha256",
|
||||
"case_operation_coverage_sha256", "historical_remediation_sha256",
|
||||
"current_plan_provider_call_count", "historical_provider_call_count",
|
||||
"combined_provider_call_count", "complete_real_green",
|
||||
))
|
||||
for name in (
|
||||
"summary_raw_sha256", "ledger_identity_raw_sha256", "ledger_identity_sha256",
|
||||
"campaign_identity_raw_sha256", "campaign_identity_sha256", "plan_raw_sha256",
|
||||
"case_results_sha256", "operation_statistics_sha256",
|
||||
"case_operation_coverage_sha256", "historical_remediation_sha256",
|
||||
):
|
||||
require_sha(terminal[name], f"RC05 terminal {name}")
|
||||
current_calls = _require_count(
|
||||
terminal["current_plan_provider_call_count"], "RC05 current-plan calls", maximum=276
|
||||
)
|
||||
historical_calls = _require_count(
|
||||
terminal["historical_provider_call_count"], "RC05 historical calls", maximum=280
|
||||
)
|
||||
combined_calls = _require_count(
|
||||
terminal["combined_provider_call_count"], "RC05 combined calls", maximum=280
|
||||
)
|
||||
if (
|
||||
terminal["summary_schema_version"] != RC05_SUMMARY_SCHEMA
|
||||
or terminal["plan_raw_sha256"] != plan_raw
|
||||
or terminal["complete_real_green"] is not True
|
||||
or current_calls < 264
|
||||
or historical_calls != 4
|
||||
or combined_calls != current_calls + historical_calls
|
||||
):
|
||||
raise Rc06ProtocolError("RC05 terminal evidence is not complete real-provider green evidence.")
|
||||
return candidate
|
||||
|
||||
|
||||
def _validate_golden_disposition(value: Any) -> dict[str, Any]:
|
||||
row = require_exact(value, "Golden disposition", (
|
||||
"sample_id", "suite", "asset_id", "source_case_sha256", "status",
|
||||
"human_confirmed", "decision_source", "decision_maker", "decided_at",
|
||||
"evidence_sha256", "risk_acceptance", "notes",
|
||||
))
|
||||
require_text(row["sample_id"], "Golden disposition sample_id", maximum=191)
|
||||
require_text(row["asset_id"], "Golden disposition asset_id", maximum=191)
|
||||
require_sha(row["source_case_sha256"], "Golden disposition source hash")
|
||||
if row["suite"] not in {"contract_generation", "clause_recommendation"}:
|
||||
raise Rc06ProtocolError("Golden disposition suite is invalid.")
|
||||
if row["status"] not in DISPOSITION_STATUSES or row["status"] == "unresolved":
|
||||
raise Rc06ProtocolError("Golden disposition is unresolved or invalid.")
|
||||
if row["human_confirmed"] is not True or row["decision_source"] != "single_reviewer_batch":
|
||||
raise Rc06ProtocolError("Golden disposition lacks the sole-reviewer decision.")
|
||||
maker = require_exact(row["decision_maker"], "Golden decision maker", (
|
||||
"approver_id", "name", "role", "organization",
|
||||
))
|
||||
if maker["approver_id"] != SOLE_REVIEWER["approver_id"] or maker["name"] != SOLE_REVIEWER["name"]:
|
||||
raise Rc06ProtocolError("Golden disposition reviewer identity is invalid.")
|
||||
require_text(maker["role"], "Golden reviewer role", maximum=256)
|
||||
require_text(maker["organization"], "Golden reviewer organization", maximum=256)
|
||||
decided_at = parse_time(row["decided_at"], "Golden disposition decided_at")
|
||||
require_sha(row["evidence_sha256"], "Golden disposition evidence hash")
|
||||
if not isinstance(row["notes"], str) or len(row["notes"]) > 4000:
|
||||
raise Rc06ProtocolError("Golden disposition notes are invalid.")
|
||||
if row["status"] == "named_legal_approval":
|
||||
if row["risk_acceptance"] is not None:
|
||||
raise Rc06ProtocolError("Named legal approval cannot contain risk acceptance.")
|
||||
else:
|
||||
risk = require_exact(row["risk_acceptance"], "Golden risk acceptance", (
|
||||
"owner", "scope", "expires_at", "controls", "use_limits",
|
||||
))
|
||||
if risk["owner"] != SOLE_REVIEWER["name"]:
|
||||
raise Rc06ProtocolError("Golden risk-acceptance owner is invalid.")
|
||||
require_text(risk["scope"], "Golden risk-acceptance scope", maximum=4000)
|
||||
if parse_time(risk["expires_at"], "Golden risk-acceptance expiry") <= decided_at:
|
||||
raise Rc06ProtocolError("Golden risk acceptance is already expired at its decision.")
|
||||
for name in ("controls", "use_limits"):
|
||||
values = risk[name]
|
||||
if not isinstance(values, list) or not values or any(
|
||||
not isinstance(item, str) or not item.strip() or item != item.strip()
|
||||
for item in values
|
||||
):
|
||||
raise Rc06ProtocolError(f"Golden risk-acceptance {name} is invalid.")
|
||||
return row
|
||||
|
||||
|
||||
def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
manifest = require_exact(value, "publication batch", (
|
||||
"schema_version", "candidate", "asset_batch", "prompt_module_batch",
|
||||
@@ -303,24 +591,7 @@ def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
{key: child for key, child in manifest.items() if key != "manifest_payload_sha256"}
|
||||
):
|
||||
raise Rc06ProtocolError("Publication batch payload hash drift.")
|
||||
candidate = manifest["candidate"]
|
||||
if not isinstance(candidate, dict) or COMMIT.fullmatch(str(candidate.get("commit"))) is None:
|
||||
raise Rc06ProtocolError("Candidate commit is invalid.")
|
||||
require_text(candidate.get("tag"), "candidate tag", maximum=128)
|
||||
images = candidate.get("images")
|
||||
if not isinstance(images, list):
|
||||
raise Rc06ProtocolError("Candidate image identities are missing.")
|
||||
image_by_role: dict[str, str] = {}
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
raise Rc06ProtocolError("Candidate image identity is invalid.")
|
||||
role = require_text(image.get("role"), "candidate image role", maximum=64)
|
||||
digest = require_text(image.get("manifest_digest"), "candidate image digest", maximum=71)
|
||||
if IMAGE_DIGEST.fullmatch(digest) is None or role in image_by_role:
|
||||
raise Rc06ProtocolError("Candidate image identities are invalid or duplicated.")
|
||||
image_by_role[role] = digest
|
||||
if "contract-api" not in image_by_role or "ai-platform-api" not in image_by_role:
|
||||
raise Rc06ProtocolError("Candidate does not identify both publication participants.")
|
||||
candidate = _validate_candidate(manifest["candidate"])
|
||||
|
||||
assets = require_exact(manifest["asset_batch"], "asset batch", ("count", "items", "inventory_sha256"))
|
||||
if assets["count"] != EXPECTED_ASSETS or not isinstance(assets["items"], list) or len(assets["items"]) != EXPECTED_ASSETS:
|
||||
@@ -331,8 +602,16 @@ def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
if require_sha(assets["inventory_sha256"], "asset inventory hash") != sha256_json(asset_rows):
|
||||
raise Rc06ProtocolError("Asset inventory hash drift.")
|
||||
|
||||
modules = manifest["prompt_module_batch"]
|
||||
if not isinstance(modules, dict) or modules.get("count") != EXPECTED_MODULES:
|
||||
modules = require_exact(manifest["prompt_module_batch"], "Prompt module batch", (
|
||||
"count", "drafting_count", "review_count", "drafting_prompt_keys",
|
||||
"review_routes", "items", "inventory_sha256",
|
||||
))
|
||||
if (
|
||||
modules["count"] != EXPECTED_MODULES
|
||||
or modules["drafting_count"] != 6
|
||||
or modules["review_count"] != 8
|
||||
or modules["drafting_prompt_keys"] != ["contract_drafting"]
|
||||
):
|
||||
raise Rc06ProtocolError("Publication batch must contain exactly 14 Prompt modules.")
|
||||
module_rows = modules.get("items")
|
||||
if not isinstance(module_rows, list) or len(module_rows) != EXPECTED_MODULES:
|
||||
@@ -340,6 +619,21 @@ def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
module_rows = [_validate_module(item) for item in module_rows]
|
||||
if len({item["key"] for item in module_rows}) != EXPECTED_MODULES:
|
||||
raise Rc06ProtocolError("Prompt module identities are duplicated.")
|
||||
drafting_keys = [item["key"] for item in module_rows if item["category"] == "drafting"]
|
||||
review_routes = sorted(item["key"] for item in module_rows if item["category"] == "review")
|
||||
if (
|
||||
set(drafting_keys) != set(DRAFTING_MODULE_ORDER)
|
||||
or modules["review_routes"] != review_routes
|
||||
or len(review_routes) != 8
|
||||
):
|
||||
raise Rc06ProtocolError("Prompt module drafting/review mapping drift.")
|
||||
for item in module_rows:
|
||||
mapping = item["publication_mapping"]
|
||||
if item["category"] == "drafting":
|
||||
if mapping != {"prompt_key": "contract_drafting", "route": "contract_drafting"}:
|
||||
raise Rc06ProtocolError("Drafting Prompt module publication mapping drift.")
|
||||
elif mapping != {"prompt_key": item["key"], "route": item["key"]}:
|
||||
raise Rc06ProtocolError("Review Prompt module publication mapping drift.")
|
||||
if require_sha(modules.get("inventory_sha256"), "Prompt module inventory hash") != sha256_json(module_rows):
|
||||
raise Rc06ProtocolError("Prompt module inventory hash drift.")
|
||||
|
||||
@@ -356,6 +650,15 @@ def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
or len({item.get("runtime_key") for item in runtime_rows if isinstance(item, dict)}) != EXPECTED_RUNTIME
|
||||
):
|
||||
raise Rc06ProtocolError("Runtime Prompt inventory must contain exactly 14 identities.")
|
||||
publication_modules = {
|
||||
item["key"]: {
|
||||
"key": item["key"],
|
||||
"version": item["version"],
|
||||
"content_sha256": item["content_sha256"],
|
||||
}
|
||||
for item in module_rows
|
||||
}
|
||||
runtime_keys_by_kind: dict[str, set[str]] = {}
|
||||
for item in runtime_rows:
|
||||
require_exact(item, "runtime Prompt", (
|
||||
"kind", "runtime_key", "version", "content_sha256", "publication_dependency",
|
||||
@@ -366,23 +669,105 @@ def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
require_text(item["runtime_key"], "runtime Prompt key", maximum=128)
|
||||
require_text(item["version"], "runtime Prompt version", maximum=128)
|
||||
require_sha(item["content_sha256"], "runtime Prompt content hash")
|
||||
runtime_keys_by_kind.setdefault(item["kind"], set()).add(item["runtime_key"])
|
||||
expected_publication_dependency = {
|
||||
"composed_drafting": "six_module_composition",
|
||||
"new_review_module": "published_module",
|
||||
"existing_domain_prompt": "preexisting_domain_prompt",
|
||||
}[item["kind"]]
|
||||
if item["publication_dependency"] != expected_publication_dependency:
|
||||
raise Rc06ProtocolError("Runtime Prompt publication dependency drift.")
|
||||
expected_module_keys = (
|
||||
list(DRAFTING_MODULE_ORDER)
|
||||
if item["kind"] == "composed_drafting"
|
||||
else [item["runtime_key"]]
|
||||
if item["kind"] == "new_review_module"
|
||||
else []
|
||||
)
|
||||
if item["publication_module_dependencies"] != [
|
||||
publication_modules[key] for key in expected_module_keys
|
||||
]:
|
||||
raise Rc06ProtocolError("Runtime Prompt module dependency identity drift.")
|
||||
if (
|
||||
runtime_keys_by_kind.get("composed_drafting") != {"contract_drafting_v1"}
|
||||
or runtime_keys_by_kind.get("new_review_module") != set(review_routes)
|
||||
or runtime_keys_by_kind.get("existing_domain_prompt") != EXISTING_DOMAIN_PROMPTS
|
||||
):
|
||||
raise Rc06ProtocolError("Runtime Prompt 1+8+5 identity inventory drift.")
|
||||
if require_sha(runtime["inventory_sha256"], "runtime Prompt inventory hash") != sha256_json(runtime_rows):
|
||||
raise Rc06ProtocolError("Runtime Prompt inventory hash drift.")
|
||||
|
||||
golden = manifest["golden_dispositions"]
|
||||
boundary = require_exact(manifest["default_prompt_asset_boundary"], "default Prompt boundary", (
|
||||
"included_in_publication_batch", "count", "source_path", "source_raw_sha256",
|
||||
"inventory", "inventory_sha256", "boundary_note",
|
||||
))
|
||||
boundary_inventory = boundary["inventory"]
|
||||
if (
|
||||
not isinstance(golden, dict)
|
||||
or golden.get("count") != EXPECTED_GOLDEN
|
||||
or not isinstance(golden.get("items"), list)
|
||||
or len(golden["items"]) != EXPECTED_GOLDEN
|
||||
or any(item.get("status") == "unresolved" for item in golden["items"] if isinstance(item, dict))
|
||||
or golden.get("inventory_sha256") != sha256_json(golden["items"])
|
||||
boundary["included_in_publication_batch"] is not False
|
||||
or boundary["count"] != EXPECTED_DEFAULT_PROMPTS
|
||||
or not isinstance(boundary_inventory, list)
|
||||
or len(boundary_inventory) != EXPECTED_DEFAULT_PROMPTS
|
||||
or len({item.get("prompt_key") for item in boundary_inventory if isinstance(item, dict)})
|
||||
!= EXPECTED_DEFAULT_PROMPTS
|
||||
or set(item["prompt_key"] for item in boundary_inventory).intersection(
|
||||
item["key"] for item in module_rows
|
||||
)
|
||||
or require_sha(boundary["source_raw_sha256"], "default Prompt source hash")
|
||||
!= boundary["source_raw_sha256"]
|
||||
or require_sha(boundary["inventory_sha256"], "default Prompt inventory hash")
|
||||
!= sha256_json(boundary_inventory)
|
||||
):
|
||||
raise Rc06ProtocolError("The 46 default Prompt assets are not kept outside this batch.")
|
||||
require_text(boundary["source_path"], "default Prompt source path", maximum=4096)
|
||||
require_text(boundary["boundary_note"], "default Prompt boundary note", maximum=4000)
|
||||
|
||||
golden = require_exact(manifest["golden_dispositions"], "Golden dispositions", (
|
||||
"count", "counts_by_status", "items", "inventory_sha256",
|
||||
))
|
||||
golden_items = golden["items"]
|
||||
if (
|
||||
golden["count"] != EXPECTED_GOLDEN
|
||||
or not isinstance(golden_items, list)
|
||||
or len(golden_items) != EXPECTED_GOLDEN
|
||||
):
|
||||
raise Rc06ProtocolError("Golden dispositions are incomplete or unresolved.")
|
||||
control = manifest["publication_control"]
|
||||
if not isinstance(control, dict) or control.get("rc05_terminal_evidence_complete") is not True or control.get(
|
||||
"golden_dispositions_complete"
|
||||
) is not True or control.get("release_ready") is not False:
|
||||
golden_items = [_validate_golden_disposition(item) for item in golden_items]
|
||||
if len({item["sample_id"] for item in golden_items}) != EXPECTED_GOLDEN:
|
||||
raise Rc06ProtocolError("Golden disposition identities are duplicated.")
|
||||
actual_counts = {
|
||||
status: sum(item["status"] == status for item in golden_items)
|
||||
for status in sorted(DISPOSITION_STATUSES)
|
||||
}
|
||||
if (
|
||||
golden["counts_by_status"] != actual_counts
|
||||
or require_sha(golden["inventory_sha256"], "Golden disposition inventory hash")
|
||||
!= sha256_json(golden_items)
|
||||
or sum(item["suite"] == "contract_generation" for item in golden_items)
|
||||
!= EXPECTED_GOLDEN_GENERATION
|
||||
or sum(item["suite"] == "clause_recommendation" for item in golden_items)
|
||||
!= EXPECTED_GOLDEN_CLAUSE
|
||||
):
|
||||
raise Rc06ProtocolError("Golden disposition count, suite, or hash drift.")
|
||||
control = require_exact(manifest["publication_control"], "publication control", (
|
||||
"rc05_terminal_evidence_complete", "golden_dispositions_complete",
|
||||
"approval_receipt_attached", "apply_receipt_attached",
|
||||
"single_reviewer_batch_and_system_item_receipts_complete",
|
||||
"runtime_executable_prompt_snapshots_complete", "blocked_controls", "release_ready",
|
||||
"approval_receipt_schema", "apply_receipt_schema", "apply_performed_by_this_tool",
|
||||
))
|
||||
if (
|
||||
control["rc05_terminal_evidence_complete"] is not True
|
||||
or control["golden_dispositions_complete"] is not True
|
||||
or control["approval_receipt_attached"] is not False
|
||||
or control["apply_receipt_attached"] is not False
|
||||
or control["single_reviewer_batch_and_system_item_receipts_complete"] is not False
|
||||
or control["runtime_executable_prompt_snapshots_complete"] is not False
|
||||
or control["blocked_controls"] != list(BLOCKED_CONTROLS)
|
||||
or control["release_ready"] is not False
|
||||
or control["approval_receipt_schema"] != APPROVAL_SCHEMA
|
||||
or control["apply_receipt_schema"] != APPLY_RECEIPT_SCHEMA
|
||||
or control["apply_performed_by_this_tool"] is not False
|
||||
):
|
||||
raise Rc06ProtocolError("Offline publication control is not eligible for RC06 apply.")
|
||||
if manifest["side_effects"] != {
|
||||
"http_calls": 0, "database_writes": 0, "model_calls": 0, "publication_api_calls": 0
|
||||
@@ -490,6 +875,7 @@ def _validate_approval_keyring(
|
||||
raise Rc06ProtocolError("Approval keyring contains no keys.")
|
||||
validated: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
seen_public_keys: set[bytes] = set()
|
||||
for value in keys:
|
||||
key = require_exact(value, "approval key", (
|
||||
"key_id", "algorithm", "public_key_pem", "approver_id", "name",
|
||||
@@ -501,15 +887,19 @@ def _validate_approval_keyring(
|
||||
if {"approver_id": key["approver_id"], "name": key["name"]} != SOLE_REVIEWER:
|
||||
raise Rc06ProtocolError("Approval key does not belong to the sole release reviewer.")
|
||||
public_key = load_approval_public_key(key["public_key_pem"])
|
||||
if not isinstance(public_key, Ed25519PublicKey) or public_key_raw(public_key) == public_key_raw(
|
||||
trust.approval_root_public_key
|
||||
public_bytes = public_key_raw(public_key)
|
||||
if (
|
||||
not isinstance(public_key, Ed25519PublicKey)
|
||||
or public_bytes == public_key_raw(trust.approval_root_public_key)
|
||||
or public_bytes in seen_public_keys
|
||||
):
|
||||
raise Rc06ProtocolError("Approval key is invalid or reuses the offline root.")
|
||||
not_before = parse_time(key["not_before"], "approval key not_before")
|
||||
not_after = parse_time(key["not_after"], "approval key not_after")
|
||||
if not not_before <= approved_at < not_after:
|
||||
raise Rc06ProtocolError("Approval key is outside its validity window.")
|
||||
if not_before >= not_after:
|
||||
raise Rc06ProtocolError("Approval key validity window is invalid.")
|
||||
seen.add(key_id)
|
||||
seen_public_keys.add(public_bytes)
|
||||
validated.append({**key, "public_key": public_key, "not_before_parsed": not_before, "not_after_parsed": not_after})
|
||||
return validated
|
||||
|
||||
@@ -621,10 +1011,28 @@ def _validate_approval(
|
||||
))
|
||||
):
|
||||
raise Rc06ProtocolError("Approval receipt material drift.")
|
||||
for disposition in manifest["golden_dispositions"]["items"]:
|
||||
decided_at = parse_time(
|
||||
disposition["decided_at"],
|
||||
f"Golden disposition {disposition['sample_id']} decided_at",
|
||||
)
|
||||
if decided_at > approved_at:
|
||||
raise Rc06ProtocolError("Golden disposition was decided after the approval time.")
|
||||
if disposition["status"] == "scoped_internal_risk_acceptance":
|
||||
risk_expires_at = parse_time(
|
||||
disposition["risk_acceptance"]["expires_at"],
|
||||
f"Golden disposition {disposition['sample_id']} risk acceptance expires_at",
|
||||
)
|
||||
if approved_at >= risk_expires_at or expires_at > risk_expires_at:
|
||||
raise Rc06ProtocolError(
|
||||
"Approval validity exceeds a Golden risk-acceptance window."
|
||||
)
|
||||
matches = [key for key in trusted_keys if key["key_id"] == payload["key_id"]]
|
||||
if (
|
||||
expires_at > keyring_expires_at
|
||||
or len(matches) != 1
|
||||
or approved_at < matches[0]["not_before_parsed"]
|
||||
or approved_at >= matches[0]["not_after_parsed"]
|
||||
or expires_at > matches[0]["not_after_parsed"]
|
||||
):
|
||||
raise Rc06ProtocolError("Approval signing key is not uniquely valid for the receipt window.")
|
||||
@@ -656,7 +1064,9 @@ def _validate_legal(
|
||||
request_sha = require_sha(legal_request["request_sha256"], "legal review request hash")
|
||||
if request_sha != sha256_json({key: child for key, child in legal_request.items() if key != "request_sha256"}):
|
||||
raise Rc06ProtocolError("Legal review request embedded hash drift.")
|
||||
parse_time(legal_request["created_at"], "legal review request created_at")
|
||||
request_created_at = parse_time(
|
||||
legal_request["created_at"], "legal review request created_at"
|
||||
)
|
||||
scope = require_exact(legal_request["scope"], "legal review scope", (
|
||||
"scene_count", "reference_occurrence_count", "distinct_source_record_count",
|
||||
"distinct_url_count",
|
||||
@@ -674,15 +1084,20 @@ def _validate_legal(
|
||||
or len(occurrences) != 43
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review request does not cover exact 30 scenes and 43 occurrences.")
|
||||
if (
|
||||
not isinstance(legal_request["source_files"], list)
|
||||
or not legal_request["source_files"]
|
||||
or any(
|
||||
not isinstance(item, str) or not item.strip() or item != item.strip()
|
||||
for item in legal_request["source_files"]
|
||||
)
|
||||
):
|
||||
source_files = legal_request["source_files"]
|
||||
if not isinstance(source_files, list) or not source_files:
|
||||
raise Rc06ProtocolError("Legal review source file inventory is empty or invalid.")
|
||||
source_paths: list[str] = []
|
||||
for value in source_files:
|
||||
source_file = require_exact(
|
||||
value, "legal review source file", ("path", "raw_sha256")
|
||||
)
|
||||
source_paths.append(
|
||||
require_text(source_file["path"], "legal review source file path", maximum=512)
|
||||
)
|
||||
require_sha(source_file["raw_sha256"], "legal review source file hash")
|
||||
if source_paths != sorted(set(source_paths)):
|
||||
raise Rc06ProtocolError("Legal review source file inventory is not canonical.")
|
||||
if legal_request["guardrails"] != {
|
||||
"not_legal_approval": True,
|
||||
"authorizes_publication": False,
|
||||
@@ -691,16 +1106,91 @@ def _validate_legal(
|
||||
}:
|
||||
raise Rc06ProtocolError("Legal review guardrails drift.")
|
||||
occurrence_by_id: dict[str, dict[str, Any]] = {}
|
||||
scene_ids: set[str] = set()
|
||||
source_record_hashes: set[str] = set()
|
||||
source_urls: set[str] = set()
|
||||
manifest_sources: list[tuple[str, str, str]] = []
|
||||
manifest_scene_ids: set[str] = set()
|
||||
for asset in manifest["asset_batch"]["items"]:
|
||||
scene = ((asset.get("input_schema") or {}).get("scene") or {})
|
||||
references = scene.get("source_references")
|
||||
if references is None:
|
||||
continue
|
||||
if not isinstance(references, list):
|
||||
raise Rc06ProtocolError("Manifest scene source references are invalid.")
|
||||
if not references:
|
||||
continue
|
||||
scene_id = require_text(scene.get("id"), "manifest scene id", maximum=128)
|
||||
manifest_scene_ids.add(scene_id)
|
||||
manifest_sources.extend(
|
||||
(scene_id, asset["template_id"], sha256_json(source))
|
||||
for source in references
|
||||
)
|
||||
if len(manifest_scene_ids) != 30 or len(manifest_sources) != 43:
|
||||
raise Rc06ProtocolError(
|
||||
"Manifest legal source inventory is not exact 30 scenes and 43 occurrences."
|
||||
)
|
||||
occurrence_sources: list[tuple[str, str, str]] = []
|
||||
for occurrence in occurrences:
|
||||
if not isinstance(occurrence, dict):
|
||||
raise Rc06ProtocolError("Legal review occurrence is invalid.")
|
||||
occurrence_id = require_text(occurrence.get("occurrence_id"), "legal occurrence id", maximum=512)
|
||||
require_sha(occurrence.get("source_record_sha256"), "legal source record hash")
|
||||
row = require_exact(occurrence, "legal review occurrence", (
|
||||
"occurrence_id", "scene_id", "template_id", "source_key",
|
||||
"source_pack_path", "source_record_sha256", "source",
|
||||
"required_human_decisions",
|
||||
))
|
||||
occurrence_id = require_text(
|
||||
row["occurrence_id"], "legal occurrence id", maximum=512
|
||||
)
|
||||
if occurrence_id in occurrence_by_id:
|
||||
raise Rc06ProtocolError("Legal review occurrence is duplicated.")
|
||||
occurrence_by_id[occurrence_id] = occurrence
|
||||
scene_id = require_text(row["scene_id"], "legal scene id", maximum=128)
|
||||
template_id = require_text(
|
||||
row["template_id"], "legal template id", maximum=191
|
||||
)
|
||||
require_text(row["source_key"], "legal source key", maximum=128)
|
||||
source_path = require_text(
|
||||
row["source_pack_path"], "legal source pack path", maximum=512
|
||||
)
|
||||
if source_path not in source_paths:
|
||||
raise Rc06ProtocolError("Legal occurrence references an unknown source file.")
|
||||
source = require_exact(row["source"], "legal source record", (
|
||||
"title", "url", "publication_date", "enactment_date", "accessed_on",
|
||||
"scope", "current_effectiveness_verified",
|
||||
))
|
||||
for field in (
|
||||
"title", "publication_date", "enactment_date", "accessed_on", "scope"
|
||||
):
|
||||
require_text(source[field], f"legal source {field}")
|
||||
source_url = require_official_url(source["url"], "legal source URL")
|
||||
if source["current_effectiveness_verified"] is not False:
|
||||
raise Rc06ProtocolError(
|
||||
"Legal review preparation source must remain explicitly unverified."
|
||||
)
|
||||
source_hash = require_sha(
|
||||
row["source_record_sha256"], "legal source record hash"
|
||||
)
|
||||
if source_hash != sha256_json(source):
|
||||
raise Rc06ProtocolError("Legal source record hash drift.")
|
||||
if row["required_human_decisions"] != [
|
||||
"current_effectiveness_verified",
|
||||
"transaction_applicability_verified",
|
||||
]:
|
||||
raise Rc06ProtocolError("Legal occurrence decision requirements drift.")
|
||||
occurrence_by_id[occurrence_id] = row
|
||||
scene_ids.add(scene_id)
|
||||
source_record_hashes.add(source_hash)
|
||||
source_urls.add(source_url)
|
||||
occurrence_sources.append((scene_id, template_id, source_hash))
|
||||
if list(occurrence_by_id) != sorted(occurrence_by_id):
|
||||
raise Rc06ProtocolError("Legal review occurrences are not canonically ordered.")
|
||||
if (
|
||||
len(scene_ids) != 30
|
||||
or len(source_record_hashes) != 8
|
||||
or len(source_urls) != 7
|
||||
or sorted(occurrence_sources) != sorted(manifest_sources)
|
||||
):
|
||||
raise Rc06ProtocolError(
|
||||
"Legal review occurrence inventory does not match the frozen manifest."
|
||||
)
|
||||
decision = request["legal_source_review_decision"]
|
||||
if not isinstance(decision, dict) or decision.get("schema_version") != LEGAL_DECISION_SCHEMA:
|
||||
raise Rc06ProtocolError("Legal review decision schema is unsupported.")
|
||||
@@ -713,7 +1203,8 @@ def _validate_legal(
|
||||
"approval expires_at",
|
||||
)
|
||||
if (
|
||||
not reviewed_at <= now <= expires_at
|
||||
reviewed_at < request_created_at
|
||||
or not reviewed_at <= now <= expires_at
|
||||
or expires_at <= reviewed_at
|
||||
or expires_at - reviewed_at > MAX_LEGAL_WINDOW
|
||||
or expires_at > approval_expires_at
|
||||
@@ -744,7 +1235,9 @@ def _validate_legal(
|
||||
or item.get("transaction_applicability_verified") is not True
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review item is not affirmatively verified.")
|
||||
parse_time(item.get("checked_at"), "legal item checked_at")
|
||||
checked_at = parse_time(item.get("checked_at"), "legal item checked_at")
|
||||
if not request_created_at <= checked_at <= reviewed_at:
|
||||
raise Rc06ProtocolError("Legal review item timestamp is outside the review window.")
|
||||
require_official_url(item.get("authoritative_status_url"), "legal authoritative URL")
|
||||
rationale = require_text(item.get("rationale"), "legal rationale", maximum=4096)
|
||||
if len(rationale) < 20 or rationale.casefold() in LEGAL_PLACEHOLDERS:
|
||||
@@ -762,8 +1255,11 @@ def _validate_legal(
|
||||
key = matches[0]
|
||||
if key.get("status") != "trusted" or key.get("revoked_at") is not None:
|
||||
raise Rc06ProtocolError("Legal review key is not active.")
|
||||
if not parse_time(key.get("valid_from"), "legal key valid_from") <= reviewed_at <= parse_time(
|
||||
key.get("valid_until"), "legal key valid_until"
|
||||
key_valid_from = parse_time(key.get("valid_from"), "legal key valid_from")
|
||||
key_valid_until = parse_time(key.get("valid_until"), "legal key valid_until")
|
||||
if not (
|
||||
key_valid_from <= reviewed_at <= key_valid_until
|
||||
and expires_at <= key_valid_until
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review key is outside its validity window.")
|
||||
for decision_field, key_field in (
|
||||
|
||||
+185
-35
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from alembic.config import Config
|
||||
@@ -277,27 +278,92 @@ RC06_COLUMNS_BY_TABLE = {
|
||||
),
|
||||
}
|
||||
RC06_CONSTRAINTS_BY_TABLE = {
|
||||
"rc06_publication_sagas": (
|
||||
"ux_rc06_publication_sagas_idempotency",
|
||||
"ck_rc06_publication_sagas_state",
|
||||
),
|
||||
"rc06_template_publication_items": (
|
||||
"ux_rc06_template_publication_items_transaction_template",
|
||||
"ck_rc06_template_item_state",
|
||||
),
|
||||
"rc06_online_publication_transactions": (
|
||||
"ux_rc06_online_publication_idempotency",
|
||||
"ux_rc06_online_publication_challenge_id",
|
||||
"ux_rc06_online_publication_challenge_sha256",
|
||||
"ck_rc06_online_publication_state",
|
||||
),
|
||||
"rc06_apply_challenge_uses": (
|
||||
"ux_rc06_apply_challenge_nonce",
|
||||
"ux_rc06_apply_challenge_transaction",
|
||||
"ux_rc06_apply_challenge_sha256",
|
||||
"fk_rc06_apply_challenge_transaction",
|
||||
"ck_rc06_apply_challenge_outcome",
|
||||
),
|
||||
"rc06_publication_sagas": {
|
||||
"primary": {"primary_key": ("transaction_id",)},
|
||||
"unique": {
|
||||
"ux_rc06_publication_sagas_idempotency": ("tenant_id", "idempotency_key"),
|
||||
},
|
||||
"check": {
|
||||
"ck_rc06_publication_sagas_state": (
|
||||
"state",
|
||||
frozenset({
|
||||
"preparing", "prepared", "ai_committed", "contract_committed",
|
||||
"committed", "compensating", "compensated", "compensation_failed",
|
||||
}),
|
||||
),
|
||||
},
|
||||
"foreign": {
|
||||
"fk_rc06_publication_saga_authority_transaction": (
|
||||
("transaction_id",),
|
||||
"rc06_online_publication_transactions",
|
||||
("transaction_id",),
|
||||
"__current__",
|
||||
"NO ACTION",
|
||||
),
|
||||
},
|
||||
},
|
||||
"rc06_template_publication_items": {
|
||||
"primary": {"primary_key": ("id",)},
|
||||
"unique": {
|
||||
"ux_rc06_template_publication_items_transaction_template": (
|
||||
"transaction_id", "template_id",
|
||||
),
|
||||
},
|
||||
"check": {
|
||||
"ck_rc06_template_item_state": (
|
||||
"state", frozenset({"prepared", "applied", "compensated"}),
|
||||
),
|
||||
},
|
||||
"foreign": {},
|
||||
},
|
||||
"rc06_online_publication_transactions": {
|
||||
"primary": {"primary_key": ("transaction_id",)},
|
||||
"unique": {
|
||||
"ux_rc06_online_publication_idempotency": ("tenant_id", "idempotency_key"),
|
||||
"ux_rc06_online_publication_challenge_id": ("challenge_id",),
|
||||
"ux_rc06_online_publication_challenge_sha256": ("challenge_sha256",),
|
||||
},
|
||||
"check": {
|
||||
"ck_rc06_online_publication_state": (
|
||||
"state",
|
||||
frozenset({
|
||||
"authorized", "saga_prepared", "saga_committing",
|
||||
"ai_committed", "contract_committed", "evidence_pending",
|
||||
"online_primary_signed", "online_attested", "online_receipt_persisted",
|
||||
"runtime_primary_signed", "runtime_attested",
|
||||
"online_committed_pending_countersign", "finalized", "failed_before_commit",
|
||||
"compensating", "rolled_back", "compensation_failed",
|
||||
}),
|
||||
),
|
||||
},
|
||||
"foreign": {},
|
||||
},
|
||||
"rc06_apply_challenge_uses": {
|
||||
"primary": {"primary_key": ("challenge_id",)},
|
||||
"unique": {
|
||||
"ux_rc06_apply_challenge_nonce": ("nonce_sha256",),
|
||||
"ux_rc06_apply_challenge_transaction": ("transaction_id",),
|
||||
"ux_rc06_apply_challenge_sha256": ("challenge_sha256",),
|
||||
},
|
||||
"check": {
|
||||
"ck_rc06_apply_challenge_outcome": (
|
||||
"outcome",
|
||||
frozenset({
|
||||
"reserved", "pending_countersign", "finalized", "rolled_back",
|
||||
"compensation_failed", "no_go",
|
||||
}),
|
||||
),
|
||||
},
|
||||
"foreign": {
|
||||
"fk_rc06_apply_challenge_transaction": (
|
||||
("transaction_id",),
|
||||
"rc06_online_publication_transactions",
|
||||
("transaction_id",),
|
||||
"__current__",
|
||||
"NO ACTION",
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
PLAYBOOK_OPERATIONS_COLUMNS_BY_TABLE = {
|
||||
"product_experience_events": (
|
||||
@@ -766,23 +832,103 @@ def _check_rc06_schema(inspector, table_names: set[str], *, dialect: str) -> Run
|
||||
)
|
||||
for table_name, expected_constraints in RC06_CONSTRAINTS_BY_TABLE.items():
|
||||
if table_name not in table_names:
|
||||
missing_constraints.extend(f"{table_name}.{name}" for name in expected_constraints)
|
||||
continue
|
||||
actual = {
|
||||
str(item.get("name"))
|
||||
for item in (
|
||||
inspector.get_unique_constraints(table_name, schema=schema)
|
||||
+ inspector.get_check_constraints(table_name, schema=schema)
|
||||
+ inspector.get_foreign_keys(table_name, schema=schema)
|
||||
+ inspector.get_indexes(table_name, schema=schema)
|
||||
missing_constraints.extend(
|
||||
f"{table_name}.{name}"
|
||||
for kind in expected_constraints.values()
|
||||
for name in kind
|
||||
)
|
||||
continue
|
||||
actual_unique = {
|
||||
str(item.get("name")): tuple(item.get("column_names") or ())
|
||||
for item in inspector.get_unique_constraints(table_name, schema=schema)
|
||||
if item.get("name")
|
||||
}
|
||||
missing_constraints.extend(
|
||||
f"{table_name}.{name}"
|
||||
for name in expected_constraints
|
||||
if name not in actual
|
||||
actual_checks = {
|
||||
str(item.get("name")): str(item.get("sqltext") or "").lower()
|
||||
for item in inspector.get_check_constraints(table_name, schema=schema)
|
||||
if item.get("name")
|
||||
}
|
||||
actual_primary = tuple(
|
||||
inspector.get_pk_constraint(table_name, schema=schema).get(
|
||||
"constrained_columns"
|
||||
)
|
||||
or ()
|
||||
)
|
||||
actual_foreign = {
|
||||
str(item.get("name")): (
|
||||
tuple(item.get("constrained_columns") or ()),
|
||||
str(item.get("referred_table") or ""),
|
||||
tuple(item.get("referred_columns") or ()),
|
||||
(
|
||||
"__current__"
|
||||
if item.get("referred_schema") in {None, "", schema}
|
||||
else str(item.get("referred_schema"))
|
||||
),
|
||||
str(
|
||||
(item.get("options") or {}).get("ondelete")
|
||||
or item.get("ondelete")
|
||||
or "NO ACTION"
|
||||
).upper().replace("_", " "),
|
||||
)
|
||||
for item in inspector.get_foreign_keys(table_name, schema=schema)
|
||||
if item.get("name")
|
||||
}
|
||||
for name, columns in expected_constraints["primary"].items():
|
||||
if actual_primary != columns:
|
||||
missing_constraints.append(f"{table_name}.{name}")
|
||||
for name, columns in expected_constraints["unique"].items():
|
||||
if actual_unique.get(name) != columns:
|
||||
missing_constraints.append(f"{table_name}.{name}")
|
||||
for name, (column, expected_values) in expected_constraints["check"].items():
|
||||
sqltext = actual_checks.get(name, "")
|
||||
identifier = (
|
||||
rf'(?:"{re.escape(column)}"|{re.escape(column)}|'
|
||||
rf'\[{re.escape(column)}\]|`{re.escape(column)}`)'
|
||||
)
|
||||
match = re.fullmatch(
|
||||
rf"\s*\(*\s*{identifier}\s+in\s*\((?P<values>[^)]*)\)\s*\)*\s*",
|
||||
sqltext,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
pg_any = re.fullmatch(
|
||||
rf"\s*\(*\s*(?:\(\s*)?{identifier}(?:\s*\))?"
|
||||
r"(?:::\s*(?:text|character\s+varying))?\s*=\s*any\s*"
|
||||
r"\(\s*\(*\s*array\s*\[(?P<values>[^\]]*)\]\s*\)*"
|
||||
r"(?:::\s*text\s*\[\s*\])?\s*\)\s*\)*\s*",
|
||||
sqltext,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
literal_source = (
|
||||
match.group("values")
|
||||
if match is not None
|
||||
else pg_any.group("values")
|
||||
if pg_any is not None
|
||||
else ""
|
||||
)
|
||||
# Accept only a closed list of SQL string literals. Merely finding
|
||||
# the expected quoted values is unsafe: an expression such as
|
||||
# ``state IN ('expected-1', ..., state)`` is tautological while it
|
||||
# still contains every expected literal. PostgreSQL may add benign
|
||||
# text/varchar casts when deparsing CHECK constraints, so those are
|
||||
# the only non-literal tokens permitted here.
|
||||
sql_literal = (
|
||||
r"'(?:''|[^'])*'"
|
||||
r"(?:\s*::\s*(?:text|character\s+varying"
|
||||
r"(?:\s*\(\s*\d+\s*\))?))?"
|
||||
)
|
||||
literal_list = rf"\s*{sql_literal}(?:\s*,\s*{sql_literal})*\s*"
|
||||
if re.fullmatch(literal_list, literal_source, flags=re.IGNORECASE):
|
||||
actual_values = frozenset(
|
||||
value.replace("''", "'")
|
||||
for value in re.findall(r"'((?:''|[^'])*)'", literal_source)
|
||||
)
|
||||
else:
|
||||
actual_values = frozenset()
|
||||
if actual_values != expected_values:
|
||||
missing_constraints.append(f"{table_name}.{name}")
|
||||
for name, identity in expected_constraints["foreign"].items():
|
||||
if actual_foreign.get(name) != identity:
|
||||
missing_constraints.append(f"{table_name}.{name}")
|
||||
if missing_columns or missing_constraints:
|
||||
return RuntimeSchemaCheck(
|
||||
name="rc06_schema",
|
||||
@@ -799,7 +945,11 @@ def _check_rc06_schema(inspector, table_names: set[str], *, dialect: str) -> Run
|
||||
message="Contract RC06 saga columns and integrity constraints are present.",
|
||||
details={
|
||||
"checked_columns": sum(map(len, RC06_COLUMNS_BY_TABLE.values())),
|
||||
"checked_constraints": sum(map(len, RC06_CONSTRAINTS_BY_TABLE.values())),
|
||||
"checked_constraints": sum(
|
||||
len(kind)
|
||||
for table in RC06_CONSTRAINTS_BY_TABLE.values()
|
||||
for kind in table.values()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+349
-28
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -9,6 +10,7 @@ from threading import Event
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
import yuqei_contract_api.rc06_publication_facade as facade_module
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
from yuqei_contract_api.persistence.models import Rc06OnlinePublicationTransactionModel
|
||||
from yuqei_contract_api.rc06_publication import Rc06SagaStatus
|
||||
@@ -360,6 +362,41 @@ def _service(
|
||||
return repository, service, signer, coordinator, apply_private, material, headers
|
||||
|
||||
|
||||
def _signed_apply_receipt(material, execution, apply_private, countersigned_at):
|
||||
runtime_at = execution["runtime_snapshot_receipt"]["payload"]["captured_at"]
|
||||
apply_payload = {
|
||||
"schema_version": APPLY_RECEIPT_PAYLOAD_SCHEMA,
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"batch_manifest_payload_sha256": material.request["batch_manifest_payload_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"authorized_commit_at": runtime_at,
|
||||
"countersigned_at": iso_time(countersigned_at),
|
||||
"generated_by": {"type": "system", "component": "rc06-apply-authority"},
|
||||
"transaction_mode": "durable_saga",
|
||||
"cross_database_atomicity": False,
|
||||
"item_results": material.expected_publication_results,
|
||||
"write_count": 74,
|
||||
"status": "applied_and_verified",
|
||||
"online_transaction_receipt": execution["online_transaction_receipt"],
|
||||
"runtime_snapshot_receipt": execution["runtime_snapshot_receipt"],
|
||||
}
|
||||
signature = apply_private.sign(
|
||||
APPLY_RECEIPT_SIGNATURE_DOMAIN + canonical_json(apply_payload)
|
||||
)
|
||||
return {
|
||||
"schema_version": APPLY_RECEIPT_SCHEMA,
|
||||
"receipt_id": "rc06-apply-receipt-"
|
||||
+ sha256_bytes(canonical_json(apply_payload) + signature)[:24],
|
||||
"payload": apply_payload,
|
||||
"signature": base64.b64encode(signature).decode(),
|
||||
}
|
||||
|
||||
|
||||
def test_apply_uses_exactly_four_kms_calls_and_recovers_without_resigning(tmp_path):
|
||||
repository, service, signer, coordinator, _, material, headers = _service(tmp_path)
|
||||
try:
|
||||
@@ -421,34 +458,12 @@ def test_finalize_is_idempotent_and_status_retains_all_receipts(tmp_path):
|
||||
try:
|
||||
execution = service.apply(material.request, headers)
|
||||
runtime_at = execution["runtime_snapshot_receipt"]["payload"]["captured_at"]
|
||||
apply_payload = {
|
||||
"schema_version": APPLY_RECEIPT_PAYLOAD_SCHEMA,
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"batch_manifest_payload_sha256": material.request["batch_manifest_payload_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"authorized_commit_at": runtime_at,
|
||||
"countersigned_at": runtime_at,
|
||||
"generated_by": {"type": "system", "component": "rc06-apply-authority"},
|
||||
"transaction_mode": "durable_saga",
|
||||
"cross_database_atomicity": False,
|
||||
"item_results": material.expected_publication_results,
|
||||
"write_count": 74,
|
||||
"status": "applied_and_verified",
|
||||
"online_transaction_receipt": execution["online_transaction_receipt"],
|
||||
"runtime_snapshot_receipt": execution["runtime_snapshot_receipt"],
|
||||
}
|
||||
signature = apply_private.sign(APPLY_RECEIPT_SIGNATURE_DOMAIN + canonical_json(apply_payload))
|
||||
apply_receipt = {
|
||||
"schema_version": APPLY_RECEIPT_SCHEMA,
|
||||
"receipt_id": "rc06-apply-receipt-" + sha256_bytes(canonical_json(apply_payload) + signature)[:24],
|
||||
"payload": apply_payload,
|
||||
"signature": base64.b64encode(signature).decode(),
|
||||
}
|
||||
apply_receipt = _signed_apply_receipt(
|
||||
material,
|
||||
execution,
|
||||
apply_private,
|
||||
datetime.fromisoformat(runtime_at),
|
||||
)
|
||||
finalize_request = {
|
||||
"schema_version": FINALIZE_REQUEST_SCHEMA,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
@@ -480,6 +495,73 @@ def test_finalize_is_idempotent_and_status_retains_all_receipts(tmp_path):
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_concurrent_distinct_finalize_receipts_cannot_overwrite_terminal_evidence(tmp_path):
|
||||
repository, service, _, _, apply_private, material, headers = _service(tmp_path)
|
||||
try:
|
||||
execution = service.apply(material.request, headers)
|
||||
runtime_at = datetime.fromisoformat(
|
||||
execution["runtime_snapshot_receipt"]["payload"]["captured_at"]
|
||||
)
|
||||
# Both countersigns are independently well-formed and within the
|
||||
# frozen authority window. Only the winner may become terminal.
|
||||
time.sleep(1.1)
|
||||
receipts = [
|
||||
_signed_apply_receipt(
|
||||
material,
|
||||
execution,
|
||||
apply_private,
|
||||
runtime_at + timedelta(seconds=offset),
|
||||
)
|
||||
for offset in (0, 1)
|
||||
]
|
||||
|
||||
def finalize(receipt):
|
||||
request = {
|
||||
"schema_version": FINALIZE_REQUEST_SCHEMA,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"online_transaction_receipt_sha256": sha256_json(
|
||||
execution["online_transaction_receipt"]
|
||||
),
|
||||
"runtime_snapshot_receipt_sha256": sha256_json(
|
||||
execution["runtime_snapshot_receipt"]
|
||||
),
|
||||
"apply_receipt_sha256": sha256_json(receipt),
|
||||
"apply_receipt": receipt,
|
||||
}
|
||||
request_headers = Rc06FacadeHeaders(
|
||||
**{**headers.__dict__, "request_sha256": sha256_json(request)}
|
||||
)
|
||||
try:
|
||||
service.finalize(
|
||||
material.request["transaction_id"],
|
||||
request,
|
||||
request_headers,
|
||||
)
|
||||
return "success", receipt
|
||||
except Rc06FacadeConflict as error:
|
||||
return "conflict", str(error)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(finalize, receipts))
|
||||
|
||||
assert sorted(kind for kind, _ in results) == ["conflict", "success"]
|
||||
winner = next(value for kind, value in results if kind == "success")
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "finalized"
|
||||
assert status["apply_receipt"] == winner
|
||||
assert status["apply_receipt_sha256"] in {
|
||||
sha256_json(receipt) for receipt in receipts
|
||||
}
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_post_contract_rollback_is_forbidden_without_calling_compensation(tmp_path):
|
||||
repository, service, _, coordinator, _, material, headers = _service(tmp_path)
|
||||
try:
|
||||
@@ -539,6 +621,50 @@ def test_pre_contract_failure_can_produce_exact_rollback_v2(tmp_path):
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_rollback_recovers_ai_commit_lost_before_facade_journal_update(tmp_path):
|
||||
repository, service, _, coordinator, _, material, headers = _service(tmp_path)
|
||||
try:
|
||||
service._authorize_and_reserve( # noqa: SLF001 - crash-boundary regression
|
||||
material.request,
|
||||
sha256_json(material.request),
|
||||
datetime.now(UTC),
|
||||
)
|
||||
coordinator.state = coordinator._status("prepared") # noqa: SLF001
|
||||
compensate = coordinator.compensate
|
||||
|
||||
def compensate_after_lost_commit(transaction_id, *, tenant_id=None):
|
||||
coordinator.state = coordinator._status("ai_committed") # noqa: SLF001
|
||||
return compensate(transaction_id, tenant_id=tenant_id)
|
||||
|
||||
coordinator.compensate = compensate_after_lost_commit
|
||||
rollback_request = {
|
||||
"schema_version": ROLLBACK_REQUEST_SCHEMA,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"transaction_mode": "durable_saga",
|
||||
"cross_database_atomicity": False,
|
||||
}
|
||||
|
||||
rollback = service.rollback(
|
||||
material.request["transaction_id"], rollback_request, headers
|
||||
)
|
||||
assert rollback["state"] == "rolled_back"
|
||||
assert rollback["participant_states_before"][0]["state"] == "committed"
|
||||
assert len(rollback["compensation_item_results"]) == 14
|
||||
assert len(rollback["runtime_snapshot_compensation_results"]) == 14
|
||||
assert rollback["removed_item_count"] == 28
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "rolled_back"
|
||||
assert status["rollback_response"] == rollback
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_post_contract_kms_failure_is_no_go_recoverable_and_never_compensated(tmp_path):
|
||||
repository, service, signer, coordinator, _, material, headers = _service(
|
||||
tmp_path, fail_sign_once_at=2
|
||||
@@ -571,6 +697,113 @@ def test_post_contract_kms_failure_is_no_go_recoverable_and_never_compensated(tm
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_persisted_runtime_payload_drift_is_never_signed_on_recovery(tmp_path):
|
||||
repository, service, signer, coordinator, _, material, headers = _service(
|
||||
tmp_path, fail_sign_once_at=3
|
||||
)
|
||||
try:
|
||||
with pytest.raises(Rc06FacadeError, match="KMS response loss"):
|
||||
service.apply(material.request, headers)
|
||||
assert len(signer.calls) == 3
|
||||
with repository._session_factory.begin() as session:
|
||||
row = session.get(
|
||||
Rc06OnlinePublicationTransactionModel,
|
||||
material.request["transaction_id"],
|
||||
)
|
||||
persisted = json.loads(row.runtime_payload_json)
|
||||
persisted["runtime_snapshot_dependency_count"] = 13
|
||||
row.runtime_payload_json = json.dumps(
|
||||
persisted,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
with pytest.raises(Rc06FacadeConflict, match="runtime receipt payload drifted"):
|
||||
service.apply(
|
||||
material.request,
|
||||
headers,
|
||||
now=datetime.now(UTC) + timedelta(days=1),
|
||||
)
|
||||
assert len(signer.calls) == 3
|
||||
assert coordinator.compensate_calls == 0
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_finalize_rejects_future_or_backfilled_countersign_time(tmp_path, monkeypatch):
|
||||
repository, service, _, _, apply_private, material, headers = _service(tmp_path)
|
||||
try:
|
||||
execution = service.apply(material.request, headers)
|
||||
row = service._load(material.request["transaction_id"])
|
||||
server_now = datetime.now(UTC)
|
||||
future = _signed_apply_receipt(
|
||||
material,
|
||||
execution,
|
||||
apply_private,
|
||||
server_now + timedelta(minutes=5),
|
||||
)
|
||||
with pytest.raises(Rc06FacadeError, match="authority window"):
|
||||
service._validate_apply_receipt(row, future, now=server_now)
|
||||
|
||||
runtime_at = datetime.fromisoformat(
|
||||
execution["runtime_snapshot_receipt"]["payload"]["captured_at"]
|
||||
)
|
||||
backfilled = _signed_apply_receipt(
|
||||
material,
|
||||
execution,
|
||||
apply_private,
|
||||
runtime_at,
|
||||
)
|
||||
expired_now = datetime.fromisoformat(
|
||||
material.approval["payload"]["expires_at"]
|
||||
) + timedelta(seconds=1)
|
||||
with pytest.raises(Rc06FacadeError, match="authority window"):
|
||||
service._validate_apply_receipt(row, backfilled, now=expired_now)
|
||||
|
||||
finalize_request = {
|
||||
"schema_version": FINALIZE_REQUEST_SCHEMA,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"online_transaction_receipt_sha256": sha256_json(
|
||||
execution["online_transaction_receipt"]
|
||||
),
|
||||
"runtime_snapshot_receipt_sha256": sha256_json(
|
||||
execution["runtime_snapshot_receipt"]
|
||||
),
|
||||
"apply_receipt_sha256": sha256_json(backfilled),
|
||||
"apply_receipt": backfilled,
|
||||
}
|
||||
finalize_headers = Rc06FacadeHeaders(
|
||||
**{**headers.__dict__, "request_sha256": sha256_json(finalize_request)}
|
||||
)
|
||||
monkeypatch.setattr(facade_module, "_database_wall_clock", lambda _session: expired_now)
|
||||
with pytest.raises(Rc06FacadeConflict, match="authority deadline"):
|
||||
service.finalize(
|
||||
material.request["transaction_id"], finalize_request, finalize_headers
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"_database_wall_clock",
|
||||
lambda _session: runtime_at - timedelta(seconds=1),
|
||||
)
|
||||
with pytest.raises(Rc06FacadeConflict, match="database commit clock"):
|
||||
service.finalize(
|
||||
material.request["transaction_id"], finalize_request, finalize_headers
|
||||
)
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "committed_and_verified"
|
||||
assert status["apply_receipt"] is None
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_two_facade_instances_singleflight_the_four_kms_stages(tmp_path):
|
||||
repository, first, signer, coordinator, apply_private, material, headers = _service(
|
||||
tmp_path,
|
||||
@@ -603,6 +836,58 @@ def test_two_facade_instances_singleflight_the_four_kms_stages(tmp_path):
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_rollback_wins_race_and_later_apply_cannot_commit(tmp_path):
|
||||
repository, service, _, coordinator, _, material, headers = _service(tmp_path)
|
||||
started = Event()
|
||||
release = Event()
|
||||
try:
|
||||
service._authorize_and_reserve(
|
||||
material.request,
|
||||
sha256_json(material.request),
|
||||
datetime.now(UTC),
|
||||
)
|
||||
coordinator.state = coordinator._status("prepared")
|
||||
original_compensate = coordinator.compensate
|
||||
|
||||
def delayed_compensate(transaction_id, *, tenant_id=None):
|
||||
started.set()
|
||||
assert release.wait(timeout=10)
|
||||
return original_compensate(transaction_id, tenant_id=tenant_id)
|
||||
|
||||
coordinator.compensate = delayed_compensate
|
||||
rollback_request = {
|
||||
"schema_version": ROLLBACK_REQUEST_SCHEMA,
|
||||
"idempotency_key": material.request["idempotency_key"],
|
||||
"transaction_id": material.request["transaction_id"],
|
||||
"batch_manifest_raw_sha256": material.request["batch_manifest_raw_sha256"],
|
||||
"approval_receipt_sha256": material.request["approval_receipt_sha256"],
|
||||
"legal_source_review_binding_sha256": material.legal_binding_sha256,
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"transaction_mode": "durable_saga",
|
||||
"cross_database_atomicity": False,
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
rollback_future = executor.submit(
|
||||
service.rollback,
|
||||
material.request["transaction_id"],
|
||||
rollback_request,
|
||||
headers,
|
||||
)
|
||||
assert started.wait(timeout=10)
|
||||
apply_future = executor.submit(service.apply, material.request, headers)
|
||||
release.set()
|
||||
assert rollback_future.result(timeout=20)["state"] == "rolled_back"
|
||||
with pytest.raises(Rc06FacadeConflict):
|
||||
apply_future.result(timeout=20)
|
||||
assert coordinator.commit_calls == 0
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "rolled_back"
|
||||
assert status["rollback_response"] is not None
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_expired_authority_cannot_prepare_or_commit_a_reserved_transaction(tmp_path):
|
||||
initial = datetime.now(UTC).replace(microsecond=0)
|
||||
expires_at = initial + timedelta(minutes=5)
|
||||
@@ -693,3 +978,39 @@ def test_expired_authority_can_only_finish_proven_contract_commit(tmp_path):
|
||||
assert len(signer.calls) == 4
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_status_reconciles_a_lagging_facade_after_contract_commit(tmp_path):
|
||||
initial = datetime.now(UTC).replace(microsecond=0)
|
||||
expires_at = initial + timedelta(minutes=5)
|
||||
repository, service, signer, coordinator, _, material, headers = _service(
|
||||
tmp_path,
|
||||
authority_expires_at=expires_at,
|
||||
)
|
||||
try:
|
||||
service._authorize_and_reserve( # noqa: SLF001 - crash-window regression
|
||||
material.request,
|
||||
sha256_json(material.request),
|
||||
initial,
|
||||
)
|
||||
coordinator.state = coordinator._status("committed") # noqa: SLF001
|
||||
|
||||
reconciled = service.status(material.request["transaction_id"], headers)
|
||||
assert reconciled["state"] == "irreversible_failure"
|
||||
assert reconciled["state_version"] == 2
|
||||
assert reconciled["no_go"] is True
|
||||
assert reconciled["failure_code"] == "rc06facadeerror"
|
||||
assert reconciled["publication_item_results"] == []
|
||||
assert len(signer.calls) == 0
|
||||
|
||||
recovered = service.apply(
|
||||
material.request,
|
||||
headers,
|
||||
now=expires_at + timedelta(seconds=1),
|
||||
)
|
||||
assert recovered["publication_item_count"] == 74
|
||||
assert coordinator.prepare_calls == 0
|
||||
assert coordinator.commit_calls == 0
|
||||
assert len(signer.calls) == 4
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa, utils
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from yuqei_ai_platform_api.models import PromptTemplateModel
|
||||
from yuqei_ai_platform_api.packaged_prompt_assets_import import (
|
||||
DEFAULT_SOURCE_FILE,
|
||||
DEFAULT_VERSION,
|
||||
load_packaged_prompt_assets,
|
||||
)
|
||||
from yuqei_ai_platform_api.rc06_publication import (
|
||||
PromptPrepareRequest,
|
||||
Rc06PromptPublicationService,
|
||||
)
|
||||
from yuqei_ai_platform_api.repository import SqlAlchemyAiPlatformRepository
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
from yuqei_contract_api.rc06_publication_facade import (
|
||||
ConfiguredRc06Authority,
|
||||
Rc06FacadeHeaders,
|
||||
Rc06PublicationFacadeService,
|
||||
)
|
||||
from yuqei_contract_api.rc06_publication_protocol import (
|
||||
APPROVAL_KEYRING_SIGNATURE_DOMAIN,
|
||||
APPROVAL_SIGNATURE_DOMAIN,
|
||||
CHALLENGE_SIGNATURE_DOMAIN,
|
||||
LEGAL_SIGNATURE_DOMAIN,
|
||||
AuthorityTrust,
|
||||
canonical_json,
|
||||
public_key_raw,
|
||||
publication_idempotency_key,
|
||||
publication_transaction_id,
|
||||
sha256_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
from yuqei_contract_api.sqlalchemy_contract_repository import SqlAlchemyContractRepository
|
||||
from yuqei_sdk.prompt_source_baseline import DOMAIN_REVIEW_RUNTIME_IDENTITIES
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _load_apply_fixture():
|
||||
path = REPO_ROOT / "yuqei-infra/tests/test_rc06_publication_apply.py"
|
||||
name = "rc06_facade_real_client_fixture"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _resign_authority_material(material: dict) -> dict:
|
||||
"""Replace the fixture's stub legal signature with fully verified material."""
|
||||
|
||||
result = deepcopy(material)
|
||||
reviewer_private = Ed25519PrivateKey.generate()
|
||||
keyring = result["approval_keyring"]
|
||||
keyring["keys"][0]["public_key_pem"] = reviewer_private.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
unsigned_keyring = {
|
||||
key: keyring[key]
|
||||
for key in (
|
||||
"schema_version",
|
||||
"root_key_id",
|
||||
"epoch",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
"authorized_batch_manifest_raw_sha256",
|
||||
"keys",
|
||||
)
|
||||
}
|
||||
keyring["keyring_payload_sha256"] = sha256_json(unsigned_keyring)
|
||||
keyring["root_signature_base64"] = base64.b64encode(
|
||||
result["root_private"].sign(
|
||||
APPROVAL_KEYRING_SIGNATURE_DOMAIN + canonical_json(unsigned_keyring)
|
||||
)
|
||||
).decode("ascii")
|
||||
result["approval_keyring_raw"] = sha256_json(keyring)
|
||||
|
||||
approval = result["approval"]
|
||||
approval_payload = approval["payload"]
|
||||
approval_payload["approval_keyring_payload_sha256"] = keyring[
|
||||
"keyring_payload_sha256"
|
||||
]
|
||||
approval_signature = reviewer_private.sign(
|
||||
APPROVAL_SIGNATURE_DOMAIN + canonical_json(approval_payload)
|
||||
)
|
||||
approval["signature"] = base64.b64encode(approval_signature).decode("ascii")
|
||||
approval["approval_id"] = (
|
||||
"rc06-batch-approval-"
|
||||
+ sha256_bytes(canonical_json(approval_payload) + approval_signature)[:24]
|
||||
)
|
||||
result["approval_raw"] = sha256_json(approval)
|
||||
|
||||
legal_private = Ed25519PrivateKey.generate()
|
||||
legal_request = result["legal_request"]
|
||||
source_path = "fixture-only/not-production.json"
|
||||
occurrences = []
|
||||
for asset in result["manifest"]["asset_batch"]["items"]:
|
||||
scene = ((asset.get("input_schema") or {}).get("scene") or {})
|
||||
for index, source in enumerate(scene.get("source_references") or []):
|
||||
source_key = f"source-{index}"
|
||||
occurrences.append(
|
||||
{
|
||||
"occurrence_id": (
|
||||
f"{scene['id']}:{asset['template_id']}:{source_key}"
|
||||
),
|
||||
"scene_id": scene["id"],
|
||||
"template_id": asset["template_id"],
|
||||
"source_key": source_key,
|
||||
"source_pack_path": source_path,
|
||||
"source_record_sha256": sha256_json(source),
|
||||
"source": source,
|
||||
"required_human_decisions": [
|
||||
"current_effectiveness_verified",
|
||||
"transaction_applicability_verified",
|
||||
],
|
||||
}
|
||||
)
|
||||
occurrences.sort(key=lambda item: item["occurrence_id"])
|
||||
assert len(occurrences) == 43
|
||||
legal_request["source_files"] = [
|
||||
{"path": source_path, "raw_sha256": "9" * 64}
|
||||
]
|
||||
legal_request["occurrences"] = occurrences
|
||||
legal_request["request_sha256"] = sha256_json(
|
||||
{key: value for key, value in legal_request.items() if key != "request_sha256"}
|
||||
)
|
||||
|
||||
legal_keyring = result["legal_keyring"]
|
||||
legal_keyring["keys"][0]["public_key_raw_base64"] = base64.b64encode(
|
||||
public_key_raw(legal_private.public_key())
|
||||
).decode("ascii")
|
||||
result["legal_keyring_raw"] = sha256_json(legal_keyring)
|
||||
legal_decision = result["legal_decision"]
|
||||
legal_decision["request_sha256"] = legal_request["request_sha256"]
|
||||
item_template = legal_decision["items"][0]
|
||||
legal_decision["items"] = [
|
||||
{
|
||||
**item_template,
|
||||
"occurrence_id": occurrence["occurrence_id"],
|
||||
"source_record_sha256": occurrence["source_record_sha256"],
|
||||
}
|
||||
for occurrence in occurrences
|
||||
]
|
||||
legal_unsigned = {
|
||||
key: value for key, value in legal_decision.items() if key != "signature"
|
||||
}
|
||||
legal_signature = legal_private.sign(
|
||||
LEGAL_SIGNATURE_DOMAIN + canonical_json(legal_unsigned)
|
||||
)
|
||||
legal_decision["signature"]["value_base64"] = base64.b64encode(
|
||||
legal_signature
|
||||
).decode("ascii")
|
||||
result["legal_binding"] = sha256_json(
|
||||
{
|
||||
"request_sha256": result["legal_request"]["request_sha256"],
|
||||
"decision_sha256": sha256_json(legal_decision),
|
||||
"keyring_raw_sha256": result["legal_keyring_raw"],
|
||||
}
|
||||
)
|
||||
|
||||
result["idempotency_key"] = publication_idempotency_key(
|
||||
result["manifest_raw"],
|
||||
result["approval_raw"],
|
||||
result["legal_binding"],
|
||||
result["environment_id"],
|
||||
result["tenant_id"],
|
||||
)
|
||||
result["transaction_id"] = publication_transaction_id(result["idempotency_key"])
|
||||
challenge = result["authority_challenge"]
|
||||
challenge_payload = challenge["payload"]
|
||||
challenge_payload.update(
|
||||
{
|
||||
"approval_receipt_sha256": result["approval_raw"],
|
||||
"legal_source_review_binding_sha256": result["legal_binding"],
|
||||
"idempotency_key": result["idempotency_key"],
|
||||
"transaction_id": result["transaction_id"],
|
||||
}
|
||||
)
|
||||
challenge_signature = result["apply_private"].sign(
|
||||
CHALLENGE_SIGNATURE_DOMAIN + canonical_json(challenge_payload)
|
||||
)
|
||||
challenge["signature"] = base64.b64encode(challenge_signature).decode("ascii")
|
||||
challenge["challenge_id"] = (
|
||||
"rc06-apply-challenge-"
|
||||
+ sha256_bytes(canonical_json(challenge_payload) + challenge_signature)[:24]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class _LocalAiClient:
|
||||
def __init__(self, service: Rc06PromptPublicationService) -> None:
|
||||
self.service = service
|
||||
|
||||
def prepare(self, payload):
|
||||
return self.service.prepare(PromptPrepareRequest.model_validate(payload)).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
|
||||
def commit(self, transaction_id):
|
||||
return self.service.commit(transaction_id).model_dump(mode="json")
|
||||
|
||||
def status(self, transaction_id):
|
||||
return self.service.status(transaction_id).model_dump(mode="json")
|
||||
|
||||
def seal(self, transaction_id, capability):
|
||||
return self.service.seal(transaction_id, capability).model_dump(mode="json")
|
||||
|
||||
def compensate(self, transaction_id, capability):
|
||||
return self.service.compensate(transaction_id, capability).model_dump(mode="json")
|
||||
|
||||
|
||||
class _RsaKmsSigner:
|
||||
def __init__(self, private_key) -> None:
|
||||
self.private_key = private_key
|
||||
self.calls = 0
|
||||
|
||||
def sign(self, *, domain, payload, include_request_id):
|
||||
self.calls += 1
|
||||
digest = hashlib.sha256(domain + canonical_json(payload)).digest()
|
||||
value = self.private_key.sign(
|
||||
digest,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=hashes.SHA256().digest_size,
|
||||
),
|
||||
utils.Prehashed(hashes.SHA256()),
|
||||
)
|
||||
request_id = f"fixture-kms-request-{self.calls}"
|
||||
signature = {
|
||||
"provider": "fixture-kms",
|
||||
"key_reference": "kms://fixture/rc06-online",
|
||||
"key_version": "version-1",
|
||||
"algorithm": "RSA_PSS_SHA_256",
|
||||
"value_base64": base64.b64encode(value).decode("ascii"),
|
||||
}
|
||||
if include_request_id:
|
||||
signature["request_id"] = request_id
|
||||
return signature, request_id
|
||||
|
||||
|
||||
class _DirectFacadeClient:
|
||||
def __init__(
|
||||
self,
|
||||
service: Rc06PublicationFacadeService,
|
||||
*,
|
||||
lose_apply_response: bool = False,
|
||||
lose_finalize_response: bool = False,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.lose_apply_response = lose_apply_response
|
||||
self.lose_finalize_response = lose_finalize_response
|
||||
|
||||
@staticmethod
|
||||
def _headers(values: dict[str, str]) -> Rc06FacadeHeaders:
|
||||
return Rc06FacadeHeaders(
|
||||
idempotency_key=values["Idempotency-Key"],
|
||||
manifest_sha256=values["X-RC06-Manifest-SHA256"],
|
||||
approval_sha256=values["X-RC06-Approval-SHA256"],
|
||||
legal_sha256=values["X-RC06-Legal-Review-SHA256"],
|
||||
tenant_id=values["X-RC06-Tenant-ID"],
|
||||
request_sha256=values.get("X-RC06-Request-SHA256"),
|
||||
)
|
||||
|
||||
def request(self, method, path, *, payload, headers):
|
||||
facade_headers = self._headers(headers)
|
||||
if method == "POST" and path.endswith("/apply"):
|
||||
response = self.service.apply(payload, facade_headers)
|
||||
if self.lose_apply_response:
|
||||
self.lose_apply_response = False
|
||||
raise OSError("injected lost apply response")
|
||||
return response
|
||||
transaction_id = path.split("/")[-2]
|
||||
if method == "GET" and path.endswith("/status"):
|
||||
return self.service.status(transaction_id, facade_headers)
|
||||
if method == "POST" and path.endswith("/finalize"):
|
||||
response = self.service.finalize(transaction_id, payload, facade_headers)
|
||||
if self.lose_finalize_response:
|
||||
self.lose_finalize_response = False
|
||||
raise OSError("injected lost finalize response")
|
||||
return response
|
||||
if method == "POST" and path.endswith("/rollback"):
|
||||
return self.service.rollback(transaction_id, payload, facade_headers)
|
||||
raise AssertionError((method, path))
|
||||
|
||||
|
||||
def _authority(material: dict) -> ConfiguredRc06Authority:
|
||||
images = {
|
||||
item["role"]: item["manifest_digest"]
|
||||
for item in material["manifest"]["candidate"]["images"]
|
||||
}
|
||||
authority = object.__new__(ConfiguredRc06Authority)
|
||||
authority._trust = AuthorityTrust( # noqa: SLF001 - exact trust fixture
|
||||
approval_root_public_key=material["root_private"].public_key(),
|
||||
approval_root_key_id=material["approval_keyring"]["root_key_id"],
|
||||
approval_keyring_epoch=material["approval_keyring"]["epoch"],
|
||||
apply_service_public_key=material["apply_private"].public_key(),
|
||||
legal_keyring_sha256=material["legal_keyring_raw"],
|
||||
environment_id=material["environment_id"],
|
||||
tenant_id=material["tenant_id"],
|
||||
frozen_candidate_commit=material["manifest"]["candidate"]["commit"],
|
||||
contract_release_image_digest=images["contract-api"],
|
||||
ai_release_image_digest=images["ai-platform-api"],
|
||||
)
|
||||
return authority
|
||||
|
||||
|
||||
def test_real_client_validates_facade_status_and_recovers_lost_responses(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fixture = _load_apply_fixture()
|
||||
material = _resign_authority_material(
|
||||
fixture._material(monkeypatch, datetime.now(UTC).replace(microsecond=0))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
fixture.apply.batch,
|
||||
"TRUSTED_SCENE_LEGAL_REVIEW_KEYRING_SHA256",
|
||||
material["legal_keyring_raw"],
|
||||
)
|
||||
images = {
|
||||
item["role"]: item["manifest_digest"]
|
||||
for item in material["manifest"]["candidate"]["images"]
|
||||
}
|
||||
contract = SqlAlchemyContractRepository(
|
||||
f"sqlite:///{tmp_path / 'contract.sqlite3'}",
|
||||
auto_create=True,
|
||||
seed_demo_data=False,
|
||||
file_storage_dir=tmp_path / "files",
|
||||
)
|
||||
ai = SqlAlchemyAiPlatformRepository(
|
||||
f"sqlite:///{tmp_path / 'ai.sqlite3'}",
|
||||
auto_create=True,
|
||||
)
|
||||
ledger = fixture.apply.ApplyLedger(tmp_path / "ledger.sqlite3")
|
||||
replacement = None
|
||||
try:
|
||||
packaged, _, duplicates = load_packaged_prompt_assets(DEFAULT_SOURCE_FILE)
|
||||
assert duplicates == []
|
||||
by_key = {item.prompt_key: item for item in packaged}
|
||||
with ai._session_factory.begin() as session:
|
||||
for name in DOMAIN_REVIEW_RUNTIME_IDENTITIES:
|
||||
session.add(
|
||||
PromptTemplateModel(
|
||||
id=f"existing-{name}",
|
||||
name=name,
|
||||
version=DEFAULT_VERSION,
|
||||
content=by_key[name].content,
|
||||
enabled=True,
|
||||
status="published",
|
||||
published_at=material["now"],
|
||||
updated_at=material["now"],
|
||||
)
|
||||
)
|
||||
ai_service = Rc06PromptPublicationService(
|
||||
ai._session_factory,
|
||||
frozen_candidate_commit=material["manifest"]["candidate"]["commit"],
|
||||
release_image_digest=images["ai-platform-api"],
|
||||
)
|
||||
token = tmp_path / "facade.token"
|
||||
token.write_text("facade-token-00000000000000000000000001", encoding="utf-8")
|
||||
settings = ContractApiSettings(
|
||||
environment="test",
|
||||
rc06_publication_facade_enabled=True,
|
||||
rc06_publication_environment_id=material["environment_id"],
|
||||
rc06_publication_tenant_id=material["tenant_id"],
|
||||
rc06_publication_bearer_token_file=str(token.resolve()),
|
||||
rc06_frozen_candidate_commit=material["manifest"]["candidate"]["commit"],
|
||||
rc06_release_image_digest=images["contract-api"],
|
||||
rc06_ai_release_image_digest=images["ai-platform-api"],
|
||||
)
|
||||
signer = _RsaKmsSigner(material["online_private"])
|
||||
service = Rc06PublicationFacadeService(
|
||||
settings=settings,
|
||||
repository=contract,
|
||||
authority=_authority(material),
|
||||
signer=signer,
|
||||
ai_client=_LocalAiClient(ai_service),
|
||||
apply_service_public_key=material["apply_private"].public_key(),
|
||||
)
|
||||
client = _DirectFacadeClient(
|
||||
service,
|
||||
lose_apply_response=True,
|
||||
lose_finalize_response=True,
|
||||
)
|
||||
|
||||
execution = fixture._execute(material, client, ledger)
|
||||
assert signer.calls == 4
|
||||
receipt = fixture._countersign(material, execution, datetime.now(UTC))
|
||||
final = fixture._finalize(
|
||||
material,
|
||||
client,
|
||||
ledger,
|
||||
execution,
|
||||
receipt,
|
||||
datetime.now(UTC),
|
||||
)
|
||||
assert final["online_finalize_status"]["state"] == "finalized"
|
||||
assert final["online_finalize_status"]["state_version"] == 4
|
||||
|
||||
# A lost local ledger must be able to recover all three receipts from
|
||||
# the exact finalized server status without another KMS signature.
|
||||
replacement = fixture.apply.ApplyLedger(tmp_path / "replacement.sqlite3")
|
||||
recovered = fixture._execute(material, client, replacement)
|
||||
assert recovered == execution
|
||||
assert signer.calls == 4
|
||||
finally:
|
||||
if replacement is not None:
|
||||
replacement.close()
|
||||
ledger.close()
|
||||
ai._engine.dispose()
|
||||
contract.engine.dispose()
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yuqei_contract_api.rc06_publication_protocol import (
|
||||
Rc06ProtocolError,
|
||||
sha256_json,
|
||||
validate_manifest,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _fixture_manifest() -> dict:
|
||||
path = REPO_ROOT / "yuqei-infra/tests/test_rc06_publication_batch.py"
|
||||
name = "rc06_contract_protocol_manifest_fixture"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
inventory = module.batch.load_runtime_inventory(REPO_ROOT)
|
||||
return module._manifest(inventory, resolved=True)
|
||||
|
||||
|
||||
def _rehash(manifest: dict) -> dict:
|
||||
manifest["manifest_payload_sha256"] = sha256_json(
|
||||
{key: value for key, value in manifest.items() if key != "manifest_payload_sha256"}
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def test_runtime_manifest_gate_accepts_the_exact_offline_validated_shape() -> None:
|
||||
manifest = _fixture_manifest()
|
||||
assert validate_manifest(manifest) == manifest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
(
|
||||
"rc05_not_green",
|
||||
"invalid_failure_class",
|
||||
"invalid_asset_template_id",
|
||||
"missing_release_image",
|
||||
"unresolved_golden",
|
||||
"publication_control_shape",
|
||||
),
|
||||
)
|
||||
def test_runtime_manifest_gate_rejects_rehashed_release_control_drift(mutation: str) -> None:
|
||||
manifest = deepcopy(_fixture_manifest())
|
||||
if mutation == "rc05_not_green":
|
||||
manifest["candidate"]["rc05_terminal_evidence"]["complete_real_green"] = False
|
||||
elif mutation == "invalid_failure_class":
|
||||
controls = manifest["candidate"]["rc05_plan_identity"]["release_controls"]
|
||||
first = next(iter(controls["failure_classes"]))
|
||||
controls["failure_classes"][first] = "!!! not-a-stable-id !!!"
|
||||
elif mutation == "invalid_asset_template_id":
|
||||
manifest["asset_batch"]["items"][0]["template_id"] = "not a stable id"
|
||||
elif mutation == "missing_release_image":
|
||||
manifest["candidate"]["images"].pop()
|
||||
elif mutation == "unresolved_golden":
|
||||
item = manifest["golden_dispositions"]["items"][0]
|
||||
item["status"] = "unresolved"
|
||||
manifest["golden_dispositions"]["counts_by_status"] = {
|
||||
status: sum(
|
||||
row["status"] == status
|
||||
for row in manifest["golden_dispositions"]["items"]
|
||||
)
|
||||
for status in (
|
||||
"named_legal_approval",
|
||||
"scoped_internal_risk_acceptance",
|
||||
"unresolved",
|
||||
)
|
||||
}
|
||||
manifest["golden_dispositions"]["inventory_sha256"] = sha256_json(
|
||||
manifest["golden_dispositions"]["items"]
|
||||
)
|
||||
elif mutation == "publication_control_shape":
|
||||
manifest["publication_control"]["caller_claimed_release_ready"] = True
|
||||
else: # pragma: no cover - parametrization is closed above
|
||||
raise AssertionError(mutation)
|
||||
_rehash(manifest)
|
||||
|
||||
with pytest.raises(Rc06ProtocolError):
|
||||
validate_manifest(manifest)
|
||||
@@ -26,6 +26,7 @@ from yuqei_ai_platform_api.repository import SqlAlchemyAiPlatformRepository
|
||||
from yuqei_contract_api.config import ContractApiSettings, check_runtime_config
|
||||
from yuqei_contract_api.main import create_app
|
||||
from yuqei_contract_api.main import _is_rc06_internal_path
|
||||
import yuqei_contract_api.rc06_publication as rc06_publication_module
|
||||
from yuqei_contract_api.persistence.models import (
|
||||
Rc06PublicationSagaModel,
|
||||
Rc06TemplatePublicationItemModel,
|
||||
@@ -77,6 +78,31 @@ class LocalAiClient:
|
||||
return self.service.compensate(transaction_id, capability).model_dump(mode="json")
|
||||
|
||||
|
||||
def test_postgresql_authority_clock_uses_wall_clock_not_transaction_timestamp():
|
||||
observed = datetime.now(UTC)
|
||||
|
||||
class _Dialect:
|
||||
name = "postgresql"
|
||||
|
||||
class _Bind:
|
||||
dialect = _Dialect()
|
||||
|
||||
class _Session:
|
||||
statement = None
|
||||
|
||||
def get_bind(self):
|
||||
return _Bind()
|
||||
|
||||
def scalar(self, statement):
|
||||
self.statement = str(statement)
|
||||
return observed
|
||||
|
||||
session = _Session()
|
||||
assert rc06_publication_module._database_utc_now(session) == observed
|
||||
assert "clock_timestamp()" in session.statement
|
||||
assert "current_timestamp" not in session.statement.lower()
|
||||
|
||||
|
||||
def _ai_service(repository):
|
||||
return Rc06PromptPublicationService(
|
||||
repository._session_factory,
|
||||
@@ -138,6 +164,7 @@ def _request(transaction_id="rc06-saga-transaction-0001"):
|
||||
batch_manifest_raw_sha256="d" * 64,
|
||||
batch_manifest_payload_sha256="b" * 64,
|
||||
approval_receipt_sha256="c" * 64,
|
||||
authority_expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
assets=contract_source_assets(),
|
||||
prompt_modules=modules,
|
||||
runtime_identities=identities,
|
||||
@@ -170,6 +197,7 @@ def test_sqlite_saga_commits_exactly_74_items_and_14_runtime_snapshots(tmp_path)
|
||||
assert committed.ai_committed_at is not None
|
||||
assert committed.contract_committed_at is not None
|
||||
assert committed.committed_at is not None
|
||||
assert committed.committed_at == committed.contract_committed_at
|
||||
assert sum(item["kind"] == "asset" for item in committed.item_results) == 60
|
||||
assert sum(item["kind"] == "prompt_module" for item in committed.item_results) == 14
|
||||
assert coordinator.commit(request.transaction_id) == committed
|
||||
@@ -217,6 +245,47 @@ def test_lost_ai_commit_response_is_reconciled_by_transaction_status(tmp_path):
|
||||
contract.engine.dispose()
|
||||
|
||||
|
||||
def test_authority_expiry_at_contract_boundary_rolls_back_all_contract_writes(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
contract, ai = _repositories(tmp_path)
|
||||
try:
|
||||
base = datetime.now(UTC).replace(microsecond=0)
|
||||
expiry = base + timedelta(seconds=10)
|
||||
observed = iter(
|
||||
(base, base + timedelta(seconds=1), base + timedelta(seconds=2), expiry)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rc06_publication_module,
|
||||
"_database_utc_now",
|
||||
lambda _session: next(observed),
|
||||
)
|
||||
coordinator = _coordinator(
|
||||
repository=contract,
|
||||
ai_client=LocalAiClient(_ai_service(ai)),
|
||||
authorization_verifier=TestAuthorizationVerifier(),
|
||||
)
|
||||
request = _request("rc06-saga-expired-at-contract-boundary").model_copy(
|
||||
update={"authority_expires_at": expiry}
|
||||
)
|
||||
assert coordinator.prepare(request, tenant_id="default").state == "prepared"
|
||||
|
||||
with pytest.raises(Rc06Conflict, match="commit boundary"):
|
||||
coordinator.commit(request.transaction_id, tenant_id="default")
|
||||
|
||||
assert coordinator.status(request.transaction_id).state == "compensated"
|
||||
with contract._session_factory() as session:
|
||||
assert session.scalars(select(TemplateLifecycleEventModel)).all() == []
|
||||
assert session.scalars(select(TemplatePublishedSnapshotModel)).all() == []
|
||||
items = session.scalars(select(Rc06TemplatePublicationItemModel)).all()
|
||||
assert len(items) == 60
|
||||
assert all(item.state == "compensated" for item in items)
|
||||
finally:
|
||||
ai._engine.dispose()
|
||||
contract.engine.dispose()
|
||||
|
||||
|
||||
def test_readback_accepts_historical_snapshot_but_requires_current_event_binding(tmp_path):
|
||||
contract, ai = _repositories(tmp_path)
|
||||
try:
|
||||
@@ -584,6 +653,9 @@ def test_transient_ai_status_failure_after_both_commits_never_rolls_back(tmp_pat
|
||||
pending = coordinator.status(request.transaction_id)
|
||||
assert pending.state == "contract_committed"
|
||||
assert pending.no_go is True
|
||||
assert pending.write_count == 74
|
||||
assert pending.runtime_snapshot_count == 14
|
||||
assert pending.committed_at == pending.contract_committed_at
|
||||
assert service.status(request.transaction_id).state == "committed"
|
||||
with contract._session_factory() as session:
|
||||
assert len(session.scalars(select(TemplateLifecycleEventModel)).all()) == 60
|
||||
@@ -591,8 +663,11 @@ def test_transient_ai_status_failure_after_both_commits_never_rolls_back(tmp_pat
|
||||
assert len(snapshots) == 60
|
||||
assert all(item.deleted_at is None for item in snapshots)
|
||||
|
||||
committed = coordinator.commit(request.transaction_id)
|
||||
irreversible_capture_at = pending.committed_at
|
||||
committed = coordinator.recover_after_contract_commit(request.transaction_id)
|
||||
assert committed.state == "committed"
|
||||
assert committed.committed_at == irreversible_capture_at
|
||||
assert committed.committed_at == committed.contract_committed_at
|
||||
with contract._session_factory() as session:
|
||||
assert len(session.scalars(select(TemplateLifecycleEventModel)).all()) == 60
|
||||
assert len(session.scalars(select(TemplatePublishedSnapshotModel)).all()) == 60
|
||||
@@ -1085,6 +1160,18 @@ def test_contract_rejects_forged_sealed_state_markers(tmp_path):
|
||||
expected_state="sealed",
|
||||
)
|
||||
|
||||
wrong_authority_expiry = deepcopy(valid)
|
||||
wrong_authority_expiry["authority_expires_at"] = (
|
||||
request.authority_expires_at + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
with pytest.raises(Rc06PublicationError, match="incomplete"):
|
||||
Rc06PublicationCoordinator._assert_ai_state(
|
||||
wrong_authority_expiry,
|
||||
request.transaction_id,
|
||||
frozen,
|
||||
expected_state="sealed",
|
||||
)
|
||||
|
||||
wrong_no_go = deepcopy(valid)
|
||||
wrong_no_go["no_go"] = True
|
||||
with pytest.raises(Rc06PublicationError, match="incomplete"):
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from yuqei_contract_api.schema_check import (
|
||||
RC06_COLUMNS_BY_TABLE,
|
||||
RC06_CONSTRAINTS_BY_TABLE,
|
||||
_check_rc06_schema,
|
||||
)
|
||||
|
||||
|
||||
class _Inspector:
|
||||
def __init__(self) -> None:
|
||||
self.checks = {
|
||||
table: [
|
||||
{
|
||||
"name": name,
|
||||
"sqltext": (
|
||||
f"{column} IN ("
|
||||
+ ", ".join(f"'{value}'" for value in sorted(values))
|
||||
+ ")"
|
||||
),
|
||||
}
|
||||
for name, (column, values) in constraints["check"].items()
|
||||
]
|
||||
for table, constraints in RC06_CONSTRAINTS_BY_TABLE.items()
|
||||
}
|
||||
self.foreign = {
|
||||
table: [
|
||||
{
|
||||
"name": name,
|
||||
"constrained_columns": list(columns),
|
||||
"referred_table": referred_table,
|
||||
"referred_columns": list(referred_columns),
|
||||
"referred_schema": None if referred_schema == "__current__" else referred_schema,
|
||||
"options": {"ondelete": ondelete},
|
||||
}
|
||||
for name, (
|
||||
columns,
|
||||
referred_table,
|
||||
referred_columns,
|
||||
referred_schema,
|
||||
ondelete,
|
||||
) in constraints["foreign"].items()
|
||||
]
|
||||
for table, constraints in RC06_CONSTRAINTS_BY_TABLE.items()
|
||||
}
|
||||
|
||||
def get_columns(self, table, schema=None):
|
||||
return [{"name": name} for name in RC06_COLUMNS_BY_TABLE[table]]
|
||||
|
||||
def get_unique_constraints(self, table, schema=None):
|
||||
return [
|
||||
{"name": name, "column_names": list(columns)}
|
||||
for name, columns in RC06_CONSTRAINTS_BY_TABLE[table]["unique"].items()
|
||||
]
|
||||
|
||||
def get_pk_constraint(self, table, schema=None):
|
||||
return {
|
||||
"constrained_columns": list(
|
||||
RC06_CONSTRAINTS_BY_TABLE[table]["primary"]["primary_key"]
|
||||
)
|
||||
}
|
||||
|
||||
def get_check_constraints(self, table, schema=None):
|
||||
return deepcopy(self.checks[table])
|
||||
|
||||
def get_foreign_keys(self, table, schema=None):
|
||||
return deepcopy(self.foreign[table])
|
||||
|
||||
|
||||
def test_rc06_schema_check_rejects_check_literal_and_ondelete_drift() -> None:
|
||||
tables = set(RC06_COLUMNS_BY_TABLE)
|
||||
inspector = _Inspector()
|
||||
assert _check_rc06_schema(inspector, tables, dialect="postgresql").status == "ok"
|
||||
|
||||
pg_deparsed = _Inspector()
|
||||
pg_saga_check = next(
|
||||
item
|
||||
for item in pg_deparsed.checks["rc06_publication_sagas"]
|
||||
if item["name"] == "ck_rc06_publication_sagas_state"
|
||||
)
|
||||
values = RC06_CONSTRAINTS_BY_TABLE["rc06_publication_sagas"]["check"][
|
||||
"ck_rc06_publication_sagas_state"
|
||||
][1]
|
||||
pg_saga_check["sqltext"] = (
|
||||
"((state)::text = ANY ((ARRAY["
|
||||
+ ", ".join(f"'{value}'::character varying" for value in sorted(values))
|
||||
+ "])::text[]))"
|
||||
)
|
||||
assert _check_rc06_schema(pg_deparsed, tables, dialect="postgresql").status == "ok"
|
||||
|
||||
fake_pg_any = _Inspector()
|
||||
fake_saga_check = next(
|
||||
item
|
||||
for item in fake_pg_any.checks["rc06_publication_sagas"]
|
||||
if item["name"] == "ck_rc06_publication_sagas_state"
|
||||
)
|
||||
fake_saga_check["sqltext"] = (
|
||||
"((state IS NOT NULL) AND ('preparing'::text = ANY (ARRAY["
|
||||
+ ", ".join(f"'{value}'::text" for value in sorted(values))
|
||||
+ "])))"
|
||||
)
|
||||
bypass = _check_rc06_schema(fake_pg_any, tables, dialect="postgresql")
|
||||
assert bypass.status == "error"
|
||||
assert "ck_rc06_publication_sagas_state" in bypass.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
|
||||
expression_in_literal_list = _Inspector()
|
||||
expression_check = next(
|
||||
item
|
||||
for item in expression_in_literal_list.checks["rc06_publication_sagas"]
|
||||
if item["name"] == "ck_rc06_publication_sagas_state"
|
||||
)
|
||||
expression_check["sqltext"] = expression_check["sqltext"].replace(
|
||||
")", ", state)", 1
|
||||
)
|
||||
bypass = _check_rc06_schema(
|
||||
expression_in_literal_list,
|
||||
tables,
|
||||
dialect="postgresql",
|
||||
)
|
||||
assert bypass.status == "error"
|
||||
assert "ck_rc06_publication_sagas_state" in bypass.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
|
||||
always_true = _Inspector()
|
||||
always_true_check = next(
|
||||
item
|
||||
for item in always_true.checks["rc06_publication_sagas"]
|
||||
if item["name"] == "ck_rc06_publication_sagas_state"
|
||||
)
|
||||
always_true_check["sqltext"] += " OR 1=1"
|
||||
bypass = _check_rc06_schema(always_true, tables, dialect="sqlite")
|
||||
assert bypass.status == "error"
|
||||
assert "ck_rc06_publication_sagas_state" in bypass.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
|
||||
saga_check = next(
|
||||
item
|
||||
for item in inspector.checks["rc06_publication_sagas"]
|
||||
if item["name"] == "ck_rc06_publication_sagas_state"
|
||||
)
|
||||
saga_check["sqltext"] = saga_check["sqltext"].replace("'committed', ", "")
|
||||
for table in ("rc06_publication_sagas", "rc06_apply_challenge_uses"):
|
||||
inspector.foreign[table][0]["options"]["ondelete"] = "CASCADE"
|
||||
inspector.foreign[table][0]["referred_schema"] = "attacker_shadow"
|
||||
|
||||
result = _check_rc06_schema(inspector, tables, dialect="postgresql")
|
||||
assert result.status == "error"
|
||||
assert "ck_rc06_publication_sagas_state" in result.details["missing_constraints"]
|
||||
assert "fk_rc06_publication_saga_authority_transaction" in result.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
assert "fk_rc06_apply_challenge_transaction" in result.details["missing_constraints"]
|
||||
|
||||
missing_pk = _Inspector()
|
||||
missing_pk.get_pk_constraint = lambda _table, schema=None: {
|
||||
"constrained_columns": []
|
||||
}
|
||||
result = _check_rc06_schema(missing_pk, tables, dialect="postgresql")
|
||||
assert result.status == "error"
|
||||
assert "rc06_online_publication_transactions.primary_key" in result.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
assert "rc06_apply_challenge_uses.primary_key" in result.details[
|
||||
"missing_constraints"
|
||||
]
|
||||
Reference in New Issue
Block a user