diff --git a/yuqei-contract-platform/services/contract-api/migrations/versions/20261006_0082_add_rc06_publication_facade.py b/yuqei-contract-platform/services/contract-api/migrations/versions/20261006_0082_add_rc06_publication_facade.py new file mode 100644 index 00000000..c450ba43 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/migrations/versions/20261006_0082_add_rc06_publication_facade.py @@ -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") diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py index 7be9a928..63db6920 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py @@ -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( diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py index e5e2cbd0..e22f22f6 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/main.py @@ -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) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py index b412b749..c3622ad4 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/persistence/models.py @@ -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__ = ( diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py index ae3e7b73..961f8029 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py @@ -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, ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py new file mode 100644 index 00000000..24f83ad9 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py @@ -0,0 +1,1672 @@ +"""Durable, fail-closed RC06 online publication transaction facade. + +The facade is the only production HTTP entry point for the RC06 coordinator. +It verifies the complete frozen authority package before the in-process saga is +called, journals every irreversible boundary, and produces KMS-signed online +and runtime evidence. The two participant databases remain a durable saga; +this module never describes their writes as cross-database atomic. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from threading import RLock +from typing import Any, Mapping, Protocol + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from fastapi import FastAPI, Header, HTTPException, Request +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError + +from .config import ContractApiSettings +from .persistence.models import ( + Rc06ApplyChallengeUseModel, + Rc06OnlinePublicationTransactionModel, +) +from .rc06_publication import ( + AiRc06Client, + HttpAiRc06Client, + Rc06AuthorizationVerifier, + Rc06Conflict, + Rc06PrepareRequest, + Rc06PublicationCoordinator, + Rc06PublicationError, + Rc06SagaStatus, +) +from .rc06_publication_protocol import ( + APPLY_RECEIPT_PAYLOAD_SCHEMA, + APPLY_RECEIPT_SCHEMA, + APPLY_RECEIPT_SIGNATURE_DOMAIN, + APPLY_RESPONSE_SCHEMA, + APPLY_STATUS_SCHEMA, + FINALIZE_REQUEST_SCHEMA, + FINALIZE_RESPONSE_SCHEMA, + KMS_ATTESTATION_PAYLOAD_SCHEMA, + KMS_ATTESTATION_SCHEMA, + ONLINE_ATTESTATION_DOMAIN, + ONLINE_PAYLOAD_SCHEMA, + ONLINE_RECEIPT_SCHEMA, + ONLINE_SIGNATURE_DOMAIN, + ROLLBACK_REQUEST_SCHEMA, + ROLLBACK_RESPONSE_SCHEMA, + RUNTIME_ATTESTATION_DOMAIN, + RUNTIME_PAYLOAD_SCHEMA, + RUNTIME_RECEIPT_SCHEMA, + RUNTIME_SIGNATURE_DOMAIN, + TRANSACTION_MODE, + TRUSTED_COMPONENT, + AuthorityTrust, + AuthorizedMaterial, + Rc06ProtocolError, + canonical_json, + iso_time, + load_pinned_ed25519, + parse_time, + publication_transaction_id, + require_exact, + sha256_bytes, + sha256_json, + validate_authority, +) +from .reliable_evidence_signing import ( + EvidenceSigningError, + load_public_key, + public_key_fingerprint, + sign_digest, + verify_digest_signature, +) + + +_facade_lock = RLock() +_TRANSACTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$") + + +class Rc06FacadeError(RuntimeError): + pass + + +class Rc06FacadeConflict(Rc06FacadeError): + pass + + +class Rc06FacadeAuthority(Protocol): + def authorize(self, request: Any, *, now: datetime) -> AuthorizedMaterial: ... + + +class Rc06FacadeSigner(Protocol): + def sign( + self, + *, + domain: bytes, + payload: Mapping[str, Any], + include_request_id: bool, + ) -> tuple[dict[str, Any], str]: ... + + +class Rc06FacadeCoordinator(Protocol): + def prepare(self, payload: Rc06PrepareRequest, *, tenant_id: str) -> Rc06SagaStatus: ... + def commit(self, transaction_id: str, *, tenant_id: str | None = None) -> Rc06SagaStatus: ... + def compensate(self, transaction_id: str, *, tenant_id: str | None = None) -> Rc06SagaStatus: ... + def status(self, transaction_id: str, *, tenant_id: str | None = None) -> Rc06SagaStatus: ... + + +class ConfiguredRc06Authority: + def __init__(self, settings: ContractApiSettings) -> None: + self._trust = AuthorityTrust( + approval_root_public_key=load_pinned_ed25519( + settings.rc06_approval_root_public_key_file, + settings.rc06_approval_root_public_key_sha256, + "approval root public key", + ), + approval_root_key_id=settings.rc06_approval_root_key_id, + approval_keyring_epoch=settings.rc06_approval_keyring_epoch, + apply_service_public_key=load_pinned_ed25519( + settings.rc06_apply_service_public_key_file, + settings.rc06_apply_service_public_key_sha256, + "apply service public key", + ), + legal_keyring_sha256=settings.rc06_legal_review_keyring_sha256, + environment_id=settings.rc06_publication_environment_id, + tenant_id=settings.rc06_publication_tenant_id, + frozen_candidate_commit=settings.rc06_frozen_candidate_commit, + contract_release_image_digest=settings.rc06_release_image_digest, + ai_release_image_digest=settings.rc06_ai_release_image_digest, + ) + + @property + def apply_service_public_key(self) -> Ed25519PublicKey: + return self._trust.apply_service_public_key + + def authorize(self, request: Any, *, now: datetime) -> AuthorizedMaterial: + return validate_authority(request, self._trust, now=now) + + +class KmsRc06FacadeSigner: + """Use the existing KMS gateway transport with a separately pinned key.""" + + def __init__(self, settings: ContractApiSettings) -> None: + if settings.reliable_evidence_signing_backend != "kms_gateway": + raise Rc06FacadeError("RC06 online receipts require the KMS gateway backend.") + self._settings = settings + self._provider = settings.rc06_online_signing_provider.strip() + self._key_reference = settings.rc06_online_signing_key_reference.strip() + self._key_version = settings.rc06_online_signing_key_version.strip() + self._algorithm = settings.rc06_online_signing_algorithm.strip() + public_key = load_public_key( + settings, + key_reference=self._key_reference, + key_version=self._key_version, + algorithm=self._algorithm, + ) + if ( + public_key.provider != self._provider + or public_key.key_reference != self._key_reference + or public_key.key_version != self._key_version + or public_key.algorithm != self._algorithm + or public_key_fingerprint(public_key.public_key_pem) + != settings.rc06_online_signing_public_key_spki_sha256 + ): + raise Rc06FacadeError("RC06 KMS public key identity does not match its frozen configuration.") + self._public_key_pem = public_key.public_key_pem + + @property + def public_key_pem(self) -> str: + return self._public_key_pem + + def sign( + self, + *, + domain: bytes, + payload: Mapping[str, Any], + include_request_id: bool, + ) -> tuple[dict[str, Any], str]: + digest = hashlib.sha256(domain + canonical_json(payload)).digest() + signature = sign_digest( + self._settings, + key_reference=self._key_reference, + key_version=self._key_version, + algorithm=self._algorithm, + digest=digest, + ) + request_id = signature.request_id + if ( + signature.provider != self._provider + or signature.key_reference != self._key_reference + or signature.key_version != self._key_version + or signature.algorithm != self._algorithm + or not isinstance(request_id, str) + or not request_id.strip() + or len(request_id) > 512 + or not verify_digest_signature( + public_key_pem=self._public_key_pem, + algorithm=self._algorithm, + digest=digest, + signature_b64=signature.value, + ) + ): + raise Rc06FacadeError("RC06 KMS signature identity, request id, or signature is invalid.") + block: dict[str, Any] = { + "provider": signature.provider, + "key_reference": signature.key_reference, + "key_version": signature.key_version, + "algorithm": signature.algorithm, + "value_base64": signature.value, + } + if include_request_id: + block["request_id"] = request_id + return block, request_id + + +class JournalRc06AuthorizationVerifier(Rc06AuthorizationVerifier): + """Permit the low-level saga only for material already authorized by this facade.""" + + def __init__(self, session_factory: Any, tenant_id: str) -> None: + self._session_factory = session_factory + self._tenant_id = tenant_id + + def verify(self, payload: Rc06PrepareRequest, *, tenant_id: str) -> None: + prepare = payload.model_dump(mode="json") + with self._session_factory() as session: + row = session.get(Rc06OnlinePublicationTransactionModel, payload.transaction_id) + if ( + row is None + or tenant_id != self._tenant_id + or row.tenant_id != self._tenant_id + or row.idempotency_key != payload.idempotency_key + or _decode(row.prepare_request_json, {}) != prepare + or row.state not in { + "authorized", "saga_prepared", "saga_committing", "ai_committed", + "contract_committed", "evidence_pending", + } + ): + raise Rc06PublicationError("RC06 saga material was not authorized by the durable facade.") + + +@dataclass(frozen=True) +class Rc06FacadeHeaders: + idempotency_key: str + manifest_sha256: str + approval_sha256: str + legal_sha256: str + tenant_id: str + request_sha256: str | None = None + + +def _decode(value: str | None, default: Any) -> Any: + return default if value is None else json.loads(value) + + +def _encode(value: Any) -> str: + return canonical_json(value).decode("utf-8") + + +def _utc(value: datetime | None) -> datetime | None: + if value is None: + return None + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def _strict_json(raw: bytes, *, maximum: int) -> dict[str, Any]: + if not raw or len(raw) > maximum: + raise Rc06FacadeError("RC06 request body is empty or exceeds the configured limit.") + + def object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise Rc06FacadeError("RC06 JSON contains a duplicate object key.") + result[key] = value + return result + + try: + value = json.loads( + raw.decode("utf-8", errors="strict"), + object_pairs_hook=object_pairs, + parse_constant=lambda value: (_ for _ in ()).throw( + Rc06FacadeError(f"RC06 JSON constant is forbidden: {value}") + ), + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise Rc06FacadeError("RC06 request body is not strict UTF-8 JSON.") from error + if not isinstance(value, dict): + raise Rc06FacadeError("RC06 request body must be a JSON object.") + return value + + +def _state_evidence(participant: str, state: str, write_count: int, runtime_count: int) -> dict[str, Any]: + row = { + "participant": participant, + "state": state, + "write_count": write_count, + "runtime_snapshot_count": runtime_count, + } + return {**row, "durable_state_sha256": sha256_json(row)} + + +class Rc06PublicationFacadeService: + def __init__( + self, + *, + settings: ContractApiSettings, + repository: Any, + authority: Rc06FacadeAuthority | None = None, + signer: Rc06FacadeSigner | None = None, + coordinator: Rc06FacadeCoordinator | None = None, + ai_client: AiRc06Client | None = None, + apply_service_public_key: Ed25519PublicKey | None = None, + ) -> None: + session_factory = getattr(repository, "_session_factory", None) + if session_factory is None: + raise Rc06FacadeError("RC06 facade requires the SQLAlchemy Contract repository.") + self._settings = settings + self._session_factory = session_factory + self._tenant_id = settings.rc06_publication_tenant_id + self._environment_id = settings.rc06_publication_environment_id + self._authority = authority or ConfiguredRc06Authority(settings) + self._signer = signer or KmsRc06FacadeSigner(settings) + self._apply_service_public_key = apply_service_public_key or getattr( + self._authority, "apply_service_public_key", None + ) + if not isinstance(self._apply_service_public_key, Ed25519PublicKey): + self._apply_service_public_key = load_pinned_ed25519( + settings.rc06_apply_service_public_key_file, + settings.rc06_apply_service_public_key_sha256, + "apply service public key", + ) + if coordinator is None: + authorization = JournalRc06AuthorizationVerifier(session_factory, self._tenant_id) + coordinator = Rc06PublicationCoordinator( + repository=repository, + ai_client=ai_client or HttpAiRc06Client( + base_url=settings.ai_platform_base_url, + service_key=settings.ai_platform_rc06_publication_key, + timeout=settings.ai_platform_timeout_seconds, + ), + authorization_verifier=authorization, + frozen_candidate_commit=settings.rc06_frozen_candidate_commit, + release_image_digest=settings.rc06_release_image_digest, + ) + self._coordinator = coordinator + + def authenticate(self, authorization: str | None) -> None: + if not isinstance(authorization, str) or not authorization.startswith("Bearer "): + raise Rc06FacadeError("Authenticated RC06 publication service access is required.") + supplied = authorization.removeprefix("Bearer ") + token_path = Path(self._settings.rc06_publication_bearer_token_file) + if not token_path.is_absolute(): + raise Rc06FacadeError("RC06 publication service credential path is not configured.") + try: + expected = token_path.read_text(encoding="utf-8").strip() + except OSError as error: + raise Rc06FacadeError("RC06 publication service credential is unavailable.") from error + if ( + len(expected.encode("utf-8")) < 32 + or len(supplied.encode("utf-8")) > 8192 + or not hmac.compare_digest(supplied, expected) + ): + raise Rc06FacadeError("Authenticated RC06 publication service access is required.") + + def apply( + self, + request: dict[str, Any], + headers: Rc06FacadeHeaders, + *, + now: datetime | None = None, + ) -> dict[str, Any]: + checked_at = (now or datetime.now(UTC)).astimezone(UTC) + request_sha256 = sha256_json(request) + self._assert_request_headers(request, headers, request_sha256=request_sha256) + material, row = self._authorize_and_reserve(request, request_sha256, checked_at) + if row.state == "rolled_back": + raise Rc06FacadeConflict("RC06 transaction was already rolled back.") + if row.state == "finalized": + return self._apply_response(row) + try: + saga = self._drive_saga(row.transaction_id, material) + return self._drive_receipts(row.transaction_id, material, saga) + except (Rc06FacadeError, Rc06FacadeConflict, Rc06ProtocolError, Rc06PublicationError, Rc06Conflict) as error: + self._record_error(row.transaction_id, error) + raise + except Exception as error: + self._record_error(row.transaction_id, error) + raise Rc06FacadeError(f"RC06 online apply failed: {type(error).__name__}") from error + + def _authorize_and_reserve( + self, + request: dict[str, Any], + request_sha256: str, + now: datetime, + ) -> tuple[AuthorizedMaterial, Rc06OnlinePublicationTransactionModel]: + idempotency_key = request.get("idempotency_key") + transaction_id = request.get("transaction_id") + if ( + not isinstance(idempotency_key, str) + or len(idempotency_key) != 64 + or re.fullmatch(r"[a-f0-9]{64}", idempotency_key) is None + or not isinstance(transaction_id, str) + or _TRANSACTION_ID.fullmatch(transaction_id) is None + or transaction_id != publication_transaction_id(idempotency_key) + ): + raise Rc06FacadeError("RC06 transaction or idempotency identity is invalid.") + replay_time: datetime | None = None + with self._session_factory() as session: + existing = session.scalar(select(Rc06OnlinePublicationTransactionModel).where( + Rc06OnlinePublicationTransactionModel.tenant_id == self._tenant_id, + Rc06OnlinePublicationTransactionModel.idempotency_key == idempotency_key, + )) + if existing is not None: + if existing.transaction_id != transaction_id or existing.request_sha256 != request_sha256: + raise Rc06FacadeConflict("Idempotency key was already used for different RC06 material.") + replay_time = _utc(existing.created_at) + material = self._authority.authorize(request, now=replay_time or now) + prepare = self._prepare_request(material).model_dump(mode="json") + challenge = material.challenge + challenge_payload = challenge["payload"] + nonce = base64.b64decode(challenge_payload["nonce_base64"], validate=True) + challenge_sha256 = sha256_json(challenge) + nonce_sha256 = sha256_bytes(nonce) + legal_expires_at = parse_time( + material.legal_summary["expires_at"], "legal review expires_at" + ) + try: + with _facade_lock, self._session_factory.begin() as session: + self._lock_transaction(session, transaction_id) + existing = session.scalar(select(Rc06OnlinePublicationTransactionModel).where( + Rc06OnlinePublicationTransactionModel.tenant_id == self._tenant_id, + Rc06OnlinePublicationTransactionModel.idempotency_key == idempotency_key, + )) + if existing is not None: + if ( + existing.transaction_id != transaction_id + or existing.request_sha256 != request_sha256 + or existing.challenge_id != challenge["challenge_id"] + or existing.challenge_sha256 != challenge_sha256 + or _decode(existing.request_json, {}) != request + or _decode(existing.prepare_request_json, {}) != prepare + ): + raise Rc06FacadeConflict("RC06 durable reservation material drifted.") + session.expunge(existing) + return material, existing + if session.get(Rc06OnlinePublicationTransactionModel, transaction_id) is not None: + raise Rc06FacadeConflict("RC06 transaction id was already used.") + created = Rc06OnlinePublicationTransactionModel( + transaction_id=transaction_id, + tenant_id=self._tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + environment_id=self._environment_id, + candidate_commit=material.manifest["candidate"]["commit"], + contract_release_image_digest=material.contract_image_digest, + ai_release_image_digest=material.ai_image_digest, + batch_manifest_raw_sha256=request["batch_manifest_raw_sha256"], + batch_manifest_payload_sha256=request["batch_manifest_payload_sha256"], + approval_receipt_sha256=request["approval_receipt_sha256"], + legal_source_review_binding_sha256=material.legal_binding_sha256, + legal_source_review_expires_at=legal_expires_at, + challenge_id=challenge["challenge_id"], + challenge_sha256=challenge_sha256, + state="authorized", + request_json=_encode(request), + prepare_request_json=_encode(prepare), + saga_status_json=None, + participant_evidence_json=None, + online_payload_json=None, + online_primary_signature_json=None, + online_attestation_json=None, + online_primary_kms_request_id=None, + online_attestation_kms_request_id=None, + online_receipt_json=None, + online_receipt_sha256=None, + runtime_payload_json=None, + runtime_primary_signature_json=None, + runtime_attestation_json=None, + runtime_primary_kms_request_id=None, + runtime_attestation_kms_request_id=None, + runtime_receipt_json=None, + runtime_receipt_sha256=None, + rollback_response_json=None, + rollback_evidence_sha256=None, + rollback_response_sha256=None, + apply_receipt_json=None, + apply_receipt_sha256=None, + error_message=None, + failure_code=None, + failure_at=None, + failure_evidence_sha256=None, + created_at=now, + updated_at=now, + contract_committed_at=None, + pending_countersign_at=None, + finalized_at=None, + rolled_back_at=None, + ) + session.add(created) + session.flush() + session.add(Rc06ApplyChallengeUseModel( + challenge_id=challenge["challenge_id"], + nonce_sha256=nonce_sha256, + challenge_sha256=challenge_sha256, + tenant_id=self._tenant_id, + idempotency_key=idempotency_key, + transaction_id=transaction_id, + request_sha256=request_sha256, + outcome="reserved", + reserved_at=now, + consumed_at=None, + )) + session.flush() + session.expunge(created) + return material, created + except IntegrityError as error: + raise Rc06FacadeConflict("RC06 challenge, nonce, transaction, or idempotency key was already consumed.") from error + + @staticmethod + def _prepare_request(material: AuthorizedMaterial) -> Rc06PrepareRequest: + source_kind = { + "composed_drafting": "composed_drafting_modules", + "new_review_module": "new_review_module_composite", + "existing_domain_prompt": "existing_domain_prompt_asset", + } + runtime = [ + { + "name": item["runtime_key"], + "version": item["version"], + "content_sha256": item["content_sha256"], + "source_kind": source_kind[item["kind"]], + } + for item in material.manifest["runtime_prompt_dependencies"]["items"] + ] + request = material.request + return Rc06PrepareRequest( + transaction_id=request["transaction_id"], + idempotency_key=request["idempotency_key"], + candidate_commit=material.manifest["candidate"]["commit"], + contract_release_image_digest=material.contract_image_digest, + ai_release_image_digest=material.ai_image_digest, + candidate_tag=material.manifest["candidate"]["tag"], + batch_manifest_raw_sha256=request["batch_manifest_raw_sha256"], + batch_manifest_payload_sha256=request["batch_manifest_payload_sha256"], + approval_receipt_sha256=request["approval_receipt_sha256"], + assets=material.manifest["asset_batch"]["items"], + prompt_modules=material.manifest["prompt_module_batch"]["items"], + runtime_identities=runtime, + ) + + def _drive_saga(self, transaction_id: str, material: AuthorizedMaterial) -> Rc06SagaStatus: + row = self._load(transaction_id) + prepare = Rc06PrepareRequest.model_validate(_decode(row.prepare_request_json, {})) + try: + status = self._coordinator.status(transaction_id, tenant_id=self._tenant_id) + except KeyError: + status = self._coordinator.prepare(prepare, tenant_id=self._tenant_id) + self._record_saga_status(transaction_id, status) + if status.state == "prepared": + self._set_state(transaction_id, "saga_prepared") + if status.state in {"prepared", "ai_committed", "contract_committed"}: + self._set_state(transaction_id, self._facade_state(status.state, committing=True)) + try: + status = self._coordinator.commit(transaction_id, tenant_id=self._tenant_id) + except Exception as error: + try: + status = self._coordinator.status(transaction_id, tenant_id=self._tenant_id) + except Exception: + self._record_error(transaction_id, error) + raise + self._record_saga_status(transaction_id, status) + if status.state != "committed" or status.write_count != 74 or status.runtime_snapshot_count != 14: + mapped = self._facade_state(status.state) + self._set_state(transaction_id, mapped, error=status.error) + raise Rc06FacadeError(f"RC06 saga did not reach exact committed readback: {status.state}") + self._assert_committed_saga(status, material) + self._set_state( + transaction_id, + "contract_committed", + contract_committed_at=status.contract_committed_at, + ) + return status + + def _assert_committed_saga(self, status: Rc06SagaStatus, material: AuthorizedMaterial) -> None: + if ( + status.no_go is not False + or status.cross_database_atomicity is not False + or status.candidate_commit != material.manifest["candidate"]["commit"] + or status.contract_candidate_commit != material.manifest["candidate"]["commit"] + or status.ai_candidate_commit != material.manifest["candidate"]["commit"] + or status.contract_release_image_digest != material.contract_image_digest + or status.ai_release_image_digest != material.ai_image_digest + or status.item_results != material.expected_publication_results + or status.ai_committed_at is None + or status.contract_committed_at is None + or status.committed_at is None + ): + raise Rc06FacadeError("RC06 committed participant evidence drifted.") + expected_runtime = {item["runtime_key"]: item for item in material.expected_runtime_results} + actual = {item.get("name"): item for item in status.runtime_snapshots} + if set(actual) != set(expected_runtime): + raise Rc06FacadeError("RC06 committed runtime inventory drifted.") + for name, expected in expected_runtime.items(): + value = actual[name] + if ( + value.get("version") != expected["version"] + or value.get("online_content_sha256") != expected["online_content_sha256"] + or value.get("verified") is not True + or value.get("enabled") is not True + or value.get("status") != "published" + ): + raise Rc06FacadeError(f"RC06 runtime readback drifted: {name}") + + def _drive_receipts( + self, + transaction_id: str, + material: AuthorizedMaterial, + status: Rc06SagaStatus, + ) -> dict[str, Any]: + existing = self._load(transaction_id) + if existing.online_receipt_json is not None and existing.runtime_receipt_json is not None: + return self._apply_response(existing) + participant_evidence = self._participant_evidence(transaction_id, material, status) + committed_at = _utc(status.committed_at) + if committed_at is None: + raise Rc06FacadeError("RC06 coordinator commit time is unavailable.") + authority_expires_at = parse_time( + material.challenge["payload"]["expires_at"], "challenge expires_at" + ) + approval_expires_at = parse_time( + material.approval["payload"]["expires_at"], "approval expires_at" + ) + if committed_at >= authority_expires_at or committed_at >= approval_expires_at: + self._set_state(transaction_id, "evidence_pending", error="commit completed outside authority window") + raise Rc06FacadeError("RC06 commit completed outside its signed authority window.") + online_payload = { + "schema_version": ONLINE_PAYLOAD_SCHEMA, + "candidate_commit": material.manifest["candidate"]["commit"], + "candidate_tag": material.manifest["candidate"]["tag"], + "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": self._environment_id, + "tenant_id": self._tenant_id, + "idempotency_key": material.request["idempotency_key"], + "transaction_id": transaction_id, + "transaction_mode": TRANSACTION_MODE, + "cross_database_atomicity": False, + "coordinator_deployment": { + "component": TRUSTED_COMPONENT, + "candidate_commit": material.manifest["candidate"]["commit"], + "release_image_digest": material.contract_image_digest, + }, + "participant_evidence": participant_evidence, + "committed_at": iso_time(committed_at), + "authority_challenge": material.challenge, + "generated_by": {"type": "system", "component": TRUSTED_COMPONENT}, + "item_results": material.expected_publication_results, + "write_count": 74, + "status": "committed_and_verified", + } + self._ensure_online_receipt(transaction_id, online_payload) + row = self._load(transaction_id) + online_receipt = _decode(row.online_receipt_json, None) + if not isinstance(online_receipt, dict): + raise Rc06FacadeError("RC06 online receipt persistence is incomplete.") + captured_at = datetime.now(UTC) + if captured_at < committed_at: + captured_at = committed_at + if captured_at >= authority_expires_at or captured_at >= approval_expires_at: + self._set_state(transaction_id, "evidence_pending", error="runtime capture outside authority window") + raise Rc06FacadeError("RC06 runtime evidence could not be captured inside its authority window.") + runtime_payload = { + "schema_version": RUNTIME_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": self._environment_id, + "tenant_id": self._tenant_id, + "idempotency_key": material.request["idempotency_key"], + "transaction_id": transaction_id, + "online_transaction_receipt_sha256": sha256_json(online_receipt), + "captured_at": iso_time(captured_at), + "generated_by": {"type": "system", "component": TRUSTED_COMPONENT}, + "transaction_mode": TRANSACTION_MODE, + "cross_database_atomicity": False, + "publication_item_count": 74, + "runtime_snapshot_dependencies": material.expected_runtime_results, + "runtime_snapshot_dependency_count": 14, + "status": "published_and_resolved", + } + self._ensure_runtime_receipt(transaction_id, runtime_payload) + row = self._load(transaction_id) + return self._apply_response(row) + + @staticmethod + def _participant_evidence( + transaction_id: str, + material: AuthorizedMaterial, + status: Rc06SagaStatus, + ) -> list[dict[str, Any]]: + prompt_results = [ + item for item in material.expected_publication_results if item["kind"] == "prompt_module" + ] + asset_results = [ + item for item in material.expected_publication_results if item["kind"] == "asset" + ] + values = [ + { + "participant": "ai-platform", + "state": "committed", + "transaction_id": transaction_id, + "candidate_commit": material.manifest["candidate"]["commit"], + "release_image_digest": material.ai_image_digest, + "committed_at": iso_time(status.ai_committed_at), + "write_count": 14, + "runtime_snapshot_count": 14, + "committed_item_results_sha256": sha256_json(prompt_results), + }, + { + "participant": "contract-platform", + "state": "committed", + "transaction_id": transaction_id, + "candidate_commit": material.manifest["candidate"]["commit"], + "release_image_digest": material.contract_image_digest, + "committed_at": iso_time(status.contract_committed_at), + "write_count": 60, + "runtime_snapshot_count": 0, + "committed_item_results_sha256": sha256_json(asset_results), + }, + ] + return [ + {**value, "durable_commit_evidence_sha256": sha256_json(value)} + for value in values + ] + + def _ensure_online_receipt(self, transaction_id: str, payload: dict[str, Any]) -> None: + row = self._load(transaction_id) + persisted_payload = _decode(row.online_payload_json, None) + if persisted_payload is not None and persisted_payload != payload: + raise Rc06FacadeConflict("Persisted online receipt payload drifted.") + if persisted_payload is None: + self._update(transaction_id, state="evidence_pending", online_payload_json=_encode(payload)) + row = self._load(transaction_id) + signature = _decode(row.online_primary_signature_json, None) + if signature is None: + signature, request_id = self._signer.sign( + domain=ONLINE_SIGNATURE_DOMAIN, + payload=payload, + include_request_id=True, + ) + self._update( + transaction_id, + state="online_primary_signed", + online_primary_signature_json=_encode(signature), + online_primary_kms_request_id=request_id, + ) + row = self._load(transaction_id) + elif row.online_primary_kms_request_id != signature.get("request_id"): + raise Rc06FacadeError("Persisted online KMS request identity drifted.") + attestation = _decode(row.online_attestation_json, None) + if attestation is None: + attestation, attestation_request_id = self._request_id_attestation( + payload=payload, + primary_signature=signature, + domain=ONLINE_ATTESTATION_DOMAIN, + ) + self._update( + transaction_id, + state="online_attested", + online_attestation_json=_encode(attestation), + online_attestation_kms_request_id=attestation_request_id, + ) + row = self._load(transaction_id) + receipt = _decode(row.online_receipt_json, None) + expected = { + "schema_version": ONLINE_RECEIPT_SCHEMA, + "receipt_id": "rc06-online-transaction-" + sha256_bytes( + canonical_json(payload) + canonical_json(signature) + canonical_json(attestation) + )[:24], + "payload": payload, + "signature": signature, + "request_id_attestation": attestation, + } + if receipt is not None and receipt != expected: + raise Rc06FacadeConflict("Persisted online receipt envelope drifted.") + if receipt is None: + self._update( + transaction_id, + state="online_receipt_persisted", + participant_evidence_json=_encode(payload["participant_evidence"]), + online_receipt_json=_encode(expected), + online_receipt_sha256=sha256_json(expected), + ) + + def _ensure_runtime_receipt(self, transaction_id: str, payload: dict[str, Any]) -> None: + row = self._load(transaction_id) + persisted_payload = _decode(row.runtime_payload_json, None) + if persisted_payload is not None: + # captured_at is frozen by the first attempt; recovery must reuse it. + payload = persisted_payload + else: + self._update(transaction_id, runtime_payload_json=_encode(payload)) + row = self._load(transaction_id) + signature = _decode(row.runtime_primary_signature_json, None) + if signature is None: + signature, request_id = self._signer.sign( + domain=RUNTIME_SIGNATURE_DOMAIN, + payload=payload, + include_request_id=True, + ) + self._update( + transaction_id, + state="runtime_primary_signed", + runtime_primary_signature_json=_encode(signature), + runtime_primary_kms_request_id=request_id, + ) + row = self._load(transaction_id) + elif row.runtime_primary_kms_request_id != signature.get("request_id"): + raise Rc06FacadeError("Persisted runtime KMS request identity drifted.") + attestation = _decode(row.runtime_attestation_json, None) + if attestation is None: + attestation, attestation_request_id = self._request_id_attestation( + payload=payload, + primary_signature=signature, + domain=RUNTIME_ATTESTATION_DOMAIN, + ) + self._update( + transaction_id, + state="runtime_attested", + runtime_attestation_json=_encode(attestation), + runtime_attestation_kms_request_id=attestation_request_id, + ) + row = self._load(transaction_id) + receipt = _decode(row.runtime_receipt_json, None) + expected = { + "schema_version": RUNTIME_RECEIPT_SCHEMA, + "receipt_id": "rc06-runtime-snapshot-" + sha256_bytes( + canonical_json(payload) + canonical_json(signature) + canonical_json(attestation) + )[:24], + "payload": payload, + "signature": signature, + "request_id_attestation": attestation, + } + if receipt is not None and receipt != expected: + raise Rc06FacadeConflict("Persisted runtime receipt envelope drifted.") + if receipt is None: + now = datetime.now(UTC) + self._update( + transaction_id, + state="online_committed_pending_countersign", + runtime_receipt_json=_encode(expected), + runtime_receipt_sha256=sha256_json(expected), + pending_countersign_at=now, + error_message=None, + failure_code=None, + failure_at=None, + failure_evidence_sha256=None, + ) + with _facade_lock, self._session_factory.begin() as session: + use = session.get(Rc06ApplyChallengeUseModel, self._load(transaction_id).challenge_id) + if use is None: + raise Rc06FacadeError("RC06 challenge consumption ledger is missing.") + use.outcome = "pending_countersign" + use.consumed_at = now + + def _request_id_attestation( + self, + *, + payload: Mapping[str, Any], + primary_signature: Mapping[str, Any], + domain: bytes, + ) -> tuple[dict[str, Any], str]: + try: + signature_bytes = base64.b64decode(primary_signature["value_base64"], validate=True) + except (KeyError, TypeError, ValueError) as error: + raise Rc06FacadeError("Primary KMS signature encoding is invalid.") from error + attestation_payload = { + "schema_version": KMS_ATTESTATION_PAYLOAD_SCHEMA, + "receipt_payload_sha256": sha256_json(payload), + "primary_signature_sha256": sha256_bytes(signature_bytes), + "primary_provider": primary_signature["provider"], + "primary_key_reference": primary_signature["key_reference"], + "primary_key_version": primary_signature["key_version"], + "primary_algorithm": primary_signature["algorithm"], + "primary_request_id": primary_signature["request_id"], + } + signature, request_id = self._signer.sign( + domain=domain, + payload=attestation_payload, + include_request_id=False, + ) + return { + "schema_version": KMS_ATTESTATION_SCHEMA, + "payload": attestation_payload, + "signature": signature, + }, request_id + + def status(self, transaction_id: str, headers: Rc06FacadeHeaders) -> dict[str, Any]: + row = self._load(transaction_id) + self._assert_status_headers(row, headers) + request = _decode(row.request_json, {}) + committed = row.state in {"online_committed_pending_countersign", "finalized"} + rolled_back = row.state == "rolled_back" + irreversible_failure = ( + not committed + and not rolled_back + and ( + row.contract_committed_at is not None + or row.state in { + "contract_committed", "evidence_pending", "online_primary_signed", + "online_attested", "online_receipt_persisted", "runtime_primary_signed", + "runtime_attested", "compensation_failed", + } + ) + ) + if committed: + public_state = "finalized" if row.state == "finalized" else "committed_and_verified" + state_version = 4 if row.state == "finalized" else 3 + elif rolled_back: + public_state = "rolled_back" + state_version = 2 + elif irreversible_failure: + public_state = "irreversible_failure" + state_version = 2 + else: + public_state = "prepared" + state_version = 1 + if irreversible_failure and not all((row.failure_code, row.failure_at, row.failure_evidence_sha256)): + raise Rc06FacadeError("RC06 irreversible failure evidence is incomplete.") + online = _decode(row.online_receipt_json, None) if committed else None + runtime = _decode(row.runtime_receipt_json, None) if committed else None + apply_receipt = _decode(row.apply_receipt_json, None) if row.state == "finalized" else None + rollback = _decode(row.rollback_response_json, None) if rolled_back else None + return { + "schema_version": APPLY_STATUS_SCHEMA, + "idempotency_key": row.idempotency_key, + "request_sha256": row.request_sha256, + "transaction_id": row.transaction_id, + "state": public_state, + "state_version": state_version, + "no_go": row.state != "finalized", + "legal_source_review_binding_sha256": row.legal_source_review_binding_sha256, + "environment_id": row.environment_id, + "tenant_id": row.tenant_id, + "transaction_mode": TRANSACTION_MODE, + "cross_database_atomicity": False, + "publication_item_results": ( + request.get("expected_publication_item_results", []) if committed else [] + ), + "publication_item_count": 74 if committed else 0, + "runtime_snapshot_dependencies": ( + request.get("expected_runtime_snapshot_dependencies", []) if committed else [] + ), + "runtime_snapshot_dependency_count": 14 if committed else 0, + "participant_evidence": _decode(row.participant_evidence_json, []) if committed else [], + "online_transaction_receipt": online, + "online_transaction_receipt_sha256": row.online_receipt_sha256 if committed else None, + "runtime_snapshot_receipt": runtime, + "runtime_snapshot_receipt_sha256": row.runtime_receipt_sha256 if committed else None, + "apply_receipt": apply_receipt, + "apply_receipt_sha256": row.apply_receipt_sha256 if row.state == "finalized" else None, + "finalized_at": iso_time(_utc(row.finalized_at)) if row.state == "finalized" else None, + "rollback_response": rollback, + "rollback_response_sha256": row.rollback_response_sha256 if rolled_back else None, + "failure_code": row.failure_code if irreversible_failure else None, + "failure_at": iso_time(_utc(row.failure_at)) if irreversible_failure else None, + "failure_evidence_sha256": row.failure_evidence_sha256 if irreversible_failure else None, + "status_updated_at": iso_time(_utc(row.updated_at)), + } + + def _apply_response(self, row: Rc06OnlinePublicationTransactionModel) -> dict[str, Any]: + online = _decode(row.online_receipt_json, None) + runtime = _decode(row.runtime_receipt_json, None) + if not isinstance(online, dict) or not isinstance(runtime, dict): + raise Rc06FacadeError("RC06 signed receipt pair is incomplete.") + frozen = _decode(row.request_json, {}) + return { + "schema_version": APPLY_RESPONSE_SCHEMA, + "idempotency_key": row.idempotency_key, + "request_sha256": row.request_sha256, + "legal_source_review_binding_sha256": row.legal_source_review_binding_sha256, + "legal_source_review_expires_at": frozen["legal_source_review_expires_at"], + "transaction_id": row.transaction_id, + "environment_id": row.environment_id, + "tenant_id": row.tenant_id, + "transaction_mode": TRANSACTION_MODE, + "cross_database_atomicity": False, + "publication_item_count": 74, + "runtime_snapshot_dependency_count": 14, + "online_transaction_receipt": online, + "runtime_snapshot_receipt": runtime, + } + + def rollback( + self, + transaction_id: str, + request: dict[str, Any], + headers: Rc06FacadeHeaders, + ) -> dict[str, Any]: + row = self._load(transaction_id) + self._assert_status_headers(row, headers) + require_exact(request, "rollback request", ( + "schema_version", "idempotency_key", "transaction_id", + "batch_manifest_raw_sha256", "approval_receipt_sha256", + "legal_source_review_binding_sha256", "environment_id", "tenant_id", + "transaction_mode", "cross_database_atomicity", + )) + if ( + request["schema_version"] != ROLLBACK_REQUEST_SCHEMA + or request["idempotency_key"] != row.idempotency_key + or request["transaction_id"] != transaction_id + or request["batch_manifest_raw_sha256"] != row.batch_manifest_raw_sha256 + or request["approval_receipt_sha256"] != row.approval_receipt_sha256 + or request["legal_source_review_binding_sha256"] != row.legal_source_review_binding_sha256 + or request["environment_id"] != self._environment_id + or request["tenant_id"] != self._tenant_id + or request["transaction_mode"] != TRANSACTION_MODE + or request["cross_database_atomicity"] is not False + ): + raise Rc06FacadeConflict("RC06 rollback request binding drifted.") + persisted = _decode(row.rollback_response_json, None) + if persisted is not None: + return persisted + if row.state in { + "contract_committed", "evidence_pending", "online_primary_signed", + "online_attested", "online_receipt_persisted", "runtime_primary_signed", + "runtime_attested", "online_committed_pending_countersign", "finalized", + } or row.contract_committed_at is not None: + self._mark_no_go(row, "rollback forbidden after Contract durable commit") + raise Rc06FacadeConflict( + "RC06 Contract participant crossed the irreversible boundary; rollback is forbidden." + ) + try: + before = self._coordinator.status(transaction_id, tenant_id=self._tenant_id) + except KeyError: + before = None + if before is not None and before.state in {"contract_committed", "committed"}: + self._set_state( + transaction_id, + "contract_committed", + contract_committed_at=before.contract_committed_at, + error="rollback forbidden after reconciled Contract durable commit", + ) + self._mark_no_go(row, "rollback forbidden after reconciled Contract durable commit") + raise Rc06FacadeConflict( + "RC06 Contract participant crossed the irreversible boundary; rollback is forbidden." + ) + ai_was_committed = bool( + before is not None + and ( + before.state == "ai_committed" + or before.compensated_from_state == "committed" + ) + ) + ai_before_state = "committed" if ai_was_committed else ( + "prepared" if before is not None else "not_started" + ) + contract_before_state = "prepared" if before is not None else "not_started" + before_states = [ + _state_evidence("ai-platform", ai_before_state, 14 if ai_was_committed else 0, 14 if ai_was_committed else 0), + _state_evidence("contract-platform", contract_before_state, 0, 0), + ] + self._set_state(transaction_id, "compensating") + if before is None: + after = None + compensation_items: list[dict[str, Any]] = [] + runtime_items: list[dict[str, Any]] = [] + counts = (0, 0, 0) + else: + try: + after = self._coordinator.compensate(transaction_id, tenant_id=self._tenant_id) + except Exception as error: + try: + after = self._coordinator.status(transaction_id, tenant_id=self._tenant_id) + except Exception: + self._set_state(transaction_id, "compensation_failed", error=str(error)) + self._mark_no_go(row, "compensation outcome could not be proven", outcome="compensation_failed") + raise Rc06FacadeError("RC06 compensation outcome could not be proven.") from error + if after.state != "compensated": + self._set_state(transaction_id, "compensation_failed", error=after.error) + self._mark_no_go(row, "compensation did not reach a verified terminal state", outcome="compensation_failed") + raise Rc06FacadeError("RC06 compensation did not reach a verified terminal state.") + compensation_items = after.compensation_item_results + runtime_items = after.runtime_snapshot_compensation_results + counts = ( + after.restored_item_count, + after.removed_item_count, + after.unchanged_item_count, + ) + if ai_was_committed: + if ( + after.compensated_from_state != "committed" + or len(compensation_items) != 14 + or len(runtime_items) != 14 + or not isinstance(after.compensation_evidence_sha256, str) + ): + raise Rc06FacadeError("RC06 AI committed compensation evidence is incomplete.") + elif compensation_items or runtime_items or after.compensated_from_state != "prepared": + raise Rc06FacadeError("RC06 prepared compensation falsely claims writes.") + after_states = [ + _state_evidence( + "ai-platform", + "compensated_and_verified" if ai_was_committed else "unchanged", + 0, + 0, + ), + _state_evidence("contract-platform", "unchanged", 0, 0), + ] + response = { + "schema_version": ROLLBACK_RESPONSE_SCHEMA, + "idempotency_key": row.idempotency_key, + "transaction_id": transaction_id, + "state": "rolled_back", + "participant_states_before": before_states, + "participant_states_after": after_states, + "compensation_item_results": compensation_items, + "runtime_snapshot_compensation_results": runtime_items, + "restored_item_count": counts[0], + "removed_item_count": counts[1], + "unchanged_item_count": counts[2], + } + response["rollback_evidence_sha256"] = sha256_json(response) + response_sha256 = sha256_json(response) + now = datetime.now(UTC) + self._update( + transaction_id, + state="rolled_back", + rollback_response_json=_encode(response), + rollback_evidence_sha256=response["rollback_evidence_sha256"], + rollback_response_sha256=response_sha256, + rolled_back_at=now, + error_message=None, + failure_code=None, + failure_at=None, + failure_evidence_sha256=None, + ) + self._consume_challenge(row.challenge_id, "rolled_back", now) + return response + + def finalize( + self, + transaction_id: str, + request: dict[str, Any], + headers: Rc06FacadeHeaders, + ) -> dict[str, Any]: + row = self._load(transaction_id) + self._assert_status_headers(row, headers) + request_sha256 = sha256_json(request) + if headers.request_sha256 != request_sha256: + raise Rc06FacadeConflict("RC06 finalize request hash header drifted.") + require_exact(request, "finalize request", ( + "schema_version", "idempotency_key", "transaction_id", "environment_id", + "tenant_id", "batch_manifest_raw_sha256", "approval_receipt_sha256", + "legal_source_review_binding_sha256", "online_transaction_receipt_sha256", + "runtime_snapshot_receipt_sha256", "apply_receipt_sha256", "apply_receipt", + )) + if ( + request["schema_version"] != FINALIZE_REQUEST_SCHEMA + or request["idempotency_key"] != row.idempotency_key + or request["transaction_id"] != transaction_id + or request["environment_id"] != row.environment_id + or request["tenant_id"] != row.tenant_id + or request["batch_manifest_raw_sha256"] != row.batch_manifest_raw_sha256 + or request["approval_receipt_sha256"] != row.approval_receipt_sha256 + or request["legal_source_review_binding_sha256"] != row.legal_source_review_binding_sha256 + or request["online_transaction_receipt_sha256"] != row.online_receipt_sha256 + or request["runtime_snapshot_receipt_sha256"] != row.runtime_receipt_sha256 + or request["apply_receipt_sha256"] != sha256_json(request["apply_receipt"]) + ): + raise Rc06FacadeConflict("RC06 finalize request binding drifted.") + if row.state == "finalized": + if row.apply_receipt_sha256 != request["apply_receipt_sha256"]: + raise Rc06FacadeConflict("RC06 transaction was finalized with different countersign evidence.") + return self._finalize_response(row) + if row.state != "online_committed_pending_countersign": + raise Rc06FacadeConflict("RC06 transaction is not awaiting offline countersign.") + countersigned_at = self._validate_apply_receipt(row, request["apply_receipt"]) + now = datetime.now(UTC) + if now < countersigned_at: + now = countersigned_at + self._update( + transaction_id, + state="finalized", + apply_receipt_json=_encode(request["apply_receipt"]), + apply_receipt_sha256=request["apply_receipt_sha256"], + finalized_at=now, + error_message=None, + failure_code=None, + failure_at=None, + failure_evidence_sha256=None, + ) + self._consume_challenge(row.challenge_id, "finalized", now) + return self._finalize_response(self._load(transaction_id)) + + def _validate_apply_receipt( + self, + row: Rc06OnlinePublicationTransactionModel, + receipt: Any, + ) -> datetime: + envelope = require_exact(receipt, "apply receipt", ( + "schema_version", "receipt_id", "payload", "signature", + )) + if envelope["schema_version"] != APPLY_RECEIPT_SCHEMA: + raise Rc06FacadeError("Apply receipt schema is unsupported.") + payload = require_exact(envelope["payload"], "apply receipt payload", ( + "schema_version", "batch_manifest_raw_sha256", "batch_manifest_payload_sha256", + "approval_receipt_sha256", "legal_source_review_binding_sha256", "environment_id", + "tenant_id", "idempotency_key", "transaction_id", "authorized_commit_at", + "countersigned_at", "generated_by", "transaction_mode", "cross_database_atomicity", + "item_results", "write_count", "status", "online_transaction_receipt", + "runtime_snapshot_receipt", + )) + frozen = _decode(row.request_json, {}) + online = _decode(row.online_receipt_json, None) + runtime = _decode(row.runtime_receipt_json, None) + if ( + payload["schema_version"] != APPLY_RECEIPT_PAYLOAD_SCHEMA + or payload["batch_manifest_raw_sha256"] != row.batch_manifest_raw_sha256 + or payload["batch_manifest_payload_sha256"] != row.batch_manifest_payload_sha256 + or payload["approval_receipt_sha256"] != row.approval_receipt_sha256 + or payload["legal_source_review_binding_sha256"] != row.legal_source_review_binding_sha256 + or payload["environment_id"] != row.environment_id + or payload["tenant_id"] != row.tenant_id + or payload["idempotency_key"] != row.idempotency_key + or payload["transaction_id"] != row.transaction_id + or payload["generated_by"] != {"type": "system", "component": "rc06-apply-authority"} + or payload["transaction_mode"] != TRANSACTION_MODE + or payload["cross_database_atomicity"] is not False + or payload["item_results"] != frozen["expected_publication_item_results"] + or payload["write_count"] != 74 + or payload["status"] != "applied_and_verified" + or payload["online_transaction_receipt"] != online + or payload["runtime_snapshot_receipt"] != runtime + ): + raise Rc06FacadeError("Offline apply receipt binding drifted.") + authorized_at = parse_time(payload["authorized_commit_at"], "authorized_commit_at") + countersigned_at = parse_time(payload["countersigned_at"], "countersigned_at") + runtime_captured_at = parse_time(runtime["payload"]["captured_at"], "runtime captured_at") + approval = frozen["approval_receipt"] + if ( + authorized_at != runtime_captured_at + or countersigned_at < authorized_at + or countersigned_at >= parse_time(approval["payload"]["expires_at"], "approval expires_at") + or countersigned_at >= _utc(row.legal_source_review_expires_at) + ): + raise Rc06FacadeError("Offline apply receipt is outside its authority window.") + try: + signature = base64.b64decode(envelope["signature"], validate=True) + self._apply_service_public_key.verify( + signature, + APPLY_RECEIPT_SIGNATURE_DOMAIN + canonical_json(payload), + ) + except (TypeError, ValueError, InvalidSignature) as error: + raise Rc06FacadeError("Offline apply receipt signature is invalid.") from error + expected_id = "rc06-apply-receipt-" + sha256_bytes(canonical_json(payload) + signature)[:24] + if envelope["receipt_id"] != expected_id: + raise Rc06FacadeError("Offline apply receipt id drifted.") + return countersigned_at + + @staticmethod + def _finalize_response(row: Rc06OnlinePublicationTransactionModel) -> dict[str, Any]: + return { + "schema_version": FINALIZE_RESPONSE_SCHEMA, + "idempotency_key": row.idempotency_key, + "transaction_id": row.transaction_id, + "state": "finalized", + "no_go": False, + "apply_receipt_sha256": row.apply_receipt_sha256, + "finalized_at": iso_time(_utc(row.finalized_at)), + } + + def _assert_request_headers( + self, + request: Mapping[str, Any], + headers: Rc06FacadeHeaders, + *, + request_sha256: str, + ) -> None: + if ( + headers.idempotency_key != request.get("idempotency_key") + or headers.manifest_sha256 != request.get("batch_manifest_raw_sha256") + or headers.approval_sha256 != request.get("approval_receipt_sha256") + or headers.legal_sha256 != request.get("legal_source_review_binding_sha256") + or headers.tenant_id != self._tenant_id + or request.get("tenant_id") != self._tenant_id + or request.get("environment_id") != self._environment_id + or headers.request_sha256 != request_sha256 + ): + raise Rc06FacadeConflict("RC06 request headers do not match the server-bound request.") + + def _assert_status_headers( + self, + row: Rc06OnlinePublicationTransactionModel, + headers: Rc06FacadeHeaders, + ) -> None: + if ( + headers.idempotency_key != row.idempotency_key + or headers.manifest_sha256 != row.batch_manifest_raw_sha256 + or headers.approval_sha256 != row.approval_receipt_sha256 + or headers.legal_sha256 != row.legal_source_review_binding_sha256 + or headers.tenant_id != self._tenant_id + ): + raise Rc06FacadeConflict("RC06 status headers do not match the durable transaction.") + + def _load(self, transaction_id: str) -> Rc06OnlinePublicationTransactionModel: + if _TRANSACTION_ID.fullmatch(transaction_id) is None: + raise KeyError(transaction_id) + with self._session_factory() as session: + row = session.get(Rc06OnlinePublicationTransactionModel, transaction_id) + if row is None or row.tenant_id != self._tenant_id: + raise KeyError(transaction_id) + self._assert_frozen_row(row) + session.expunge(row) + return row + + @staticmethod + def _assert_frozen_row(row: Rc06OnlinePublicationTransactionModel) -> None: + try: + request = _decode(row.request_json, {}) + prepare = Rc06PrepareRequest.model_validate(_decode(row.prepare_request_json, {})) + except Exception as error: + raise Rc06FacadeError("RC06 durable transaction JSON is invalid.") from error + if ( + sha256_json(request) != row.request_sha256 + or request.get("transaction_id") != row.transaction_id + or request.get("idempotency_key") != row.idempotency_key + or request.get("environment_id") != row.environment_id + or request.get("tenant_id") != row.tenant_id + or request.get("batch_manifest_raw_sha256") != row.batch_manifest_raw_sha256 + or request.get("batch_manifest_payload_sha256") != row.batch_manifest_payload_sha256 + or request.get("approval_receipt_sha256") != row.approval_receipt_sha256 + or request.get("legal_source_review_binding_sha256") != row.legal_source_review_binding_sha256 + or request.get("authority_challenge", {}).get("challenge_id") != row.challenge_id + or sha256_json(request.get("authority_challenge")) != row.challenge_sha256 + or prepare.transaction_id != row.transaction_id + or prepare.idempotency_key != row.idempotency_key + or prepare.candidate_commit != row.candidate_commit + or prepare.contract_release_image_digest != row.contract_release_image_digest + or prepare.ai_release_image_digest != row.ai_release_image_digest + ): + raise Rc06FacadeError("RC06 durable transaction binding drifted.") + + def _update(self, transaction_id: str, **values: Any) -> None: + with _facade_lock, self._session_factory.begin() as session: + self._lock_transaction(session, transaction_id) + row = session.get(Rc06OnlinePublicationTransactionModel, transaction_id) + if row is None or row.tenant_id != self._tenant_id: + raise KeyError(transaction_id) + self._assert_frozen_row(row) + for key, value in values.items(): + if not hasattr(row, key): + raise Rc06FacadeError(f"Unsupported RC06 durable field: {key}") + setattr(row, key, value) + row.updated_at = datetime.now(UTC) + + def _set_state( + self, + transaction_id: str, + state: str, + *, + error: str | None = None, + contract_committed_at: datetime | None = None, + ) -> None: + values: dict[str, Any] = { + "state": state, + "error_message": error[:4000] if isinstance(error, str) else None, + } + if contract_committed_at is not None: + values["contract_committed_at"] = _utc(contract_committed_at) + self._update(transaction_id, **values) + + def _record_saga_status(self, transaction_id: str, status: Rc06SagaStatus) -> None: + self._update( + transaction_id, + saga_status_json=_encode(status.model_dump(mode="json")), + contract_committed_at=_utc(status.contract_committed_at), + error_message=status.error, + ) + + def _record_error(self, transaction_id: str, error: Exception) -> None: + try: + row = self._load(transaction_id) + state = row.state + if state in {"online_committed_pending_countersign", "finalized", "rolled_back"}: + # These externally meaningful states are monotonic. A bad + # replay/finalize request cannot overwrite valid evidence. + return + if row.contract_committed_at is not None and state in { + "authorized", "saga_prepared", "saga_committing", "ai_committed", + }: + state = "contract_committed" + message = f"{type(error).__name__}: {error}" + irreversible = row.contract_committed_at is not None or state in { + "contract_committed", "evidence_pending", "online_primary_signed", + "online_attested", "online_receipt_persisted", "runtime_primary_signed", + "runtime_attested", "compensation_failed", + } + if irreversible: + failed_at = datetime.now(UTC) + code = re.sub(r"[^a-z0-9_]+", "_", type(error).__name__.lower()).strip("_")[:128] + saga_status = _decode(row.saga_status_json, None) + evidence = { + "schema_version": "yuqei.rc06-irreversible-failure-evidence.v1", + "transaction_id": transaction_id, + "request_sha256": row.request_sha256, + "failure_code": code, + "failure_at": iso_time(failed_at), + "internal_state": state, + "contract_committed_at": ( + iso_time(_utc(row.contract_committed_at)) + if row.contract_committed_at is not None else None + ), + "saga_status_sha256": sha256_json(saga_status) if saga_status is not None else None, + } + self._update( + transaction_id, + state=state, + error_message=message[:4000], + failure_code=code, + failure_at=failed_at, + failure_evidence_sha256=sha256_json(evidence), + ) + self._consume_challenge(row.challenge_id, "no_go", failed_at) + else: + self._set_state(transaction_id, state, error=message) + except Exception: + return + + def _consume_challenge(self, challenge_id: str, outcome: str, at: datetime) -> None: + with _facade_lock, self._session_factory.begin() as session: + use = session.get(Rc06ApplyChallengeUseModel, challenge_id) + if use is None: + raise Rc06FacadeError("RC06 challenge consumption ledger is missing.") + if use.outcome == "finalized" and outcome != "finalized": + raise Rc06FacadeConflict("Finalized RC06 challenge outcome is immutable.") + use.outcome = outcome + use.consumed_at = at + + def _mark_no_go( + self, + row: Rc06OnlinePublicationTransactionModel, + error: str, + *, + outcome: str = "no_go", + ) -> None: + self._record_error(row.transaction_id, Rc06FacadeError(error)) + if outcome != "no_go": + self._consume_challenge(row.challenge_id, outcome, datetime.now(UTC)) + + @staticmethod + def _facade_state(saga_state: str, *, committing: bool = False) -> str: + if saga_state == "prepared": + return "saga_committing" if committing else "saga_prepared" + if saga_state == "ai_committed": + return "ai_committed" + if saga_state in {"contract_committed", "committed"}: + return "contract_committed" + if saga_state == "compensated": + return "failed_before_commit" + if saga_state == "compensation_failed": + return "compensation_failed" + return "failed_before_commit" + + @staticmethod + def _public_state(row: Rc06OnlinePublicationTransactionModel) -> str: + if row.state == "online_committed_pending_countersign": + # Apply-status v2 predates the explicit countersign state; expose + # its exact success token while the durable journal preserves the + # stronger internal boundary. Finalized status uses its own schema. + return "committed_and_verified" + mapping = { + "authorized": "prepared", + "saga_prepared": "prepared", + "saga_committing": "ai_committed", + "online_primary_signed": "evidence_pending", + "online_attested": "evidence_pending", + "online_receipt_persisted": "evidence_pending", + "runtime_primary_signed": "evidence_pending", + "runtime_attested": "evidence_pending", + "compensating": "ai_committed", + } + return mapping.get(row.state, row.state) + + @staticmethod + def _lock_transaction(session: Any, transaction_id: str) -> None: + dialect = session.get_bind().dialect.name + if dialect == "postgresql": + key = int.from_bytes( + hashlib.sha256(f"rc06-online-facade:{transaction_id}".encode()).digest()[:8], + "big", + signed=True, + ) + session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": key}) + elif dialect == "sqlite": + session.execute(text("BEGIN IMMEDIATE")) + + +def is_rc06_publication_facade_path(path: str) -> bool: + base = "/internal/rc06/publication-transactions" + if path == f"{base}/apply": + return True + transaction = r"[A-Za-z0-9][A-Za-z0-9._:-]{7,63}" + return re.fullmatch( + rf"{re.escape(base)}/{transaction}/(?:status|rollback|finalize)", path + ) is not None + + +def register_rc06_publication_facade_routes( + app: FastAPI, + *, + settings: ContractApiSettings, + repository: Any, + ai_client: AiRc06Client | None = None, + authority: Rc06FacadeAuthority | None = None, + signer: Rc06FacadeSigner | None = None, + coordinator: Rc06FacadeCoordinator | None = None, + apply_service_public_key: Ed25519PublicKey | None = None, +) -> Rc06PublicationFacadeService | None: + if not settings.rc06_publication_facade_enabled: + return None + service = Rc06PublicationFacadeService( + settings=settings, + repository=repository, + ai_client=ai_client, + authority=authority, + signer=signer, + coordinator=coordinator, + apply_service_public_key=apply_service_public_key, + ) + app.state.rc06_publication_facade = service + base = "/internal/rc06/publication-transactions" + + def headers( + idempotency_key: str | None, + manifest_sha256: str | None, + approval_sha256: str | None, + legal_sha256: str | None, + tenant_id: str | None, + request_sha256: str | None = None, + ) -> Rc06FacadeHeaders: + values = (idempotency_key, manifest_sha256, approval_sha256, legal_sha256, tenant_id) + if any(not isinstance(value, str) or not value for value in values): + raise HTTPException(status_code=400, detail="Required RC06 binding headers are missing.") + return Rc06FacadeHeaders( + idempotency_key=idempotency_key, + manifest_sha256=manifest_sha256, + approval_sha256=approval_sha256, + legal_sha256=legal_sha256, + tenant_id=tenant_id, + request_sha256=request_sha256, + ) + + def authenticate(authorization: str | None) -> None: + try: + service.authenticate(authorization) + except Rc06FacadeError as error: + raise HTTPException(status_code=401, detail=str(error)) from error + + async def body(request: Request) -> dict[str, Any]: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + raise HTTPException(status_code=415, detail="RC06 requests require application/json.") + declared = request.headers.get("content-length") + if declared is not None: + try: + if int(declared) > settings.rc06_publication_max_body_bytes: + raise HTTPException(status_code=413, detail="RC06 request body is too large.") + except ValueError as error: + raise HTTPException(status_code=400, detail="Invalid Content-Length header.") from error + try: + return _strict_json( + await request.body(), maximum=settings.rc06_publication_max_body_bytes + ) + except Rc06FacadeError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + def invoke(operation): + try: + return operation() + except KeyError as error: + raise HTTPException(status_code=404, detail="RC06 publication transaction not found.") from error + except (Rc06FacadeConflict, Rc06Conflict) as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except Rc06ProtocolError as error: + raise HTTPException(status_code=403, detail=str(error)) from error + except (Rc06FacadeError, Rc06PublicationError, EvidenceSigningError) as error: + raise HTTPException(status_code=503, detail=str(error)) from error + + @app.post(f"{base}/apply", tags=["internal", "rc06"]) + async def apply( + request: Request, + authorization: str | None = Header(default=None, alias="Authorization"), + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + manifest_sha256: str | None = Header(default=None, alias="X-RC06-Manifest-SHA256"), + approval_sha256: str | None = Header(default=None, alias="X-RC06-Approval-SHA256"), + legal_sha256: str | None = Header(default=None, alias="X-RC06-Legal-Review-SHA256"), + tenant_id: str | None = Header(default=None, alias="X-RC06-Tenant-ID"), + request_sha256: str | None = Header(default=None, alias="X-RC06-Request-SHA256"), + ): + authenticate(authorization) + payload = await body(request) + bound = headers( + idempotency_key, manifest_sha256, approval_sha256, legal_sha256, + tenant_id, request_sha256, + ) + return invoke(lambda: service.apply(payload, bound)) + + @app.get(f"{base}/{{transaction_id}}/status", tags=["internal", "rc06"]) + def status( + transaction_id: str, + authorization: str | None = Header(default=None, alias="Authorization"), + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + manifest_sha256: str | None = Header(default=None, alias="X-RC06-Manifest-SHA256"), + approval_sha256: str | None = Header(default=None, alias="X-RC06-Approval-SHA256"), + legal_sha256: str | None = Header(default=None, alias="X-RC06-Legal-Review-SHA256"), + tenant_id: str | None = Header(default=None, alias="X-RC06-Tenant-ID"), + ): + authenticate(authorization) + bound = headers(idempotency_key, manifest_sha256, approval_sha256, legal_sha256, tenant_id) + return invoke(lambda: service.status(transaction_id, bound)) + + @app.post(f"{base}/{{transaction_id}}/rollback", tags=["internal", "rc06"]) + async def rollback( + transaction_id: str, + request: Request, + authorization: str | None = Header(default=None, alias="Authorization"), + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + manifest_sha256: str | None = Header(default=None, alias="X-RC06-Manifest-SHA256"), + approval_sha256: str | None = Header(default=None, alias="X-RC06-Approval-SHA256"), + legal_sha256: str | None = Header(default=None, alias="X-RC06-Legal-Review-SHA256"), + tenant_id: str | None = Header(default=None, alias="X-RC06-Tenant-ID"), + ): + authenticate(authorization) + payload = await body(request) + bound = headers(idempotency_key, manifest_sha256, approval_sha256, legal_sha256, tenant_id) + return invoke(lambda: service.rollback(transaction_id, payload, bound)) + + @app.post(f"{base}/{{transaction_id}}/finalize", tags=["internal", "rc06"]) + async def finalize( + transaction_id: str, + request: Request, + authorization: str | None = Header(default=None, alias="Authorization"), + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + manifest_sha256: str | None = Header(default=None, alias="X-RC06-Manifest-SHA256"), + approval_sha256: str | None = Header(default=None, alias="X-RC06-Approval-SHA256"), + legal_sha256: str | None = Header(default=None, alias="X-RC06-Legal-Review-SHA256"), + tenant_id: str | None = Header(default=None, alias="X-RC06-Tenant-ID"), + request_sha256: str | None = Header(default=None, alias="X-RC06-Request-SHA256"), + ): + authenticate(authorization) + payload = await body(request) + bound = headers( + idempotency_key, manifest_sha256, approval_sha256, legal_sha256, + tenant_id, request_sha256, + ) + return invoke(lambda: service.finalize(transaction_id, payload, bound)) + + return service diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_protocol.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_protocol.py new file mode 100644 index 00000000..2f5e03c3 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_protocol.py @@ -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"], + ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py index a35587c8..bb3f8a0b 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/schema_check.py @@ -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": ( diff --git a/yuqei-contract-platform/services/contract-api/tests/test_rc06_migrations.py b/yuqei-contract-platform/services/contract-api/tests/test_rc06_migrations.py index 415c1f35..33a0e1a2 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_rc06_migrations.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_rc06_migrations.py @@ -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() diff --git a/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_facade.py b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_facade.py new file mode 100644 index 00000000..3167e3c4 --- /dev/null +++ b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_facade.py @@ -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()