feat(rc06): add durable online publication facade
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
"""add durable RC06 online publication facade journal
|
||||
|
||||
Revision ID: 20261006_0082
|
||||
Revises: 20261006_0081
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20261006_0082"
|
||||
down_revision = "20261006_0081"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("rc06_publication_sagas") as batch:
|
||||
batch.add_column(sa.Column("ai_compensation_status_json", sa.Text(), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
"rc06_online_publication_transactions",
|
||||
sa.Column("transaction_id", sa.String(length=64), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("environment_id", sa.String(length=256), nullable=False),
|
||||
sa.Column("candidate_commit", sa.String(length=64), nullable=False),
|
||||
sa.Column("contract_release_image_digest", sa.String(length=71), nullable=False),
|
||||
sa.Column("ai_release_image_digest", sa.String(length=71), nullable=False),
|
||||
sa.Column("batch_manifest_raw_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("batch_manifest_payload_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("approval_receipt_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("legal_source_review_binding_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("legal_source_review_expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("challenge_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("challenge_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("state", sa.String(length=64), nullable=False),
|
||||
sa.Column("request_json", sa.Text(), nullable=False),
|
||||
sa.Column("prepare_request_json", sa.Text(), nullable=False),
|
||||
sa.Column("saga_status_json", sa.Text(), nullable=True),
|
||||
sa.Column("participant_evidence_json", sa.Text(), nullable=True),
|
||||
sa.Column("online_payload_json", sa.Text(), nullable=True),
|
||||
sa.Column("online_primary_signature_json", sa.Text(), nullable=True),
|
||||
sa.Column("online_attestation_json", sa.Text(), nullable=True),
|
||||
sa.Column("online_primary_kms_request_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("online_attestation_kms_request_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("online_receipt_json", sa.Text(), nullable=True),
|
||||
sa.Column("online_receipt_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("runtime_payload_json", sa.Text(), nullable=True),
|
||||
sa.Column("runtime_primary_signature_json", sa.Text(), nullable=True),
|
||||
sa.Column("runtime_attestation_json", sa.Text(), nullable=True),
|
||||
sa.Column("runtime_primary_kms_request_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("runtime_attestation_kms_request_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("runtime_receipt_json", sa.Text(), nullable=True),
|
||||
sa.Column("runtime_receipt_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("rollback_response_json", sa.Text(), nullable=True),
|
||||
sa.Column("rollback_evidence_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("rollback_response_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("apply_receipt_json", sa.Text(), nullable=True),
|
||||
sa.Column("apply_receipt_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("failure_code", sa.String(length=128), nullable=True),
|
||||
sa.Column("failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("failure_evidence_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("contract_committed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("pending_countersign_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"state in ('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')",
|
||||
name="ck_rc06_online_publication_state",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "idempotency_key", name="ux_rc06_online_publication_idempotency"
|
||||
),
|
||||
sa.UniqueConstraint("challenge_id", name="ux_rc06_online_publication_challenge_id"),
|
||||
sa.UniqueConstraint("challenge_sha256", name="ux_rc06_online_publication_challenge_sha256"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rc06_online_publication_transactions_tenant_id",
|
||||
"rc06_online_publication_transactions",
|
||||
["tenant_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rc06_online_publication_transactions_candidate_commit",
|
||||
"rc06_online_publication_transactions",
|
||||
["candidate_commit"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rc06_online_publication_transactions_state",
|
||||
"rc06_online_publication_transactions",
|
||||
["state"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"rc06_apply_challenge_uses",
|
||||
sa.Column("challenge_id", sa.String(length=64), primary_key=True),
|
||||
sa.Column("nonce_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("challenge_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||
sa.Column("transaction_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("outcome", sa.String(length=32), nullable=False),
|
||||
sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"outcome in ('reserved', 'pending_countersign', 'finalized', 'rolled_back', "
|
||||
"'compensation_failed', 'no_go')",
|
||||
name="ck_rc06_apply_challenge_outcome",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["transaction_id"],
|
||||
["rc06_online_publication_transactions.transaction_id"],
|
||||
ondelete="NO ACTION",
|
||||
),
|
||||
sa.UniqueConstraint("nonce_sha256", name="ux_rc06_apply_challenge_nonce"),
|
||||
sa.UniqueConstraint("transaction_id", name="ux_rc06_apply_challenge_transaction"),
|
||||
sa.UniqueConstraint("challenge_sha256", name="ux_rc06_apply_challenge_sha256"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_rc06_apply_challenge_uses_tenant_id",
|
||||
"rc06_apply_challenge_uses",
|
||||
["tenant_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
online_count = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM rc06_online_publication_transactions")
|
||||
).scalar_one()
|
||||
challenge_count = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM rc06_apply_challenge_uses")
|
||||
).scalar_one()
|
||||
if online_count or challenge_count:
|
||||
raise RuntimeError(
|
||||
"RC06 migration 0082 refuses to erase durable publication authority, "
|
||||
"receipt, countersign, or challenge-consumption evidence."
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_rc06_apply_challenge_uses_tenant_id",
|
||||
table_name="rc06_apply_challenge_uses",
|
||||
)
|
||||
op.drop_table("rc06_apply_challenge_uses")
|
||||
op.drop_index(
|
||||
"ix_rc06_online_publication_transactions_state",
|
||||
table_name="rc06_online_publication_transactions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_rc06_online_publication_transactions_candidate_commit",
|
||||
table_name="rc06_online_publication_transactions",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_rc06_online_publication_transactions_tenant_id",
|
||||
table_name="rc06_online_publication_transactions",
|
||||
)
|
||||
op.drop_table("rc06_online_publication_transactions")
|
||||
with op.batch_alter_table("rc06_publication_sagas") as batch:
|
||||
batch.drop_column("ai_compensation_status_json")
|
||||
@@ -29,8 +29,25 @@ class ContractApiSettings(BaseSettings):
|
||||
rc06_publication_shared_secret: str = ""
|
||||
rc06_frozen_candidate_commit: str = ""
|
||||
rc06_release_image_digest: str = ""
|
||||
rc06_ai_release_image_digest: str = ""
|
||||
rc06_low_level_coordinator_http_enabled: bool = False
|
||||
rc06_publication_tenant_id: str = "default"
|
||||
rc06_publication_facade_enabled: bool = False
|
||||
rc06_publication_environment_id: str = ""
|
||||
rc06_publication_bearer_token_file: str = ""
|
||||
rc06_publication_max_body_bytes: int = Field(default=16 * 1024 * 1024, ge=1024, le=32 * 1024 * 1024)
|
||||
rc06_approval_root_public_key_file: str = ""
|
||||
rc06_approval_root_public_key_sha256: str = ""
|
||||
rc06_approval_root_key_id: str = "yuqei-rc06-approval-root-v1"
|
||||
rc06_approval_keyring_epoch: int = 0
|
||||
rc06_apply_service_public_key_file: str = ""
|
||||
rc06_apply_service_public_key_sha256: str = ""
|
||||
rc06_legal_review_keyring_sha256: str = ""
|
||||
rc06_online_signing_provider: str = ""
|
||||
rc06_online_signing_key_reference: str = ""
|
||||
rc06_online_signing_key_version: str = ""
|
||||
rc06_online_signing_algorithm: str = "RSA_PSS_SHA_256"
|
||||
rc06_online_signing_public_key_spki_sha256: str = ""
|
||||
web_proxy_shared_secret: str = ""
|
||||
require_web_proxy_auth: bool = False
|
||||
playbook_direct_mutations_enabled: bool = False
|
||||
@@ -160,6 +177,7 @@ def check_runtime_config(settings: ContractApiSettings) -> RuntimeConfigStatus:
|
||||
_check_seed_config(settings),
|
||||
_check_ai_platform_config(settings),
|
||||
_check_rc06_publication_config(settings),
|
||||
_check_rc06_publication_facade_config(settings),
|
||||
_check_web_proxy_auth_config(settings),
|
||||
_check_playbook_governance_config(settings),
|
||||
_check_playbook_calendar_oauth_config(settings),
|
||||
@@ -695,6 +713,70 @@ def _check_rc06_publication_config(settings: ContractApiSettings) -> RuntimeConf
|
||||
)
|
||||
|
||||
|
||||
def _check_rc06_publication_facade_config(settings: ContractApiSettings) -> RuntimeConfigCheck:
|
||||
if not settings.rc06_publication_facade_enabled:
|
||||
return RuntimeConfigCheck(
|
||||
name="rc06_publication_facade",
|
||||
status="warning",
|
||||
message="RC06 online publication facade is disabled.",
|
||||
details={"enabled": False},
|
||||
)
|
||||
errors: list[str] = []
|
||||
if settings.rc06_low_level_coordinator_http_enabled:
|
||||
errors.append("low-level coordinator HTTP routes must be disabled")
|
||||
if not settings.rc06_publication_environment_id.strip():
|
||||
errors.append("environment id is missing")
|
||||
if not settings.rc06_publication_tenant_id.strip():
|
||||
errors.append("tenant id is missing")
|
||||
if settings.reliable_evidence_signing_backend != "kms_gateway":
|
||||
errors.append("online receipt signing must use the KMS gateway")
|
||||
if settings.rc06_approval_keyring_epoch < 1:
|
||||
errors.append("approval keyring epoch is not frozen")
|
||||
if re.fullmatch(r"(?:[a-f0-9]{40}|[a-f0-9]{64})", settings.rc06_frozen_candidate_commit) is None:
|
||||
errors.append("candidate commit is not frozen")
|
||||
for label, value in (
|
||||
("Contract image", settings.rc06_release_image_digest),
|
||||
("AI image", settings.rc06_ai_release_image_digest),
|
||||
):
|
||||
if re.fullmatch(r"sha256:[a-f0-9]{64}", value) is None:
|
||||
errors.append(f"{label} digest is invalid")
|
||||
for label, value in (
|
||||
("approval root pin", settings.rc06_approval_root_public_key_sha256),
|
||||
("apply service pin", settings.rc06_apply_service_public_key_sha256),
|
||||
("legal keyring pin", settings.rc06_legal_review_keyring_sha256),
|
||||
("online signing SPKI pin", settings.rc06_online_signing_public_key_spki_sha256),
|
||||
):
|
||||
if re.fullmatch(r"[a-f0-9]{64}", value) is None:
|
||||
errors.append(f"{label} is invalid")
|
||||
for label, value in (
|
||||
("approval root public key", settings.rc06_approval_root_public_key_file),
|
||||
("apply service public key", settings.rc06_apply_service_public_key_file),
|
||||
("facade bearer token", settings.rc06_publication_bearer_token_file),
|
||||
):
|
||||
if not value or not Path(value).is_absolute():
|
||||
errors.append(f"{label} path must be absolute")
|
||||
if settings.rc06_online_signing_algorithm not in {
|
||||
"RSA_PSS_SHA_256", "RSA_PKCS1_SHA_256", "ECDSA_SHA_256"
|
||||
}:
|
||||
errors.append("online signing algorithm is unsupported")
|
||||
if not all((
|
||||
settings.rc06_online_signing_provider.strip(),
|
||||
settings.rc06_online_signing_key_reference.strip(),
|
||||
settings.rc06_online_signing_key_version.strip(),
|
||||
)):
|
||||
errors.append("online KMS identity is incomplete")
|
||||
return RuntimeConfigCheck(
|
||||
name="rc06_publication_facade",
|
||||
status="error" if errors else "ok",
|
||||
message=(
|
||||
"RC06 online publication facade configuration is invalid: " + "; ".join(errors)
|
||||
if errors
|
||||
else "RC06 online publication facade trust and KMS identities are frozen."
|
||||
),
|
||||
details={"enabled": True, "error_count": len(errors)},
|
||||
)
|
||||
|
||||
|
||||
def _check_worker_config(settings: ContractApiSettings) -> RuntimeConfigCheck:
|
||||
if not settings.review_worker_name.strip():
|
||||
return RuntimeConfigCheck(
|
||||
|
||||
@@ -34,6 +34,10 @@ from yuqei_contract_api.api.support import register_support_routes
|
||||
from yuqei_contract_api.api.system import register_system_routes
|
||||
from yuqei_contract_api.api.templates import register_template_routes
|
||||
from yuqei_contract_api.rc06_publication import AiRc06Client, register_rc06_publication_routes
|
||||
from yuqei_contract_api.rc06_publication_facade import (
|
||||
is_rc06_publication_facade_path,
|
||||
register_rc06_publication_facade_routes,
|
||||
)
|
||||
from yuqei_contract_api.config import ContractApiSettings, get_settings
|
||||
from yuqei_contract_api.logging import (
|
||||
clear_request_tenant_id,
|
||||
@@ -126,10 +130,15 @@ def create_app(
|
||||
trace_id = request.headers.get("X-Trace-Id") or f"trace-{uuid.uuid4().hex[:16]}"
|
||||
set_trace_id(trace_id)
|
||||
|
||||
rc06_internal = (
|
||||
rc06_low_level_internal = (
|
||||
resolved_settings.rc06_low_level_coordinator_http_enabled
|
||||
and _is_rc06_internal_path(request.url.path, resolved_settings.api_prefix)
|
||||
)
|
||||
rc06_facade_internal = (
|
||||
resolved_settings.rc06_publication_facade_enabled
|
||||
and is_rc06_publication_facade_path(request.url.path)
|
||||
)
|
||||
rc06_internal = rc06_low_level_internal or rc06_facade_internal
|
||||
if (
|
||||
resolved_settings.require_web_proxy_auth
|
||||
and not rc06_internal
|
||||
@@ -149,7 +158,7 @@ def create_app(
|
||||
if rc06_internal
|
||||
else request.headers.get("X-Tenant-Id") or "default"
|
||||
)
|
||||
user_id = request.headers.get("X-User-Id")
|
||||
user_id = "rc06-publication-service" if rc06_facade_internal else request.headers.get("X-User-Id")
|
||||
set_tenant_context(tenant_id)
|
||||
set_user_context(user_id)
|
||||
set_request_tenant_id(tenant_id)
|
||||
@@ -351,6 +360,12 @@ def create_app(
|
||||
repository=repository,
|
||||
ai_client=rc06_ai_client,
|
||||
)
|
||||
register_rc06_publication_facade_routes(
|
||||
app,
|
||||
settings=resolved_settings,
|
||||
repository=repository,
|
||||
ai_client=rc06_ai_client,
|
||||
)
|
||||
register_drafting_routes(app, settings=resolved_settings, repository=repository)
|
||||
register_party_profile_routes(app, settings=resolved_settings, repository=repository)
|
||||
register_legal_asset_routes(app, settings=resolved_settings, repository=repository)
|
||||
|
||||
+102
@@ -1756,6 +1756,7 @@ class Rc06PublicationSagaModel(Base):
|
||||
item_results_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runtime_snapshots_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_status_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_compensation_status_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
compensation_capability: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -1765,6 +1766,107 @@ class Rc06PublicationSagaModel(Base):
|
||||
compensated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Rc06OnlinePublicationTransactionModel(Base):
|
||||
"""Durable facade journal for authority, receipts, and countersign state."""
|
||||
|
||||
__tablename__ = "rc06_online_publication_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="ux_rc06_online_publication_idempotency",
|
||||
),
|
||||
CheckConstraint(
|
||||
"state in ('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')",
|
||||
name="ck_rc06_online_publication_state",
|
||||
),
|
||||
)
|
||||
|
||||
transaction_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
environment_id: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
candidate_commit: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
contract_release_image_digest: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
ai_release_image_digest: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
batch_manifest_raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
batch_manifest_payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
approval_receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
legal_source_review_binding_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
legal_source_review_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
challenge_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
challenge_sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
state: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
request_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
prepare_request_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
saga_status_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
participant_evidence_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
online_payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
online_primary_signature_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
online_attestation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
online_primary_kms_request_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
online_attestation_kms_request_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
online_receipt_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
online_receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
runtime_payload_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runtime_primary_signature_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runtime_attestation_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runtime_primary_kms_request_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
runtime_attestation_kms_request_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
runtime_receipt_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runtime_receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
rollback_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rollback_evidence_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
rollback_response_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
apply_receipt_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
apply_receipt_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
failure_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
failure_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
failure_evidence_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
contract_committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
pending_countersign_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Rc06ApplyChallengeUseModel(Base):
|
||||
"""One-way reservation ledger preventing apply-challenge replay."""
|
||||
|
||||
__tablename__ = "rc06_apply_challenge_uses"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("nonce_sha256", name="ux_rc06_apply_challenge_nonce"),
|
||||
UniqueConstraint("transaction_id", name="ux_rc06_apply_challenge_transaction"),
|
||||
CheckConstraint(
|
||||
"outcome in ('reserved', 'pending_countersign', 'finalized', 'rolled_back', "
|
||||
"'compensation_failed', 'no_go')",
|
||||
name="ck_rc06_apply_challenge_outcome",
|
||||
),
|
||||
)
|
||||
|
||||
challenge_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
nonce_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
challenge_sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
transaction_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("rc06_online_publication_transactions.transaction_id", ondelete="NO ACTION"),
|
||||
nullable=False,
|
||||
)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
outcome: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
reserved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class Rc06TemplatePublicationItemModel(Base):
|
||||
__tablename__ = "rc06_template_publication_items"
|
||||
__table_args__ = (
|
||||
|
||||
+148
-4
@@ -94,6 +94,14 @@ class Rc06SagaStatus(BaseModel):
|
||||
ai_sealed_at: datetime | None = None
|
||||
contract_committed_at: datetime | None = None
|
||||
committed_at: datetime | None = None
|
||||
compensated_from_state: str | None = None
|
||||
compensation_item_results: list[dict[str, Any]] = Field(default_factory=list)
|
||||
runtime_snapshot_compensation_results: list[dict[str, Any]] = Field(default_factory=list)
|
||||
restored_item_count: int = 0
|
||||
removed_item_count: int = 0
|
||||
unchanged_item_count: int = 0
|
||||
compensated_at: datetime | None = None
|
||||
compensation_evidence_sha256: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -524,19 +532,22 @@ class Rc06PublicationCoordinator:
|
||||
current.state = "compensating"
|
||||
current.updated_at = datetime.now(UTC)
|
||||
errors: list[str] = []
|
||||
ai_status: dict[str, Any] | None = None
|
||||
try:
|
||||
self._compensate_templates(transaction_id)
|
||||
except Exception as error:
|
||||
errors.append(f"contract:{type(error).__name__}:{error}")
|
||||
try:
|
||||
ai_status = self._ai.compensate(transaction_id, compensation_capability)
|
||||
if ai_status.get("state") != "compensated":
|
||||
raise Rc06PublicationError("AI participant did not confirm compensation.")
|
||||
self._assert_ai_compensation_state(ai_status, transaction_id, self._load_saga(
|
||||
transaction_id, tenant_id=tenant_id
|
||||
))
|
||||
except Exception as error:
|
||||
try:
|
||||
ai_status = self._ai.status(transaction_id)
|
||||
if ai_status.get("state") != "compensated":
|
||||
raise Rc06PublicationError("AI participant compensation outcome is not durable.")
|
||||
self._assert_ai_compensation_state(ai_status, transaction_id, self._load_saga(
|
||||
transaction_id, tenant_id=tenant_id
|
||||
))
|
||||
except Exception as status_error:
|
||||
errors.append(
|
||||
f"ai:{type(error).__name__}:{error};"
|
||||
@@ -552,6 +563,9 @@ class Rc06PublicationCoordinator:
|
||||
current.error_message = "; ".join(errors)[:4000] if errors else None
|
||||
current.compensated_at = now if not errors else None
|
||||
if not errors:
|
||||
if ai_status is None:
|
||||
raise Rc06PublicationError("AI compensation evidence is unavailable.")
|
||||
current.ai_compensation_status_json = _encode(ai_status)
|
||||
current.compensation_capability = None
|
||||
current.updated_at = now
|
||||
result = self._status_row(current)
|
||||
@@ -827,6 +841,125 @@ class Rc06PublicationCoordinator:
|
||||
raise Rc06PublicationError("Committed RC06 saga has incomplete or stale durable evidence.")
|
||||
return self._status_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _assert_ai_compensation_state(
|
||||
payload: dict[str, Any],
|
||||
transaction_id: str,
|
||||
saga: Rc06PublicationSagaModel,
|
||||
) -> None:
|
||||
request = Rc06PublicationCoordinator._frozen_request(saga)
|
||||
if (
|
||||
payload.get("transaction_id") != transaction_id
|
||||
or payload.get("state") != "compensated"
|
||||
or payload.get("candidate_commit") != request["candidate_commit"]
|
||||
or payload.get("release_image_digest") != request["ai_release_image_digest"]
|
||||
or payload.get("candidate_tag") != request["candidate_tag"]
|
||||
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 payload.get("no_go") is not True
|
||||
):
|
||||
raise Rc06PublicationError("AI participant compensation identity drifted.")
|
||||
from_state = payload.get("compensated_from_state")
|
||||
item_results = payload.get("compensation_item_results")
|
||||
runtime_results = payload.get("runtime_snapshot_compensation_results")
|
||||
if from_state not in {"prepared", "committed"} or not isinstance(item_results, list) or not isinstance(runtime_results, list):
|
||||
raise Rc06PublicationError("AI participant compensation evidence is incomplete.")
|
||||
if from_state == "prepared":
|
||||
if item_results or runtime_results:
|
||||
raise Rc06PublicationError("Prepared AI compensation falsely claims publication writes.")
|
||||
else:
|
||||
expected_items = sorted(
|
||||
("prompt_module", item["key"]) for item in request["prompt_modules"]
|
||||
)
|
||||
supplied_items = []
|
||||
for index, item in enumerate(item_results):
|
||||
if not isinstance(item, dict) or set(item) != {
|
||||
"kind", "item_id", "pre_apply_content_sha256",
|
||||
"post_compensation_content_sha256", "action", "status",
|
||||
}:
|
||||
raise Rc06PublicationError(f"AI compensation module row {index} drifted.")
|
||||
supplied_items.append((item.get("kind"), item.get("item_id")))
|
||||
Rc06PublicationCoordinator._assert_compensation_hashes(item, f"module row {index}")
|
||||
if supplied_items != expected_items:
|
||||
raise Rc06PublicationError("AI compensation module identities are not exact 14.")
|
||||
kind_by_source = {
|
||||
"composed_drafting_modules": "composed_drafting",
|
||||
"new_review_module_composite": "new_review_module",
|
||||
"existing_domain_prompt_asset": "existing_domain_prompt",
|
||||
}
|
||||
expected_runtime = sorted(
|
||||
(kind_by_source[item["source_kind"]], item["name"], item["version"])
|
||||
for item in request["runtime_identities"]
|
||||
)
|
||||
expected_by_identity = {
|
||||
(kind_by_source[item["source_kind"]], item["name"], item["version"]): item
|
||||
for item in request["runtime_identities"]
|
||||
}
|
||||
supplied_runtime = []
|
||||
for index, item in enumerate(runtime_results):
|
||||
if not isinstance(item, dict) or set(item) != {
|
||||
"kind", "runtime_key", "version", "pre_apply_content_sha256",
|
||||
"post_compensation_content_sha256", "action", "status",
|
||||
}:
|
||||
raise Rc06PublicationError(f"AI compensation runtime row {index} drifted.")
|
||||
identity = (item.get("kind"), item.get("runtime_key"), item.get("version"))
|
||||
supplied_runtime.append(identity)
|
||||
expected = expected_by_identity.get(identity)
|
||||
unchanged = (
|
||||
expected["content_sha256"]
|
||||
if expected is not None and expected["source_kind"] == "existing_domain_prompt_asset"
|
||||
else None
|
||||
)
|
||||
Rc06PublicationCoordinator._assert_compensation_hashes(
|
||||
item, f"runtime row {index}", unchanged_sha256=unchanged
|
||||
)
|
||||
if unchanged is not None and item["action"] != "unchanged_preexisting":
|
||||
raise Rc06PublicationError("Pre-existing runtime Prompt was falsely compensated.")
|
||||
if supplied_runtime != expected_runtime:
|
||||
raise Rc06PublicationError("AI compensation runtime identities are not exact 14.")
|
||||
actions = [item.get("action") for item in [*item_results, *runtime_results]]
|
||||
counts = {
|
||||
"restored_item_count": actions.count("restored_previous"),
|
||||
"removed_item_count": actions.count("removed_new"),
|
||||
"unchanged_item_count": actions.count("unchanged_preexisting"),
|
||||
}
|
||||
if any(payload.get(key) != value for key, value in counts.items()):
|
||||
raise Rc06PublicationError("AI compensation counts drifted.")
|
||||
compensated_at = _parse_time(payload.get("compensated_at"))
|
||||
compensation_evidence_sha256 = payload.get("compensation_evidence_sha256")
|
||||
if (
|
||||
compensated_at is None
|
||||
or not isinstance(compensation_evidence_sha256, str)
|
||||
or re.fullmatch(HEX64, compensation_evidence_sha256) is None
|
||||
):
|
||||
raise Rc06PublicationError("AI compensation evidence hash is missing or invalid.")
|
||||
# The AI participant validates this hash against additional private
|
||||
# before/after Prompt snapshots before its status method returns. Those
|
||||
# snapshots intentionally do not cross the service boundary. Contract
|
||||
# independently validates every public compensation row/count above,
|
||||
# then persists the participant's durable evidence hash verbatim.
|
||||
|
||||
@staticmethod
|
||||
def _assert_compensation_hashes(
|
||||
item: dict[str, Any],
|
||||
label: str,
|
||||
*,
|
||||
unchanged_sha256: str | None = None,
|
||||
) -> None:
|
||||
if item.get("status") != "verified":
|
||||
raise Rc06PublicationError(f"AI compensation {label} is unverified.")
|
||||
action = item.get("action")
|
||||
before = item.get("pre_apply_content_sha256")
|
||||
after = item.get("post_compensation_content_sha256")
|
||||
if action == "restored_previous" and isinstance(before, str) and re.fullmatch(HEX64, before) and after == before:
|
||||
return
|
||||
if action == "removed_new" and before is None and after is None:
|
||||
return
|
||||
if action == "unchanged_preexisting" and unchanged_sha256 is not None and before == after == unchanged_sha256:
|
||||
return
|
||||
raise Rc06PublicationError(f"AI compensation {label} hash evidence drifted.")
|
||||
|
||||
@staticmethod
|
||||
def _assert_ai_state(
|
||||
payload: dict[str, Any],
|
||||
@@ -1127,6 +1260,7 @@ class Rc06PublicationCoordinator:
|
||||
results = _decode(row.item_results_json, [])
|
||||
snapshots = _decode(row.runtime_snapshots_json, [])
|
||||
ai_status = _decode(row.ai_status_json, {})
|
||||
compensation = _decode(row.ai_compensation_status_json, {})
|
||||
return Rc06SagaStatus(
|
||||
transaction_id=row.transaction_id,
|
||||
state=row.state,
|
||||
@@ -1149,6 +1283,16 @@ class Rc06PublicationCoordinator:
|
||||
ai_sealed_at=_parse_time(ai_status.get("sealed_at")),
|
||||
contract_committed_at=_parse_time(row.contract_committed_at),
|
||||
committed_at=_parse_time(row.committed_at),
|
||||
compensated_from_state=compensation.get("compensated_from_state"),
|
||||
compensation_item_results=compensation.get("compensation_item_results", []),
|
||||
runtime_snapshot_compensation_results=compensation.get(
|
||||
"runtime_snapshot_compensation_results", []
|
||||
),
|
||||
restored_item_count=compensation.get("restored_item_count", 0),
|
||||
removed_item_count=compensation.get("removed_item_count", 0),
|
||||
unchanged_item_count=compensation.get("unchanged_item_count", 0),
|
||||
compensated_at=_parse_time(compensation.get("compensated_at")),
|
||||
compensation_evidence_sha256=compensation.get("compensation_evidence_sha256"),
|
||||
error=row.error_message,
|
||||
)
|
||||
|
||||
|
||||
+1672
File diff suppressed because it is too large
Load Diff
+849
@@ -0,0 +1,849 @@
|
||||
"""Runtime-safe RC06 publication protocol validation.
|
||||
|
||||
This module deliberately has no CLI, Git, database, or service imports. The
|
||||
offline builders perform the broader candidate/source audit; the online
|
||||
facade repeats the security-critical checks over the exact material presented
|
||||
for the durable publication transaction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
|
||||
BATCH_SCHEMA = "yuqei.rc06-publication-batch.v4"
|
||||
APPROVAL_SCHEMA = "yuqei.rc06-publication-batch-approval.v5"
|
||||
APPROVAL_PAYLOAD_SCHEMA = "yuqei.rc06-publication-batch-approval-payload.v4"
|
||||
APPROVAL_KEYRING_SCHEMA = "yuqei.rc06-publication-approval-keyring.v4"
|
||||
APPLY_REQUEST_SCHEMA = "yuqei.rc06-online-apply-request.v3"
|
||||
APPLY_RESPONSE_SCHEMA = "yuqei.rc06-online-apply-response.v2"
|
||||
APPLY_STATUS_SCHEMA = "yuqei.rc06-online-transaction-status.v3"
|
||||
ROLLBACK_REQUEST_SCHEMA = "yuqei.rc06-online-rollback-request.v2"
|
||||
ROLLBACK_RESPONSE_SCHEMA = "yuqei.rc06-online-rollback-response.v2"
|
||||
FINALIZE_REQUEST_SCHEMA = "yuqei.rc06-online-finalize-request.v1"
|
||||
FINALIZE_RESPONSE_SCHEMA = "yuqei.rc06-online-finalize-response.v1"
|
||||
FINALIZE_STATUS_SCHEMA = "yuqei.rc06-online-finalize-status.v1"
|
||||
APPLY_RECEIPT_SCHEMA = "yuqei.rc06-publication-system-item-apply-receipt.v6"
|
||||
APPLY_RECEIPT_PAYLOAD_SCHEMA = "yuqei.rc06-publication-system-item-apply-payload.v4"
|
||||
ONLINE_RECEIPT_SCHEMA = "yuqei.rc06-online-transaction-receipt.v5"
|
||||
ONLINE_PAYLOAD_SCHEMA = "yuqei.rc06-online-transaction-payload.v4"
|
||||
RUNTIME_RECEIPT_SCHEMA = "yuqei.rc06-runtime-snapshot-receipt.v3"
|
||||
RUNTIME_PAYLOAD_SCHEMA = "yuqei.rc06-runtime-snapshot-payload.v2"
|
||||
KMS_ATTESTATION_SCHEMA = "yuqei.rc06-kms-request-id-attestation.v1"
|
||||
KMS_ATTESTATION_PAYLOAD_SCHEMA = "yuqei.rc06-kms-request-id-attestation-payload.v1"
|
||||
CHALLENGE_SCHEMA = "yuqei.rc06-apply-challenge.v2"
|
||||
CHALLENGE_PAYLOAD_SCHEMA = "yuqei.rc06-apply-challenge-payload.v2"
|
||||
LEGAL_REQUEST_SCHEMA = "yuqei.scene-source-legal-review-request.v1"
|
||||
LEGAL_KEYRING_SCHEMA = "yuqei.scene-source-legal-review-keyring.v1"
|
||||
LEGAL_DECISION_SCHEMA = "yuqei.scene-source-legal-review-decision.v1"
|
||||
|
||||
APPROVAL_SIGNATURE_DOMAIN = b"YUQEI_RC06_PUBLICATION_BATCH_APPROVAL_V1\0"
|
||||
APPROVAL_KEYRING_SIGNATURE_DOMAIN = b"YUQEI_RC06_APPROVAL_KEYRING_V1\0"
|
||||
APPLY_RECEIPT_SIGNATURE_DOMAIN = b"YUQEI_RC06_SYSTEM_APPLY_RECEIPT_V2\0"
|
||||
CHALLENGE_SIGNATURE_DOMAIN = b"YUQEI_RC06_APPLY_CHALLENGE_V2\0"
|
||||
LEGAL_SIGNATURE_DOMAIN = b"YUQEI_SCENE_SOURCE_LEGAL_REVIEW_V1\0"
|
||||
ONLINE_SIGNATURE_DOMAIN = b"YUQEI_RC06_ONLINE_TRANSACTION_RECEIPT_V2\0"
|
||||
RUNTIME_SIGNATURE_DOMAIN = b"YUQEI_RC06_RUNTIME_SNAPSHOT_RECEIPT_V2\0"
|
||||
ONLINE_ATTESTATION_DOMAIN = b"YUQEI_RC06_ONLINE_TRANSACTION_REQUEST_ID_ATTESTATION_V1\0"
|
||||
RUNTIME_ATTESTATION_DOMAIN = b"YUQEI_RC06_RUNTIME_SNAPSHOT_REQUEST_ID_ATTESTATION_V1\0"
|
||||
|
||||
APPLY_OPERATION = "apply_rc06_publication_batch"
|
||||
TRANSACTION_MODE = "durable_saga"
|
||||
TRUSTED_COMPONENT = "rc06-publication-service"
|
||||
SOLE_REVIEWER = {"approver_id": "chen-yuqi", "name": "陈钰麒"}
|
||||
HEX64 = re.compile(r"^[a-f0-9]{64}$")
|
||||
COMMIT = re.compile(r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")
|
||||
IMAGE_DIGEST = re.compile(r"^sha256:[a-f0-9]{64}$")
|
||||
TRANSACTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$")
|
||||
MAX_APPROVAL_WINDOW = timedelta(days=7)
|
||||
MAX_LEGAL_WINDOW = timedelta(days=7)
|
||||
MAX_CHALLENGE_WINDOW = timedelta(minutes=10)
|
||||
EXPECTED_ASSETS = 60
|
||||
EXPECTED_MODULES = 14
|
||||
EXPECTED_RUNTIME = 14
|
||||
EXPECTED_GOLDEN = 31
|
||||
|
||||
|
||||
class Rc06ProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityTrust:
|
||||
approval_root_public_key: Ed25519PublicKey
|
||||
approval_root_key_id: str
|
||||
approval_keyring_epoch: int
|
||||
apply_service_public_key: Ed25519PublicKey
|
||||
legal_keyring_sha256: str
|
||||
environment_id: str
|
||||
tenant_id: str
|
||||
frozen_candidate_commit: str
|
||||
contract_release_image_digest: str
|
||||
ai_release_image_digest: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedMaterial:
|
||||
request: dict[str, Any]
|
||||
manifest: dict[str, Any]
|
||||
approval: dict[str, Any]
|
||||
approval_keyring: dict[str, Any]
|
||||
legal_summary: dict[str, Any]
|
||||
legal_binding_sha256: str
|
||||
challenge: dict[str, Any]
|
||||
expected_publication_results: list[dict[str, Any]]
|
||||
expected_runtime_results: list[dict[str, Any]]
|
||||
contract_image_digest: str
|
||||
ai_image_digest: str
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return sha256_bytes(canonical_json(value))
|
||||
|
||||
|
||||
def require_sha(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or HEX64.fullmatch(value) is None:
|
||||
raise Rc06ProtocolError(f"{label} must be a lowercase SHA-256 value.")
|
||||
return value
|
||||
|
||||
|
||||
def require_text(value: Any, label: str, *, maximum: int = 4096) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or value != value.strip() or len(value) > maximum:
|
||||
raise Rc06ProtocolError(f"{label} is invalid.")
|
||||
return value
|
||||
|
||||
|
||||
def require_exact(value: Any, label: str, fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or set(value) != set(fields):
|
||||
raise Rc06ProtocolError(f"{label} fields are invalid.")
|
||||
return value
|
||||
|
||||
|
||||
def parse_time(value: Any, label: str) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise Rc06ProtocolError(f"{label} must be a timestamp.")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise Rc06ProtocolError(f"{label} is invalid.") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise Rc06ProtocolError(f"{label} must include a timezone.")
|
||||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def iso_time(value: datetime) -> str:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise Rc06ProtocolError("A protocol timestamp must include a timezone.")
|
||||
return value.astimezone(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def public_key_raw(public_key: Ed25519PublicKey) -> bytes:
|
||||
return public_key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
|
||||
|
||||
def public_key_spki(public_key: Any) -> bytes:
|
||||
try:
|
||||
return public_key.public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise Rc06ProtocolError("Public key is invalid.") from exc
|
||||
|
||||
|
||||
def load_pinned_ed25519(path_value: str, expected_sha256: str, label: str) -> Ed25519PublicKey:
|
||||
expected = require_sha(expected_sha256, f"{label} pin")
|
||||
path = Path(path_value)
|
||||
if not path.is_absolute():
|
||||
raise Rc06ProtocolError(f"{label} path must be absolute.")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise Rc06ProtocolError(f"{label} is unavailable.") from exc
|
||||
try:
|
||||
if len(raw) == 32:
|
||||
public_key = Ed25519PublicKey.from_public_bytes(raw)
|
||||
else:
|
||||
loaded = serialization.load_pem_public_key(raw)
|
||||
if not isinstance(loaded, Ed25519PublicKey):
|
||||
raise ValueError("not Ed25519")
|
||||
public_key = loaded
|
||||
except ValueError as exc:
|
||||
raise Rc06ProtocolError(f"{label} is not an Ed25519 public key.") from exc
|
||||
if sha256_bytes(public_key_raw(public_key)) != expected:
|
||||
raise Rc06ProtocolError(f"{label} does not match its configured pin.")
|
||||
return public_key
|
||||
|
||||
|
||||
def _validate_asset(value: Any) -> dict[str, Any]:
|
||||
fields = (
|
||||
"template_id", "name", "version", "document_kind", "contract_type", "source",
|
||||
"source_lifecycle_status", "body_content", "content_sha256", "input_schema",
|
||||
"required_variables", "optional_variables", "validation_rules", "clause_ids",
|
||||
"review_rule_ids", "review_identity", "body_sha256", "input_schema_sha256",
|
||||
"variables_sha256", "review_identity_sha256", "asset_payload_sha256",
|
||||
)
|
||||
row = require_exact(value, "asset", fields)
|
||||
template_id = require_text(row["template_id"], "asset template_id", maximum=128)
|
||||
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}")
|
||||
schema_hash = sha256_json(row["input_schema"]) if row["input_schema"] is not None else None
|
||||
if row["input_schema_sha256"] != schema_hash:
|
||||
raise Rc06ProtocolError(f"Asset input schema hash drift: {template_id}")
|
||||
if row["variables_sha256"] != sha256_json({
|
||||
"required": row["required_variables"], "optional": row["optional_variables"]
|
||||
}):
|
||||
raise Rc06ProtocolError(f"Asset variable hash drift: {template_id}")
|
||||
if row["review_identity_sha256"] != sha256_json(row["review_identity"]):
|
||||
raise Rc06ProtocolError(f"Asset review identity hash drift: {template_id}")
|
||||
content_projection = {
|
||||
"id": row["template_id"], "contract_type": row["contract_type"],
|
||||
"required_variables": row["required_variables"], "optional_variables": row["optional_variables"],
|
||||
"validation_rules": row["validation_rules"], "input_schema": row["input_schema"],
|
||||
"clause_ids": row["clause_ids"], "review_rule_ids": row["review_rule_ids"],
|
||||
"body_content": row["body_content"],
|
||||
}
|
||||
if require_sha(row["content_sha256"], "asset content hash") != sha256_json(content_projection):
|
||||
raise Rc06ProtocolError(f"Asset content hash drift: {template_id}")
|
||||
if require_sha(row["asset_payload_sha256"], "asset payload hash") != sha256_json(
|
||||
{key: child for key, child in row.items() if key != "asset_payload_sha256"}
|
||||
):
|
||||
raise Rc06ProtocolError(f"Asset payload hash drift: {template_id}")
|
||||
return row
|
||||
|
||||
|
||||
def _validate_module(value: Any) -> dict[str, Any]:
|
||||
row = require_exact(value, "Prompt module", (
|
||||
"category", "key", "version", "content", "content_sha256", "publication_mapping",
|
||||
"module_payload_sha256",
|
||||
))
|
||||
key = require_text(row["key"], "Prompt module key", maximum=128)
|
||||
if row["category"] not in {"drafting", "review"}:
|
||||
raise Rc06ProtocolError(f"Prompt module category is invalid: {key}")
|
||||
content = require_text(row["content"], f"Prompt module {key} content", maximum=4_000_000)
|
||||
if require_sha(row["content_sha256"], "Prompt module content hash") != sha256_bytes(content.encode("utf-8")):
|
||||
raise Rc06ProtocolError(f"Prompt module content hash drift: {key}")
|
||||
if require_sha(row["module_payload_sha256"], "Prompt module payload hash") != sha256_json(
|
||||
{name: child for name, child in row.items() if name != "module_payload_sha256"}
|
||||
):
|
||||
raise Rc06ProtocolError(f"Prompt module payload hash drift: {key}")
|
||||
return row
|
||||
|
||||
|
||||
def validate_manifest(value: Any) -> dict[str, Any]:
|
||||
manifest = require_exact(value, "publication batch", (
|
||||
"schema_version", "candidate", "asset_batch", "prompt_module_batch",
|
||||
"runtime_prompt_dependencies", "default_prompt_asset_boundary", "golden_dispositions",
|
||||
"publication_control", "side_effects", "claims_not_made", "manifest_payload_sha256",
|
||||
))
|
||||
if manifest["schema_version"] != BATCH_SCHEMA:
|
||||
raise Rc06ProtocolError("Publication batch schema is unsupported.")
|
||||
if require_sha(manifest["manifest_payload_sha256"], "manifest payload hash") != sha256_json(
|
||||
{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.")
|
||||
|
||||
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:
|
||||
raise Rc06ProtocolError("Publication batch must contain exactly 60 assets.")
|
||||
asset_rows = [_validate_asset(item) for item in assets["items"]]
|
||||
if len({item["template_id"] for item in asset_rows}) != EXPECTED_ASSETS:
|
||||
raise Rc06ProtocolError("Asset identities are duplicated.")
|
||||
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:
|
||||
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:
|
||||
raise Rc06ProtocolError("Prompt module inventory is incomplete.")
|
||||
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.")
|
||||
if require_sha(modules.get("inventory_sha256"), "Prompt module inventory hash") != sha256_json(module_rows):
|
||||
raise Rc06ProtocolError("Prompt module inventory hash drift.")
|
||||
|
||||
runtime = require_exact(
|
||||
manifest["runtime_prompt_dependencies"], "runtime Prompt dependencies",
|
||||
("publication_item_count", "count", "items", "inventory_sha256"),
|
||||
)
|
||||
runtime_rows = runtime["items"]
|
||||
if (
|
||||
runtime["publication_item_count"] != 74
|
||||
or runtime["count"] != EXPECTED_RUNTIME
|
||||
or not isinstance(runtime_rows, list)
|
||||
or len(runtime_rows) != EXPECTED_RUNTIME
|
||||
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.")
|
||||
for item in runtime_rows:
|
||||
require_exact(item, "runtime Prompt", (
|
||||
"kind", "runtime_key", "version", "content_sha256", "publication_dependency",
|
||||
"publication_module_dependencies",
|
||||
))
|
||||
if item["kind"] not in {"composed_drafting", "new_review_module", "existing_domain_prompt"}:
|
||||
raise Rc06ProtocolError("Runtime Prompt kind is invalid.")
|
||||
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")
|
||||
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"]
|
||||
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"])
|
||||
):
|
||||
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:
|
||||
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
|
||||
}:
|
||||
raise Rc06ProtocolError("Offline manifest side-effect boundary drift.")
|
||||
return manifest
|
||||
|
||||
|
||||
def expected_publication_results(manifest: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
{
|
||||
"kind": "asset", "item_id": item["template_id"],
|
||||
"expected_content_sha256": item["content_sha256"],
|
||||
"online_content_sha256": item["content_sha256"], "status": "applied_and_verified",
|
||||
}
|
||||
for item in manifest["asset_batch"]["items"]
|
||||
] + [
|
||||
{
|
||||
"kind": "prompt_module", "item_id": item["key"],
|
||||
"expected_content_sha256": item["content_sha256"],
|
||||
"online_content_sha256": item["content_sha256"], "status": "applied_and_verified",
|
||||
}
|
||||
for item in manifest["prompt_module_batch"]["items"]
|
||||
]
|
||||
return sorted(rows, key=lambda item: (item["kind"], item["item_id"]))
|
||||
|
||||
|
||||
def expected_runtime_results(manifest: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"kind": item["kind"], "runtime_key": item["runtime_key"], "version": item["version"],
|
||||
"expected_content_sha256": item["content_sha256"],
|
||||
"online_content_sha256": item["content_sha256"], "status": "published_and_resolved",
|
||||
"publication_dependency": item["publication_dependency"],
|
||||
"publication_module_dependencies": item["publication_module_dependencies"],
|
||||
}
|
||||
for item in manifest["runtime_prompt_dependencies"]["items"]
|
||||
]
|
||||
|
||||
|
||||
def publication_idempotency_key(
|
||||
manifest_raw_sha256: str,
|
||||
approval_raw_sha256: str,
|
||||
legal_binding_sha256: str,
|
||||
environment_id: str,
|
||||
tenant_id: str,
|
||||
) -> str:
|
||||
return sha256_json({
|
||||
"operation": APPLY_OPERATION,
|
||||
"manifest_raw_sha256": require_sha(manifest_raw_sha256, "manifest raw hash"),
|
||||
"approval_raw_sha256": require_sha(approval_raw_sha256, "approval raw hash"),
|
||||
"legal_review_binding_sha256": require_sha(legal_binding_sha256, "legal binding hash"),
|
||||
"environment_id": require_text(environment_id, "environment_id", maximum=256),
|
||||
"tenant_id": require_text(tenant_id, "tenant_id", maximum=64),
|
||||
})
|
||||
|
||||
|
||||
def publication_transaction_id(idempotency_key: str) -> str:
|
||||
return "rc06-" + require_sha(idempotency_key, "idempotency key")[:59]
|
||||
|
||||
|
||||
def _validate_approval_keyring(
|
||||
keyring: Any,
|
||||
*,
|
||||
raw_sha256: str,
|
||||
canonical_sha256: str,
|
||||
trust: AuthorityTrust,
|
||||
approved_at: datetime,
|
||||
manifest_raw_sha256: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
row = require_exact(keyring, "approval keyring", (
|
||||
"schema_version", "root_key_id", "epoch", "issued_at", "expires_at",
|
||||
"authorized_batch_manifest_raw_sha256", "keys", "keyring_payload_sha256",
|
||||
"root_signature_base64",
|
||||
))
|
||||
if row["schema_version"] != APPROVAL_KEYRING_SCHEMA:
|
||||
raise Rc06ProtocolError("Approval keyring schema is unsupported.")
|
||||
require_sha(raw_sha256, "approval keyring raw hash")
|
||||
if require_sha(canonical_sha256, "approval keyring canonical hash") != sha256_json(row):
|
||||
raise Rc06ProtocolError("Approval keyring canonical hash drift.")
|
||||
unsigned_fields = (
|
||||
"schema_version", "root_key_id", "epoch", "issued_at", "expires_at",
|
||||
"authorized_batch_manifest_raw_sha256", "keys",
|
||||
)
|
||||
unsigned = {field: row[field] for field in unsigned_fields}
|
||||
if row["keyring_payload_sha256"] != sha256_json(unsigned):
|
||||
raise Rc06ProtocolError("Approval keyring payload hash drift.")
|
||||
if (
|
||||
row["root_key_id"] != trust.approval_root_key_id
|
||||
or row["epoch"] != trust.approval_keyring_epoch
|
||||
or row["authorized_batch_manifest_raw_sha256"] != manifest_raw_sha256
|
||||
):
|
||||
raise Rc06ProtocolError("Approval keyring trust identity drift.")
|
||||
issued_at = parse_time(row["issued_at"], "approval keyring issued_at")
|
||||
expires_at = parse_time(row["expires_at"], "approval keyring expires_at")
|
||||
if not issued_at <= approved_at < expires_at:
|
||||
raise Rc06ProtocolError("Approval keyring is not valid at approval time.")
|
||||
try:
|
||||
signature = base64.b64decode(require_text(row["root_signature_base64"], "root signature"), validate=True)
|
||||
trust.approval_root_public_key.verify(signature, APPROVAL_KEYRING_SIGNATURE_DOMAIN + canonical_json(unsigned))
|
||||
except (ValueError, InvalidSignature) as exc:
|
||||
raise Rc06ProtocolError("Approval keyring root signature is invalid.") from exc
|
||||
keys = row["keys"]
|
||||
if not isinstance(keys, list) or not keys:
|
||||
raise Rc06ProtocolError("Approval keyring contains no keys.")
|
||||
validated: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for value in keys:
|
||||
key = require_exact(value, "approval key", (
|
||||
"key_id", "algorithm", "public_key_pem", "approver_id", "name",
|
||||
"not_before", "not_after", "revoked",
|
||||
))
|
||||
key_id = require_text(key["key_id"], "approval key id", maximum=128)
|
||||
if key_id in seen or key["algorithm"] != "Ed25519" or key["revoked"] is not False:
|
||||
raise Rc06ProtocolError("Approval key is duplicated, revoked, or unsupported.")
|
||||
if {"approver_id": key["approver_id"], "name": key["name"]} != SOLE_REVIEWER:
|
||||
raise Rc06ProtocolError("Approval key does not belong to the sole release reviewer.")
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(require_text(
|
||||
key["public_key_pem"], "approval public key", maximum=16384
|
||||
).encode("ascii"))
|
||||
except (UnicodeError, ValueError) as exc:
|
||||
raise Rc06ProtocolError("Approval public key is invalid.") from exc
|
||||
if not isinstance(public_key, Ed25519PublicKey) or public_key_raw(public_key) == public_key_raw(
|
||||
trust.approval_root_public_key
|
||||
):
|
||||
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.")
|
||||
seen.add(key_id)
|
||||
validated.append({**key, "public_key": public_key, "not_before_parsed": not_before, "not_after_parsed": not_after})
|
||||
return validated
|
||||
|
||||
|
||||
def _approval_summary(manifest: Mapping[str, Any], manifest_raw_sha256: str, reviewer: Mapping[str, Any]) -> dict[str, Any]:
|
||||
candidate = manifest["candidate"]
|
||||
terminal = candidate["rc05_terminal_evidence"]
|
||||
identity = {field: reviewer[field] for field in ("approver_id", "name", "role", "organization", "authorization_scope")}
|
||||
return {
|
||||
"schema_version": "yuqei.rc06-single-reviewer-summary.v2",
|
||||
"decision_scope": "approve_exact_rc06_publication_batch",
|
||||
"reviewer": identity,
|
||||
"candidate": {
|
||||
"commit": candidate["commit"], "tag": candidate["tag"],
|
||||
"source_manifest_sha256": candidate["source_manifest_sha256"],
|
||||
"source_manifest_payload_sha256": candidate["source_manifest_payload_sha256"],
|
||||
"image_identities_sha256": candidate["image_identities_sha256"],
|
||||
"image_identity_set_sha256": candidate["image_identity_set_sha256"],
|
||||
"rc05_plan_sha256": candidate["rc05_plan_sha256"],
|
||||
"rc05_summary_raw_sha256": terminal["summary_raw_sha256"],
|
||||
"rc05_ledger_identity_raw_sha256": terminal["ledger_identity_raw_sha256"],
|
||||
"rc05_campaign_identity_raw_sha256": terminal["campaign_identity_raw_sha256"],
|
||||
"rc05_complete_real_green": terminal["complete_real_green"],
|
||||
},
|
||||
"batch": {
|
||||
"asset_count": manifest["asset_batch"]["count"],
|
||||
"asset_inventory_sha256": manifest["asset_batch"]["inventory_sha256"],
|
||||
"prompt_module_count": manifest["prompt_module_batch"]["count"],
|
||||
"prompt_module_inventory_sha256": manifest["prompt_module_batch"]["inventory_sha256"],
|
||||
"golden_disposition_count": manifest["golden_dispositions"]["count"],
|
||||
"golden_disposition_counts_by_status": manifest["golden_dispositions"]["counts_by_status"],
|
||||
"golden_disposition_inventory_sha256": manifest["golden_dispositions"]["inventory_sha256"],
|
||||
},
|
||||
"manifest_raw_sha256": manifest_raw_sha256,
|
||||
"manifest_payload_sha256": manifest["manifest_payload_sha256"],
|
||||
"approval_validity_max_hours": 168,
|
||||
"remaining_controls_after_approval": [
|
||||
"system_generated_item_apply_receipts_and_online_snapshots",
|
||||
"runtime_executable_prompt_snapshots_1_drafting_plus_13_review",
|
||||
],
|
||||
"claims_not_made": [
|
||||
"This approval does not itself publish any asset or Prompt module.",
|
||||
"This approval does not authorize public production traffic cutover.",
|
||||
"System apply evidence and executable runtime Prompt snapshots remain separate gates.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _validate_approval(
|
||||
manifest: Mapping[str, Any], request: Mapping[str, Any], trust: AuthorityTrust, now: datetime
|
||||
) -> dict[str, Any]:
|
||||
receipt = require_exact(request["approval_receipt"], "approval receipt", (
|
||||
"schema_version", "approval_id", "payload", "signature",
|
||||
))
|
||||
if receipt["schema_version"] != APPROVAL_SCHEMA:
|
||||
raise Rc06ProtocolError("Approval receipt schema is unsupported.")
|
||||
payload = require_exact(receipt["payload"], "approval payload", (
|
||||
"schema_version", "batch_manifest_raw_sha256", "batch_manifest_payload_sha256",
|
||||
"decision", "approved_at", "expires_at", "key_id", "approval_keyring_epoch",
|
||||
"approval_keyring_payload_sha256", "approver", "asset_payload_sha256s",
|
||||
"prompt_module_payload_sha256s", "golden_disposition_inventory_sha256",
|
||||
"review_summary_sha256",
|
||||
))
|
||||
approved_at = parse_time(payload["approved_at"], "approval approved_at")
|
||||
expires_at = parse_time(payload["expires_at"], "approval expires_at")
|
||||
if (
|
||||
payload["schema_version"] != APPROVAL_PAYLOAD_SCHEMA
|
||||
or payload["decision"] != "approved"
|
||||
or payload["batch_manifest_raw_sha256"] != request["batch_manifest_raw_sha256"]
|
||||
or payload["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]
|
||||
or expires_at <= approved_at
|
||||
or expires_at - approved_at > MAX_APPROVAL_WINDOW
|
||||
or not approved_at <= now < expires_at
|
||||
):
|
||||
raise Rc06ProtocolError("Approval receipt identity or validity window drift.")
|
||||
reviewer = require_exact(payload["approver"], "approval approver", (
|
||||
"approver_id", "name", "role", "organization", "authorization_scope", "human_confirmed",
|
||||
))
|
||||
if (
|
||||
{"approver_id": reviewer["approver_id"], "name": reviewer["name"]} != SOLE_REVIEWER
|
||||
or reviewer["human_confirmed"] is not True
|
||||
or any(not isinstance(reviewer[field], str) or not reviewer[field].strip() for field in (
|
||||
"role", "organization", "authorization_scope"
|
||||
))
|
||||
):
|
||||
raise Rc06ProtocolError("Approval does not identify the confirmed sole release reviewer.")
|
||||
keyring = request["approval_keyring"]
|
||||
trusted_keys = _validate_approval_keyring(
|
||||
keyring,
|
||||
raw_sha256=request["approval_keyring_raw_sha256"],
|
||||
canonical_sha256=request["approval_keyring_canonical_sha256"],
|
||||
trust=trust,
|
||||
approved_at=approved_at,
|
||||
manifest_raw_sha256=request["batch_manifest_raw_sha256"],
|
||||
)
|
||||
if (
|
||||
payload["approval_keyring_epoch"] != keyring["epoch"]
|
||||
or payload["approval_keyring_payload_sha256"] != keyring["keyring_payload_sha256"]
|
||||
or payload["asset_payload_sha256s"] != {
|
||||
item["template_id"]: item["asset_payload_sha256"] for item in manifest["asset_batch"]["items"]
|
||||
}
|
||||
or payload["prompt_module_payload_sha256s"] != {
|
||||
item["key"]: item["module_payload_sha256"] for item in manifest["prompt_module_batch"]["items"]
|
||||
}
|
||||
or payload["golden_disposition_inventory_sha256"] != manifest["golden_dispositions"]["inventory_sha256"]
|
||||
or payload["review_summary_sha256"] != sha256_json(_approval_summary(
|
||||
manifest, request["batch_manifest_raw_sha256"], reviewer
|
||||
))
|
||||
):
|
||||
raise Rc06ProtocolError("Approval receipt material drift.")
|
||||
matches = [key for key in trusted_keys if key["key_id"] == payload["key_id"]]
|
||||
if len(matches) != 1 or expires_at > matches[0]["not_after_parsed"]:
|
||||
raise Rc06ProtocolError("Approval signing key is not uniquely valid for the receipt window.")
|
||||
try:
|
||||
signature = base64.b64decode(require_text(receipt["signature"], "approval signature"), validate=True)
|
||||
matches[0]["public_key"].verify(signature, APPROVAL_SIGNATURE_DOMAIN + canonical_json(payload))
|
||||
except (ValueError, InvalidSignature) as exc:
|
||||
raise Rc06ProtocolError("Approval signature is invalid.") from exc
|
||||
if receipt["approval_id"] != "rc06-batch-approval-" + sha256_bytes(canonical_json(payload) + signature)[:24]:
|
||||
raise Rc06ProtocolError("Approval receipt id drift.")
|
||||
return receipt
|
||||
|
||||
|
||||
def _validate_legal(
|
||||
manifest: Mapping[str, Any], request: Mapping[str, Any], trust: AuthorityTrust, now: datetime
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
keyring = require_exact(request["legal_source_review_keyring"], "legal review keyring", (
|
||||
"schema_version", "keys",
|
||||
))
|
||||
if keyring["schema_version"] != LEGAL_KEYRING_SCHEMA:
|
||||
raise Rc06ProtocolError("Legal review keyring schema is unsupported.")
|
||||
raw_pin = require_sha(request["legal_source_review_keyring_raw_sha256"], "legal keyring hash")
|
||||
if raw_pin != trust.legal_keyring_sha256 or sha256_json(keyring) != raw_pin:
|
||||
raise Rc06ProtocolError("Legal review keyring does not match the server pin.")
|
||||
legal_request = require_exact(request["legal_source_review_request"], "legal review request", (
|
||||
"schema_version", "status", "candidate_commit", "created_at", "scope", "source_files",
|
||||
"occurrences", "guardrails", "request_sha256",
|
||||
))
|
||||
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.")
|
||||
scope = legal_request["scope"]
|
||||
occurrences = legal_request["occurrences"]
|
||||
if (
|
||||
legal_request["schema_version"] != LEGAL_REQUEST_SCHEMA
|
||||
or legal_request["status"] != "pending_qualified_human_review"
|
||||
or legal_request["candidate_commit"] != manifest["candidate"]["commit"]
|
||||
or not isinstance(scope, dict)
|
||||
or scope.get("scene_count") != 30
|
||||
or scope.get("reference_occurrence_count") != 43
|
||||
or not isinstance(occurrences, list)
|
||||
or len(occurrences) != 43
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review request does not cover exact 30 scenes and 43 occurrences.")
|
||||
occurrence_by_id: dict[str, dict[str, Any]] = {}
|
||||
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")
|
||||
if occurrence_id in occurrence_by_id:
|
||||
raise Rc06ProtocolError("Legal review occurrence is duplicated.")
|
||||
occurrence_by_id[occurrence_id] = occurrence
|
||||
if list(occurrence_by_id) != sorted(occurrence_by_id):
|
||||
raise Rc06ProtocolError("Legal review occurrences are not canonically ordered.")
|
||||
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.")
|
||||
if decision.get("request_sha256") != request_sha or decision.get("candidate_commit") != manifest["candidate"]["commit"]:
|
||||
raise Rc06ProtocolError("Legal review decision binding drift.")
|
||||
reviewed_at = parse_time(decision.get("reviewed_at"), "legal reviewed_at")
|
||||
expires_at = parse_time(decision.get("expires_at"), "legal expires_at")
|
||||
if not reviewed_at <= now <= expires_at or expires_at - reviewed_at > MAX_LEGAL_WINDOW:
|
||||
raise Rc06ProtocolError("Legal review decision is outside its validity window.")
|
||||
reviewer = decision.get("reviewer")
|
||||
if not isinstance(reviewer, dict) or any(not isinstance(reviewer.get(field), str) or not reviewer[field].strip() for field in (
|
||||
"name", "organization", "qualification", "credential_reference"
|
||||
)):
|
||||
raise Rc06ProtocolError("Legal reviewer identity is incomplete.")
|
||||
items = decision.get("items")
|
||||
if not isinstance(items, list) or len(items) != 43:
|
||||
raise Rc06ProtocolError("Legal review decision must contain exactly 43 items.")
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
raise Rc06ProtocolError("Legal review item is invalid.")
|
||||
occurrence_id = item.get("occurrence_id")
|
||||
if occurrence_id in seen or occurrence_id not in occurrence_by_id:
|
||||
raise Rc06ProtocolError("Legal review item identity is unexpected or duplicated.")
|
||||
expected = occurrence_by_id[occurrence_id]
|
||||
if (
|
||||
item.get("source_record_sha256") != expected["source_record_sha256"]
|
||||
or item.get("current_effectiveness_verified") is not True
|
||||
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")
|
||||
require_text(item.get("authoritative_status_url"), "legal authoritative URL", maximum=4096)
|
||||
if len(require_text(item.get("rationale"), "legal rationale", maximum=4096)) < 20:
|
||||
raise Rc06ProtocolError("Legal review rationale is insufficient.")
|
||||
seen.add(occurrence_id)
|
||||
signature = decision.get("signature")
|
||||
if not isinstance(signature, dict) or signature.get("algorithm") != "Ed25519":
|
||||
raise Rc06ProtocolError("Legal review signature is missing.")
|
||||
key_id = require_text(signature.get("key_id"), "legal signature key id", maximum=128)
|
||||
matches = [key for key in keyring["keys"] if isinstance(key, dict) and key.get("key_id") == key_id]
|
||||
if len(matches) != 1:
|
||||
raise Rc06ProtocolError("Legal review key is not uniquely trusted.")
|
||||
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"
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review key is outside its validity window.")
|
||||
for decision_field, key_field in (
|
||||
("name", "reviewer_name"), ("organization", "reviewer_organization"),
|
||||
("qualification", "reviewer_qualification"), ("credential_reference", "credential_reference"),
|
||||
):
|
||||
if reviewer[decision_field] != key.get(key_field):
|
||||
raise Rc06ProtocolError("Legal reviewer identity does not match the trusted key.")
|
||||
try:
|
||||
public_raw = base64.b64decode(require_text(key.get("public_key_raw_base64"), "legal public key"), validate=True)
|
||||
signature_raw = base64.b64decode(require_text(signature.get("value_base64"), "legal signature"), validate=True)
|
||||
Ed25519PublicKey.from_public_bytes(public_raw).verify(
|
||||
signature_raw,
|
||||
LEGAL_SIGNATURE_DOMAIN + canonical_json({field: child for field, child in decision.items() if field != "signature"}),
|
||||
)
|
||||
except (ValueError, InvalidSignature) as exc:
|
||||
raise Rc06ProtocolError("Legal review signature verification failed.") from exc
|
||||
summary = {
|
||||
"status": "verified_qualified_human_review",
|
||||
"candidate_commit": legal_request["candidate_commit"],
|
||||
"request_sha256": request_sha,
|
||||
"reviewer_name": reviewer["name"],
|
||||
"reviewed_occurrence_count": 43,
|
||||
"all_current_effectiveness_verified": True,
|
||||
"all_transaction_applicability_verified": True,
|
||||
"expires_at": decision["expires_at"],
|
||||
"authorizes_publication_by_itself": False,
|
||||
}
|
||||
binding = sha256_json({
|
||||
"request_sha256": request_sha,
|
||||
"decision_sha256": sha256_json(decision),
|
||||
"keyring_raw_sha256": raw_pin,
|
||||
})
|
||||
return summary, binding
|
||||
|
||||
|
||||
def _validate_challenge(
|
||||
manifest: Mapping[str, Any], request: Mapping[str, Any], trust: AuthorityTrust,
|
||||
legal_binding: str, legal_expires_at: str, now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
challenge = require_exact(request["authority_challenge"], "apply challenge", (
|
||||
"schema_version", "challenge_id", "payload", "signature",
|
||||
))
|
||||
if challenge["schema_version"] != CHALLENGE_SCHEMA:
|
||||
raise Rc06ProtocolError("Apply challenge schema is unsupported.")
|
||||
payload = require_exact(challenge["payload"], "apply challenge payload", (
|
||||
"schema_version", "nonce_base64", "candidate_commit", "candidate_tag",
|
||||
"batch_manifest_raw_sha256", "batch_manifest_payload_sha256", "approval_receipt_sha256",
|
||||
"legal_source_review_binding_sha256", "legal_source_review_expires_at", "operation",
|
||||
"environment_id", "tenant_id", "idempotency_key", "transaction_id", "actor",
|
||||
"issued_at", "expires_at",
|
||||
))
|
||||
expected_idempotency = publication_idempotency_key(
|
||||
request["batch_manifest_raw_sha256"], request["approval_receipt_sha256"], legal_binding,
|
||||
trust.environment_id, trust.tenant_id,
|
||||
)
|
||||
if (
|
||||
payload["schema_version"] != CHALLENGE_PAYLOAD_SCHEMA
|
||||
or payload["candidate_commit"] != manifest["candidate"]["commit"]
|
||||
or payload["candidate_tag"] != manifest["candidate"]["tag"]
|
||||
or payload["batch_manifest_raw_sha256"] != request["batch_manifest_raw_sha256"]
|
||||
or payload["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]
|
||||
or payload["approval_receipt_sha256"] != request["approval_receipt_sha256"]
|
||||
or payload["legal_source_review_binding_sha256"] != legal_binding
|
||||
or payload["legal_source_review_expires_at"] != legal_expires_at
|
||||
or payload["operation"] != APPLY_OPERATION
|
||||
or payload["environment_id"] != trust.environment_id
|
||||
or payload["tenant_id"] != trust.tenant_id
|
||||
or payload["idempotency_key"] != expected_idempotency
|
||||
or payload["transaction_id"] != publication_transaction_id(expected_idempotency)
|
||||
or payload["actor"] != {"type": "system", "component": TRUSTED_COMPONENT}
|
||||
):
|
||||
raise Rc06ProtocolError("Apply challenge binding drift.")
|
||||
issued_at = parse_time(payload["issued_at"], "challenge issued_at")
|
||||
expires_at = parse_time(payload["expires_at"], "challenge expires_at")
|
||||
if (
|
||||
expires_at <= issued_at
|
||||
or expires_at - issued_at > MAX_CHALLENGE_WINDOW
|
||||
or expires_at > parse_time(legal_expires_at, "legal expires_at")
|
||||
or not issued_at <= now < expires_at
|
||||
):
|
||||
raise Rc06ProtocolError("Apply challenge is outside its authority window.")
|
||||
try:
|
||||
nonce = base64.b64decode(require_text(payload["nonce_base64"], "challenge nonce", maximum=128), validate=True)
|
||||
signature = base64.b64decode(require_text(challenge["signature"], "challenge signature", maximum=1024), validate=True)
|
||||
if len(nonce) != 32:
|
||||
raise ValueError("nonce length")
|
||||
trust.apply_service_public_key.verify(signature, CHALLENGE_SIGNATURE_DOMAIN + canonical_json(payload))
|
||||
except (ValueError, InvalidSignature) as exc:
|
||||
raise Rc06ProtocolError("Apply challenge signature verification failed.") from exc
|
||||
if challenge["challenge_id"] != "rc06-apply-challenge-" + sha256_bytes(canonical_json(payload) + signature)[:24]:
|
||||
raise Rc06ProtocolError("Apply challenge id drift.")
|
||||
return challenge
|
||||
|
||||
|
||||
def validate_authority(request: Any, trust: AuthorityTrust, *, now: datetime) -> AuthorizedMaterial:
|
||||
fields = (
|
||||
"schema_version", "idempotency_key", "transaction_id", "environment_id", "tenant_id",
|
||||
"transaction_mode", "cross_database_atomicity", "batch_manifest_raw_sha256",
|
||||
"batch_manifest_payload_sha256", "approval_receipt_sha256", "approval_keyring",
|
||||
"approval_keyring_raw_sha256", "approval_keyring_canonical_sha256",
|
||||
"legal_source_review_binding_sha256", "legal_source_review_expires_at", "authority_challenge",
|
||||
"manifest", "approval_receipt", "legal_source_review_request", "legal_source_review_decision",
|
||||
"legal_source_review_keyring", "legal_source_review_keyring_raw_sha256",
|
||||
"expected_publication_item_results", "expected_publication_item_count",
|
||||
"expected_runtime_snapshot_dependencies", "expected_runtime_snapshot_dependency_count",
|
||||
)
|
||||
row = require_exact(request, "online apply request", fields)
|
||||
if (
|
||||
row["schema_version"] != APPLY_REQUEST_SCHEMA
|
||||
or row["environment_id"] != trust.environment_id
|
||||
or row["tenant_id"] != trust.tenant_id
|
||||
or row["transaction_mode"] != TRANSACTION_MODE
|
||||
or row["cross_database_atomicity"] is not False
|
||||
):
|
||||
raise Rc06ProtocolError("Online apply request trust boundary drift.")
|
||||
manifest = validate_manifest(row["manifest"])
|
||||
if (
|
||||
row["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]
|
||||
or manifest["candidate"]["commit"] != trust.frozen_candidate_commit
|
||||
):
|
||||
raise Rc06ProtocolError("Online apply candidate binding drift.")
|
||||
image_by_role = {item["role"]: item["manifest_digest"] for item in manifest["candidate"]["images"]}
|
||||
if (
|
||||
image_by_role["contract-api"] != trust.contract_release_image_digest
|
||||
or image_by_role["ai-platform-api"] != trust.ai_release_image_digest
|
||||
):
|
||||
raise Rc06ProtocolError("Participant deployment image does not match the approved candidate.")
|
||||
approval = _validate_approval(manifest, row, trust, now)
|
||||
legal_summary, legal_binding = _validate_legal(manifest, row, trust, now)
|
||||
if (
|
||||
row["legal_source_review_binding_sha256"] != legal_binding
|
||||
or row["legal_source_review_expires_at"] != legal_summary["expires_at"]
|
||||
):
|
||||
raise Rc06ProtocolError("Legal review binding drift.")
|
||||
challenge = _validate_challenge(
|
||||
manifest, row, trust, legal_binding, legal_summary["expires_at"], now
|
||||
)
|
||||
expected_items = expected_publication_results(manifest)
|
||||
expected_runtime = expected_runtime_results(manifest)
|
||||
expected_key = publication_idempotency_key(
|
||||
row["batch_manifest_raw_sha256"], row["approval_receipt_sha256"], legal_binding,
|
||||
trust.environment_id, trust.tenant_id,
|
||||
)
|
||||
if (
|
||||
row["idempotency_key"] != expected_key
|
||||
or row["transaction_id"] != publication_transaction_id(expected_key)
|
||||
or row["expected_publication_item_count"] != 74
|
||||
or row["expected_publication_item_results"] != expected_items
|
||||
or row["expected_runtime_snapshot_dependency_count"] != 14
|
||||
or row["expected_runtime_snapshot_dependencies"] != expected_runtime
|
||||
):
|
||||
raise Rc06ProtocolError("Online apply expected result or transaction identity drift.")
|
||||
return AuthorizedMaterial(
|
||||
request=dict(row), manifest=manifest, approval=approval,
|
||||
approval_keyring=dict(row["approval_keyring"]), legal_summary=legal_summary,
|
||||
legal_binding_sha256=legal_binding, challenge=challenge,
|
||||
expected_publication_results=expected_items, expected_runtime_results=expected_runtime,
|
||||
contract_image_digest=image_by_role["contract-api"],
|
||||
ai_image_digest=image_by_role["ai-platform-api"],
|
||||
)
|
||||
@@ -62,6 +62,8 @@ CORE_TABLES = (
|
||||
"template_published_snapshots",
|
||||
"rc06_publication_sagas",
|
||||
"rc06_template_publication_items",
|
||||
"rc06_online_publication_transactions",
|
||||
"rc06_apply_challenge_uses",
|
||||
"template_edit_drafts",
|
||||
"legal_asset_lifecycle_events",
|
||||
"legal_asset_published_snapshots",
|
||||
@@ -193,6 +195,7 @@ RC06_COLUMNS_BY_TABLE = {
|
||||
"ai_candidate_commit",
|
||||
"ai_release_image_digest",
|
||||
"compensation_capability",
|
||||
"ai_compensation_status_json",
|
||||
"request_json",
|
||||
"state",
|
||||
"contract_committed_at",
|
||||
@@ -209,6 +212,49 @@ RC06_COLUMNS_BY_TABLE = {
|
||||
"online_content_sha256",
|
||||
"state",
|
||||
),
|
||||
"rc06_online_publication_transactions": (
|
||||
"transaction_id",
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
"request_sha256",
|
||||
"environment_id",
|
||||
"candidate_commit",
|
||||
"contract_release_image_digest",
|
||||
"ai_release_image_digest",
|
||||
"challenge_id",
|
||||
"challenge_sha256",
|
||||
"state",
|
||||
"request_json",
|
||||
"prepare_request_json",
|
||||
"participant_evidence_json",
|
||||
"online_primary_kms_request_id",
|
||||
"online_attestation_kms_request_id",
|
||||
"online_receipt_json",
|
||||
"runtime_primary_kms_request_id",
|
||||
"runtime_attestation_kms_request_id",
|
||||
"runtime_receipt_json",
|
||||
"rollback_response_json",
|
||||
"rollback_response_sha256",
|
||||
"apply_receipt_json",
|
||||
"failure_code",
|
||||
"failure_at",
|
||||
"failure_evidence_sha256",
|
||||
"contract_committed_at",
|
||||
"pending_countersign_at",
|
||||
"finalized_at",
|
||||
),
|
||||
"rc06_apply_challenge_uses": (
|
||||
"challenge_id",
|
||||
"nonce_sha256",
|
||||
"challenge_sha256",
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
"transaction_id",
|
||||
"request_sha256",
|
||||
"outcome",
|
||||
"reserved_at",
|
||||
"consumed_at",
|
||||
),
|
||||
}
|
||||
RC06_CONSTRAINTS_BY_TABLE = {
|
||||
"rc06_publication_sagas": (
|
||||
@@ -219,6 +265,15 @@ RC06_CONSTRAINTS_BY_TABLE = {
|
||||
"ux_rc06_template_publication_items_transaction_template",
|
||||
"ck_rc06_template_item_state",
|
||||
),
|
||||
"rc06_online_publication_transactions": (
|
||||
"ux_rc06_online_publication_idempotency",
|
||||
"ck_rc06_online_publication_state",
|
||||
),
|
||||
"rc06_apply_challenge_uses": (
|
||||
"ux_rc06_apply_challenge_nonce",
|
||||
"ux_rc06_apply_challenge_transaction",
|
||||
"ck_rc06_apply_challenge_outcome",
|
||||
),
|
||||
}
|
||||
PLAYBOOK_OPERATIONS_COLUMNS_BY_TABLE = {
|
||||
"product_experience_events": (
|
||||
|
||||
@@ -125,7 +125,7 @@ def test_fresh_alembic_upgrade_then_old_head_round_trip(tmp_path, monkeypatch):
|
||||
with engine.connect() as connection:
|
||||
assert connection.exec_driver_sql(
|
||||
"SELECT version_num FROM contract_api_alembic_version"
|
||||
).scalar_one() == "20261006_0081"
|
||||
).scalar_one() == "20261006_0082"
|
||||
engine.dispose()
|
||||
|
||||
command.downgrade(config, "20261006_0080")
|
||||
@@ -138,3 +138,65 @@ def test_fresh_alembic_upgrade_then_old_head_round_trip(tmp_path, monkeypatch):
|
||||
engine.dispose()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_rc06_facade_migration_round_trip_and_refuses_evidence_erasure(tmp_path):
|
||||
engine = sa.create_engine(f"sqlite+pysqlite:///{(tmp_path / 'contract-facade.sqlite3').as_posix()}")
|
||||
hardening = _load("20261006_0081_harden_rc06_publication_saga.py")
|
||||
facade = _load("20261006_0082_add_rc06_publication_facade.py")
|
||||
with engine.begin() as connection:
|
||||
_old_head(connection)
|
||||
_run(connection, hardening, "upgrade")
|
||||
_run(connection, facade, "upgrade")
|
||||
inspector = sa.inspect(connection)
|
||||
assert {
|
||||
"rc06_online_publication_transactions",
|
||||
"rc06_apply_challenge_uses",
|
||||
} <= set(inspector.get_table_names())
|
||||
assert "ai_compensation_status_json" in {
|
||||
item["name"] for item in inspector.get_columns("rc06_publication_sagas")
|
||||
}
|
||||
transaction_columns = {
|
||||
item["name"] for item in inspector.get_columns("rc06_online_publication_transactions")
|
||||
}
|
||||
assert {
|
||||
"online_primary_kms_request_id",
|
||||
"online_attestation_kms_request_id",
|
||||
"runtime_primary_kms_request_id",
|
||||
"runtime_attestation_kms_request_id",
|
||||
"apply_receipt_json",
|
||||
"rollback_response_json",
|
||||
} <= transaction_columns
|
||||
|
||||
now = datetime.now(UTC)
|
||||
connection.execute(sa.text(
|
||||
"INSERT INTO rc06_online_publication_transactions ("
|
||||
"transaction_id,tenant_id,idempotency_key,request_sha256,environment_id,candidate_commit,"
|
||||
"contract_release_image_digest,ai_release_image_digest,batch_manifest_raw_sha256,"
|
||||
"batch_manifest_payload_sha256,approval_receipt_sha256,legal_source_review_binding_sha256,"
|
||||
"legal_source_review_expires_at,challenge_id,challenge_sha256,state,request_json,"
|
||||
"prepare_request_json,created_at,updated_at) VALUES ("
|
||||
":transaction_id,'tenant',:idempotency,:request_sha,'environment',:commit,"
|
||||
":contract_image,:ai_image,:manifest_raw,:manifest_payload,:approval,:legal,"
|
||||
":expires_at,:challenge_id,:challenge_sha,'authorized','{}','{}',:created_at,:updated_at)"
|
||||
), {
|
||||
"transaction_id": "rc06-migration-facade-0001",
|
||||
"idempotency": "1" * 64,
|
||||
"request_sha": "2" * 64,
|
||||
"commit": "3" * 40,
|
||||
"contract_image": "sha256:" + "4" * 64,
|
||||
"ai_image": "sha256:" + "5" * 64,
|
||||
"manifest_raw": "6" * 64,
|
||||
"manifest_payload": "7" * 64,
|
||||
"approval": "8" * 64,
|
||||
"legal": "9" * 64,
|
||||
"expires_at": now,
|
||||
"challenge_id": "rc06-apply-challenge-migration",
|
||||
"challenge_sha": "a" * 64,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
with pytest.raises(RuntimeError, match="refuses to erase"):
|
||||
_run(connection, facade, "downgrade")
|
||||
assert "rc06_online_publication_transactions" in sa.inspect(connection).get_table_names()
|
||||
engine.dispose()
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from yuqei_contract_api.config import ContractApiSettings
|
||||
from yuqei_contract_api.persistence.models import Rc06OnlinePublicationTransactionModel
|
||||
from yuqei_contract_api.rc06_publication import Rc06SagaStatus
|
||||
from yuqei_contract_api.rc06_publication_facade import (
|
||||
Rc06FacadeConflict,
|
||||
Rc06FacadeError,
|
||||
Rc06FacadeHeaders,
|
||||
Rc06PublicationFacadeService,
|
||||
)
|
||||
from yuqei_contract_api.rc06_publication_protocol import (
|
||||
APPLY_RECEIPT_PAYLOAD_SCHEMA,
|
||||
APPLY_RECEIPT_SCHEMA,
|
||||
APPLY_RECEIPT_SIGNATURE_DOMAIN,
|
||||
APPLY_STATUS_SCHEMA,
|
||||
FINALIZE_REQUEST_SCHEMA,
|
||||
ROLLBACK_REQUEST_SCHEMA,
|
||||
AuthorizedMaterial,
|
||||
canonical_json,
|
||||
iso_time,
|
||||
publication_transaction_id,
|
||||
sha256_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
from yuqei_contract_api.sqlalchemy_contract_repository import SqlAlchemyContractRepository
|
||||
|
||||
|
||||
CANDIDATE = "a" * 40
|
||||
CONTRACT_IMAGE = "sha256:" + "b" * 64
|
||||
AI_IMAGE = "sha256:" + "c" * 64
|
||||
TENANT = "rc06-production-tenant"
|
||||
ENVIRONMENT = "production-cn-1"
|
||||
|
||||
|
||||
class FakeAuthority:
|
||||
def __init__(self, material: AuthorizedMaterial):
|
||||
self.material = material
|
||||
self.checked_at: list[datetime] = []
|
||||
|
||||
def authorize(self, request, *, now):
|
||||
assert request == self.material.request
|
||||
self.checked_at.append(now)
|
||||
return self.material
|
||||
|
||||
|
||||
class FakeSigner:
|
||||
def __init__(self, *, fail_once_at: int | None = None):
|
||||
self.calls: list[tuple[bytes, dict, bool]] = []
|
||||
self.fail_once_at = fail_once_at
|
||||
self.failed = False
|
||||
|
||||
def sign(self, *, domain, payload, include_request_id):
|
||||
self.calls.append((domain, dict(payload), include_request_id))
|
||||
if len(self.calls) == self.fail_once_at and not self.failed:
|
||||
self.failed = True
|
||||
raise Rc06FacadeError("injected KMS response loss")
|
||||
request_id = f"kms-request-{len(self.calls)}"
|
||||
block = {
|
||||
"provider": "test-kms",
|
||||
"key_reference": "rc06-online",
|
||||
"key_version": "v1",
|
||||
"algorithm": "RSA_PSS_SHA_256",
|
||||
"value_base64": base64.b64encode(f"signature-{len(self.calls)}".encode()).decode(),
|
||||
}
|
||||
if include_request_id:
|
||||
block["request_id"] = request_id
|
||||
return block, request_id
|
||||
|
||||
|
||||
class FakeCoordinator:
|
||||
def __init__(self, material: AuthorizedMaterial, *, fail_commit: bool = False):
|
||||
self.material = material
|
||||
self.state: Rc06SagaStatus | None = None
|
||||
self.fail_commit = fail_commit
|
||||
self.compensate_calls = 0
|
||||
|
||||
def _status(self, state: str) -> Rc06SagaStatus:
|
||||
now = datetime.now(UTC)
|
||||
committed = state == "committed"
|
||||
return Rc06SagaStatus(
|
||||
transaction_id=self.material.request["transaction_id"],
|
||||
state=state,
|
||||
candidate_commit=CANDIDATE,
|
||||
contract_candidate_commit=CANDIDATE,
|
||||
contract_release_image_digest=CONTRACT_IMAGE,
|
||||
ai_candidate_commit=CANDIDATE,
|
||||
ai_release_image_digest=AI_IMAGE,
|
||||
candidate_tag="rc06-test",
|
||||
batch_manifest_raw_sha256=self.material.request["batch_manifest_raw_sha256"],
|
||||
batch_manifest_payload_sha256=self.material.request["batch_manifest_payload_sha256"],
|
||||
approval_receipt_sha256=self.material.request["approval_receipt_sha256"],
|
||||
item_results=self.material.expected_publication_results if committed else [],
|
||||
runtime_snapshots=[
|
||||
{
|
||||
"name": item["runtime_key"],
|
||||
"version": item["version"],
|
||||
"online_content_sha256": item["online_content_sha256"],
|
||||
"status": "published",
|
||||
"enabled": True,
|
||||
"verified": True,
|
||||
}
|
||||
for item in self.material.expected_runtime_results
|
||||
] if committed else [],
|
||||
write_count=74 if committed else 0,
|
||||
runtime_snapshot_count=14 if committed else 0,
|
||||
no_go=not committed,
|
||||
cross_database_atomicity=False,
|
||||
ai_committed_at=now if committed else None,
|
||||
contract_committed_at=now if committed else None,
|
||||
committed_at=now if committed else None,
|
||||
)
|
||||
|
||||
def prepare(self, payload, *, tenant_id):
|
||||
assert tenant_id == TENANT
|
||||
assert payload.contract_release_image_digest == CONTRACT_IMAGE
|
||||
self.state = self._status("prepared")
|
||||
return self.state
|
||||
|
||||
def commit(self, transaction_id, *, tenant_id=None):
|
||||
assert tenant_id == TENANT
|
||||
if self.fail_commit:
|
||||
raise Rc06FacadeError("injected pre-Contract commit failure")
|
||||
self.state = self._status("committed")
|
||||
return self.state
|
||||
|
||||
def status(self, transaction_id, *, tenant_id=None):
|
||||
if self.state is None:
|
||||
raise KeyError(transaction_id)
|
||||
return self.state
|
||||
|
||||
def compensate(self, transaction_id, *, tenant_id=None):
|
||||
self.compensate_calls += 1
|
||||
self.state = self._status("compensated")
|
||||
self.state.compensated_from_state = "prepared"
|
||||
self.state.compensated_at = datetime.now(UTC)
|
||||
self.state.compensation_evidence_sha256 = "d" * 64
|
||||
return self.state
|
||||
|
||||
|
||||
def _material(now: datetime | None = None):
|
||||
now = now or datetime.now(UTC)
|
||||
expires = now + timedelta(hours=1)
|
||||
idempotency = "1" * 64
|
||||
transaction_id = publication_transaction_id(idempotency)
|
||||
assets = [{"template_id": f"asset-{index:02d}"} for index in range(60)]
|
||||
modules = [{"key": f"module-{index:02d}"} for index in range(14)]
|
||||
runtime = [
|
||||
{
|
||||
"kind": "existing_domain_prompt" if index < 5 else "new_review_module",
|
||||
"runtime_key": f"runtime-{index:02d}",
|
||||
"version": "v1",
|
||||
"content_sha256": f"{index + 10:064x}",
|
||||
"publication_dependency": f"dependency-{index:02d}",
|
||||
"publication_module_dependencies": [],
|
||||
}
|
||||
for index in range(14)
|
||||
]
|
||||
expected = sorted([
|
||||
{
|
||||
"kind": "asset",
|
||||
"item_id": item["template_id"],
|
||||
"expected_content_sha256": "e" * 64,
|
||||
"online_content_sha256": "e" * 64,
|
||||
"status": "applied_and_verified",
|
||||
}
|
||||
for item in assets
|
||||
] + [
|
||||
{
|
||||
"kind": "prompt_module",
|
||||
"item_id": item["key"],
|
||||
"expected_content_sha256": "f" * 64,
|
||||
"online_content_sha256": "f" * 64,
|
||||
"status": "applied_and_verified",
|
||||
}
|
||||
for item in modules
|
||||
], key=lambda item: (item["kind"], item["item_id"]))
|
||||
expected_runtime = [
|
||||
{
|
||||
"kind": item["kind"],
|
||||
"runtime_key": item["runtime_key"],
|
||||
"version": item["version"],
|
||||
"expected_content_sha256": item["content_sha256"],
|
||||
"online_content_sha256": item["content_sha256"],
|
||||
"status": "published_and_resolved",
|
||||
"publication_dependency": item["publication_dependency"],
|
||||
"publication_module_dependencies": [],
|
||||
}
|
||||
for item in runtime
|
||||
]
|
||||
challenge = {
|
||||
"schema_version": "yuqei.rc06-apply-challenge.v2",
|
||||
"challenge_id": "rc06-apply-challenge-test0001",
|
||||
"payload": {
|
||||
"nonce_base64": base64.b64encode(b"n" * 32).decode(),
|
||||
"expires_at": iso_time(expires),
|
||||
},
|
||||
"signature": base64.b64encode(b"challenge").decode(),
|
||||
}
|
||||
approval = {"payload": {"expires_at": iso_time(expires)}}
|
||||
manifest = {
|
||||
"candidate": {"commit": CANDIDATE, "tag": "rc06-test"},
|
||||
"asset_batch": {"items": assets},
|
||||
"prompt_module_batch": {"items": modules},
|
||||
"runtime_prompt_dependencies": {"items": runtime},
|
||||
}
|
||||
request = {
|
||||
"idempotency_key": idempotency,
|
||||
"transaction_id": transaction_id,
|
||||
"environment_id": ENVIRONMENT,
|
||||
"tenant_id": TENANT,
|
||||
"batch_manifest_raw_sha256": "2" * 64,
|
||||
"batch_manifest_payload_sha256": "3" * 64,
|
||||
"approval_receipt_sha256": "4" * 64,
|
||||
"legal_source_review_binding_sha256": "5" * 64,
|
||||
"legal_source_review_expires_at": iso_time(expires),
|
||||
"authority_challenge": challenge,
|
||||
"approval_receipt": approval,
|
||||
"expected_publication_item_results": expected,
|
||||
"expected_runtime_snapshot_dependencies": expected_runtime,
|
||||
}
|
||||
return AuthorizedMaterial(
|
||||
request=request,
|
||||
manifest=manifest,
|
||||
approval=approval,
|
||||
approval_keyring={},
|
||||
legal_summary={"expires_at": iso_time(expires)},
|
||||
legal_binding_sha256="5" * 64,
|
||||
challenge=challenge,
|
||||
expected_publication_results=expected,
|
||||
expected_runtime_results=expected_runtime,
|
||||
contract_image_digest=CONTRACT_IMAGE,
|
||||
ai_image_digest=AI_IMAGE,
|
||||
)
|
||||
|
||||
|
||||
def _service(tmp_path, *, fail_commit=False, fail_sign_once_at=None):
|
||||
repository = SqlAlchemyContractRepository(
|
||||
f"sqlite:///{tmp_path / 'contract.sqlite3'}",
|
||||
auto_create=True,
|
||||
seed_demo_data=False,
|
||||
file_storage_dir=tmp_path / "files",
|
||||
)
|
||||
material = _material()
|
||||
signer = FakeSigner(fail_once_at=fail_sign_once_at)
|
||||
coordinator = FakeCoordinator(material, fail_commit=fail_commit)
|
||||
apply_private = Ed25519PrivateKey.generate()
|
||||
token = tmp_path / "rc06-token"
|
||||
token.write_text("test-only-dedicated-token-0123456789abcdef", encoding="utf-8")
|
||||
settings = ContractApiSettings(
|
||||
environment="test",
|
||||
rc06_publication_facade_enabled=True,
|
||||
rc06_publication_environment_id=ENVIRONMENT,
|
||||
rc06_publication_tenant_id=TENANT,
|
||||
rc06_publication_bearer_token_file=str(token.resolve()),
|
||||
rc06_frozen_candidate_commit=CANDIDATE,
|
||||
rc06_release_image_digest=CONTRACT_IMAGE,
|
||||
rc06_ai_release_image_digest=AI_IMAGE,
|
||||
)
|
||||
service = Rc06PublicationFacadeService(
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
authority=FakeAuthority(material),
|
||||
signer=signer,
|
||||
coordinator=coordinator,
|
||||
apply_service_public_key=apply_private.public_key(),
|
||||
)
|
||||
request = material.request
|
||||
headers = Rc06FacadeHeaders(
|
||||
idempotency_key=request["idempotency_key"],
|
||||
manifest_sha256=request["batch_manifest_raw_sha256"],
|
||||
approval_sha256=request["approval_receipt_sha256"],
|
||||
legal_sha256=request["legal_source_review_binding_sha256"],
|
||||
tenant_id=TENANT,
|
||||
request_sha256=sha256_json(request),
|
||||
)
|
||||
return repository, service, signer, coordinator, apply_private, material, headers
|
||||
|
||||
|
||||
def test_apply_uses_exactly_four_kms_calls_and_recovers_without_resigning(tmp_path):
|
||||
repository, service, signer, coordinator, _, material, headers = _service(tmp_path)
|
||||
try:
|
||||
response = service.apply(material.request, headers)
|
||||
assert len(signer.calls) == 4
|
||||
assert [item[2] for item in signer.calls] == [True, False, True, False]
|
||||
assert response["publication_item_count"] == 74
|
||||
assert response["runtime_snapshot_dependency_count"] == 14
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["schema_version"] == APPLY_STATUS_SCHEMA
|
||||
assert status["state"] == "committed_and_verified"
|
||||
assert status["state_version"] == 3
|
||||
assert status["no_go"] is True
|
||||
assert status["online_transaction_receipt"] == response["online_transaction_receipt"]
|
||||
assert status["runtime_snapshot_receipt"] == response["runtime_snapshot_receipt"]
|
||||
|
||||
replay = service.apply(
|
||||
material.request,
|
||||
headers,
|
||||
now=datetime.now(UTC) + timedelta(days=1),
|
||||
)
|
||||
assert replay == response
|
||||
assert len(signer.calls) == 4
|
||||
with repository._session_factory() as session:
|
||||
row = session.get(
|
||||
Rc06OnlinePublicationTransactionModel,
|
||||
material.request["transaction_id"],
|
||||
)
|
||||
assert row.online_primary_kms_request_id == "kms-request-1"
|
||||
assert row.online_attestation_kms_request_id == "kms-request-2"
|
||||
assert row.runtime_primary_kms_request_id == "kms-request-3"
|
||||
assert row.runtime_attestation_kms_request_id == "kms-request-4"
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_facade_requires_dedicated_bearer_and_rejects_caller_tenant_override(tmp_path):
|
||||
repository, service, _, _, _, material, headers = _service(tmp_path)
|
||||
try:
|
||||
with pytest.raises(Rc06FacadeError, match="Authenticated"):
|
||||
service.authenticate(None)
|
||||
with pytest.raises(Rc06FacadeError, match="Authenticated"):
|
||||
service.authenticate("Bearer wrong")
|
||||
service.authenticate("Bearer test-only-dedicated-token-0123456789abcdef")
|
||||
forged = Rc06FacadeHeaders(**{**headers.__dict__, "tenant_id": "caller-controlled"})
|
||||
with pytest.raises(Rc06FacadeConflict, match="server-bound"):
|
||||
service.apply(material.request, forged)
|
||||
with repository._session_factory() as session:
|
||||
assert session.get(
|
||||
Rc06OnlinePublicationTransactionModel,
|
||||
material.request["transaction_id"],
|
||||
) is None
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_finalize_is_idempotent_and_status_retains_all_receipts(tmp_path):
|
||||
repository, service, _, _, apply_private, material, headers = _service(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(),
|
||||
}
|
||||
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(apply_receipt),
|
||||
"apply_receipt": apply_receipt,
|
||||
}
|
||||
finalize_headers = Rc06FacadeHeaders(**{
|
||||
**headers.__dict__, "request_sha256": sha256_json(finalize_request)
|
||||
})
|
||||
first = service.finalize(material.request["transaction_id"], finalize_request, finalize_headers)
|
||||
assert first["state"] == "finalized"
|
||||
assert service.finalize(material.request["transaction_id"], finalize_request, finalize_headers) == first
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "finalized"
|
||||
assert status["state_version"] == 4
|
||||
assert status["no_go"] is False
|
||||
assert status["online_transaction_receipt"] == execution["online_transaction_receipt"]
|
||||
assert status["runtime_snapshot_receipt"] == execution["runtime_snapshot_receipt"]
|
||||
assert status["apply_receipt"] == apply_receipt
|
||||
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:
|
||||
service.apply(material.request, headers)
|
||||
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 pytest.raises(Rc06FacadeConflict, match="irreversible"):
|
||||
service.rollback(material.request["transaction_id"], rollback_request, headers)
|
||||
assert coordinator.compensate_calls == 0
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "committed_and_verified"
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
|
||||
|
||||
def test_pre_contract_failure_can_produce_exact_rollback_v2(tmp_path):
|
||||
repository, service, _, coordinator, _, material, headers = _service(tmp_path, fail_commit=True)
|
||||
try:
|
||||
with pytest.raises(Rc06FacadeError, match="did not reach"):
|
||||
service.apply(material.request, headers)
|
||||
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["compensation_item_results"] == []
|
||||
assert rollback["runtime_snapshot_compensation_results"] == []
|
||||
assert rollback["rollback_evidence_sha256"] == sha256_json({
|
||||
key: value for key, value in rollback.items() if key != "rollback_evidence_sha256"
|
||||
})
|
||||
status = service.status(material.request["transaction_id"], headers)
|
||||
assert status["state"] == "rolled_back"
|
||||
assert status["state_version"] == 2
|
||||
assert status["rollback_response"] == rollback
|
||||
assert status["rollback_response_sha256"] == sha256_json(rollback)
|
||||
assert coordinator.compensate_calls == 1
|
||||
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
|
||||
)
|
||||
try:
|
||||
with pytest.raises(Rc06FacadeError, match="KMS response loss"):
|
||||
service.apply(material.request, headers)
|
||||
failed = service.status(material.request["transaction_id"], headers)
|
||||
assert failed["state"] == "irreversible_failure"
|
||||
assert failed["state_version"] == 2
|
||||
assert failed["no_go"] is True
|
||||
assert failed["publication_item_results"] == []
|
||||
assert failed["online_transaction_receipt"] is None
|
||||
assert failed["failure_code"] == "rc06facadeerror"
|
||||
assert len(failed["failure_evidence_sha256"]) == 64
|
||||
assert coordinator.compensate_calls == 0
|
||||
|
||||
recovered = service.apply(
|
||||
material.request,
|
||||
headers,
|
||||
now=datetime.now(UTC) + timedelta(days=1),
|
||||
)
|
||||
assert recovered["publication_item_count"] == 74
|
||||
assert len(signer.calls) == 5 # one ambiguous failure plus four durable signatures
|
||||
committed = service.status(material.request["transaction_id"], headers)
|
||||
assert committed["state"] == "committed_and_verified"
|
||||
assert committed["failure_code"] is None
|
||||
assert coordinator.compensate_calls == 0
|
||||
finally:
|
||||
repository.engine.dispose()
|
||||
Reference in New Issue
Block a user