diff --git a/.github/release-control-contract.json b/.github/release-control-contract.json new file mode 100644 index 00000000..775157b5 --- /dev/null +++ b/.github/release-control-contract.json @@ -0,0 +1,207 @@ +{ + "schema_version": 1, + "contract_edition": "wp562-f1-c", + "repository": { + "owner": "aisystant", + "name": "aist_bot" + }, + "source_refs": { + "pilot": "pilot", + "production": "new-architecture" + }, + "artifact": { + "digest_kind": "oci_manifest", + "oci_repository": "registry.example.invalid/team/app", + "platform": { + "os": "linux", + "architecture": "amd64" + }, + "immutable_reference_required": true, + "embedded_manifest_excludes": [ + "artifact_digest", + "manifest_hash" + ] + }, + "authority": { + "audience": "iwe.release-control.runner", + "independent_approver_required": true, + "max_uses": 1, + "purpose": "iwe.release-control.authority.v1", + "trust_root_identity": "release-authority-root", + "required_signed_fields": [ + "approver_identity", + "audience", + "authority_id", + "contract_digest", + "contract_edition", + "expires_at", + "issued_at", + "key_id", + "max_uses", + "nonce", + "operation_fingerprint", + "purpose", + "release_id", + "repository", + "required_checks_digest", + "runner_identity", + "schema_version", + "target" + ], + "ttl_seconds": 3600 + }, + "ledger": { + "append_only_required": true, + "claim_field_allowlist": [ + "artifact_digest", + "authorization_id", + "contract_digest", + "evidence_hash", + "external_id", + "operation_fingerprint", + "operation_kind", + "target_key" + ], + "durable_claim_required": true, + "external_receipt_required": true, + "raw_evidence_forbidden": true, + "target_version_cas_required": true + }, + "promotion": { + "build_once": true, + "check_suite_max_age_seconds": 3600, + "check_suite_receipt_required": true, + "independent_review_required": true, + "pilot_qualification_independent_verifier_required": true, + "pilot_qualification_max_age_seconds": 300, + "pilot_qualification_receipt_required": true, + "pilot_qualification_required_signals": [ + "canary", + "data-invariants", + "readiness", + "runtime-attestation" + ], + "provider_deadline_required": true, + "provider_fencing_required": true, + "provider_idempotency_required": true, + "same_digest_required": true, + "source_candidate_max_age_seconds": 3600, + "source_candidate_receipt_required": true, + "reconciliation": { + "zero": "observed_not_applied", + "one": "observed_applied", + "many": "manual_review" + }, + "reconciliation_settling_seconds": 60, + "zero_settlement_min_interval_seconds": 30, + "zero_settlement_observations": 2 + }, + "rollback": { + "compatibility_max_age_seconds": 1800, + "compatibility_receipt_required": true, + "historical_build_contract_receipt_required": true, + "independent_verifier_required": true, + "same_previous_good_digest": true, + "rebuild_forbidden": true, + "require_can_rollback": true, + "require_schema_compatibility": true, + "retention_recheck_seconds": 3600, + "reattestation_seconds": 1800 + }, + "required_checks": [ + "bandit-sast", + "full-regression", + "l1-l2-smoke-regression", + "pilot-production-semantic-parity", + "pip-audit", + "release-candidate-build", + "release-control-contract", + "scenario-compliance" + ], + "forbidden_paths": [ + "hashlib.py", + "hashlib/__init__.py", + "pytest.py", + "pytest/__init__.py", + "pytest/__main__.py", + "release_manifest/__init__.py", + "scripts.py", + "scripts/__init__.py", + "scripts/pytest.py", + "scripts/release_control/__init__.py", + "scripts/release_manifest.py", + "scripts/sitecustomize.py", + "scripts/usercustomize.py", + "sitecustomize.py", + "tests/conftest.py", + "usercustomize.py" + ], + "protected_paths": [ + ".dockerignore", + ".gitattributes", + ".githooks/pre-push", + ".github/CODEOWNERS", + ".github/actions/reject-python-shadows/action.yml", + ".github/release-control-contract.json", + ".github/release-metadata.json", + ".github/workflows/check-dt-indicators.yml", + ".github/workflows/pilot-prod-sync.yml", + ".github/workflows/release-control-dry-run.yml", + ".github/workflows/scenario-compliance.yml", + ".github/workflows/security.yml", + ".github/workflows/smoke-tests.yml", + ".gitmodules", + "CLAUDE.md", + "Dockerfile", + "PROCESSES.md", + "bot.py", + "clients/github_content.py", + "config/settings.py", + "conftest.py", + "core/autofix.py", + "core/health_check.py", + "core/scheduler.py", + "db/migrations/**", + "db/models.py", + "db/queries/autofix.py", + "oauth_server.py", + "pytest.ini", + "railway.json", + "readiness.py", + "release-manifest.unavailable.json", + "release_attestation.py", + "release_manifest.py", + "requirements.in", + "requirements.txt", + "scripts/build_release_manifest.py", + "scripts/check_pilot_prod_parity.py", + "scripts/check_release_metadata.py", + "scripts/record_pilot_prod_correspondence.py", + "scripts/release_control.py", + "tests/__init__.py", + "tests/test_autofix_security.py", + "tests/test_build_release_manifest.py", + "tests/test_health_check_l3.py", + "tests/test_pilot_prod_correspondence.py", + "tests/test_pilot_prod_parity.py", + "tests/test_readiness.py", + "tests/test_release_attestation.py", + "tests/test_release_control.py", + "tests/test_release_control_workflows.py", + "tests/test_release_manifest.py", + "tests/test_release_metadata.py", + "tests/test_repo/__init__.py", + "tests/test_repo/requirements-scenarios.yaml", + "tests/test_repo/scripts/__init__.py", + "tests/test_repo/scripts/checker.py", + "tests/test_repo/scripts/code_analyzer.py", + "tests/test_repo/scripts/report_generator.py", + "wheels/activity_hub-0.1.0-py3-none-any.whl" + ], + "cutover": { + "enabled": false, + "negative_matrix_required": true, + "negative_matrix_max_age_seconds": 86400, + "negative_matrix_evidence_ref": null + } +} diff --git a/.github/release-drift-manifest.jsonl b/.github/release-drift-manifest.jsonl new file mode 100644 index 00000000..f0a3b6d6 --- /dev/null +++ b/.github/release-drift-manifest.jsonl @@ -0,0 +1,42 @@ +{"schema_version": 2, "delta_id": "new_architecture_only:04c8a69087a5fc26d50a46260de821251548827f", "direction": "new_architecture_only", "source_sha": "04c8a69087a5fc26d50a46260de821251548827f", "source_subject": "chore(wp554): удалить /migrate_tokens — своё дело сделала", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat", "token"], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:12ff418a0010a832e1b5fb1e23f2bfc880676089", "direction": "new_architecture_only", "source_sha": "12ff418a0010a832e1b5fb1e23f2bfc880676089", "source_subject": "fix(rewards): grant rewards_points_engine_owner baseline point_balances access", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:1428756cdd514d39bdda754611e577bdbe4b7492", "direction": "new_architecture_only", "source_sha": "1428756cdd514d39bdda754611e577bdbe4b7492", "source_subject": "fix(publisher): avoid duplicate schedule confirmations", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:180ecaf54a25b44fee3ce3f249bddc4de57d6957", "direction": "new_architecture_only", "source_sha": "180ecaf54a25b44fee3ce3f249bddc4de57d6957", "source_subject": "fix(wp544): minimize tool audit payload", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:1a625d4d4b95a296bbc3198fe12ebbef3fe5235e", "direction": "new_architecture_only", "source_sha": "1a625d4d4b95a296bbc3198fe12ebbef3fe5235e", "source_subject": "fix(privacy): remove PII from consent event ids", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["consent", "privacy"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:1f4497c9897c9f2db4e945519692156ae85e5e28", "direction": "new_architecture_only", "source_sha": "1f4497c9897c9f2db4e945519692156ae85e5e28", "source_subject": "feat(github-app): принудительная приватность репо после установки", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["oauth", "privacy"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:2a5c7c2c8f7d937ed9a5553d45be08c24b6c1b1f", "direction": "new_architecture_only", "source_sha": "2a5c7c2c8f7d937ed9a5553d45be08c24b6c1b1f", "source_subject": "feat(wp554): Б4 — шифровать все OAuth-токены в user_integrations/dt_tokens", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["oauth", "token"], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:2c90c997516e2576950010c5533e59e25a52975a", "direction": "new_architecture_only", "source_sha": "2c90c997516e2576950010c5533e59e25a52975a", "source_subject": "fix(dev-stats): 4th instance of the wrong-schema bug — learning./health. → public. [cherry-pick-ok]", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:3028fd7bb8ff009a99aac1ee251d79baea4d2dee", "direction": "new_architecture_only", "source_sha": "3028fd7bb8ff009a99aac1ee251d79baea4d2dee", "source_subject": "fix(gdpr): reset_learning_data() had the same wrong-schema bug [incident-ok]", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["gdpr"], "override_flags": ["incident-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:3e28679e7d0fdc337950836cc9568f2ce2fbccab", "direction": "new_architecture_only", "source_sha": "3e28679e7d0fdc337950836cc9568f2ce2fbccab", "source_subject": "feat(wp554): delete learning.cp_assessments on account erasure", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:41244d221a588aeac5ed679089e4fdc25cdff46a", "direction": "new_architecture_only", "source_sha": "41244d221a588aeac5ed679089e4fdc25cdff46a", "source_subject": "fix(publisher): weekly backfill for ready posts stuck past 14-day scan window", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:7b500a05257e534e8ef3baf54ed1280de1f5c735", "direction": "new_architecture_only", "source_sha": "7b500a05257e534e8ef3baf54ed1280de1f5c735", "source_subject": "feat(wp554): use dedicated journal erasure role", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:7df5532e4b69caa592ddbb3ad54e2713fe16dfae", "direction": "new_architecture_only", "source_sha": "7df5532e4b69caa592ddbb3ad54e2713fe16dfae", "source_subject": "fix(gdpr): fail closed on account deletion", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["gdpr"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:8416900eddf7f44deb251b5ae50dd30825074b30", "direction": "new_architecture_only", "source_sha": "8416900eddf7f44deb251b5ae50dd30825074b30", "source_subject": "fix(link): не предлагать consent повторно, если уже дан на этот account_id [cherry-pick-ok]", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["consent"], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:96d1a0086d133121ab4f5eb2edcd3aa3b78d533f", "direction": "new_architecture_only", "source_sha": "96d1a0086d133121ab4f5eb2edcd3aa3b78d533f", "source_subject": "feat(wp522): checklist next-step hint on onboarder offer", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:9d938d32bc1f8f78f2731c5f820fe76ed3c5690d", "direction": "new_architecture_only", "source_sha": "9d938d32bc1f8f78f2731c5f820fe76ed3c5690d", "source_subject": "feat(wp554): Б4 — разовая команда /migrate_tokens для дошифровки старых токенов", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat", "token"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:9e922274df25a917b576b0fc96483641625840eb", "direction": "new_architecture_only", "source_sha": "9e922274df25a917b576b0fc96483641625840eb", "source_subject": "docs(github-app): уточнить P-17 — манифест vs установка, edge-case private=None", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["privacy"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:a1b914b787378458456175064fe67761c18d3885", "direction": "new_architecture_only", "source_sha": "a1b914b787378458456175064fe67761c18d3885", "source_subject": "fix(rewards): route confirmed burns through protected apply path", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:ab81b5519e52633785b8ceccfe33d6bf5fe4f2cf", "direction": "new_architecture_only", "source_sha": "ab81b5519e52633785b8ceccfe33d6bf5fe4f2cf", "source_subject": "fix(privacy): redact account ids from consent logs", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["consent", "privacy"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:cbda18ae7c9f9bd33ccfb270139cc90c9ae8a5f5", "direction": "new_architecture_only", "source_sha": "cbda18ae7c9f9bd33ccfb270139cc90c9ae8a5f5", "source_subject": "fix(consent+diagnose): подсказка вместо отказа при отсутствии Ory-токена, /unlink, RU-тексты диагностики [cherry-pick-ok]", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["consent"], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:cdd699d561f1e071592f150e6df2e4c560e6a21a", "direction": "new_architecture_only", "source_sha": "cdd699d561f1e071592f150e6df2e4c560e6a21a", "source_subject": "feat(wp522): /checklist dev-команда - клиент checklist-mcp (§3в)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:d14a4c548d697a663eb888bf2012abd8fc066f9e", "direction": "new_architecture_only", "source_sha": "d14a4c548d697a663eb888bf2012abd8fc066f9e", "source_subject": "chore: stop tracking .DS_Store, add to .gitignore", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:dafca9d44dc6eed7f5eecd44564ecb94617ef4a1", "direction": "new_architecture_only", "source_sha": "dafca9d44dc6eed7f5eecd44564ecb94617ef4a1", "source_subject": "docs(wp547): record production burn-path cutover as migration 040", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:e066dd48ec64f728ce22abab0c31e550c026342d", "direction": "new_architecture_only", "source_sha": "e066dd48ec64f728ce22abab0c31e550c026342d", "source_subject": "fix(scheduler): update direct-senders allowlist for publisher lock split", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:ea8d843cb5220e29aa63d3a563d40f627faa8175", "direction": "new_architecture_only", "source_sha": "ea8d843cb5220e29aa63d3a563d40f627faa8175", "source_subject": "fix(rewards): route GDPR balance erasure through protected path", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["gdpr", "migrat"], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:f9537f6c9ccc015113a7063336d2ca9bbb605c9c", "direction": "new_architecture_only", "source_sha": "f9537f6c9ccc015113a7063336d2ca9bbb605c9c", "source_subject": "fix(gdpr): learning-pool leg used wrong schema — delete_all_user_data() broken [incident-ok]", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["gdpr"], "override_flags": ["incident-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "new_architecture_only:fde25be8accaa84b69a0dc59aede480bf0d9a0f0", "direction": "new_architecture_only", "source_sha": "fde25be8accaa84b69a0dc59aede480bf0d9a0f0", "source_subject": "docs(dev-commands): +/migrate_tokens (WP-554 Б4)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat", "token"], "override_flags": ["cherry-pick-ok"], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:7e59546c46f7c5c514a6822976ee65f17d5b89b0", "direction": "pilot_only", "source_sha": "7e59546c46f7c5c514a6822976ee65f17d5b89b0", "source_subject": "docs(wp562): add P-18 release contract for pilot→production drift/incident-ok", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T11:25:28.930548+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T11:25:28.930548+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:16ddbeea06fa0f04557756d15ec80bd3f4f5e62b", "direction": "pilot_only", "source_sha": "16ddbeea06fa0f04557756d15ec80bd3f4f5e62b", "source_subject": "feat(wp562): release-drift-detector.py — двусторонний детектор смыслового расхождения pilot/new-architecture", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": ["cherry-pick-ok", "incident-ok"], "first_seen_unmatched_at": "2026-09-09T12:57:49.103544+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T12:57:49.103544+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:a5b4362882509d5059328ddcc56077d120908d20", "direction": "pilot_only", "source_sha": "a5b4362882509d5059328ddcc56077d120908d20", "source_subject": "feat(subscription): show linked account email on /subscription when active", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T12:57:49.103544+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T12:57:49.103544+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:f88a40d6bbe09732a3bff4d4e2486106b29ef17f", "direction": "pilot_only", "source_sha": "f88a40d6bbe09732a3bff4d4e2486106b29ef17f", "source_subject": "fix(subscription): read email from ory_identity.email column, not traits", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-09T12:57:49.103544+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-10T12:57:49.103544+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:1cacffd043ba7cbbfc50ceaa31aeb471775b7df7", "direction": "pilot_only", "source_sha": "1cacffd043ba7cbbfc50ceaa31aeb471775b7df7", "source_subject": "docs(data): request_traces переехал в health-БД (WP-562)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:54ffc126eddfd2f8d049a3f229e1a86ec7688c48", "direction": "pilot_only", "source_sha": "54ffc126eddfd2f8d049a3f229e1a86ec7688c48", "source_subject": "fix(wp554): исправить номер фазы в комментариях — Ф9 → Ф11", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:5e48867c9dab707229605b66396873d735fa6063", "direction": "pilot_only", "source_sha": "5e48867c9dab707229605b66396873d735fa6063", "source_subject": "fix(token_crypto): explicit bytea cast for pgcrypto decrypt guard", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["token"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:6d1b66d75caa0a8a1243919fff006c76803938ae", "direction": "pilot_only", "source_sha": "6d1b66d75caa0a8a1243919fff006c76803938ae", "source_subject": "fix: day_14 milestone больше не хвалит активность без данных", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:6fe88fba3ad1728c12f8eb23ecfe8b72ef304696", "direction": "pilot_only", "source_sha": "6fe88fba3ad1728c12f8eb23ecfe8b72ef304696", "source_subject": "WP-406 Ф22: /github notes → GitHub App (feature-flagged) (#311)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["auth", "oauth"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:81c94ab4d5fe637c33577f3593c7d289bc08b943", "direction": "pilot_only", "source_sha": "81c94ab4d5fe637c33577f3593c7d289bc08b943", "source_subject": "docs(wp554): persona-финализатор в сценарии /mydata (Ф9)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:9231ff50de3f0171f7003fc94c49c52bc720fe96", "direction": "pilot_only", "source_sha": "9231ff50de3f0171f7003fc94c49c52bc720fe96", "source_subject": "chore(guard): allowlist _send_direct_masterskaya_invite direct sender", "candidate_matches": [], "equivalence_level": "none", "policy_tags": [], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:a2a229dd1af4104dff873a4a97d9bf45aa32ba2e", "direction": "pilot_only", "source_sha": "a2a229dd1af4104dff873a4a97d9bf45aa32ba2e", "source_subject": "fix(db): drop legacy main-pool request_traces (WP-562)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:edcde19512b4fa0cd26c216cc11f28771eac7bf8", "direction": "pilot_only", "source_sha": "edcde19512b4fa0cd26c216cc11f28771eac7bf8", "source_subject": "feat(workshop): прямая оплата Мастерской IWE, минуя Семинар (WP-181)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["migrat", "payment"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:ef1c7be1de12f63aa67636e5f632075f31539367", "direction": "pilot_only", "source_sha": "ef1c7be1de12f63aa67636e5f632075f31539367", "source_subject": "fix(github-app): защита от настройки чужого GitHub App (WP-406)", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["oauth"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} +{"schema_version": 2, "delta_id": "pilot_only:eff3fea40680ba789f9a6cbbf63bfac143df70f4", "direction": "pilot_only", "source_sha": "eff3fea40680ba789f9a6cbbf63bfac143df70f4", "source_subject": "fix(wp554): persona-финализатор — удалять ory_identity/consent_grants/bot_profile", "candidate_matches": [], "equivalence_level": "none", "policy_tags": ["consent"], "override_flags": [], "first_seen_unmatched_at": "2026-09-10T18:00:34.382256+00:00", "last_rechecked_at": "2026-09-10T18:00:34.382256+00:00", "status": "unmatched", "sla_deadline_at": "2026-09-11T18:00:34.382256+00:00", "classification": "unknown", "observation": "legacy_resolution_unproven", "provenance": {"kind": "legacy_manifest_migration", "source_sha256": "c2ce4ebc8b3327f80f632f6e4dba7003842859fc87540f628fc08790ffe9bcfd", "source_record_count": 297, "reason": "legacy_resolution_unproven", "migrated_at": "2026-09-12T19:22:53.674587+00:00"}} diff --git a/.github/workflows/pilot-prod-sync.yml b/.github/workflows/pilot-prod-sync.yml new file mode 100644 index 00000000..104bf534 --- /dev/null +++ b/.github/workflows/pilot-prod-sync.yml @@ -0,0 +1,95 @@ +name: Release drift observation + +on: + push: + branches: [new-architecture] + # Pilot pushes request this trusted observer through workflow_run. No code + # from the triggering pilot commit is executed with the observer's token. + workflow_run: + workflows: ["Smoke Tests (L1+L2)"] + types: [completed] + branches: [pilot] + schedule: + - cron: '17 * * * *' + workflow_dispatch: + inputs: + bootstrap: + description: 'Initialize the first journal only (existing history is never reset)' + type: boolean + default: false + +permissions: + contents: read + actions: read + +concurrency: + group: release-drift-observation + cancel-in-progress: false + +jobs: + observe: + name: Release drift observation + if: github.event_name == 'workflow_dispatch' || vars.RELEASE_DRIFT_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out trusted observer + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: new-architecture + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.11' + - name: Restore previous journal + id: restore + env: + GH_TOKEN: ${{ github.token }} + BOOTSTRAP: ${{ inputs.bootstrap == true }} + run: | + args=() + if [[ "$BOOTSTRAP" == true ]]; then args+=(--bootstrap); fi + python -I scripts/release_drift_ci.py restore --directory drift-state \ + --seed .github/release-drift-manifest.jsonl "${args[@]}" + - name: Observe both remote branches + id: observe + run: | + set +e + python -I scripts/release-drift-detector.py \ + --repo . --manifest drift-state/release-drift-manifest.jsonl \ + --no-fetch --output drift-report.json + result=$? + set -e + echo "result=$result" >> "$GITHUB_OUTPUT" + case "$result" in 0|3) ;; *) exit "$result" ;; esac + - name: Seal valid journal even after an observation error + id: seal + if: always() && steps.restore.outcome == 'success' + run: python -I scripts/release_drift_ci.py seal --directory drift-state + - name: Preserve authoritative journal + if: always() && steps.seal.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-drift-state + path: | + drift-state/release-drift-manifest.jsonl + drift-state/release-drift-state.json + if-no-files-found: error + retention-days: 90 + - name: Preserve report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-drift-report + path: drift-report.json + if-no-files-found: warn + retention-days: 90 + - name: Enforce observation and 24-hour deadline + if: always() + env: + OBSERVED: ${{ steps.observe.outcome }} + RESULT: ${{ steps.observe.outputs.result }} + run: | + test "$OBSERVED" = success + test "$RESULT" = 0 diff --git a/.github/workflows/release-control-dry-run.yml b/.github/workflows/release-control-dry-run.yml new file mode 100644 index 00000000..93868eab --- /dev/null +++ b/.github/workflows/release-control-dry-run.yml @@ -0,0 +1,44 @@ +name: Release Control Contract + +on: + push: + branches: [pilot, new-architecture] + pull_request: + +permissions: + contents: read + +jobs: + release-control-contract: + name: release-control-contract + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out the candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.11' + + - name: Install test dependencies + run: python -I -m pip install pytest + + - name: Require production mutations to remain disabled + run: >- + python -I -B scripts/release_control.py validate-contract + .github/release-control-contract.json + --require-cutover-disabled + + - name: Check release planning and rejected operations + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + run: >- + python -I -B -m pytest --import-mode=importlib + -p no:cacheprovider + tests/test_release_control.py + tests/test_release_manifest.py + -q --tb=short diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 2dfcccd4..09f2f30e 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -3,16 +3,11 @@ name: Smoke Tests (L1+L2) on: push: branches: [pilot, new-architecture] - paths-ignore: - - 'docs/**' - - '*.md' - - 'tests/test_repo/**' pull_request: branches: [pilot, new-architecture] - paths-ignore: - - 'docs/**' - - '*.md' - - 'tests/test_repo/**' + +permissions: + contents: read jobs: smoke-tests: @@ -22,10 +17,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.11' @@ -34,36 +31,33 @@ jobs: pip install -r requirements.txt pip install pytest pytest-asyncio pytest-mock - - name: Run smoke tests + - name: Check release infrastructure + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + run: >- + python -I -B -m pytest -p no:cacheprovider + tests/test_release_smoke.py + tests/test_release_drift_detector.py + tests/test_release_drift_ci.py + tests/test_branch_auto_deploy.py + -q --tb=short + + - name: Run the exact candidate smoke profile run: | - python -m pytest tests/smoke/ tests/test_search_path_independence.py \ - tests/test_db_pool_settings.py tests/test_readiness.py \ - -v --tb=short --junitxml=smoke-results.xml + python -I -B scripts/run_release_smoke.py \ + --candidate-commit "${GITHUB_SHA}" \ + --profile l1-l2 \ + --output-dir "${RUNNER_TEMP}/release-smoke-${GITHUB_RUN_ATTEMPT}" - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: smoke-test-results - path: smoke-results.xml + path: ${{ runner.temp }}/release-smoke-${{ github.run_attempt }}/ + if-no-files-found: error retention-days: 30 - - name: Test summary - if: always() - run: | - if [ -f smoke-results.xml ]; then - TESTS=$(grep -oP 'tests="\K[^"]+' smoke-results.xml | head -1) - FAILURES=$(grep -oP 'failures="\K[^"]+' smoke-results.xml | head -1) - ERRORS=$(grep -oP 'errors="\K[^"]+' smoke-results.xml | head -1) - echo "## 🧪 Smoke Tests (L1+L2)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY - echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Tests | $TESTS |" >> $GITHUB_STEP_SUMMARY - echo "| Failures | $FAILURES |" >> $GITHUB_STEP_SUMMARY - echo "| Errors | $ERRORS |" >> $GITHUB_STEP_SUMMARY - fi - post-deploy-healthcheck: name: Post-Deploy Health Check runs-on: ubuntu-latest diff --git a/core/operator_alerts.py b/core/operator_alerts.py new file mode 100644 index 00000000..4fded635 --- /dev/null +++ b/core/operator_alerts.py @@ -0,0 +1,74 @@ +"""Bounded operator alerts when the database-backed delivery queue cannot work. + +Only these operational incidents may use this transport (WP-562/WP-567). +The recipient is fixed; callers cannot send arbitrary user notifications. +Delivery is best effort: an ambiguous timeout may be followed by a duplicate. +""" + +from __future__ import annotations + +import asyncio +import logging + +from aiogram import Bot + +from config import DEVELOPER_CHAT_ID + +logger = logging.getLogger(__name__) +SEND_TIMEOUT_SECONDS = 5 + + +async def _send_operator_alert(bot: Bot, text: str) -> bool: + """Return true only after Telegram acknowledges; leave the caller's bot open.""" + if not DEVELOPER_CHAT_ID: + logger.error("[OperatorAlert] Developer recipient is not configured") + return False + try: + await asyncio.wait_for( + bot.send_message( + DEVELOPER_CHAT_ID, + text, + parse_mode=None, + protect_content=True, + request_timeout=SEND_TIMEOUT_SECONDS, + ), + timeout=SEND_TIMEOUT_SECONDS, + ) + except Exception as exc: # noqa: BLE001 - A failed alert must not stop recovery. + # Exception text can contain credentials or a copy of the payment data. + logger.error("[OperatorAlert] Delivery failed (%s)", type(exc).__name__) + return False + logger.info("[OperatorAlert] Delivery acknowledged") + return True + + +async def alert_recurring_donation_failure( + bot: Bot, *, chat_id: int, charge_id: str +) -> bool: + return await _send_operator_alert( + bot, + "🔴 Recurring donation НЕ сохранён: " + f"chat_id={chat_id}, charge_id={charge_id}. " + "Нужно проверить платёж и восстановить запись.", + ) + + +async def alert_stars_subscription_failure( + bot: Bot, *, chat_id: int, charge_id: str, stars_amount: int, tariff_key: str +) -> bool: + return await _send_operator_alert( + bot, + "🔴 Stars-подписка НЕ сохранена (3 попытки): " + f"chat_id={chat_id}, charge_id={charge_id}, " + f"amount={stars_amount} XTR, tariff={tariff_key}. " + "Нужно проверить платёж и восстановить подписку и события.", + ) + + +async def alert_event_outbox_stuck(bot: Bot, *, stuck_count: int) -> bool: + return await _send_operator_alert( + bot, + f"⚠️ event_outbox: {stuck_count} событий " + "(payment_received/subscription_granted) недоставлены >10 мин — " + "дренаж не работает", + ) diff --git a/core/scheduler.py b/core/scheduler.py index 1f5aa599..e3db2fb5 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -392,7 +392,9 @@ async def _watch_event_outbox(): """ global _last_outbox_watch_alert_ts + from core.operator_alerts import alert_event_outbox_stuck from db.queries.event_outbox import count_stuck_outbox + stuck = await count_stuck_outbox(older_than_minutes=10) if not stuck: return @@ -404,12 +406,8 @@ async def _watch_event_outbox(): return bot = Bot(token=_bot_token) try: - await bot.send_message( - DEVELOPER_CHAT_ID, - f"⚠️ event_outbox: {stuck} событий (payment_received/subscription_granted) " - "недоставлены >10 мин — дренаж не работает", - ) - _last_outbox_watch_alert_ts = time.time() + if await alert_event_outbox_stuck(bot, stuck_count=stuck): + _last_outbox_watch_alert_ts = time.time() finally: await bot.session.close() diff --git a/db/migrations/048_wp567_grant_cutover.py b/db/migrations/048_wp567_grant_cutover.py index 5984459f..0eed237f 100644 --- a/db/migrations/048_wp567_grant_cutover.py +++ b/db/migrations/048_wp567_grant_cutover.py @@ -78,10 +78,11 @@ """ import asyncio -import asyncpg import os import sys +import asyncpg + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) CUTOVER_MARKER = "wp567_f1_grant_cutover" @@ -234,8 +235,9 @@ async def migrate_if_needed(pool: asyncpg.Pool) -> bool: # match would silently pick an arbitrary overload if one is ever # added (cold review, 2026-09-12). owner_already_moved = await conn.fetchval( - f"SELECT proowner::regrole::text = 'rewards_points_engine_owner' " - f"FROM pg_proc WHERE oid = '{FUNCTION_IDENTITY}'::regprocedure" + "SELECT proowner::regrole::text = 'rewards_points_engine_owner' " + "FROM pg_proc WHERE oid = $1::text::regprocedure", + FUNCTION_IDENTITY, ) if owner_already_moved: return False diff --git a/docs/processes/process-12-payment-events.md b/docs/processes/process-12-payment-events.md index 91b3032d..5cb52354 100644 --- a/docs/processes/process-12-payment-events.md +++ b/docs/processes/process-12-payment-events.md @@ -64,6 +64,37 @@ Stars-подписки фрагментированы по telegram_id). **Идемпотентность:** `external_id` в `event_outbox` — тот же ключ, что уже уходит в конверт event-gateway для каждого события (не новый составной ключ) — `ON CONFLICT (external_id) DO NOTHING` на вставке. +### Подтверждение доставки и аварийные уведомления (WP-562 / WP-567) + +Дренаж использует строгий `post_event_or_raise`: отметка доставки допустима +только после ответа шлюза `201 {inserted: true, id: <непустая строка>}` либо +`200 {inserted: false, idempotent: true}`. Отключённый `EVENT_GATEWAY_ENABLED`, +перенаправление, неизвестный статус или некорректное подтверждение оставляют +событие для повтора. После неоднозначного тайм-аута повтор идёт с прежним +`external_id`; отмена задачи не считается подтверждением. Сырой ответ шлюза +не записывается в новые сообщения об ошибке. Старый `post_event` сохраняет +свою модель доставки без гарантированного повтора. + +Несохранённый recurring-донат, Stars-подписка после трёх неудачных попыток +и зависшие события сообщаются через `core/operator_alerts.py`. Этот узкий +канал не использует БД или очередь обычных уведомлений, отправляет только +на `DEVELOPER_CHAT_ID` и ограничивает ожидание Telegram пятью секундами. +Исключение в проверке прямых отправителей дано только его функции транспорта; +обработчики платежей остаются под общим запретом. Сбой чтения языка пользователя +не прерывает обработку уже оплаченного доната: применяется русский язык. + +При отказе алерта пользователь всё равно получает сообщение о задержке +обработки. В алерте остаются необходимые идентификаторы для восстановления, +но нет сырого текста исключения. Доставка оператору не гарантирована: +неуказанный адресат или недоступный Telegram дают явную ошибку в журнале; +после неоднозначного тайм-аута или перезапуска возможен повтор. Сторож подавляет +повторные сообщения на час только после подтверждённой отправки. + +Эти условия проверяют `tests/smoke/test_operator_alerts.py` и +`tests/smoke/test_event_outbox_strict_delivery.py` в обязательном профиле +проверок кандидата P-18. Тесты используют искусственные данные и не доказывают +живую отправку, применение миграции или завершение РП-562. + ## Источники - Контекст РП: `DS-my-strategy/inbox/WP-266-guest-pass-concept.md` § Ф5c diff --git a/docs/processes/process-18-release-contract.md b/docs/processes/process-18-release-contract.md index 53ea573e..a00fb148 100644 --- a/docs/processes/process-18-release-contract.md +++ b/docs/processes/process-18-release-contract.md @@ -2,6 +2,9 @@ > Письменный контракт для контура управляемых выпусков (WP-562 Ф1): что считается дрейфом между ветками `pilot` и `new-architecture`, какие различия сред легитимны, кто и как принимает решение о продвижении, и на каких условиях допустим аварийный обход (`incident-ok`). Принят пир-сессией Claude (писатель/критик) + Codex (редактор) + Kimi (критик), консенсус за 3 раунда, 09.09.2026 — сессия `MC-sessions:2026-09/09/2026-09-09-08-wp562-f1-release-contract/`. +> Реализация и безопасное включение наблюдателя описаны в §9. Наличие кода +> наблюдения и smoke-проверок не означает приёмку Ф3–Ф5 или разрешение выпуска. + --- ## 0. Определения @@ -236,6 +239,234 @@ Conjunctive screening — профиль не сворачивается в ср --- +## 9. Эксплуатация наблюдателя и smoke-проверки (12.09.2026) + +### Что реализовано и что остаётся обязательным + +`scripts/release-drift-detector.py` записывает наблюдения commit/tree/patch-id. +Разные деревья всегда оставляют неразрешённую запись, в том числе когда +`git cherry` не видит merge-only изменение. Исчезновение SHA не закрывает долг; +автоматическое закрытие требует равенства итоговых деревьев. Исторические +неподтверждённые записи сохраняются консервативно, поэтому счётчик записей не +равен числу уникальных текущих смысловых изменений. + +Этот журнал наблюдений **не заменяет** классификацию, решения пилота и +Release Drift Manifest из §5. Он также не доказывает равенство работающих образов. +Полный выпускной запускатель, реальные адаптеры журнала/провайдера, проверка +конфигурационных профилей и production-canary остаются задачей Ф3–Ф4. + +### Проверка без внешних действий + +Для проверки локальных закреплённых ссылок используйте отдельный журнал: + +```bash +python scripts/release-drift-detector.py \ + --repo . --pilot-ref origin/pilot --production-ref origin/new-architecture \ + --no-fetch --manifest /tmp/release-drift-manifest.jsonl \ + --output /tmp/release-drift-report.json +``` + +Без `--no-fetch` детектор обновляет удалённые ссылки с ограниченным временем +ожидания. Снимки обоих commit фиксируются один раз до сравнения. Коды выхода: +`0` — наблюдение выполнено, срок не превышен; `1` — данные/журнал недостоверны +или операция не удалась; `2` — запрошенная доставка не подтверждена; +`3` — есть неразрешённые записи старше 24 часов. Код `0` сам по себе не означает +отсутствия дельт: смотрите `status`, `snapshots`, `overdue`, `oldest_deadline_at`. + +### Миграция старого журнала + +Старый алгоритм закрывал исчезнувшие SHA без достаточного доказательства и мог +перезапускать их часы. Строгая проверка останавливается на такой истории. +Явная миграция читает исходник без изменения и создаёт **новый** файл: + +```bash +python scripts/release-drift-detector.py \ + --migrate-legacy-manifest /path/to/old/release-drift-manifest.jsonl \ + --manifest /path/to/new/release-drift-manifest.jsonl +``` + +Миграция принимает только корректные записи старой схемы, сохраняет самую раннюю +дату каждого долга, повторно открывает неподтверждённые закрытия и записывает +хеш исходника. Существующий выходной файл и повреждённый исходник блокируют +операцию. Проверенная начальная копия для CI хранится в +`.github/release-drift-manifest.jsonl`; это замороженный исходный срез, +а текущая история после включения живёт в артефактах наблюдателя. + +### Включение автоматического наблюдения + +Наблюдатель `.github/workflows/pilot-prod-sync.yml` исполняет код из +`new-architecture`. События: push этой ветки, завершение smoke после pilot push, +часовое расписание и ручной запуск. Исполнение кода pilot с токеном наблюдателя +не требуется. Права токена ограничены чтением кода и результатов Actions. + +До отдельной приёмки и доставки workflow в основную ветку автоматический режим +выключен. Последовательность включения после приёмки: + +```bash +gh workflow run pilot-prod-sync.yml --repo aisystant/aist_bot \ + --ref new-architecture -f bootstrap=true +# Проверить сохранённый release-drift-state и отчёт первого запуска. +# Просроченные исторические долги законно дают красный результат, сохраняя журнал. +gh variable set RELEASE_DRIFT_ENABLED --repo aisystant/aist_bot --body true +``` + +Bootstrap использует проверенную мигрированную копию, а не пустые часы. +Выполнявшийся предыдущий запуск, включая просрочку/ошибку, остаётся источником +истории; откат к последнему зелёному запуску запрещён. Восстановление проверяет +репозиторий, запуск, контрольную сумму и состав архива. Пустые/пропущенные +задания до включения не считаются наблюдениями; старый запуск не может обогнать +уже завершившийся новый и переписать историю. + +При ошибке наблюдения сохраняется только строго валидный журнал, а итог остаётся +ошибкой. Если восстановление или выгрузка не удались, артефакт истёк или журнал +повреждён — автоматическая цепочка блокируется. Не удаляйте историю ради +bootstrap. Восстановите проверенную копию с исходными сроками и оформите явное +решение о восстановлении. Старые runs повторно не запускайте; используйте новый +workflow dispatch. Отключение новых автоматических запусков: + +```bash +gh variable set RELEASE_DRIFT_ENABLED --repo aisystant/aist_bot --body false +``` + +Ошибочный статус GitHub не является доказанной доставкой сообщения человеку. +Локальная опция `--alert` использует `iwe-tg --source release-drift-detector`. +Источник должен быть принят в транспортном реестре; отсутствие транспорта, +карантин, тестовый режим и неуспешная отправка дают явный отказ. Фактическую +доставку надо отдельно принять на разрешённом тестовом уведомлении. + +### Контроль фактически выполненного smoke + +`scripts/run_release_smoke.py` собирает разрешённый состав профиля `l1-l2` из +точного commit отдельным процессом pytest и сравнивает его с фактическим +исполнением. Нужны все уникальные ID и успешные setup/call/teardown; skip, xfail, +неизвестные/дублированные ID, отсутствующая квитанция и ошибки блокируют выпуск. +Профиль не расширяется инфраструктурными тестами — они выполняются отдельно. + +```bash +python -I -B scripts/run_release_smoke.py \ + --candidate-commit FULL_COMMIT_SHA --profile l1-l2 \ + --output-dir /tmp/new-release-smoke-evidence +``` + +Выходной каталог должен быть новым и находиться вне дерева кандидата. Чистота +дерева проверяется до и после; сетевые обращения и чтение `.env` в Python +блокируются, старый bytecode не используется. Это не полноценная песочница для +злонамеренного кандидата и не доказательство deployment identity. Исполнение +ограничено 120 секундами; 60 секунд формируют сигнал пересмотра по §6. + +### Следующий шаг Ф3: проверяемая смена источника Railway + +Наблюдатель `scripts/check_branch_auto_deploy.py` собирает только сведения об +источнике, авторазвёртывании, ID развёртываний и версии конфигурации. Заданный +`image@sha256` отделён от доказательства фактически работающего digest. +Неизвестные image auto-updates и runtime digest не превращаются в разрешение. + +```bash +python scripts/check_branch_auto_deploy.py \ + --project PROJECT_UUID --environment ENVIRONMENT_UUID --service SERVICE_UUID \ + --live +``` + +Для офлайн-проверки замените `--live` на `--input /path/to/response.json`. +Код `1` означает доступное наблюдение без допуска к выпуску; `2` — недоступное +или неполное доказательство. Наблюдатель не возвращает разрешение на выпуск. +Живое чтение 12.09 подтвердило: оба сервиса ещё используют GitHub repository +source и включённый branch auto-deploy; образ по registry digest не задан. + +Целевой путь: build once → registry digest → pilot qualification → тот же +digest в production. Docker Image source пропускает сборку; обычный повтор +развёртывания исходников этого не гарантирует. [Railway lifecycle](https://docs.railway.com/deployments/reference). + +Для reviewable-плана доступен `configEtag` и серверная проверка +`baseConfigEtag` при применении change set. Это защита конфигурации от +конкурентных изменений, а не доказанная идемпотентность любого deploy API. +Нужны также отключённые image auto-updates, неизменный профиль, отсутствие +старого branch source и проверка результата после неоднозначного ответа. +Полный `railway config apply` по неполному описанию проекта опасен: отсутствие +ресурса в таком описании означает удаление. Переход должен быть отдельным +проверенным планом с решением пилота. [Railway IaC](https://docs.railway.com/infrastructure-as-code#apply-changes), +[настройки источника](https://docs.railway.com/infrastructure-as-code/reference#sources). + +## 10. Подготовка Ф3 без переключения сред + +Ядро `scripts/release_control.py` и схема `release_manifest.py` перенесены +отдельно от продуктовых и миграционных изменений старого прототипа. +`.github/release-control-contract.json` сохраняет `cutover.enabled=false`. +CI `release-control-contract` проверяет это ограничение и поведение ядра; +проверка не выполняет сборку, публикацию образа или развёртывание. + +Предварительный просмотр принимает два JSON-файла и выводит канонический +JSON в stdout; пути передаёт вызывающий: + +```bash +python -I -B scripts/release_control.py preview-release \ + .github/release-control-contract.json \ + --embedded-manifest EMBEDDED_MANIFEST_JSON \ + --configuration-manifest CONFIGURATION_MANIFEST_JSON +``` + +Необязательный `--artifact-reference REGISTRY/REPOSITORY@sha256:DIGEST` +допускает только точный registry/repository из контракта. Без него digest +остаётся неизвестным. Код 0 означает согласованность деклараций; +`readiness=not_verified` и `mutation_authorized=false` сохраняются всегда. +Список `missing_evidence` перечисляет необходимые внешние проверки, +включая фактическое содержимое профилей и классификацию дельт. Предварительный +просмотр не читает профили, не обращается в registry и не создаёт разрешения. +Его `preview_digest` не является fingerprint исполняемой операции. + +Конфигурационный manifest хранится вне образа и связывает release ID, +хеш embedded manifest, оба профиля и реестр допустимых различий. Каждый +профиль адресуется полным commit, относительным путём и digest. Эти данные +задают ожидаемую конфигурацию; для допуска требуется отдельная проверенная +квитанция фактического состояния среды. Сам JSON квитанцией не является. + +При финализации production можно обновить наблюдение текущего состояния +среды после pilot. Менять принятую пару профилей или реестр различий нельзя: +это новый manifest и новая операция с новым подтверждением. Хеш +конфигурационного manifest входит в идентичность операции. Откат также +должен ссылаться на конфигурационный manifest возвращаемого выпуска. + +Проверки с подставными провайдерами доказывают правила ядра, но не свойства +Railway. Реальные адаптеры должны отдельно обеспечить проверенное +происхождение образа, полномочия, устойчивую запись журнала и безопасное +разрешение неопределённого результата. Статус применённой операции не +заменяет проверку production readiness, canary и инвариантов данных. + +### Предлагаемый источник образов + +Предлагаемый адрес — `ghcr.io/aisystant/aist_bot@sha256:`. +Наличие пакета и доступ Railway к нему пока не подтверждены. +Сборка должна использовать полный принятый commit в Git context, +`linux/amd64`, закреплённые Actions и публикацию по digest без пересборки +между pilot и production. [Git context Docker](https://github.com/docker/build-push-action), +[публикация по digest](https://docs.docker.com/build/exporters/image-registry/). + +Для сборочного CI нужны `contents: read` и `packages: write`; вход в GHCR +возможен через `GITHUB_TOKEN`. Подписанное GitHub-свидетельство происхождения +добавляет `id-token: write` и `attestations: write`; для private/internal +репозитория его доступность требует Enterprise Cloud. Наличие нужного +тарифа и выбранного способа подписи ещё нужно установить. Неподписанная +BuildKit provenance не заменяет проверку доверенного подписанта. +[GHCR](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), +[GitHub attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations). + +Если результат сборки — OCI index, нужно сохранить его digest и связь с +исполняемым манифестом `linux/amd64`. Они не взаимозаменяемы. Поддержка +index со свидетельствами и источник связи deployment ID с работающим +digest должны быть проверены до переключения. +[Хранение attestations](https://docs.docker.com/build/metadata/attestations/attestation-storage/). + +Для private registry Railway требует Pro и учётные данные чтения пакета; +для GHCR — отдельный PAT classic с `read:packages` и ограниченным доступом +учётной записи. Значение секрета вводится в Railway, а профиль хранит +только ссылку на его версию. Нужные тариф, права и источник runtime digest +пока не подтверждены. [Railway private registries](https://docs.railway.com/builds/private-registries). + +Следующее решение должно включать владельца пакета и выпуска, доступный +способ подписания, принятые профили/различия и точный кандидат. Затем можно +готовить конкретный Railway change set с актуальным `configEtag`. +Наличие подготовленного кода не разрешает это изменение автоматически. + ## Связанные материалы - РП562 (`DS-my-strategy/inbox/WP-562/WP-562.md`) — постановка проблемы, гипотеза H-314, фазы Ф2-Ф6. diff --git a/handlers/payments.py b/handlers/payments.py index ae8d8d8b..0cd91235 100644 --- a/handlers/payments.py +++ b/handlers/payments.py @@ -25,7 +25,7 @@ InlineKeyboardButton, ) -from config import DEVELOPER_CHAT_ID +from core.operator_alerts import alert_recurring_donation_failure from core.pricing import get_current_price from db.queries import get_intern from db.queries.subscription import save_subscription, get_active_subscription, upsert_subscription_grant @@ -153,16 +153,23 @@ async def on_successful_payment(message: Message): payment = message.successful_payment chat_id = message.chat.id - intern = await get_intern(chat_id) - lang = intern.get('language', 'ru') or 'ru' + # A language lookup must not prevent recovery of an already charged payment. + lang = "ru" + try: + intern = await get_intern(chat_id) + lang = (intern or {}).get("language", "ru") or "ru" + except Exception as exc: # noqa: BLE001 - Storage recovery must still run. + logger.warning("[Payments] Language lookup failed (%s)", type(exc).__name__) - payload = getattr(payment, 'invoice_payload', '') or '' + payload = getattr(payment, "invoice_payload", "") or "" # Разовый донат — благодарим и предлагаем ежемесячный if payload.startswith("donate_once_"): amount = payment.total_amount - await message.answer(t('donation.once_success', lang)) - logger.info(f"[Payments] One-time donation: chat_id={chat_id}, amount={amount} Stars") + await message.answer(t("donation.once_success", lang)) + logger.info( + f"[Payments] One-time donation: chat_id={chat_id}, amount={amount} Stars" + ) # Предложить сделать донат постоянным (всегда, даже при активной подписке) try: @@ -214,18 +221,12 @@ async def on_successful_payment(message: Message): is_first=is_first, ) except Exception as e: - logger.error(f"[Payments] Error saving recurring donation: {e}") - import traceback - logger.error(traceback.format_exc()) - if DEVELOPER_CHAT_ID: - try: - await message.bot.send_message( - DEVELOPER_CHAT_ID, - f"🔴 Recurring donation НЕ сохранён: chat_id={chat_id}, " - f"charge_id={charge_id}, error={e}", - ) - except Exception as alert_err: - logger.error(f"[Payments] dev-alert failed too: {alert_err}") + logger.error( + "[Payments] Recurring donation was not saved (%s)", type(e).__name__ + ) + await alert_recurring_donation_failure( + message.bot, chat_id=chat_id, charge_id=charge_id + ) # НЕ отвечаем "успешно" при реальном сбое сохранения -- раньше # `donation.recurring_success` отправлялся при ЛЮБОМ исключении, # включая упавший save_subscription (пользователь считал донат diff --git a/handlers/subscription_stars.py b/handlers/subscription_stars.py index 85dcb1c1..5dedcf46 100644 --- a/handlers/subscription_stars.py +++ b/handlers/subscription_stars.py @@ -43,7 +43,7 @@ LabeledPrice, ) -from config import DEVELOPER_CHAT_ID +from core.operator_alerts import alert_stars_subscription_failure from db.queries import get_intern from db.queries.subscription import save_subscription_with_outbox from helpers.dual_write import resolve_ory_id_from_chat @@ -288,7 +288,11 @@ async def on_successful_stars_sub(message: Message): break except Exception as e: last_error = e - logger.warning(f"[SubStars] save_subscription_with_outbox attempt {attempt + 1}/3 failed: {e}") + logger.warning( + "[SubStars] save_subscription_with_outbox attempt %s/3 failed (%s)", + attempt + 1, + type(e).__name__, + ) if attempt < 2: await asyncio.sleep(2 * (attempt + 1)) @@ -301,19 +305,15 @@ async def on_successful_stars_sub(message: Message): logger.error( f"[SubStars] CRITICAL: subscription+outbox не сохранены после 3 попыток. " f"chat_id={chat_id}, charge_id={charge_id}, amount={stars_amount}, " - f"tariff={tariff_key}, last_error={last_error}" + f"tariff={tariff_key}, error_type={type(last_error).__name__}" + ) + await alert_stars_subscription_failure( + message.bot, + chat_id=chat_id, + charge_id=charge_id, + stars_amount=stars_amount, + tariff_key=tariff_key, ) - if DEVELOPER_CHAT_ID: - try: - await message.bot.send_message( - DEVELOPER_CHAT_ID, - f"🔴 Stars-подписка НЕ сохранена (3 попытки): " - f"chat_id={chat_id}, charge_id={charge_id}, " - f"amount={stars_amount} XTR, tariff={tariff_key}. " - f"Ошибка: {last_error}", - ) - except Exception as alert_err: - logger.error(f"[SubStars] dev-alert failed too: {alert_err}") await message.answer( "⚠️ Звёзды получены, но подписка обрабатывается дольше обычного. " "Если доступ не появится в течение нескольких минут — напишите в поддержку.", diff --git a/helpers/dual_write.py b/helpers/dual_write.py index d5896072..082d879c 100644 --- a/helpers/dual_write.py +++ b/helpers/dual_write.py @@ -105,12 +105,8 @@ def _build_envelope( return envelope -async def _send_envelope(source: str, event_type: str, envelope: dict) -> None: - """POST envelope to the event-gateway. Raises on any failure -- transport, - non-2xx, or client-side exception all surface the same way, so callers - that need to know whether delivery actually happened (unlike the - fire-and-forget `post_event` below) can decide what to do about it.""" - session = _get_session() +def _build_gateway_request(source: str, envelope: dict) -> tuple[bytes, dict[str, str]]: + """Serialize and sign the exact body shared by both delivery policies.""" body = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode() headers = {"Content-Type": "application/json"} if EVENT_GATEWAY_HMAC_KEY: @@ -130,6 +126,13 @@ async def _send_envelope(source: str, event_type: str, envelope: dict) -> None: "X-IWE-Timestamp": timestamp, "X-IWE-Signature": f"sha256={signature}", }) + return body, headers + + +async def _send_envelope(source: str, event_type: str, envelope: dict) -> None: + """Legacy best-effort transport; HTTP 4xx/5xx and transport failures raise.""" + session = _get_session() + body, headers = _build_gateway_request(source, envelope) async with session.post( f"{EVENT_GATEWAY_URL}/events", @@ -141,6 +144,49 @@ async def _send_envelope(source: str, event_type: str, envelope: dict) -> None: raise RuntimeError(f"{event_type} POST failed: {resp.status} {body_text[:200]}") +def _delivery_acknowledged(status: int, payload: object) -> bool: + """Match POST /events acknowledgements: inserted 201 or idempotent 200.""" + if not isinstance(payload, dict): + return False + if status == 201: + return ( + set(payload) == {"inserted", "id"} + and payload["inserted"] is True + and isinstance(payload["id"], str) + and bool(payload["id"].strip()) + ) + return ( + status == 200 + and set(payload) == {"inserted", "idempotent"} + and payload["inserted"] is False + and payload["idempotent"] is True + ) + + +async def _send_envelope_strict(source: str, event_type: str, envelope: dict) -> None: + """Require a direct inserted/idempotent acknowledgement from POST /events.""" + session = _get_session() + body, headers = _build_gateway_request(source, envelope) + async with session.post( + f"{EVENT_GATEWAY_URL}/events", + data=body, + headers=headers, + allow_redirects=False, + ) as resp: + if resp.status not in (200, 201): + raise RuntimeError(f"{event_type} POST failed: HTTP {resp.status}") + try: + acknowledgement = await resp.json() + except (aiohttp.ClientError, ValueError): + raise RuntimeError( + f"{event_type} POST returned invalid acknowledgement: HTTP {resp.status}" + ) from None + if not _delivery_acknowledged(resp.status, acknowledgement): + raise RuntimeError( + f"{event_type} POST returned invalid acknowledgement: HTTP {resp.status}" + ) + + async def post_event( source: str, external_id: str, @@ -189,11 +235,14 @@ async def post_event_or_raise( delivery actually happened to decide retry/backoff -- `post_event`'s silent swallow is correct for its fire-and-forget callers, but would hide failure from a dispatcher whose whole job is reacting to it. + A disabled gateway is an explicit delivery failure, so the durable row + remains pending. A direct inserted/idempotent gateway acknowledgement + is required; HTTP status alone does not prove delivery. """ if not EVENT_GATEWAY_ENABLED: - return + raise RuntimeError("event gateway is disabled; event was not delivered") envelope = _build_envelope(source, external_id, event_type, schema_version, occurred_at, account_id, payload) - await _send_envelope(source, event_type, envelope) + await _send_envelope_strict(source, event_type, envelope) async def resolve_ory_id_from_chat(chat_id: int) -> Optional[str]: diff --git a/release_manifest.py b/release_manifest.py new file mode 100644 index 00000000..fd92fbd7 --- /dev/null +++ b/release_manifest.py @@ -0,0 +1,371 @@ +"""Strict, transport-neutral schema for the manifest embedded in a release. + +This module is deliberately importable by both runtime code and build/control +tools. It contains no provider, network, database, or deployment capability. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Any + +MANIFEST_SCHEMA_VERSION = 1 +_SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}\Z") +_GIT_OID_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +_RELEASE_ID_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,95}\Z") +_CHECK_ID_RE = re.compile(r"[a-z][a-z0-9-]{0,63}\Z") +_MAX_CHECKS = 32 +_MAX_SCHEMA_REVISION = 2_147_483_647 + + +class ReleaseManifestError(ValueError): + """Manifest bytes or values do not match the exact public schema.""" + + +class MigrationClass(str, Enum): + NONE = "none" + EXPAND = "expand" + CONTRACT = "contract" + DATA_REWRITE = "data_rewrite" + + +def validate_migration_semantics( + migration_class: Any, + schema_min: Any, + schema_max: Any, + *, + path: str = "migration", +) -> tuple[MigrationClass, int, int]: + """Validate the one migration-class/range contract shared by every boundary.""" + + try: + normalized_class = MigrationClass(migration_class) + except (TypeError, ValueError) as exc: + raise ReleaseManifestError(f"{path}.migration_class is unsupported") from exc + normalized_min = _exact_int( + schema_min, + f"{path}.schema_min", + minimum=0, + maximum=_MAX_SCHEMA_REVISION, + ) + normalized_max = _exact_int( + schema_max, + f"{path}.schema_max", + minimum=0, + maximum=_MAX_SCHEMA_REVISION, + ) + if normalized_min > normalized_max: + raise ReleaseManifestError(f"{path} schema range is inverted") + if normalized_class is MigrationClass.NONE: + if normalized_min != 0 or normalized_max != 0: + raise ReleaseManifestError( + f"{path} no-migration declaration must use schema 0..0" + ) + elif normalized_max == 0: + raise ReleaseManifestError( + f"{path} migration declaration must name a schema revision" + ) + return normalized_class, normalized_min, normalized_max + + +def _reject_json_constant(value: str) -> None: + raise ReleaseManifestError(f"non-finite JSON number is forbidden: {value}") + + +def _strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ReleaseManifestError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def loads_strict_json(payload: str | bytes) -> Any: + """Parse UTF-8 JSON while rejecting duplicate keys and non-finite values.""" + + try: + if isinstance(payload, bytes): + payload = payload.decode("utf-8", errors="strict") + if not isinstance(payload, str): + raise ReleaseManifestError("JSON payload must be text or bytes") + return json.loads( + payload, + object_pairs_hook=_strict_object, + parse_constant=_reject_json_constant, + ) + except ReleaseManifestError: + raise + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReleaseManifestError("invalid UTF-8 JSON payload") from exc + + +def canonical_json_bytes(value: Any) -> bytes: + """Return the sole UTF-8 representation used for release-control hashes.""" + + try: + encoded = json.dumps( + value, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + return encoded.encode("utf-8", errors="strict") + except (TypeError, ValueError, UnicodeError) as exc: + raise ReleaseManifestError("value is not canonical-JSON encodable") from exc + + +def canonical_sha256(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _exact_object(value: Any, expected: set[str], path: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ReleaseManifestError(f"{path} must be an object") + actual = set(value) + if actual != expected: + raise ReleaseManifestError(f"{path} has missing or extra fields") + return value + + +def _exact_int(value: Any, path: str, *, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise ReleaseManifestError(f"{path} is outside its integer range") + return value + + +def _full_match(value: Any, pattern: re.Pattern[str], path: str) -> str: + if not isinstance(value, str) or pattern.fullmatch(value) is None: + raise ReleaseManifestError(f"{path} has an invalid identifier") + return value + + +@dataclass(frozen=True) +class EmbeddedManifest: + schema_version: int + release_id: str + source_commit: str + source_tree: str + build_contract_digest: str + migration_class: MigrationClass + schema_min: int + schema_max: int + required_checks: tuple[str, ...] + + @classmethod + def from_mapping(cls, payload: Any) -> EmbeddedManifest: + obj = _exact_object( + payload, + { + "schema_version", + "release_id", + "source_commit", + "source_tree", + "build_contract_digest", + "migration_class", + "schema_min", + "schema_max", + "required_checks", + "platform", + }, + "embedded_manifest", + ) + if ( + obj["schema_version"] != MANIFEST_SCHEMA_VERSION + or type(obj["schema_version"]) is not int + ): + raise ReleaseManifestError("embedded_manifest.schema_version must equal 1") + release_id = _full_match( + obj["release_id"], _RELEASE_ID_RE, "embedded_manifest.release_id" + ) + source_commit = _full_match( + obj["source_commit"], _GIT_OID_RE, "embedded_manifest.source_commit" + ) + source_tree = _full_match( + obj["source_tree"], _GIT_OID_RE, "embedded_manifest.source_tree" + ) + build_contract_digest = _full_match( + obj["build_contract_digest"], + _SHA256_RE, + "embedded_manifest.build_contract_digest", + ) + migration_class, schema_min, schema_max = validate_migration_semantics( + obj["migration_class"], + obj["schema_min"], + obj["schema_max"], + path="embedded_manifest", + ) + + raw_checks = obj["required_checks"] + if not isinstance(raw_checks, list) or not 1 <= len(raw_checks) <= _MAX_CHECKS: + raise ReleaseManifestError( + "embedded_manifest.required_checks has invalid length" + ) + required_checks = tuple( + _full_match(item, _CHECK_ID_RE, "embedded_manifest.required_checks[]") + for item in raw_checks + ) + if required_checks != tuple(sorted(set(required_checks))): + raise ReleaseManifestError( + "embedded_manifest.required_checks must be sorted and unique" + ) + + platform = _exact_object( + obj["platform"], {"os", "architecture"}, "embedded_manifest.platform" + ) + if platform["os"] != "linux" or platform["architecture"] != "amd64": + raise ReleaseManifestError("embedded_manifest platform must be linux/amd64") + return cls( + schema_version=MANIFEST_SCHEMA_VERSION, + release_id=release_id, + source_commit=source_commit, + source_tree=source_tree, + build_contract_digest=build_contract_digest, + migration_class=migration_class, + schema_min=schema_min, + schema_max=schema_max, + required_checks=required_checks, + ) + + def to_mapping(self) -> dict[str, Any]: + """Return pre-build facts; OCI/self digests are intentionally absent.""" + + return { + "schema_version": self.schema_version, + "release_id": self.release_id, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "build_contract_digest": self.build_contract_digest, + "migration_class": self.migration_class.value, + "schema_min": self.schema_min, + "schema_max": self.schema_max, + "required_checks": list(self.required_checks), + "platform": {"os": "linux", "architecture": "amd64"}, + } + + @property + def manifest_hash(self) -> str: + return canonical_sha256(self.to_mapping()) + + +@dataclass(frozen=True) +class ConfigurationReference: + repository_commit: str + path: str + digest: str + + @classmethod + def from_mapping(cls, payload: Any) -> ConfigurationReference: + obj = _exact_object( + payload, {"repository_commit", "path", "digest"}, "configuration_reference" + ) + path = obj["path"] + if ( + not isinstance(path, str) + or len(path) > 512 + or re.fullmatch(r"[A-Za-z0-9._/-]+", path) is None + or any(part in {"", ".", ".."} for part in path.split("/")) + ): + raise ReleaseManifestError("configuration_reference.path is unsafe") + return cls( + repository_commit=_full_match( + obj["repository_commit"], _GIT_OID_RE, "configuration_reference.commit" + ), + path=path, + digest=_full_match( + obj["digest"], _SHA256_RE, "configuration_reference.digest" + ), + ) + + def to_mapping(self) -> dict[str, str]: + return { + "repository_commit": self.repository_commit, + "path": self.path, + "digest": self.digest, + } + + +@dataclass(frozen=True) +class ReleaseConfigurationManifest: + """Expected release configuration, outside the image and not runtime proof. + + Strict parsing establishes structure only. A verified target resolver must + check committed reference contents and actual configuration; the existing + independent operation authority must authorize the resulting fingerprint. + """ + + schema_version: int + release_id: str + embedded_manifest_hash: str + governance_repository: str + pilot: ConfigurationReference + production: ConfigurationReference + environment_delta_registry: ConfigurationReference + + @classmethod + def from_mapping(cls, payload: Any) -> ReleaseConfigurationManifest: + obj = _exact_object( + payload, + { + "schema_version", + "release_id", + "embedded_manifest_hash", + "governance_repository", + "configuration_profiles", + "environment_delta_registry", + }, + "release_configuration_manifest", + ) + if type(obj["schema_version"]) is not int or obj["schema_version"] != 1: + raise ReleaseManifestError("release configuration schema_version must be 1") + repository = obj["governance_repository"] + if ( + not isinstance(repository, str) + or len(repository) > 129 + or re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repository + ) + is None + ): + raise ReleaseManifestError("governance_repository must be an owner/repo") + profiles = _exact_object( + obj["configuration_profiles"], + {"pilot", "production"}, + "configuration_profiles", + ) + return cls( + schema_version=1, + release_id=_full_match(obj["release_id"], _RELEASE_ID_RE, "release_id"), + embedded_manifest_hash=_full_match( + obj["embedded_manifest_hash"], _SHA256_RE, "embedded_manifest_hash" + ), + governance_repository=repository, + pilot=ConfigurationReference.from_mapping(profiles["pilot"]), + production=ConfigurationReference.from_mapping(profiles["production"]), + environment_delta_registry=ConfigurationReference.from_mapping( + obj["environment_delta_registry"] + ), + ) + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "release_id": self.release_id, + "embedded_manifest_hash": self.embedded_manifest_hash, + "governance_repository": self.governance_repository, + "configuration_profiles": { + "pilot": self.pilot.to_mapping(), + "production": self.production.to_mapping(), + }, + "environment_delta_registry": self.environment_delta_registry.to_mapping(), + } + + @property + def manifest_hash(self) -> str: + return canonical_sha256(self.to_mapping()) diff --git a/scripts/check_branch_auto_deploy.py b/scripts/check_branch_auto_deploy.py new file mode 100755 index 00000000..608dcd5e --- /dev/null +++ b/scripts/check_branch_auto_deploy.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Observe P-18 Railway source configuration without authorizing a deployment. + +Supply explicit project/environment/service UUIDs and either --input PATH (a +GraphQL response fixture, no network) or --live (one read-only CLI query). +Only selected source, identity, trigger and deployment-status fields are read. +The configured registry digest never counts as proof of the running image. + +Exit 1: evidence available, release readiness still gated. Exit 2: evidence +unavailable. This observer cannot return release-ready: image auto updates, +runtime artifact identity and exclusive deployment authority need independent +evidence. It neither reads that evidence nor changes Railway configuration. + +Schema checked with Railway CLI 5.37.4 introspection, 2026-09-12. +https://docs.railway.com/cli/api +https://docs.railway.com/deployments/image-auto-updates +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID + +MAX_INPUT_BYTES = 1024 * 1024 +QUERY = """ +query ObserveReleaseSource( + $projectId: String!, $environmentId: String!, $serviceId: String! +) { + environment(id: $environmentId, projectId: $projectId) { + id projectId configEtag + } + serviceInstance(environmentId: $environmentId, serviceId: $serviceId) { + serviceId environmentId + source { image repo } + activeDeployments { id status projectId environmentId serviceId } + } + serviceInstanceAutoDeployStatus( + projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId + ) { enabled } + deploymentTriggers( + projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId, + first: 100 + ) { + pageInfo { hasNextPage } + edges { node { id projectId environmentId serviceId branch repository provider } } + } +} +""" + +_IMAGE_COMPONENT = r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*" +_PINNED_IMAGE = re.compile( + r"(?P[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?)/" + rf"{_IMAGE_COMPONENT}(?:/{_IMAGE_COMPONENT})*" + r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?" + r"@(?Psha256:[0-9a-f]{64})" +) + + +class EvidenceUnavailable(ValueError): + """A sanitized error code, never an upstream payload or exception string.""" + + +@dataclass(frozen=True) +class Target: + project_id: str + environment_id: str + service_id: str + + def variables(self) -> dict[str, str]: + return { + "projectId": self.project_id, + "environmentId": self.environment_id, + "serviceId": self.service_id, + } + + +def mapping(value: object) -> dict: + if not isinstance(value, dict): + raise EvidenceUnavailable("missing_or_invalid_object") + return value + + +def sequence(value: object) -> list: + if not isinstance(value, list): + raise EvidenceUnavailable("missing_or_invalid_list") + return value + + +def text_field(value: object) -> str: + if ( + not isinstance(value, str) + or not value.strip() + or len(value) > 2048 + or any(ord(char) < 32 for char in value) + ): + raise EvidenceUnavailable("missing_or_invalid_text") + return value + + +def boolean(value: object) -> bool: + if type(value) is not bool: + raise EvidenceUnavailable("missing_or_invalid_boolean") + return value + + +def same_identity(node: dict, expected: dict[str, str]) -> None: + if any(node.get(key) != value for key, value in expected.items()): + raise EvidenceUnavailable("target_identity_mismatch") + + +def configured_registry_digest(image: str | None) -> str | None: + """Return only a full registry reference's SHA-256 pin, never runtime proof.""" + match = _PINNED_IMAGE.fullmatch(image or "") + if match is None: + return None + registry = match.group("registry") + if "." not in registry and ":" not in registry and registry != "localhost": + return None + return match.group("digest") + + +def read_source(value: object) -> dict: + source = mapping(value) + # The unused source alternative is legitimately null. Missing keys are not. + if "image" not in source or "repo" not in source: + raise EvidenceUnavailable("missing_source_field") + image, repo = source["image"], source["repo"] + if image is None and repo is None: + raise EvidenceUnavailable("source_unavailable") + for reference in (image, repo): + if reference is not None: + text_field(reference) + # Source references are names, never authenticated URLs. + if "://" in reference or any(char.isspace() for char in reference): + raise EvidenceUnavailable("invalid_source_reference") + if image is not None and not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._:/-]*(?:@sha256:[0-9a-fA-F]+)?", image + ): + raise EvidenceUnavailable("invalid_image_reference") + if repo is not None and not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + raise EvidenceUnavailable("invalid_repository_reference") + return { + "image": image, + "repo": repo, + "configured_registry_digest": configured_registry_digest(image), + } + + +def read_triggers(value: object, target: Target) -> list[dict]: + connection = mapping(value) + page = mapping(connection.get("pageInfo")) + if boolean(page.get("hasNextPage")): + raise EvidenceUnavailable("incomplete_trigger_listing") + triggers = [] + for edge in sequence(connection.get("edges")): + node = mapping(mapping(edge).get("node")) + same_identity(node, target.variables()) + triggers.append( + { + key: text_field(node.get(key)) + for key in ("id", "branch", "repository", "provider") + } + ) + return triggers + + +def read_deployments(value: object, target: Target) -> list[dict]: + deployments = [] + for item in sequence(value): + node = mapping(item) + same_identity(node, target.variables()) + deployments.append({key: text_field(node.get(key)) for key in ("id", "status")}) + if len({item["id"] for item in deployments}) != len(deployments): + raise EvidenceUnavailable("duplicate_deployment_identity") + return deployments + + +def observe(payload: object, target: Target) -> dict: + response = mapping(payload) + if response.get("errors"): + raise EvidenceUnavailable("railway_api_errors") + data = mapping(response.get("data")) + environment = mapping(data.get("environment")) + same_identity( + environment, {"id": target.environment_id, "projectId": target.project_id} + ) + etag = text_field(environment.get("configEtag")) + service = mapping(data.get("serviceInstance")) + same_identity( + service, + {"serviceId": target.service_id, "environmentId": target.environment_id}, + ) + source = read_source(service.get("source")) + auto_deploy = mapping(data.get("serviceInstanceAutoDeployStatus")) + enabled = boolean(auto_deploy.get("enabled")) + triggers = read_triggers(data.get("deploymentTriggers"), target) + deployments = read_deployments(service.get("activeDeployments"), target) + + source_blockers = [] + if source["repo"] is not None: + source_blockers.append("repository_source_configured") + if source["configured_registry_digest"] is None: + source_blockers.append("registry_digest_not_pinned") + if enabled: + source_blockers.append("branch_auto_deploy_enabled") + if triggers: + source_blockers.append("branch_deployment_triggers_present") + blockers = list(source_blockers) + if len(deployments) != 1: + blockers.append("active_deployment_not_unique") + if any(item["status"] != "SUCCESS" for item in deployments): + blockers.append("active_deployment_not_successful") + blockers.extend( + [ + "image_auto_updates_unverified", + "runtime_artifact_unverified", + "exclusive_deployment_authority_unverified", + ] + ) + return { + "evidence_status": "available", + "readiness": "not_ready", + "config_etag": etag, + "configured_source": source, + "configured_source_ready": not source_blockers, + "branch_auto_deploy": {"enabled": enabled, "triggers": triggers}, + "active_deployments": deployments, + "image_auto_updates": "unknown", + "runtime_artifact_verified": False, + "single_writer_ready": False, + "blockers": blockers, + } + + +def parse_response(raw: bytes) -> object: + if len(raw) > MAX_INPUT_BYTES: + raise EvidenceUnavailable("response_too_large") + try: + return json.loads(raw) + except (ValueError, UnicodeError, RecursionError) as exc: + raise EvidenceUnavailable("invalid_json_response") from exc + + +def read_fixture(path: Path) -> object: + try: + with path.open("rb") as source: + raw = source.read(MAX_INPUT_BYTES + 1) + except OSError as exc: + raise EvidenceUnavailable("fixture_unavailable") from exc + return parse_response(raw) + + +def read_live(target: Target) -> object: + try: + result = subprocess.run( + ["railway", "api", QUERY, "--variables", json.dumps(target.variables())], + capture_output=True, + check=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as exc: + # CLI stderr and GraphQL error details may contain private information. + raise EvidenceUnavailable("railway_read_failed") from exc + return parse_response(result.stdout) + + +def uuid_argument(value: str) -> str: + try: + return str(UUID(value)) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "an explicit Railway UUID is required" + ) from exc + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + for name in ("project", "environment", "service"): + parser.add_argument(f"--{name}", required=True, type=uuid_argument) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--input", type=Path, help="offline GraphQL response fixture") + mode.add_argument("--live", action="store_true", help="read Railway through CLI") + args = parser.parse_args(argv) + target = Target(args.project, args.environment, args.service) + report = { + "schema_version": 1, + "observation_mode": "live_read_only" if args.live else "offline_fixture", + "observed_at": None, + "target": asdict(target), + } + try: + payload = read_live(target) if args.live else read_fixture(args.input) + report.update(observe(payload, target)) + if args.live: + report["observed_at"] = datetime.now(timezone.utc).isoformat() + code = 1 + except EvidenceUnavailable as exc: + report.update( + evidence_status="unavailable", + readiness="evidence_unavailable", + configured_source_ready=False, + runtime_artifact_verified=False, + single_writer_ready=False, + blockers=[str(exc)], + ) + code = 2 + print(json.dumps(report, ensure_ascii=False, indent=2)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release-drift-detector.py b/scripts/release-drift-detector.py index c1afa8bc..19e66cdf 100755 --- a/scripts/release-drift-detector.py +++ b/scripts/release-drift-detector.py @@ -1,44 +1,31 @@ #!/usr/bin/env python3 -"""Two-way semantic drift detector between pilot and new-architecture (WP-562 Ф2). - -Equivalence is decided by git's own patch-id comparison (`git cherry`), not by -counting unique SHAs: a commit cherry-picked or rebased across branches gets a -new SHA but the same patch-id, so a naive SHA diff overstates drift (proven live -03.09.2026: 21+22 SHA-unique commits, only 1 real content difference that day). - -Unmatched commits ("+" in `git cherry -v`) are the real delta. Each one is -tagged for security/privacy-sensitive paths (a heuristic keyword pass, not a -full policy engine — see report.md for the scope this leaves open) and checked -for an `[incident-ok]`/`[cherry-pick-ok]` override marker in its message, so a -bypass of the local pre-push drift guard leaves an auditable trail here even -when the guard itself doesn't refuse it. - -Findings are appended to a JSONL manifest (append-only; last record per -delta_id wins on read) so `first_seen_unmatched_at` survives reruns and an -alert can fire once a delta has sat unresolved past the SLA. +"""Record pilot/production drift without mutating either branch (WP-562). + +Patch IDs explain history differences; exact trees establish convergence. A +missing commit alone never proves reconciliation. Exit codes: 0 checked, 1 +operational failure, 2 requested alert not delivered, 3 unresolved SLA overdue. """ import argparse import contextlib import datetime import fcntl +import hashlib import json +import os import pathlib +import re import shutil import subprocess import sys -import re +import tempfile REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent -MANIFEST_PATH = pathlib.Path.home() / "IWE/DS-my-strategy/machine/release-drift-manifest.jsonl" +MANIFEST_PATH = ( + pathlib.Path.home() / "IWE/DS-my-strategy/machine/release-drift-manifest.jsonl" +) SLA_HOURS = 24 - -DIRECTIONS = [ - # (label, upstream, head) — head-only commits (by patch-id) are the delta - ("pilot_only", "origin/new-architecture", "origin/pilot"), - ("new_architecture_only", "origin/pilot", "origin/new-architecture"), -] - +DIRECTIONS = ("pilot_only", "new_architecture_only", "tree_difference") POLICY_KEYWORDS = re.compile( r"auth|privacy|token|secret|encrypt|gdpr|migrat|permission|password|oauth|payment|consent", re.IGNORECASE, @@ -46,86 +33,231 @@ OVERRIDE_MARKERS = re.compile(r"\[(incident-ok|cherry-pick-ok)\]") -def git(*args: str) -> str: - try: - result = subprocess.run( - ["git", "-C", str(REPO_ROOT), *args], - capture_output=True, - text=True, - check=True, - ) - except subprocess.CalledProcessError as exc: - raise RuntimeError(f"git {' '.join(args)} failed: {exc.stderr.strip()}") from exc - return result.stdout - - -def cherry(upstream: str, head: str) -> list[tuple[str, str, str]]: - """Return (sign, sha, subject) triples; '+' = real delta, '-' = patch-id match.""" - rows = [] - for line in git("cherry", "-v", upstream, head).splitlines(): - if not line.strip(): - continue - parts = line.split(" ", 2) - sign, sha = parts[0], parts[1] - subject = parts[2] if len(parts) > 2 else "" - rows.append((sign, sha, subject)) - return rows - - -def changed_paths(sha: str) -> list[str]: - # --name-only (not --stat): --stat truncates long paths for display, which - # would silently hide a policy keyword living in a truncated segment. - output = git("diff-tree", "--no-commit-id", "--name-only", "-r", sha) - return [line for line in output.splitlines() if line.strip()] - - -def policy_tags(sha: str, subject: str) -> list[str]: - texts = [subject, *changed_paths(sha)] - return sorted({m.group(0).lower() for text in texts for m in POLICY_KEYWORDS.finditer(text)}) +class DetectorError(RuntimeError): + """The detector cannot produce trustworthy evidence.""" -def override_flags(sha: str) -> list[str]: - message = git("log", "-1", "--format=%B", sha) - return sorted(set(OVERRIDE_MARKERS.findall(message))) +class Git: + def __init__(self, repo: pathlib.Path, timeout: float): + self.repo = repo + self.timeout = timeout - -def load_manifest() -> dict[str, dict]: - """Latest record per delta_id — append-only file, later lines win.""" + def run(self, *args: str, input_text: str | None = None) -> str: + try: + result = subprocess.run( + ["git", "-C", str(self.repo), *args], + input=input_text, + capture_output=True, + text=True, + check=True, + timeout=self.timeout, + ) + except subprocess.TimeoutExpired as exc: + raise DetectorError( + f"git {args[0]} timed out after {self.timeout:g}s" + ) from exc + except subprocess.CalledProcessError as exc: + # Remote diagnostics can contain credential-bearing URLs. + raise DetectorError( + f"git {args[0]} failed (exit {exc.returncode})" + ) from exc + return result.stdout + + def commit(self, ref: str) -> str: + return self.run( + "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}" + ).strip() + + def cherry(self, upstream: str, head: str) -> list[tuple[str, str, str]]: + rows = [] + for line in self.run("cherry", "-v", upstream, head).splitlines(): + sign, sha, *subject = line.split(" ", 2) + if sign not in {"+", "-"}: + raise DetectorError("Unexpected git cherry output") + rows.append((sign, sha, subject[0] if subject else "")) + return rows + + def patch_id(self, sha: str) -> str: + patch = self.run("show", "--pretty=format:", "--no-ext-diff", "--binary", sha) + result = self.run("patch-id", "--stable", input_text=patch).split() + return result[0] if result else f"empty:{sha}" + + +def timestamp(value: str) -> datetime.datetime: + if not isinstance(value, str): + raise TypeError("timestamp must be a string") + parsed = datetime.datetime.fromisoformat(value) + if parsed.tzinfo is None: + raise ValueError("timestamp must include a timezone") + return parsed + + +def has_resolution_evidence(record: dict) -> bool: + evidence = record.get("resolution_evidence", {}) + return ( + isinstance(evidence, dict) + and evidence.get("kind") == "tree_equal" + and isinstance(evidence.get("pilot_tree"), str) + and bool(evidence.get("pilot_tree")) + and evidence.get("pilot_tree") == evidence.get("production_tree") + ) + + +def unique_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + +def reject_json_constant(value: str) -> None: + raise ValueError(f"non-JSON constant: {value}") + + +def parse_manifest_record(line: str, line_number: int) -> dict: + """Validate one complete record without interpreting historical changes.""" + try: + if not line.endswith("\n"): + raise ValueError("record is missing its terminating newline") + record = json.loads( + line, object_pairs_hook=unique_object, parse_constant=reject_json_constant + ) + if not isinstance(record, dict): + raise TypeError("record must be an object") + if not isinstance(record.get("delta_id"), str) or not record["delta_id"]: + raise ValueError("delta_id is required") + if type(record.get("schema_version")) is not int or record[ + "schema_version" + ] not in {1, 2}: + raise ValueError("unsupported schema_version") + if record.get("direction") not in DIRECTIONS: + raise ValueError("unknown direction") + if record.get("status") not in {"unmatched", "resolved"}: + raise ValueError("unknown status") + if not isinstance(record.get("source_sha"), str): + raise TypeError("source_sha is required") + first_seen = timestamp(record["first_seen_unmatched_at"]) + deadline = timestamp(record["sla_deadline_at"]) + timestamp(record["last_rechecked_at"]) + if deadline != first_seen + datetime.timedelta(hours=SLA_HOURS): + raise ValueError("SLA deadline does not match first_seen") + return record + except (KeyError, TypeError, ValueError) as exc: + raise DetectorError( + f"Invalid manifest record at line {line_number}: {exc}" + ) from exc + + +def load_manifest(path: pathlib.Path) -> dict[str, dict]: + """Read strictly: malformed evidence must never silently restart an SLA.""" known: dict[str, dict] = {} - if MANIFEST_PATH.exists(): - for line_number, line in enumerate(MANIFEST_PATH.read_text().splitlines(), start=1): + if not path.exists(): + return known + with path.open(encoding="utf-8") as source: + for line_number, line in enumerate(source, start=1): if not line.strip(): continue - try: - record = json.loads(line) - except json.JSONDecodeError as exc: - # A killed writer or a mid-write crash can leave a truncated - # trailing line. One bad line must not permanently break every - # future run — skip it and keep going. - print(f"WARN: {MANIFEST_PATH}:{line_number} нечитаемая строка манифеста пропущена: {exc}", file=sys.stderr) - continue + record = parse_manifest_record(line, line_number) + prior = known.get(record["delta_id"]) + if ( + prior + and timestamp(record["first_seen_unmatched_at"]) + > timestamp(prior["first_seen_unmatched_at"]) + and ( + prior["status"] != "resolved" or not has_resolution_evidence(prior) + ) + ): + raise DetectorError( + f"Invalid manifest record at line {line_number}: " + "SLA clock advanced without resolution evidence" + ) + # Version 1 inferred resolution from a disappearing SHA. That + # claim is not sufficient evidence to discard its unresolved SLA. + if record["status"] == "resolved" and not has_resolution_evidence(record): + record = { + **record, + "status": "unmatched", + "observation": "legacy_resolution_unproven", + } known[record["delta_id"]] = record return known -def append_manifest(records: list[dict]) -> None: - if not records: - return - MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True) - with MANIFEST_PATH.open("a") as f: - for record in records: - f.write(json.dumps(record, ensure_ascii=False) + "\n") +def migrate_legacy_manifest(source: pathlib.Path, destination: pathlib.Path) -> dict: + """Explicitly recover conservative clocks into a new journal, never in place.""" + if source.resolve() == destination.resolve(): + raise DetectorError("Legacy migration requires a distinct destination") + if destination.exists() or destination.is_symlink(): + raise DetectorError("Legacy migration destination already exists") + raw = source.read_bytes() + known = {} + earliest = {} + source_count = 0 + for line_number, line in enumerate( + raw.decode("utf-8").splitlines(keepends=True), start=1 + ): + if not line.strip(): + continue + record = parse_manifest_record(line, line_number) + if record["schema_version"] != 1: + raise DetectorError("Legacy migration only accepts schema_version 1") + delta_id = record["delta_id"] + prior = known.get(delta_id) + if prior and prior["direction"] != record["direction"]: + raise DetectorError("Legacy delta changed direction") + first_seen = record["first_seen_unmatched_at"] + if delta_id not in earliest or timestamp(first_seen) < timestamp( + earliest[delta_id] + ): + earliest[delta_id] = first_seen + known[delta_id] = record + source_count += 1 + provenance = { + "kind": "legacy_manifest_migration", + "source_sha256": hashlib.sha256(raw).hexdigest(), + "source_record_count": source_count, + "reason": "legacy_resolution_unproven", + "migrated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + migrated = [] + for delta_id, record in known.items(): + first_seen = earliest[delta_id] + row = { + **record, + "schema_version": 2, + "status": "unmatched", + "classification": "unknown", + "equivalence_level": "none", + "observation": "legacy_resolution_unproven", + "provenance": provenance, + "first_seen_unmatched_at": first_seen, + "sla_deadline_at": ( + timestamp(first_seen) + datetime.timedelta(hours=SLA_HOURS) + ).isoformat(), + } + row.pop("resolution_evidence", None) + migrated.append(row) + # O_EXCL also rejects a destination created after the preflight check. + with manifest_lock(destination), destination.open("x", encoding="utf-8") as target: + for row in migrated: + target.write(json.dumps(row, ensure_ascii=False) + "\n") + target.flush() + os.fsync(target.fileno()) + return { + "schema_version": 2, + "status": "migrated", + "provenance": provenance, + "migrated_delta_count": len(migrated), + } @contextlib.contextmanager -def manifest_lock(): - """Serialize the read-modify-append cycle across overlapping invocations - (cron overlapping a manual run, two runners racing) — without it, two - processes can both see a delta as unseen and each write a different - first_seen_unmatched_at, corrupting the SLA clock.""" - MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True) - lock_path = MANIFEST_PATH.with_suffix(MANIFEST_PATH.suffix + ".lock") - with lock_path.open("w") as lock_file: +def manifest_lock(path: pathlib.Path): + """Serialize the full read/compare/durable-append transaction.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_suffix(path.suffix + ".lock").open("a") as lock_file: fcntl.flock(lock_file, fcntl.LOCK_EX) try: yield @@ -133,133 +265,362 @@ def manifest_lock(): fcntl.flock(lock_file, fcntl.LOCK_UN) -def build_delta_records(direction: str, deltas: list[tuple[str, str]], now: str, known: dict[str, dict]) -> list[dict]: +def append_manifest(path: pathlib.Path, records: list[dict]) -> None: + if not records: + return + with path.open("a", encoding="utf-8") as target: + for record in records: + target.write(json.dumps(record, ensure_ascii=False) + "\n") + target.flush() + os.fsync(target.fileno()) + + +def oldest_first_seen(priors: list[dict], now: str) -> str: + times = [ + row["first_seen_unmatched_at"] + for row in priors + if row["status"] == "unmatched" or not has_resolution_evidence(row) + ] + return min(times, key=timestamp) if times else now + + +def delta_record( + delta_id: str, direction: str, sha: str, subject: str, first_seen: str, now: str +) -> dict: + return { + "schema_version": 2, + "delta_id": delta_id, + "direction": direction, + "source_sha": sha, + "source_subject": subject, + "candidate_matches": [], + "equivalence_level": "none", + "policy_tags": [], + "override_flags": [], + "first_seen_unmatched_at": first_seen, + "last_rechecked_at": now, + "status": "unmatched", + "sla_deadline_at": ( + timestamp(first_seen) + datetime.timedelta(hours=SLA_HOURS) + ).isoformat(), + "observation": "present", + } + + +def patch_records( + git: Git, + direction: str, + rows: list[tuple[str, str, str]], + now: str, + known: dict[str, dict], +) -> list[dict]: records = [] - for sha, subject in deltas: - delta_id = f"{direction}:{sha}" - prior = known.get(delta_id) - # Only inherit first_seen from a prior UNMATCHED record. A delta that - # was previously resolved and has now reopened (possible across - # history rewrites) must restart its SLA clock — otherwise it can be - # reported as already overdue on the very run it reopens. - first_seen = prior["first_seen_unmatched_at"] if prior and prior["status"] == "unmatched" else now - sla_deadline = ( - datetime.datetime.fromisoformat(first_seen) + datetime.timedelta(hours=SLA_HOURS) - ).isoformat() - records.append( + for sign, sha, subject in rows: + if sign != "+": + continue + patch_id = git.patch_id(sha) + delta_id = f"{direction}:patch:{patch_id}" + priors = [ + record + for record in known.values() + if record["direction"] == direction + and (record.get("patch_id") == patch_id or record["source_sha"] == sha) + ] + # Legacy SHA-only evidence may be impossible to map after a rewrite. + # Its earlier clock survives conservatively until tree convergence. + if not priors: + priors = [ + record + for record in known.values() + if record["direction"] == direction + and record["status"] == "unmatched" + and not record.get("patch_id") + ] + record = delta_record( + delta_id, direction, sha, subject, oldest_first_seen(priors, now), now + ) + paths = git.run( + "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", sha + ) + record["patch_id"] = patch_id + record["policy_tags"] = sorted( { - "schema_version": 1, - "delta_id": delta_id, - "direction": direction, - "source_sha": sha, - "source_subject": subject, - "candidate_matches": [], - "equivalence_level": "none", - "policy_tags": policy_tags(sha, subject), - "override_flags": override_flags(sha), - "first_seen_unmatched_at": first_seen, - "last_rechecked_at": now, - "status": "unmatched", - "sla_deadline_at": sla_deadline, + match.group(0).lower() + for match in POLICY_KEYWORDS.finditer(subject + "\n" + paths) } ) + record["override_flags"] = sorted( + set(OVERRIDE_MARKERS.findall(git.run("log", "-1", "--format=%B", sha))) + ) + records.append(record) return records -def build_resolved_records(direction: str, active_delta_ids: set[str], now: str, known: dict[str, dict]) -> list[dict]: - """A delta_id that used to be unmatched and no longer shows up got promoted or reconciled.""" - resolved = [] - for delta_id, prior in known.items(): - if prior["direction"] == direction and prior["status"] == "unmatched" and delta_id not in active_delta_ids: - resolved.append({**prior, "last_rechecked_at": now, "status": "resolved"}) - return resolved - - -def send_alert(message: str) -> None: - if not shutil.which("iwe-tg"): - print(f"INFO: iwe-tg недоступен, alert только в лог: {message}", file=sys.stderr) - return - result = subprocess.run(["iwe-tg", message]) - if result.returncode != 0: - print(f"WARN: iwe-tg вернул код {result.returncode}, alert не доставлен: {message}", file=sys.stderr) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--alert", action="store_true", help="Send a Telegram alert for deltas overdue past the SLA") - parser.add_argument("--json", action="store_true", help="Print a JSON summary instead of human-readable text") - args = parser.parse_args() - - try: - git("fetch", "origin", "pilot", "new-architecture") - except RuntimeError as exc: - print(f"ERROR: {exc}", file=sys.stderr) - # A broken detector is worse than a silent one — without this alert, - # a persistent fetch failure (network, host down) freezes the whole - # drift pipeline with nobody told. - send_alert(f"⚠️ WP-562 Ф2: детектор расхождений pilot↔production не смог обновить данные ({exc}). Проверка приостановлена.") - sys.exit(1) - - all_new_records: list[dict] = [] - summary: dict[str, dict] = {} - - # Locked for the whole read-modify-append span: two overlapping runs (cron - # racing a manual invocation) must not both see a delta as unseen and each - # write a different first_seen_unmatched_at. - with manifest_lock(): - known = load_manifest() +def inspect( + git: Git, manifest: pathlib.Path, pilot_ref: str, production_ref: str +) -> dict: + with manifest_lock(manifest): + known = load_manifest(manifest) + # Resolve once under the journal lock, so a delayed older observation + # cannot overwrite newer evidence from a concurrent invocation. + pilot = git.commit(pilot_ref) + production = git.commit(production_ref) + pilot_tree = git.run("rev-parse", f"{pilot}^{{tree}}").strip() + production_tree = git.run("rev-parse", f"{production}^{{tree}}").strip() + equal_trees = pilot_tree == production_tree + snapshots = { + "pilot": {"commit": pilot, "tree": pilot_tree}, + "production": {"commit": production, "tree": production_tree}, + } now = datetime.datetime.now(datetime.timezone.utc).isoformat() - - for direction, upstream, head in DIRECTIONS: - rows = cherry(upstream, head) - deltas = [(sha, subject) for sign, sha, subject in rows if sign == "+"] - equivalent_count = sum(1 for sign, _, _ in rows if sign == "-") - - delta_records = build_delta_records(direction, deltas, now, known) - active_ids = {r["delta_id"] for r in delta_records} - resolved_records = build_resolved_records(direction, active_ids, now, known) - - all_new_records.extend(delta_records) - all_new_records.extend(resolved_records) - summary[direction] = { - "real_delta_count": len(deltas), - "equivalent_count": equivalent_count, - "resolved_count": len(resolved_records), + rows_by_direction = { + "pilot_only": git.cherry(production, pilot), + "new_architecture_only": git.cherry(pilot, production), + } + records = [] + if equal_trees: + evidence = { + "kind": "tree_equal", + "pilot_tree": pilot_tree, + "production_tree": production_tree, + "snapshots": snapshots, } + records.extend( + { + **prior, + "status": "resolved", + "last_rechecked_at": now, + "equivalence_level": "tree", + "resolution_evidence": evidence, + } + for prior in known.values() + if prior["status"] == "unmatched" + ) + else: + for direction, rows in rows_by_direction.items(): + records.extend(patch_records(git, direction, rows, now, known)) + tree_id = "tree_difference:pilot-production" + prior_tree = known.get(tree_id) + priors = [prior_tree] if prior_tree else list(known.values()) + tree_record = delta_record( + tree_id, + "tree_difference", + pilot, + "Pilot and production source trees differ", + oldest_first_seen(priors, now), + now, + ) + tree_record["snapshots"] = snapshots + records.append(tree_record) + active_ids = {record["delta_id"] for record in records} + records.extend( + { + **prior, + "last_rechecked_at": now, + "observation": "not_observed_resolution_unproven", + } + for key, prior in known.items() + if key not in active_ids and prior["status"] == "unmatched" + ) + append_manifest(manifest, records) - append_manifest(all_new_records) - - now_dt = datetime.datetime.now(datetime.timezone.utc) overdue = [ - r - for r in all_new_records - if r["status"] == "unmatched" and datetime.datetime.fromisoformat(r["sla_deadline_at"]) < now_dt + row + for row in records + if row["status"] == "unmatched" + and timestamp(row["sla_deadline_at"]) <= timestamp(now) ] + deadlines = [ + row["sla_deadline_at"] for row in records if row["status"] == "unmatched" + ] + summary = {} + for direction in DIRECTIONS: + scoped = [row for row in records if row["direction"] == direction] + summary[direction] = { + "real_delta_count": sum(row["status"] == "unmatched" for row in scoped), + "equivalent_count": sum( + sign == "-" for sign, _, _ in rows_by_direction.get(direction, []) + ), + "resolved_count": sum(row["status"] == "resolved" for row in scoped), + } + return { + "schema_version": 2, + "status": "clean" if equal_trees else "drift", + "checked_at": now, + "snapshots": snapshots, + "summary": summary, + "oldest_deadline_at": min(deadlines, key=timestamp) if deadlines else None, + "records": records, + "overdue": overdue, + } + + +def send_alert( + message: str, timeout: float, source: str = "release-drift-detector" +) -> None: + if ( + os.environ.get("TELEGRAM_ENV") == "test" + and os.environ.get("ALLOW_LIVE_TELEGRAM_IN_TEST") != "1" + ): + raise DetectorError("Requested alert is blocked by TELEGRAM_ENV=test") + executable = shutil.which("iwe-tg") + if not executable: + raise DetectorError("Requested alert unavailable: iwe-tg is not installed") + try: + result = subprocess.run( + [executable, "--source", source, "--", message], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise DetectorError("Requested alert delivery timed out") from exc + if result.returncode: + raise DetectorError(f"Requested alert failed (exit {result.returncode})") + +def write_report(path: pathlib.Path, report: dict) -> None: + """Replace a report atomically; partial JSON is never an output artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + pending = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as target: + pending = pathlib.Path(target.name) + json.dump(report, target, ensure_ascii=False, indent=2) + target.write("\n") + target.flush() + os.fsync(target.fileno()) + pending.replace(path) + finally: + if pending and pending.exists(): + pending.unlink() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=pathlib.Path, default=REPO_ROOT) + parser.add_argument("--manifest", type=pathlib.Path, default=MANIFEST_PATH) + parser.add_argument( + "--migrate-legacy-manifest", + type=pathlib.Path, + metavar="SOURCE", + help="Migrate version 1 history into a new --manifest; no Git or alert", + ) + parser.add_argument("--pilot-ref", default="origin/pilot") + parser.add_argument("--production-ref", default="origin/new-architecture") + parser.add_argument( + "--no-fetch", action="store_true", help="Use only pinned local snapshots" + ) + parser.add_argument( + "--git-timeout", type=float, default=120, help="Per-command timeout in seconds" + ) + parser.add_argument( + "--alert", + action="store_true", + help="Deliver a Telegram alert on overdue drift/errors", + ) + parser.add_argument( + "--alert-source", + default="release-drift-detector", + help="Registered source ID in the iwe-tg transport allowlist", + ) + parser.add_argument( + "--json", action="store_true", help="Print the full JSON report" + ) + parser.add_argument( + "--output", type=pathlib.Path, help="Atomically write the JSON report" + ) + args = parser.parse_args(argv) + if not 0 < args.git_timeout <= 3600: + parser.error("--git-timeout must be between 0 and 3600 seconds") + if args.output and args.output.resolve() in { + args.manifest.resolve(), + args.manifest.with_suffix(args.manifest.suffix + ".lock").resolve(), + }: + parser.error("--output must be separate from the manifest and its lock") + return args + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if args.migrate_legacy_manifest: + try: + if ( + args.output + and args.output.resolve() == args.migrate_legacy_manifest.resolve() + ): + raise DetectorError("Migration report must not overwrite the source") + report = migrate_legacy_manifest( + args.migrate_legacy_manifest, args.manifest + ) + if args.output: + write_report(args.output, report) + except (DetectorError, OSError, UnicodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + try: + git = Git(args.repo, args.git_timeout) + if not args.no_fetch: + git.run("fetch", "--no-tags", "origin") + report = inspect(git, args.manifest, args.pilot_ref, args.production_ref) + except (DetectorError, OSError, UnicodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + if args.alert: + try: + send_alert( + "⚠️ WP-562: детектор расхождений не смог проверить данные. Проверьте журнал запуска.", + args.git_timeout, + args.alert_source, + ) + except (DetectorError, OSError) as delivery_error: + print(f"ERROR: {delivery_error}", file=sys.stderr) + return 2 + return 1 + + exit_code = 3 if report["overdue"] else 0 + report["alert_delivery"] = "not_needed" if args.alert else "not_requested" + if args.alert and report["overdue"]: + try: + send_alert( + f"⚠️ WP-562: {len(report['overdue'])} неразрешённых записей старше {SLA_HOURS}ч. " + "Подробности в release-drift-manifest.jsonl.", + args.git_timeout, + args.alert_source, + ) + report["alert_delivery"] = "delivered" + except (DetectorError, OSError) as exc: + report["alert_delivery"] = "failed" + print(f"ERROR: {exc}", file=sys.stderr) + exit_code = 2 + try: + if args.output: + write_report(args.output, report) + except OSError as exc: + print(f"ERROR: report was not written: {exc}", file=sys.stderr) + return 1 if args.json: - print(json.dumps({"summary": summary, "records": all_new_records, "overdue": overdue}, ensure_ascii=False, indent=2)) + print(json.dumps(report, ensure_ascii=False, indent=2)) else: - for direction, stats in summary.items(): + for direction, stats in report["summary"].items(): print( - f"{direction}: {stats['real_delta_count']} реальных дельт, " - f"{stats['equivalent_count']} эквивалентных по содержанию (разные SHA), " - f"{stats['resolved_count']} закрыто с прошлого прогона" + f"{direction}: {stats['real_delta_count']} unresolved, " + f"{stats['equivalent_count']} patch-equivalent, {stats['resolved_count']} resolved" ) - if overdue: - print(f"\n⚠️ {len(overdue)} дельт(а) не разобраны дольше {SLA_HOURS}ч:") - for record in overdue: - tags = f" [{', '.join(record['policy_tags'])}]" if record["policy_tags"] else "" - overrides = f" (override: {', '.join(record['override_flags'])})" if record["override_flags"] else "" - print(f" - {record['source_sha'][:10]} ({record['direction']}): {record['source_subject']}{tags}{overrides}") - - if overdue and args.alert: - send_alert( - f"⚠️ WP-562 Ф2: {len(overdue)} расхождений pilot↔production не разобраны дольше {SLA_HOURS}ч. " - f"Подробности: release-drift-manifest.jsonl" + print( + f"status={report['status']}; overdue={len(report['overdue'])}; " + f"oldest_deadline_at={report['oldest_deadline_at']}" ) - - sys.exit(3 if overdue else 0) + return exit_code if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/release_control.py b/scripts/release_control.py new file mode 100644 index 00000000..00f688d4 --- /dev/null +++ b/scripts/release_control.py @@ -0,0 +1,6031 @@ +"""Verified, transport-neutral release-control core for WP-562. + +The module owns policy and deterministic state transitions, not credentials or +provider transports. Every fact that can authorize a mutation enters through a +narrow verified port. The current repository contract keeps cutover off, so the +code can build and inspect plans but cannot call even an injected provider. + +Live adapters must preserve three ordering invariants: + +* an independent, domain-separated authority is verified with trusted time; +* the ledger durably claims and burns that authority before the provider call; +* any uncertain claim is reconciled by fresh, complete observations and an + atomic compare-and-swap, never by retrying the mutation. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import logging +import re +import stat +import sys +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import InitVar, dataclass, replace +from dataclasses import field as dataclass_field +from datetime import datetime, timedelta, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Protocol + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_RELEASE_MANIFEST_PATH = _REPO_ROOT / "release_manifest.py" + + +def _load_exact_release_manifest() -> object: + try: + metadata = _RELEASE_MANIFEST_PATH.lstat() + except OSError as exc: + raise ImportError("trusted release-manifest module is unavailable") from exc + if not stat.S_ISREG(metadata.st_mode) or _RELEASE_MANIFEST_PATH.is_symlink(): + raise ImportError("trusted release-manifest module is not a regular file") + + existing = sys.modules.get("release_manifest") + if existing is not None: + origin = getattr(existing, "__file__", None) + if origin is None or Path(origin).resolve() != _RELEASE_MANIFEST_PATH.resolve(): + raise ImportError("ambiguous release-manifest module is forbidden") + return existing + + spec = importlib.util.spec_from_file_location( + "release_manifest", _RELEASE_MANIFEST_PATH + ) + if spec is None or spec.loader is None: + raise ImportError("trusted release-manifest module cannot be loaded") + module = importlib.util.module_from_spec(spec) + sys.modules["release_manifest"] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop("release_manifest", None) + raise + return module + + +_release_manifest = _load_exact_release_manifest() +EmbeddedManifest = _release_manifest.EmbeddedManifest +ReleaseConfigurationManifest = _release_manifest.ReleaseConfigurationManifest +MigrationClass = _release_manifest.MigrationClass +ReleaseManifestError = _release_manifest.ReleaseManifestError +canonical_json_bytes = _release_manifest.canonical_json_bytes +canonical_sha256 = _release_manifest.canonical_sha256 +loads_strict_json = _release_manifest.loads_strict_json + +LOGGER = logging.getLogger(__name__) + +OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json" +OCI_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json" +AUTHORITY_PURPOSE = "iwe.release-control.authority.v1" +AUTHORITY_AUDIENCE = "iwe.release-control.runner" +PROVIDER_IDEMPOTENCY_DOMAIN = "iwe.release-control.provider-idempotency.v1" +PILOT_QUALIFICATION_REQUIRED_SIGNALS = ( + "canary", + "data-invariants", + "readiness", + "runtime-attestation", +) + +MAX_IDENTIFIER_LENGTH = 128 +MAX_REFERENCE_LENGTH = 512 +MAX_TARGET_SNAPSHOT_AGE_SECONDS = 300 +MAX_ARTIFACT_RECEIPT_AGE_SECONDS = 900 +RECONCILIATION_MAX_SNAPSHOT_AGE_SECONDS = 300 +RECONCILIATION_MIN_SETTLING_SECONDS = 60 +RECONCILIATION_MIN_ZERO_INTERVAL_SECONDS = 30 +RECONCILIATION_MIN_ZERO_SNAPSHOTS = 2 + +AUTHORITY_REQUIRED_SIGNED_FIELDS = ( + "approver_identity", + "audience", + "authority_id", + "contract_digest", + "contract_edition", + "expires_at", + "issued_at", + "key_id", + "max_uses", + "nonce", + "operation_fingerprint", + "purpose", + "release_id", + "repository", + "required_checks_digest", + "runner_identity", + "schema_version", + "target", +) +LEDGER_CLAIM_FIELD_ALLOWLIST = ( + "artifact_digest", + "authorization_id", + "contract_digest", + "evidence_hash", + "external_id", + "operation_fingerprint", + "operation_kind", + "target_key", +) + +_SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}\Z") +_GIT_OID_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +_SAFE_ID_RE = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}\Z") +_CHECK_ID_RE = re.compile(r"[a-z][a-z0-9-]{0,63}\Z") +_REPOSITORY_PART_RE = re.compile(r"[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?\Z") +_OCI_COMPONENT_RE = re.compile(r"[a-z0-9]+(?:[._-][a-z0-9]+)*\Z") + + +class ReleaseControlError(ValueError): + """Base class for release-control failures that must stop mutation.""" + + +class SchemaError(ReleaseControlError): + """A value does not match its exact bounded schema.""" + + +class ContractError(ReleaseControlError): + """The repository release-control contract is malformed or unsafe.""" + + +class EvidenceRejected(ReleaseControlError): + """A trusted resolver did not provide exact, fresh evidence.""" + + +class AuthorityRejected(ReleaseControlError): + """The independent mutation authority is invalid or out of scope.""" + + +class CutoverBlocked(ReleaseControlError): + """Mutation is disabled by the immutable repository contract.""" + + +class LedgerSafetyError(ReleaseControlError): + """A durable state transition did not prove its safety invariants.""" + + +class RollbackRejected(ReleaseControlError): + """Rollback evidence is incomplete, stale, or mismatched.""" + + +def _mapping(value: Any, path: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise SchemaError(f"{path} must be an object") + if any(not isinstance(key, str) for key in value): + raise SchemaError(f"{path} keys must be strings") + return value + + +def _exact_keys(value: Any, expected: set[str], path: str) -> Mapping[str, Any]: + obj = _mapping(value, path) + actual = set(obj) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing or extra: + parts: list[str] = [] + if missing: + parts.append(f"missing={missing}") + if extra: + parts.append(f"extra={extra}") + raise SchemaError(f"{path} has wrong keys ({', '.join(parts)})") + return obj + + +def _literal(value: Any, expected: Any, path: str) -> None: + if type(value) is not type(expected) or value != expected: + raise SchemaError(f"{path} must equal {expected!r}") + + +def _boolean(value: Any, path: str) -> bool: + if type(value) is not bool: + raise SchemaError(f"{path} must be a boolean") + return value + + +def _bounded_int(value: Any, path: str, *, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise SchemaError(f"{path} is outside its integer range") + return value + + +def _bounded_string( + value: Any, + path: str, + *, + maximum: int = MAX_IDENTIFIER_LENGTH, + pattern: re.Pattern[str] | None = None, +) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise SchemaError(f"{path} must be non-empty trimmed text") + if len(value) > maximum or any( + ord(character) < 32 or ord(character) == 127 for character in value + ): + raise SchemaError(f"{path} exceeds its safe bounds") + if pattern is not None and pattern.fullmatch(value) is None: + raise SchemaError(f"{path} has invalid characters") + return value + + +def _safe_id(value: Any, path: str) -> str: + return _bounded_string(value, path, pattern=_SAFE_ID_RE) + + +def _canonical_uuid(value: Any, path: str) -> str: + text = _bounded_string(value, path, maximum=36) + try: + parsed = uuid.UUID(text) + except (ValueError, AttributeError) as exc: + raise SchemaError(f"{path} must be a canonical UUID") from exc + if str(parsed) != text: + raise SchemaError(f"{path} must be a lowercase canonical UUID") + return text + + +def _digest(value: Any, path: str) -> str: + text = _bounded_string(value, path, maximum=71) + if _SHA256_RE.fullmatch(text) is None: + raise SchemaError(f"{path} must be a lowercase sha256 digest") + return text + + +def _git_oid(value: Any, path: str) -> str: + text = _bounded_string(value, path, maximum=64) + if _GIT_OID_RE.fullmatch(text) is None: + raise SchemaError(f"{path} must be a full lowercase Git object ID") + return text + + +def _utc(value: Any, path: str) -> datetime: + if ( + not isinstance(value, datetime) + or value.tzinfo is None + or value.utcoffset() is None + ): + raise SchemaError(f"{path} must be a timezone-aware datetime") + return value.astimezone(timezone.utc) + + +def _parse_utc(value: Any, path: str) -> datetime: + text = _bounded_string(value, path, maximum=35) + if not text.endswith("Z"): + raise SchemaError(f"{path} must use UTC Z notation") + try: + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + except ValueError as exc: + raise SchemaError(f"{path} is not an ISO-8601 timestamp") from exc + return _utc(parsed, path) + + +def _timestamp(value: datetime) -> str: + return _utc(value, "timestamp").isoformat().replace("+00:00", "Z") + + +def _age_seconds(observed_at: datetime, now: datetime, path: str) -> float: + age = (_utc(now, "trusted clock") - _utc(observed_at, path)).total_seconds() + if age < 0: + raise EvidenceRejected(f"{path} is in the future") + return age + + +def _hash(value: Any, path: str) -> str: + try: + return canonical_sha256(value) + except ReleaseManifestError as exc: + raise SchemaError(f"{path} is not canonical JSON") from exc + + +def _check_ids(value: Any, path: str) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)) or not 1 <= len(value) <= 32: + raise SchemaError(f"{path} must contain 1..32 check IDs") + result = tuple( + _bounded_string(item, f"{path}[]", maximum=64, pattern=_CHECK_ID_RE) + for item in value + ) + if result != tuple(sorted(set(result))): + raise SchemaError(f"{path} must be sorted and unique") + return result + + +def _repository_slug(owner: Any, name: Any) -> str: + owner_text = _bounded_string( + owner, + "contract.repository.owner", + pattern=_REPOSITORY_PART_RE, + ) + name_text = _bounded_string( + name, + "contract.repository.name", + pattern=_REPOSITORY_PART_RE, + ) + return f"{owner_text}/{name_text}" + + +def _git_ref(value: Any, path: str) -> str: + ref = _bounded_string(value, path, maximum=255) + forbidden = ("..", "@{", "//", " ", "~", "^", ":", "?", "*", "[", "\\") + if ( + ref in ("@", ".") + or ref.startswith(("/", ".")) + or ref.endswith(("/", ".")) + or any(token in ref for token in forbidden) + ): + raise SchemaError(f"{path} is not a safe Git ref") + components = ref.split("/") + if any( + not component or component.startswith(".") or component.endswith((".", ".lock")) + for component in components + ): + raise SchemaError(f"{path} is not a canonical Git ref") + return ref + + +def _relative_path(value: Any, path: str) -> str: + text = _bounded_string(value, path, maximum=256) + candidate = Path(text) + if ( + candidate.is_absolute() + or text in (".", "..") + or ".." in candidate.parts + or "\\" in text + or "//" in text + or text.endswith("/") + or candidate.as_posix() != text + ): + raise SchemaError(f"{path} must be a bounded repository-relative path") + return text + + +def validate_immutable_image_reference(value: Any, expected_digest: str) -> str: + """Validate a strict digest-only OCI reference, allowing a registry port.""" + + reference = _bounded_string( + value, + "artifact.image_reference", + maximum=MAX_REFERENCE_LENGTH, + ) + if ( + reference.count("@") != 1 + or "://" in reference + or any(marker in reference for marker in ("?", "#")) + ): + raise SchemaError( + "artifact.image_reference must be a digest-only OCI reference" + ) + name, digest = reference.rsplit("@", 1) + if digest != _digest(expected_digest, "artifact.digest"): + raise SchemaError("artifact.image_reference digest mismatch") + components = name.split("/") + if not components or any(not component for component in components): + raise SchemaError("artifact.image_reference has an empty name component") + registry = components[0] + if ":" in registry: + host, port = registry.rsplit(":", 1) + if not port.isdigit() or not 1 <= int(port) <= 65535: + raise SchemaError("artifact.image_reference has an invalid registry port") + registry = host + if _OCI_COMPONENT_RE.fullmatch(registry) is None: + raise SchemaError("artifact.image_reference has an invalid registry") + for component in components[1:]: + if ":" in component: + raise SchemaError("artifact.image_reference must not contain a mutable tag") + if _OCI_COMPONENT_RE.fullmatch(component) is None: + raise SchemaError( + "artifact.image_reference has an invalid repository component" + ) + if ":" in components[-1] and len(components) == 1: + raise SchemaError("artifact.image_reference must not contain a mutable tag") + return reference + + +def _oci_repository(value: Any, path: str) -> str: + repository = _bounded_string(value, path, maximum=440) + if "@" in repository or "://" in repository or len(repository.split("/")) < 2: + raise SchemaError(f"{path} must name one canonical OCI registry/repository") + probe_digest = "sha256:" + "0" * 64 + validate_immutable_image_reference(f"{repository}@{probe_digest}", probe_digest) + return repository + + +_RELEASE_CONTRACT_SEAL = object() + + +@dataclass(frozen=True) +class ReleaseControlContract: + schema_version: int + contract_edition: str + repository: str + oci_repository: str + pilot_source_ref: str + production_source_ref: str + authority_trust_root_identity: str + authority_ttl_seconds: int + reconciliation_settling_seconds: int + zero_settlement_min_interval_seconds: int + zero_settlement_observations: int + check_suite_max_age_seconds: int + source_candidate_max_age_seconds: int + pilot_qualification_max_age_seconds: int + pilot_qualification_required_signals: tuple[str, ...] + rollback_compatibility_max_age_seconds: int + rollback_retention_recheck_seconds: int + rollback_reattestation_seconds: int + required_checks: tuple[str, ...] + protected_paths: tuple[str, ...] + forbidden_paths: tuple[str, ...] + cutover_enabled: bool + negative_matrix_max_age_seconds: int + negative_matrix_evidence_ref: str | None + contract_digest: str + _validated_seal: InitVar[object | None] = None + _validated: bool = dataclass_field( + default=False, + init=False, + repr=False, + compare=False, + ) + + def __post_init__(self, _validated_seal: object | None) -> None: + if _validated_seal is not _RELEASE_CONTRACT_SEAL: + raise ContractError("contract requires strict repository validation") + object.__setattr__(self, "_validated", True) + + @property + def required_checks_digest(self) -> str: + return _hash(list(self.required_checks), "contract.required_checks") + + +_CONTRACT_KEYS = { + "schema_version", + "contract_edition", + "repository", + "source_refs", + "artifact", + "authority", + "ledger", + "promotion", + "rollback", + "required_checks", + "protected_paths", + "forbidden_paths", + "cutover", +} + + +def _validate_contract_identity( + root: Mapping[str, Any], +) -> tuple[str, str, str, str]: + _literal(root["schema_version"], 1, "contract.schema_version") + edition = _safe_id(root["contract_edition"], "contract.contract_edition") + repository = _exact_keys( + root["repository"], {"owner", "name"}, "contract.repository" + ) + repository_slug = _repository_slug(repository["owner"], repository["name"]) + refs = _exact_keys( + root["source_refs"], {"pilot", "production"}, "contract.source_refs" + ) + pilot_ref = _git_ref(refs["pilot"], "contract.source_refs.pilot") + production_ref = _git_ref(refs["production"], "contract.source_refs.production") + if pilot_ref == production_ref: + raise SchemaError("contract source refs must be distinct") + return edition, repository_slug, pilot_ref, production_ref + + +def _validate_contract_artifact(value: Any) -> str: + artifact = _exact_keys( + value, + { + "digest_kind", + "oci_repository", + "platform", + "immutable_reference_required", + "embedded_manifest_excludes", + }, + "contract.artifact", + ) + _literal(artifact["digest_kind"], "oci_manifest", "contract.artifact.digest_kind") + oci_repository = _oci_repository( + artifact["oci_repository"], + "contract.artifact.oci_repository", + ) + platform = _exact_keys( + artifact["platform"], {"os", "architecture"}, "contract.artifact.platform" + ) + _literal(platform["os"], "linux", "contract.artifact.platform.os") + _literal( + platform["architecture"], + "amd64", + "contract.artifact.platform.architecture", + ) + _literal( + artifact["immutable_reference_required"], + True, + "contract.artifact.immutable_reference_required", + ) + if artifact["embedded_manifest_excludes"] != ["artifact_digest", "manifest_hash"]: + raise SchemaError("contract artifact must exclude self/final digests") + return oci_repository + + +def _validate_contract_authority(value: Any) -> tuple[int, str]: + authority = _exact_keys( + value, + { + "audience", + "independent_approver_required", + "max_uses", + "purpose", + "required_signed_fields", + "trust_root_identity", + "ttl_seconds", + }, + "contract.authority", + ) + _literal(authority["purpose"], AUTHORITY_PURPOSE, "contract.authority.purpose") + _literal(authority["audience"], AUTHORITY_AUDIENCE, "contract.authority.audience") + if authority["required_signed_fields"] != list(AUTHORITY_REQUIRED_SIGNED_FIELDS): + raise SchemaError("contract authority signed-field allowlist mismatch") + _literal( + authority["independent_approver_required"], + True, + "contract.authority.independent_approver_required", + ) + _literal(authority["max_uses"], 1, "contract.authority.max_uses") + ttl_seconds = _bounded_int( + authority["ttl_seconds"], + "contract.authority.ttl_seconds", + minimum=1, + maximum=86_400, + ) + trust_root = _safe_id( + authority["trust_root_identity"], + "contract.authority.trust_root_identity", + ) + return ttl_seconds, trust_root + + +def _validate_contract_ledger(value: Any) -> None: + ledger = _exact_keys( + value, + { + "append_only_required", + "claim_field_allowlist", + "durable_claim_required", + "external_receipt_required", + "raw_evidence_forbidden", + "target_version_cas_required", + }, + "contract.ledger", + ) + for key in ( + "durable_claim_required", + "append_only_required", + "external_receipt_required", + "raw_evidence_forbidden", + "target_version_cas_required", + ): + _literal(ledger[key], True, f"contract.ledger.{key}") + if ledger["claim_field_allowlist"] != list(LEDGER_CLAIM_FIELD_ALLOWLIST): + raise SchemaError("contract ledger claim-field allowlist mismatch") + + +def _validate_contract_promotion(value: Any) -> tuple[int, int, int]: + promotion = _exact_keys( + value, + { + "build_once", + "check_suite_max_age_seconds", + "check_suite_receipt_required", + "independent_review_required", + "pilot_qualification_independent_verifier_required", + "pilot_qualification_max_age_seconds", + "pilot_qualification_receipt_required", + "pilot_qualification_required_signals", + "provider_fencing_required", + "provider_deadline_required", + "provider_idempotency_required", + "reconciliation", + "reconciliation_settling_seconds", + "same_digest_required", + "source_candidate_max_age_seconds", + "source_candidate_receipt_required", + "zero_settlement_min_interval_seconds", + "zero_settlement_observations", + }, + "contract.promotion", + ) + literal_fields = { + "build_once": True, + "check_suite_receipt_required": True, + "independent_review_required": True, + "pilot_qualification_independent_verifier_required": True, + "pilot_qualification_receipt_required": True, + "provider_fencing_required": True, + "provider_deadline_required": True, + "provider_idempotency_required": True, + "reconciliation_settling_seconds": RECONCILIATION_MIN_SETTLING_SECONDS, + "same_digest_required": True, + "source_candidate_receipt_required": True, + "zero_settlement_min_interval_seconds": RECONCILIATION_MIN_ZERO_INTERVAL_SECONDS, + "zero_settlement_observations": RECONCILIATION_MIN_ZERO_SNAPSHOTS, + } + for key, expected in literal_fields.items(): + _literal(promotion[key], expected, f"contract.promotion.{key}") + check_suite_max_age = _bounded_int( + promotion["check_suite_max_age_seconds"], + "contract.promotion.check_suite_max_age_seconds", + minimum=1, + maximum=86_400, + ) + source_candidate_max_age = _bounded_int( + promotion["source_candidate_max_age_seconds"], + "contract.promotion.source_candidate_max_age_seconds", + minimum=1, + maximum=86_400, + ) + pilot_qualification_max_age = _bounded_int( + promotion["pilot_qualification_max_age_seconds"], + "contract.promotion.pilot_qualification_max_age_seconds", + minimum=1, + maximum=3600, + ) + if promotion["pilot_qualification_required_signals"] != list( + PILOT_QUALIFICATION_REQUIRED_SIGNALS + ): + raise SchemaError("contract pilot qualification signal allowlist mismatch") + reconciliation = _exact_keys( + promotion["reconciliation"], + {"zero", "one", "many"}, + "contract.promotion.reconciliation", + ) + expected_outcomes = { + "zero": "observed_not_applied", + "one": "observed_applied", + "many": "manual_review", + } + for cardinality, expected in expected_outcomes.items(): + _literal( + reconciliation[cardinality], + expected, + f"contract.promotion.reconciliation.{cardinality}", + ) + return ( + check_suite_max_age, + source_candidate_max_age, + pilot_qualification_max_age, + ) + + +def _validate_contract_rollback(value: Any) -> tuple[int, int, int]: + rollback = _exact_keys( + value, + { + "compatibility_max_age_seconds", + "compatibility_receipt_required", + "historical_build_contract_receipt_required", + "independent_verifier_required", + "same_previous_good_digest", + "rebuild_forbidden", + "require_can_rollback", + "require_schema_compatibility", + "retention_recheck_seconds", + "reattestation_seconds", + }, + "contract.rollback", + ) + for key in ( + "compatibility_receipt_required", + "historical_build_contract_receipt_required", + "independent_verifier_required", + "same_previous_good_digest", + "rebuild_forbidden", + "require_can_rollback", + "require_schema_compatibility", + ): + _literal(rollback[key], True, f"contract.rollback.{key}") + compatibility_max_age = _bounded_int( + rollback["compatibility_max_age_seconds"], + "contract.rollback.compatibility_max_age_seconds", + minimum=1, + maximum=86_400, + ) + retention_recheck = _bounded_int( + rollback["retention_recheck_seconds"], + "contract.rollback.retention_recheck_seconds", + minimum=1, + maximum=86_400, + ) + reattestation = _bounded_int( + rollback["reattestation_seconds"], + "contract.rollback.reattestation_seconds", + minimum=1, + maximum=86_400, + ) + return compatibility_max_age, retention_recheck, reattestation + + +def _validate_contract_checks_and_paths( + root: Mapping[str, Any], +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]: + checks = _check_ids(root["required_checks"], "contract.required_checks") + raw_paths = root["protected_paths"] + if not isinstance(raw_paths, list) or not 1 <= len(raw_paths) <= 256: + raise SchemaError("contract.protected_paths must contain 1..256 paths") + paths = tuple( + _relative_path(item, "contract.protected_paths[]") for item in raw_paths + ) + if paths != tuple(sorted(set(paths))): + raise SchemaError("contract.protected_paths must be sorted and unique") + if "scripts/release_control.py" not in paths: + raise SchemaError("contract must protect scripts/release_control.py") + raw_forbidden = root["forbidden_paths"] + if not isinstance(raw_forbidden, list) or not 1 <= len(raw_forbidden) <= 64: + raise SchemaError("contract.forbidden_paths must contain 1..64 paths") + forbidden = tuple( + _relative_path(item, "contract.forbidden_paths[]") for item in raw_forbidden + ) + if forbidden != tuple(sorted(set(forbidden))): + raise SchemaError("contract.forbidden_paths must be sorted and unique") + overlap = sorted(set(paths).intersection(forbidden)) + if overlap: + raise SchemaError("contract protected_paths and forbidden_paths overlap") + return checks, paths, forbidden + + +def _validate_contract_cutover(value: Any) -> tuple[bool, int, str | None]: + cutover = _exact_keys( + value, + { + "enabled", + "negative_matrix_required", + "negative_matrix_max_age_seconds", + "negative_matrix_evidence_ref", + }, + "contract.cutover", + ) + enabled = _boolean(cutover["enabled"], "contract.cutover.enabled") + _literal( + cutover["negative_matrix_required"], + True, + "contract.cutover.negative_matrix_required", + ) + negative_matrix_max_age = _bounded_int( + cutover["negative_matrix_max_age_seconds"], + "contract.cutover.negative_matrix_max_age_seconds", + minimum=1, + maximum=86_400, + ) + raw_evidence_ref = cutover["negative_matrix_evidence_ref"] + if raw_evidence_ref is None: + if enabled: + raise SchemaError( + "contract.cutover.negative_matrix_evidence_ref is required " + "while cutover.enabled is true" + ) + evidence_ref = None + else: + evidence_ref = _digest( + raw_evidence_ref, + "contract.cutover.negative_matrix_evidence_ref", + ) + return enabled, negative_matrix_max_age, evidence_ref + + +def validate_release_control_contract(payload: Any) -> ReleaseControlContract: + """Validate all contract fields; missing or unknown fields stop execution.""" + + try: + root = _exact_keys(payload, _CONTRACT_KEYS, "contract") + edition, repository, pilot_ref, production_ref = _validate_contract_identity( + root + ) + oci_repository = _validate_contract_artifact(root["artifact"]) + authority_ttl, trust_root = _validate_contract_authority(root["authority"]) + _validate_contract_ledger(root["ledger"]) + ( + check_suite_max_age, + source_candidate_max_age, + pilot_qualification_max_age, + ) = _validate_contract_promotion(root["promotion"]) + ( + rollback_compatibility_max_age, + retention_recheck, + reattestation, + ) = _validate_contract_rollback(root["rollback"]) + checks, paths, forbidden_paths = _validate_contract_checks_and_paths(root) + ( + cutover_enabled, + negative_matrix_max_age, + negative_matrix_evidence_ref, + ) = _validate_contract_cutover(root["cutover"]) + contract_digest = _hash(root, "contract") + except (SchemaError, ReleaseManifestError) as exc: + raise ContractError(str(exc)) from exc + + return ReleaseControlContract( + schema_version=1, + contract_edition=edition, + repository=repository, + oci_repository=oci_repository, + pilot_source_ref=pilot_ref, + production_source_ref=production_ref, + authority_trust_root_identity=trust_root, + authority_ttl_seconds=authority_ttl, + reconciliation_settling_seconds=RECONCILIATION_MIN_SETTLING_SECONDS, + zero_settlement_min_interval_seconds=RECONCILIATION_MIN_ZERO_INTERVAL_SECONDS, + zero_settlement_observations=RECONCILIATION_MIN_ZERO_SNAPSHOTS, + check_suite_max_age_seconds=check_suite_max_age, + source_candidate_max_age_seconds=source_candidate_max_age, + pilot_qualification_max_age_seconds=pilot_qualification_max_age, + pilot_qualification_required_signals=(PILOT_QUALIFICATION_REQUIRED_SIGNALS), + rollback_compatibility_max_age_seconds=(rollback_compatibility_max_age), + rollback_retention_recheck_seconds=retention_recheck, + rollback_reattestation_seconds=reattestation, + required_checks=checks, + protected_paths=paths, + forbidden_paths=forbidden_paths, + cutover_enabled=cutover_enabled, + negative_matrix_max_age_seconds=negative_matrix_max_age, + negative_matrix_evidence_ref=negative_matrix_evidence_ref, + contract_digest=contract_digest, + _validated_seal=_RELEASE_CONTRACT_SEAL, + ) + + +def load_release_control_contract(path: str | Path) -> ReleaseControlContract: + try: + raw = Path(path).read_bytes() + payload = loads_strict_json(raw) + except OSError as exc: + raise ContractError("cannot read release-control contract") from exc + except ReleaseManifestError as exc: + raise ContractError(str(exc)) from exc + return validate_release_control_contract(payload) + + +class TrustedClockPort(Protocol): + def now_utc(self) -> datetime: + """Return authenticated wall-clock time, independent of request input.""" + + +class _MonotonicTrustedClock: + """Reject backward trusted-time movement across one complete flow.""" + + def __init__(self, clock: TrustedClockPort) -> None: + self._clock = clock + self._last: datetime | None = None + + def now_utc(self) -> datetime: + current = _utc(self._clock.now_utc(), "trusted clock") + if self._last is not None and current < self._last: + raise EvidenceRejected( + "trusted clock moved backwards across release stages" + ) + self._last = current + return current + + +def _trusted_now(clock: TrustedClockPort) -> datetime: + try: + return _utc(clock.now_utc(), "trusted clock") + except Exception as exc: + if isinstance(exc, ReleaseControlError): + raise EvidenceRejected(str(exc)) from exc + LOGGER.warning("trusted clock failed closed (%s)", type(exc).__name__) + raise EvidenceRejected("trusted clock unavailable") from exc + + +def _trusted_post_call_now( + clock: TrustedClockPort, + started_at: datetime, + activity: str, +) -> datetime: + """Close a trusted time window around an external evidence call.""" + + finished_at = _trusted_now(clock) + if finished_at < started_at: + raise EvidenceRejected(f"trusted clock moved backwards during {activity}") + return finished_at + + +def _canonical_manifest(manifest: EmbeddedManifest) -> EmbeddedManifest: + if type(manifest) is not EmbeddedManifest: + raise SchemaError("manifest must be a validated EmbeddedManifest") + try: + canonical = EmbeddedManifest.from_mapping(manifest.to_mapping()) + except (ReleaseManifestError, AttributeError, TypeError) as exc: + raise SchemaError("embedded manifest failed canonical revalidation") from exc + if canonical != manifest or canonical.schema_version != 1: + raise SchemaError("embedded manifest is not canonical schema version 1") + return canonical + + +def _validate_manifest( + manifest: EmbeddedManifest, + contract: ReleaseControlContract, +) -> EmbeddedManifest: + if type(contract) is not ReleaseControlContract or not contract._validated: + raise ContractError("contract was not produced by strict validation") + canonical = _canonical_manifest(manifest) + if canonical.build_contract_digest != contract.contract_digest: + raise SchemaError("embedded manifest build contract digest does not match") + if tuple(canonical.required_checks) != contract.required_checks: + raise SchemaError("embedded manifest checks do not exactly match the contract") + return canonical + + +def _validate_configuration_manifest( + configuration: ReleaseConfigurationManifest, + manifest: EmbeddedManifest, +) -> ReleaseConfigurationManifest: + if type(configuration) is not ReleaseConfigurationManifest: + raise SchemaError("configuration manifest must be strictly validated") + try: + canonical = ReleaseConfigurationManifest.from_mapping( + configuration.to_mapping() + ) + except (ReleaseManifestError, AttributeError, TypeError) as exc: + raise SchemaError( + "configuration manifest failed canonical revalidation" + ) from exc + if canonical != configuration: + raise SchemaError("configuration manifest is not canonical") + if ( + canonical.release_id != manifest.release_id + or canonical.embedded_manifest_hash != manifest.manifest_hash + ): + raise EvidenceRejected( + "configuration manifest does not match embedded manifest" + ) + return canonical + + +class TargetName(str, Enum): + PILOT = "pilot" + PRODUCTION = "production" + + +class ReleaseAction(str, Enum): + PROMOTE = "promote" + ROLLBACK = "rollback" + + +class ArtifactPurpose(str, Enum): + PROMOTION = "promotion" + ROLLBACK = "rollback" + + +@dataclass(frozen=True) +class ArtifactResolutionRequest: + repository: str + contract_digest: str + contract_edition: str + oci_repository: str + release_id: str + embedded_manifest_hash: str + purpose: ArtifactPurpose + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "oci_repository": self.oci_repository, + "release_id": self.release_id, + "embedded_manifest_hash": self.embedded_manifest_hash, + "purpose": self.purpose.value, + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "artifact resolution request") + + +@dataclass(frozen=True) +class ArtifactResolutionReceipt: + schema_version: int + receipt_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + release_id: str + artifact_digest: str + digest_kind: str + media_type: str + platform_os: str + platform_architecture: str + image_reference: str + embedded_layer_digest: str + embedded_content_hash: str + provenance: str + resolver_identity: str + resolved_at: datetime + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "artifact_receipt.schema_version") + _canonical_uuid(self.receipt_id, "artifact_receipt.receipt_id") + _digest(self.request_digest, "artifact_receipt.request_digest") + _bounded_string(self.repository, "artifact_receipt.repository", maximum=129) + _digest(self.contract_digest, "artifact_receipt.contract_digest") + _safe_id(self.contract_edition, "artifact_receipt.contract_edition") + _safe_id(self.release_id, "artifact_receipt.release_id") + _digest(self.artifact_digest, "artifact_receipt.artifact_digest") + _bounded_string(self.digest_kind, "artifact_receipt.digest_kind", maximum=32) + _bounded_string(self.media_type, "artifact_receipt.media_type", maximum=128) + _bounded_string(self.platform_os, "artifact_receipt.platform_os", maximum=16) + _bounded_string( + self.platform_architecture, + "artifact_receipt.platform_architecture", + maximum=16, + ) + _bounded_string( + self.image_reference, + "artifact_receipt.image_reference", + maximum=MAX_REFERENCE_LENGTH, + ) + _digest(self.embedded_layer_digest, "artifact_receipt.embedded_layer_digest") + _digest(self.embedded_content_hash, "artifact_receipt.embedded_content_hash") + _bounded_string(self.provenance, "artifact_receipt.provenance", maximum=32) + _safe_id(self.resolver_identity, "artifact_receipt.resolver_identity") + _utc(self.resolved_at, "artifact_receipt.resolved_at") + _digest(self.evidence_hash, "artifact_receipt.evidence_hash") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "artifact_digest": self.artifact_digest, + "digest_kind": self.digest_kind, + "media_type": self.media_type, + "platform": { + "os": self.platform_os, + "architecture": self.platform_architecture, + }, + "image_reference": self.image_reference, + "embedded_layer_digest": self.embedded_layer_digest, + "embedded_content_hash": self.embedded_content_hash, + "provenance": self.provenance, + "resolver_identity": self.resolver_identity, + "resolved_at": _timestamp(self.resolved_at), + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "artifact receipt") + + +class VerifiedArtifactResolverPort(Protocol): + def resolve_verified( + self, + request: ArtifactResolutionRequest, + ) -> ArtifactResolutionReceipt: + """Resolve and verify registry plus embedded-layer/content evidence.""" + + +_ARTIFACT_DESCRIPTOR_SEAL = object() + + +@dataclass(frozen=True, init=False) +class ArtifactDescriptor: + image_reference: str + digest: str + embedded_manifest_hash: str + embedded_layer_digest: str + resolution_receipt_hash: str + resolver_identity: str + provenance: str + resolved_at: datetime + + def __init__(self) -> None: + raise EvidenceRejected("artifact descriptor requires a verified resolver") + + @classmethod + def _from_verified_receipt( + cls, + receipt: ArtifactResolutionReceipt, + seal: object, + ) -> ArtifactDescriptor: + if seal is not _ARTIFACT_DESCRIPTOR_SEAL: + raise EvidenceRejected("artifact descriptor requires a verified resolver") + descriptor = object.__new__(cls) + object.__setattr__(descriptor, "image_reference", receipt.image_reference) + object.__setattr__(descriptor, "digest", receipt.artifact_digest) + object.__setattr__( + descriptor, "embedded_manifest_hash", receipt.embedded_content_hash + ) + object.__setattr__( + descriptor, "embedded_layer_digest", receipt.embedded_layer_digest + ) + object.__setattr__(descriptor, "resolution_receipt_hash", receipt.receipt_hash) + object.__setattr__(descriptor, "resolver_identity", receipt.resolver_identity) + object.__setattr__(descriptor, "provenance", receipt.provenance) + object.__setattr__(descriptor, "resolved_at", receipt.resolved_at) + return descriptor + + +def _resolve_artifact( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + purpose: ArtifactPurpose, + resolver: VerifiedArtifactResolverPort, + clock: TrustedClockPort, +) -> ArtifactDescriptor: + request = ArtifactResolutionRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + oci_repository=contract.oci_repository, + release_id=manifest.release_id, + embedded_manifest_hash=manifest.manifest_hash, + purpose=purpose, + ) + started_at = _trusted_now(clock) + try: + receipt = resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning("artifact resolver failed closed (%s)", type(exc).__name__) + raise EvidenceRejected("verified artifact resolver failed") from exc + now = _trusted_post_call_now(clock, started_at, "artifact resolution") + if type(receipt) is not ArtifactResolutionReceipt: + raise EvidenceRejected("artifact resolver returned an untyped assertion") + expected = { + "request_digest": request.request_digest, + "repository": contract.repository, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "release_id": manifest.release_id, + "digest_kind": "oci_manifest", + "media_type": OCI_MANIFEST_MEDIA_TYPE, + "platform_os": "linux", + "platform_architecture": "amd64", + "embedded_content_hash": manifest.manifest_hash, + } + for field, value in expected.items(): + if getattr(receipt, field) != value: + raise EvidenceRejected(f"artifact receipt mismatch: {field}") + try: + validate_immutable_image_reference( + receipt.image_reference, + receipt.artifact_digest, + ) + except SchemaError as exc: + raise EvidenceRejected(str(exc)) from exc + expected_reference = f"{contract.oci_repository}@{receipt.artifact_digest}" + if receipt.image_reference != expected_reference: + raise EvidenceRejected( + "artifact receipt is outside the contract OCI repository" + ) + expected_provenance = ( + "build_once" if purpose is ArtifactPurpose.PROMOTION else "retained" + ) + if receipt.provenance != expected_provenance: + raise EvidenceRejected("artifact provenance does not match operation purpose") + if _age_seconds(receipt.resolved_at, now, "artifact_receipt.resolved_at") > ( + MAX_ARTIFACT_RECEIPT_AGE_SECONDS + ): + raise EvidenceRejected("artifact resolution receipt is stale") + return ArtifactDescriptor._from_verified_receipt( + receipt, + _ARTIFACT_DESCRIPTOR_SEAL, + ) + + +@dataclass(frozen=True) +class CheckSuiteVerificationRequest: + repository: str + contract_digest: str + contract_edition: str + release_id: str + source_commit: str + source_tree: str + embedded_manifest_hash: str + artifact_digest: str + required_checks: tuple[str, ...] + forbidden_paths: tuple[str, ...] + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "embedded_manifest_hash": self.embedded_manifest_hash, + "artifact_digest": self.artifact_digest, + "required_checks": list(self.required_checks), + "forbidden_paths": list(self.forbidden_paths), + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "check-suite verification request") + + +@dataclass(frozen=True) +class CheckResult: + check_id: str + conclusion: str + completed_at: datetime + suite_reference_hash: str + provider_reference_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _bounded_string( + self.check_id, + "check_result.check_id", + maximum=64, + pattern=_CHECK_ID_RE, + ) + _safe_id(self.conclusion, "check_result.conclusion") + _utc(self.completed_at, "check_result.completed_at") + _digest( + self.suite_reference_hash, + "check_result.suite_reference_hash", + ) + _digest( + self.provider_reference_hash, + "check_result.provider_reference_hash", + ) + _digest(self.evidence_hash, "check_result.evidence_hash") + + def to_mapping(self) -> dict[str, Any]: + return { + "check_id": self.check_id, + "conclusion": self.conclusion, + "completed_at": _timestamp(self.completed_at), + "suite_reference_hash": self.suite_reference_hash, + "provider_reference_hash": self.provider_reference_hash, + "evidence_hash": self.evidence_hash, + } + + +@dataclass(frozen=True) +class CheckSuiteReceipt: + schema_version: int + receipt_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + release_id: str + source_commit: str + source_tree: str + embedded_manifest_hash: str + artifact_digest: str + required_checks: tuple[str, ...] + forbidden_paths: tuple[str, ...] + complete: bool + forbidden_paths_absent: bool + suite_reference_hash: str + results: tuple[CheckResult, ...] + verified_at: datetime + verifier_identity: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "check_suite.schema_version") + _canonical_uuid(self.receipt_id, "check_suite.receipt_id") + _digest(self.request_digest, "check_suite.request_digest") + _bounded_string(self.repository, "check_suite.repository", maximum=129) + _digest(self.contract_digest, "check_suite.contract_digest") + _safe_id(self.contract_edition, "check_suite.contract_edition") + _safe_id(self.release_id, "check_suite.release_id") + _git_oid(self.source_commit, "check_suite.source_commit") + _git_oid(self.source_tree, "check_suite.source_tree") + _digest( + self.embedded_manifest_hash, + "check_suite.embedded_manifest_hash", + ) + _digest(self.artifact_digest, "check_suite.artifact_digest") + _check_ids(self.required_checks, "check_suite.required_checks") + if self.forbidden_paths != tuple(sorted(set(self.forbidden_paths))): + raise SchemaError("check_suite.forbidden_paths must be sorted and unique") + for path in self.forbidden_paths: + _relative_path(path, "check_suite.forbidden_paths[]") + _boolean(self.complete, "check_suite.complete") + _boolean( + self.forbidden_paths_absent, + "check_suite.forbidden_paths_absent", + ) + _digest( + self.suite_reference_hash, + "check_suite.suite_reference_hash", + ) + if not isinstance(self.results, tuple) or not 1 <= len(self.results) <= 32: + raise SchemaError("check_suite.results must be a bounded tuple") + if any(type(result) is not CheckResult for result in self.results): + raise SchemaError("check_suite contains an untyped check result") + _utc(self.verified_at, "check_suite.verified_at") + _safe_id(self.verifier_identity, "check_suite.verifier_identity") + _digest(self.evidence_hash, "check_suite.evidence_hash") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "embedded_manifest_hash": self.embedded_manifest_hash, + "artifact_digest": self.artifact_digest, + "required_checks": list(self.required_checks), + "forbidden_paths": list(self.forbidden_paths), + "complete": self.complete, + "forbidden_paths_absent": self.forbidden_paths_absent, + "suite_reference_hash": self.suite_reference_hash, + "results": [result.to_mapping() for result in self.results], + "verified_at": _timestamp(self.verified_at), + "verifier_identity": self.verifier_identity, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "check-suite receipt") + + +class VerifiedCheckSuiteResolverPort(Protocol): + def resolve_verified( + self, + request: CheckSuiteVerificationRequest, + ) -> CheckSuiteReceipt: + """Verify exact successful checks and forbidden-path absence.""" + + +def _resolve_check_suite( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + artifact: ArtifactDescriptor, + resolver: VerifiedCheckSuiteResolverPort, + clock: TrustedClockPort, +) -> CheckSuiteReceipt: + request = CheckSuiteVerificationRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + release_id=manifest.release_id, + source_commit=manifest.source_commit, + source_tree=manifest.source_tree, + embedded_manifest_hash=manifest.manifest_hash, + artifact_digest=artifact.digest, + required_checks=contract.required_checks, + forbidden_paths=contract.forbidden_paths, + ) + started_at = _trusted_now(clock) + try: + receipt = resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning("check-suite resolver failed closed (%s)", type(exc).__name__) + raise EvidenceRejected("verified check-suite resolver failed") from exc + now = _trusted_post_call_now(clock, started_at, "check-suite resolution") + if type(receipt) is not CheckSuiteReceipt: + raise EvidenceRejected("check-suite resolver returned an untyped assertion") + expected = { + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "embedded_manifest_hash": request.embedded_manifest_hash, + "artifact_digest": request.artifact_digest, + "required_checks": request.required_checks, + "forbidden_paths": request.forbidden_paths, + "complete": True, + "forbidden_paths_absent": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise EvidenceRejected(f"check-suite receipt mismatch: {field_name}") + check_ids = tuple(result.check_id for result in receipt.results) + if check_ids != contract.required_checks: + raise EvidenceRejected("check-suite results are not exact, sorted, and unique") + if any(result.conclusion != "success" for result in receipt.results): + raise EvidenceRejected("check-suite contains a non-success conclusion") + if any( + result.suite_reference_hash != receipt.suite_reference_hash + for result in receipt.results + ): + raise EvidenceRejected("check-suite mixes results from different suites") + if len({result.provider_reference_hash for result in receipt.results}) != len( + receipt.results + ): + raise EvidenceRejected("check-suite reuses a provider check reference") + if len({result.evidence_hash for result in receipt.results}) != len( + receipt.results + ): + raise EvidenceRejected("check-suite reuses check evidence") + verified_at = _utc(receipt.verified_at, "check_suite.verified_at") + if _age_seconds(verified_at, now, "check_suite.verified_at") > ( + contract.check_suite_max_age_seconds + ): + raise EvidenceRejected("check-suite receipt is stale") + if any( + _utc(result.completed_at, "check_result.completed_at") > verified_at + for result in receipt.results + ): + raise EvidenceRejected("check-suite result completed after verification") + if any( + _age_seconds(result.completed_at, now, "check_result.completed_at") + > contract.check_suite_max_age_seconds + for result in receipt.results + ): + raise EvidenceRejected("check-suite contains a stale result") + return receipt + + +def _check_suite_valid_until( + receipt: CheckSuiteReceipt, + *, + max_age_seconds: int, +) -> datetime: + """Return the earliest expiry of the wrapper and every check result.""" + + oldest_evidence = min( + _utc(receipt.verified_at, "check_suite.verified_at"), + *( + _utc(result.completed_at, "check_result.completed_at") + for result in receipt.results + ), + ) + return oldest_evidence + timedelta(seconds=max_age_seconds) + + +@dataclass(frozen=True) +class SourceCandidateVerificationRequest: + repository: str + contract_digest: str + contract_edition: str + release_id: str + source_commit: str + source_tree: str + embedded_manifest_hash: str + artifact_digest: str + candidate_ref: str + base_ref: str + check_suite_receipt_hash: str + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "embedded_manifest_hash": self.embedded_manifest_hash, + "artifact_digest": self.artifact_digest, + "candidate_ref": self.candidate_ref, + "base_ref": self.base_ref, + "check_suite_receipt_hash": self.check_suite_receipt_hash, + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "source-candidate verification request") + + +@dataclass(frozen=True) +class SourceCandidateReceipt: + schema_version: int + receipt_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + release_id: str + source_commit: str + source_tree: str + embedded_manifest_hash: str + artifact_digest: str + candidate_ref: str + base_ref: str + check_suite_receipt_hash: str + candidate_head_commit: str + candidate_head_tree: str + base_commit: str + base_tree: str + reviewed_source_commit: str + reviewed_source_tree: str + reviewed_base_commit: str + reviewed_base_tree: str + candidate_ref_reachable: bool + independent_review: bool + complete: bool + reviewer_identity: str + verifier_identity: str + review_reference_hash: str + verified_at: datetime + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "source_candidate.schema_version") + _canonical_uuid(self.receipt_id, "source_candidate.receipt_id") + _digest(self.request_digest, "source_candidate.request_digest") + _bounded_string(self.repository, "source_candidate.repository", maximum=129) + _digest(self.contract_digest, "source_candidate.contract_digest") + _safe_id(self.contract_edition, "source_candidate.contract_edition") + _safe_id(self.release_id, "source_candidate.release_id") + for field_name in ( + "source_commit", + "source_tree", + "candidate_head_commit", + "candidate_head_tree", + "base_commit", + "base_tree", + "reviewed_source_commit", + "reviewed_source_tree", + "reviewed_base_commit", + "reviewed_base_tree", + ): + _git_oid(getattr(self, field_name), f"source_candidate.{field_name}") + for field_name in ( + "embedded_manifest_hash", + "artifact_digest", + "check_suite_receipt_hash", + "review_reference_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"source_candidate.{field_name}") + _git_ref(self.candidate_ref, "source_candidate.candidate_ref") + _git_ref(self.base_ref, "source_candidate.base_ref") + _boolean( + self.candidate_ref_reachable, + "source_candidate.candidate_ref_reachable", + ) + _boolean(self.independent_review, "source_candidate.independent_review") + _boolean(self.complete, "source_candidate.complete") + _safe_id(self.reviewer_identity, "source_candidate.reviewer_identity") + _safe_id(self.verifier_identity, "source_candidate.verifier_identity") + _utc(self.verified_at, "source_candidate.verified_at") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "embedded_manifest_hash": self.embedded_manifest_hash, + "artifact_digest": self.artifact_digest, + "candidate_ref": self.candidate_ref, + "base_ref": self.base_ref, + "check_suite_receipt_hash": self.check_suite_receipt_hash, + "candidate_head_commit": self.candidate_head_commit, + "candidate_head_tree": self.candidate_head_tree, + "base_commit": self.base_commit, + "base_tree": self.base_tree, + "reviewed_source_commit": self.reviewed_source_commit, + "reviewed_source_tree": self.reviewed_source_tree, + "reviewed_base_commit": self.reviewed_base_commit, + "reviewed_base_tree": self.reviewed_base_tree, + "candidate_ref_reachable": self.candidate_ref_reachable, + "independent_review": self.independent_review, + "complete": self.complete, + "reviewer_identity": self.reviewer_identity, + "verifier_identity": self.verifier_identity, + "review_reference_hash": self.review_reference_hash, + "verified_at": _timestamp(self.verified_at), + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "source-candidate receipt") + + +class VerifiedSourceCandidateResolverPort(Protocol): + def resolve_verified( + self, + request: SourceCandidateVerificationRequest, + ) -> SourceCandidateReceipt: + """Verify candidate reachability and independent review for exact source.""" + + +def _resolve_source_candidate( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + artifact: ArtifactDescriptor, + check_suite: CheckSuiteReceipt, + resolver: VerifiedSourceCandidateResolverPort, + clock: TrustedClockPort, +) -> SourceCandidateReceipt: + request = SourceCandidateVerificationRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + release_id=manifest.release_id, + source_commit=manifest.source_commit, + source_tree=manifest.source_tree, + embedded_manifest_hash=manifest.manifest_hash, + artifact_digest=artifact.digest, + candidate_ref=contract.pilot_source_ref, + base_ref=contract.production_source_ref, + check_suite_receipt_hash=check_suite.receipt_hash, + ) + started_at = _trusted_now(clock) + try: + receipt = resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning( + "source-candidate resolver failed closed (%s)", + type(exc).__name__, + ) + raise EvidenceRejected("verified source-candidate resolver failed") from exc + now = _trusted_post_call_now(clock, started_at, "source-candidate resolution") + if type(receipt) is not SourceCandidateReceipt: + raise EvidenceRejected( + "source-candidate resolver returned an untyped assertion" + ) + expected = { + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "embedded_manifest_hash": request.embedded_manifest_hash, + "artifact_digest": request.artifact_digest, + "candidate_ref": request.candidate_ref, + "base_ref": request.base_ref, + "check_suite_receipt_hash": request.check_suite_receipt_hash, + "candidate_head_commit": request.source_commit, + "candidate_head_tree": request.source_tree, + "reviewed_source_commit": request.source_commit, + "reviewed_source_tree": request.source_tree, + "candidate_ref_reachable": True, + "independent_review": True, + "complete": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise EvidenceRejected(f"source-candidate receipt mismatch: {field_name}") + if ( + receipt.reviewed_base_commit != receipt.base_commit + or receipt.reviewed_base_tree != receipt.base_tree + ): + raise EvidenceRejected("source-candidate reviewed base snapshot mismatch") + if receipt.reviewer_identity == receipt.verifier_identity or ( + check_suite.verifier_identity + in {receipt.reviewer_identity, receipt.verifier_identity} + ): + raise EvidenceRejected("source-candidate review is not independent") + if _age_seconds(receipt.verified_at, now, "source_candidate.verified_at") > ( + contract.source_candidate_max_age_seconds + ): + raise EvidenceRejected("source-candidate receipt is stale") + return receipt + + +@dataclass(frozen=True) +class TargetSnapshotRequest: + repository: str + contract_digest: str + contract_edition: str + operation_release_id: str + candidate_release_id: str + target: TargetName + candidate_artifact_digest: str + candidate_manifest_hash: str + configuration_manifest: ReleaseConfigurationManifest + migration_class: MigrationClass + schema_min: int + schema_max: int + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "operation_release_id": self.operation_release_id, + "candidate_release_id": self.candidate_release_id, + "target": self.target.value, + "candidate_artifact_digest": self.candidate_artifact_digest, + "candidate_manifest_hash": self.candidate_manifest_hash, + "configuration_manifest": self.configuration_manifest.to_mapping(), + "migration_class": self.migration_class.value, + "schema": {"min": self.schema_min, "max": self.schema_max}, + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "target snapshot request") + + +@dataclass(frozen=True) +class TargetSnapshotReceipt: + schema_version: int + snapshot_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + operation_release_id: str + candidate_release_id: str + target: TargetName + candidate_artifact_digest: str + candidate_manifest_hash: str + configuration_manifest_hash: str + environment_delta_registry_digest: str + target_profile_digest: str + current_artifact_digest: str + current_manifest_hash: str + target_config_digest: str + current_schema: int + migration_history_digest: str + migration_class: MigrationClass + schema_compatible: bool + migration_allowed: bool + complete: bool + captured_at: datetime + source_identity: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "target_snapshot.schema_version") + _canonical_uuid(self.snapshot_id, "target_snapshot.snapshot_id") + for field in ( + "request_digest", + "contract_digest", + "candidate_artifact_digest", + "candidate_manifest_hash", + "configuration_manifest_hash", + "environment_delta_registry_digest", + "target_profile_digest", + "current_artifact_digest", + "current_manifest_hash", + "target_config_digest", + "migration_history_digest", + "evidence_hash", + ): + _digest(getattr(self, field), f"target_snapshot.{field}") + _bounded_string(self.repository, "target_snapshot.repository", maximum=129) + _safe_id(self.contract_edition, "target_snapshot.contract_edition") + _safe_id(self.operation_release_id, "target_snapshot.operation_release_id") + _safe_id(self.candidate_release_id, "target_snapshot.candidate_release_id") + if not isinstance(self.target, TargetName): + raise SchemaError("target_snapshot.target must be a TargetName") + if not isinstance(self.migration_class, MigrationClass): + raise SchemaError( + "target_snapshot.migration_class must be a MigrationClass" + ) + _bounded_int( + self.current_schema, + "target_snapshot.current_schema", + minimum=0, + maximum=2_147_483_647, + ) + _boolean(self.schema_compatible, "target_snapshot.schema_compatible") + _boolean(self.migration_allowed, "target_snapshot.migration_allowed") + _boolean(self.complete, "target_snapshot.complete") + _utc(self.captured_at, "target_snapshot.captured_at") + _safe_id(self.source_identity, "target_snapshot.source_identity") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "snapshot_id": self.snapshot_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "operation_release_id": self.operation_release_id, + "candidate_release_id": self.candidate_release_id, + "target": self.target.value, + "candidate_artifact_digest": self.candidate_artifact_digest, + "candidate_manifest_hash": self.candidate_manifest_hash, + "configuration_manifest_hash": self.configuration_manifest_hash, + "environment_delta_registry_digest": self.environment_delta_registry_digest, + "target_profile_digest": self.target_profile_digest, + "current_artifact_digest": self.current_artifact_digest, + "current_manifest_hash": self.current_manifest_hash, + "target_config_digest": self.target_config_digest, + "current_schema": self.current_schema, + "migration_history_digest": self.migration_history_digest, + "migration_class": self.migration_class.value, + "schema_compatible": self.schema_compatible, + "migration_allowed": self.migration_allowed, + "complete": self.complete, + "captured_at": _timestamp(self.captured_at), + "source_identity": self.source_identity, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "target snapshot receipt") + + +class VerifiedTargetStateResolverPort(Protocol): + def snapshot_verified( + self, request: TargetSnapshotRequest + ) -> TargetSnapshotReceipt: + """Verify committed profiles/registry and actual target configuration. + + The request contains expectations, not proof. Independently resolve the + immutable references, verify their contents/digests and approved deltas, + and compare actual configuration before returning a complete snapshot. + """ + + +_TARGET_SNAPSHOT_SEAL = object() + + +@dataclass(frozen=True, init=False) +class TargetSnapshot: + target: TargetName + configuration_manifest_hash: str + environment_delta_registry_digest: str + target_profile_digest: str + expected_current_digest: str + current_manifest_hash: str + target_config_digest: str + current_schema: int + migration_history_digest: str + receipt_hash: str + source_identity: str + captured_at: datetime + + def __init__(self) -> None: + raise EvidenceRejected("target snapshot requires a verified resolver") + + @classmethod + def _from_verified_receipt( + cls, + receipt: TargetSnapshotReceipt, + seal: object, + ) -> TargetSnapshot: + if seal is not _TARGET_SNAPSHOT_SEAL: + raise EvidenceRejected("target snapshot requires a verified resolver") + snapshot = object.__new__(cls) + object.__setattr__(snapshot, "target", receipt.target) + object.__setattr__( + snapshot, "configuration_manifest_hash", receipt.configuration_manifest_hash + ) + object.__setattr__( + snapshot, + "environment_delta_registry_digest", + receipt.environment_delta_registry_digest, + ) + object.__setattr__( + snapshot, "target_profile_digest", receipt.target_profile_digest + ) + object.__setattr__( + snapshot, "expected_current_digest", receipt.current_artifact_digest + ) + object.__setattr__( + snapshot, "current_manifest_hash", receipt.current_manifest_hash + ) + object.__setattr__( + snapshot, + "target_config_digest", + receipt.target_config_digest, + ) + object.__setattr__(snapshot, "current_schema", receipt.current_schema) + object.__setattr__( + snapshot, + "migration_history_digest", + receipt.migration_history_digest, + ) + object.__setattr__(snapshot, "receipt_hash", receipt.receipt_hash) + object.__setattr__(snapshot, "source_identity", receipt.source_identity) + object.__setattr__(snapshot, "captured_at", receipt.captured_at) + return snapshot + + +def _resolve_target_snapshot( + *, + contract: ReleaseControlContract, + operation_release_id: str, + manifest: EmbeddedManifest, + configuration_manifest: ReleaseConfigurationManifest, + artifact: ArtifactDescriptor, + target: TargetName, + resolver: VerifiedTargetStateResolverPort, + clock: TrustedClockPort, +) -> TargetSnapshot: + request = TargetSnapshotRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + operation_release_id=operation_release_id, + candidate_release_id=manifest.release_id, + target=target, + candidate_artifact_digest=artifact.digest, + candidate_manifest_hash=manifest.manifest_hash, + configuration_manifest=configuration_manifest, + migration_class=manifest.migration_class, + schema_min=manifest.schema_min, + schema_max=manifest.schema_max, + ) + started_at = _trusted_now(clock) + try: + receipt = resolver.snapshot_verified(request) + except Exception as exc: + LOGGER.warning("target resolver failed closed (%s)", type(exc).__name__) + raise EvidenceRejected("verified target resolver failed") from exc + now = _trusted_post_call_now(clock, started_at, "target resolution") + if type(receipt) is not TargetSnapshotReceipt: + raise EvidenceRejected("target resolver returned an untyped assertion") + expected = { + "request_digest": request.request_digest, + "repository": contract.repository, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "operation_release_id": operation_release_id, + "candidate_release_id": manifest.release_id, + "target": target, + "candidate_artifact_digest": artifact.digest, + "candidate_manifest_hash": manifest.manifest_hash, + "configuration_manifest_hash": configuration_manifest.manifest_hash, + "environment_delta_registry_digest": configuration_manifest.environment_delta_registry.digest, + "target_profile_digest": ( + configuration_manifest.pilot.digest + if target is TargetName.PILOT + else configuration_manifest.production.digest + ), + "migration_class": manifest.migration_class, + } + for field, value in expected.items(): + if getattr(receipt, field) != value: + raise EvidenceRejected(f"target snapshot mismatch: {field}") + if ( + not receipt.complete + or not receipt.schema_compatible + or not receipt.migration_allowed + ): + raise EvidenceRejected("target schema or migration history is not compatible") + if not manifest.schema_min <= receipt.current_schema <= manifest.schema_max: + raise EvidenceRejected("target current_schema is outside the manifest range") + if _age_seconds(receipt.captured_at, now, "target_snapshot.captured_at") > ( + MAX_TARGET_SNAPSHOT_AGE_SECONDS + ): + raise EvidenceRejected("target snapshot is stale") + return TargetSnapshot._from_verified_receipt(receipt, _TARGET_SNAPSHOT_SEAL) + + +_RELEASE_OPERATION_SEAL = object() + + +@dataclass(frozen=True) +class ReleaseOperation: + repository: str + contract_digest: str + contract_edition: str + required_checks: tuple[str, ...] + release_id: str + manifest_release_id: str + action: ReleaseAction + artifact_digest: str + image_reference: str + embedded_manifest_hash: str + configuration_manifest_hash: str + embedded_layer_digest: str + artifact_receipt_hash: str + artifact_resolver_identity: str + check_suite_receipt_hash: str + source_candidate_receipt_hash: str | None + pilot_qualification_receipt_hash: str | None + qualified_pilot_applied_receipt_hash: str | None + rollback_historical_contract_digest: str | None + rollback_historical_contract_edition: str | None + rollback_historical_required_checks_digest: str | None + rollback_historical_receipt_hash: str | None + rollback_compatibility_receipt_hash: str | None + gate_evidence_hash: str + target: TargetName + target_profile_digest: str + target_snapshot_hash: str + target_source_identity: str + expected_current_digest: str + target_config_digest: str + migration_history_digest: str + current_schema: int + migration_class: MigrationClass + schema_min: int + schema_max: int + evidence_valid_until: datetime + required_pilot_operation_fingerprint: str | None + _verified_seal: InitVar[object | None] = None + _planned: bool = dataclass_field( + default=False, + init=False, + repr=False, + compare=False, + ) + + def __post_init__(self, _verified_seal: object | None) -> None: + if _verified_seal is not _RELEASE_OPERATION_SEAL: + raise EvidenceRejected("operation requires verified planning evidence") + object.__setattr__(self, "_planned", True) + _bounded_string(self.repository, "operation.repository", maximum=129) + _digest(self.contract_digest, "operation.contract_digest") + _safe_id(self.contract_edition, "operation.contract_edition") + _check_ids(self.required_checks, "operation.required_checks") + _safe_id(self.release_id, "operation.release_id") + _safe_id(self.manifest_release_id, "operation.manifest_release_id") + if not isinstance(self.action, ReleaseAction): + raise SchemaError("operation.action must be a ReleaseAction") + for field in ( + "artifact_digest", + "embedded_manifest_hash", + "configuration_manifest_hash", + "embedded_layer_digest", + "artifact_receipt_hash", + "check_suite_receipt_hash", + "gate_evidence_hash", + "target_profile_digest", + "target_snapshot_hash", + "expected_current_digest", + "target_config_digest", + "migration_history_digest", + ): + _digest(getattr(self, field), f"operation.{field}") + _safe_id( + self.artifact_resolver_identity, + "operation.artifact_resolver_identity", + ) + _safe_id(self.target_source_identity, "operation.target_source_identity") + validate_immutable_image_reference(self.image_reference, self.artifact_digest) + if self.action is ReleaseAction.PROMOTE: + _digest( + self.source_candidate_receipt_hash, + "operation.source_candidate_receipt_hash", + ) + if any( + value is not None + for value in ( + self.rollback_historical_contract_digest, + self.rollback_historical_contract_edition, + self.rollback_historical_required_checks_digest, + self.rollback_historical_receipt_hash, + self.rollback_compatibility_receipt_hash, + ) + ): + raise SchemaError("promotion cannot carry rollback evidence") + elif self.source_candidate_receipt_hash is not None: + raise SchemaError("rollback cannot carry promotion candidate evidence") + else: + _digest( + self.rollback_historical_contract_digest, + "operation.rollback_historical_contract_digest", + ) + _safe_id( + self.rollback_historical_contract_edition, + "operation.rollback_historical_contract_edition", + ) + _digest( + self.rollback_historical_required_checks_digest, + "operation.rollback_historical_required_checks_digest", + ) + _digest( + self.rollback_historical_receipt_hash, + "operation.rollback_historical_receipt_hash", + ) + _digest( + self.rollback_compatibility_receipt_hash, + "operation.rollback_compatibility_receipt_hash", + ) + qualification_hashes = ( + self.pilot_qualification_receipt_hash, + self.qualified_pilot_applied_receipt_hash, + ) + if self.target is TargetName.PILOT: + if any(value is not None for value in qualification_hashes): + raise SchemaError("pilot cannot carry its own qualification evidence") + elif self.action is ReleaseAction.ROLLBACK: + if any(value is not None for value in qualification_hashes): + raise SchemaError( + "rollback cannot carry promotion qualification evidence" + ) + elif all(value is None for value in qualification_hashes): + pass + elif any(value is None for value in qualification_hashes): + raise SchemaError("production qualification evidence is incomplete") + else: + _digest( + self.pilot_qualification_receipt_hash, + "operation.pilot_qualification_receipt_hash", + ) + _digest( + self.qualified_pilot_applied_receipt_hash, + "operation.qualified_pilot_applied_receipt_hash", + ) + if not isinstance(self.target, TargetName): + raise SchemaError("operation.target must be a TargetName") + if not isinstance(self.migration_class, MigrationClass): + raise SchemaError("operation.migration_class must be a MigrationClass") + for field in ("current_schema", "schema_min", "schema_max"): + _bounded_int( + getattr(self, field), + f"operation.{field}", + minimum=0, + maximum=2_147_483_647, + ) + if self.schema_min > self.schema_max: + raise SchemaError("operation schema range is inverted") + _utc(self.evidence_valid_until, "operation.evidence_valid_until") + if self.target is TargetName.PRODUCTION: + _digest( + self.required_pilot_operation_fingerprint, + "operation.required_pilot_operation_fingerprint", + ) + elif self.required_pilot_operation_fingerprint is not None: + raise SchemaError("pilot operation cannot require another pilot operation") + + @property + def required_checks_digest(self) -> str: + return _hash(list(self.required_checks), "operation.required_checks") + + def fingerprint_payload(self) -> dict[str, Any]: + return { + "fingerprint_schema_version": 6, + "configuration_manifest_hash": self.configuration_manifest_hash, + "repository": self.repository, + "contract": { + "digest": self.contract_digest, + "edition": self.contract_edition, + "required_checks": list(self.required_checks), + }, + "release_id": self.release_id, + "manifest_release_id": self.manifest_release_id, + "action": self.action.value, + "artifact": { + "digest": self.artifact_digest, + "image_reference": self.image_reference, + "manifest_hash": self.embedded_manifest_hash, + "embedded_layer_digest": self.embedded_layer_digest, + "resolution_receipt_hash": self.artifact_receipt_hash, + "resolver_identity": self.artifact_resolver_identity, + }, + "checks": { + "required_checks": list(self.required_checks), + "suite_receipt_hash": self.check_suite_receipt_hash, + }, + "source_candidate_receipt_hash": self.source_candidate_receipt_hash, + "pilot_qualification_receipt_hash": (self.pilot_qualification_receipt_hash), + "qualified_pilot_applied_receipt_hash": ( + self.qualified_pilot_applied_receipt_hash + ), + "rollback_compatibility": ( + None + if self.action is ReleaseAction.PROMOTE + else { + "historical_contract_digest": ( + self.rollback_historical_contract_digest + ), + "historical_contract_edition": ( + self.rollback_historical_contract_edition + ), + "historical_required_checks_digest": ( + self.rollback_historical_required_checks_digest + ), + "historical_receipt_hash": (self.rollback_historical_receipt_hash), + "compatibility_receipt_hash": ( + self.rollback_compatibility_receipt_hash + ), + } + ), + "gate_evidence_hash": self.gate_evidence_hash, + "target": { + "name": self.target.value, + "profile_digest": self.target_profile_digest, + "snapshot_hash": self.target_snapshot_hash, + "source_identity": self.target_source_identity, + "expected_current_digest": self.expected_current_digest, + "target_config_digest": self.target_config_digest, + "migration_history_digest": self.migration_history_digest, + "current_schema": self.current_schema, + }, + "migration": { + "class": self.migration_class.value, + "schema_min": self.schema_min, + "schema_max": self.schema_max, + }, + "evidence_valid_until": _timestamp(self.evidence_valid_until), + "required_pilot_operation_fingerprint": ( + self.required_pilot_operation_fingerprint + ), + } + + @property + def operation_fingerprint(self) -> str: + return _hash(self.fingerprint_payload(), "operation fingerprint") + + +def _operation_from_verified_evidence( + *, + contract: ReleaseControlContract, + operation_release_id: str, + action: ReleaseAction, + manifest: EmbeddedManifest, + artifact: ArtifactDescriptor, + check_suite: CheckSuiteReceipt, + source_candidate: SourceCandidateReceipt | None, + pilot_qualification_receipt_hash: str | None = None, + qualified_pilot_applied_receipt_hash: str | None = None, + rollback_historical_contract_digest: str | None = None, + rollback_historical_contract_edition: str | None = None, + rollback_historical_required_checks_digest: str | None = None, + rollback_historical_receipt_hash: str | None = None, + rollback_compatibility_receipt_hash: str | None = None, + target: TargetSnapshot, + evidence_valid_until: datetime | None = None, + additional_gate_evidence_hash: str | None = None, + required_pilot_operation_fingerprint: str | None = None, +) -> ReleaseOperation: + base_valid_until = min( + _utc(artifact.resolved_at, "artifact resolved_at") + + timedelta(seconds=MAX_ARTIFACT_RECEIPT_AGE_SECONDS), + _utc(target.captured_at, "target captured_at") + + timedelta(seconds=MAX_TARGET_SNAPSHOT_AGE_SECONDS), + ) + valid_until = min(base_valid_until, evidence_valid_until or base_valid_until) + gate_evidence = { + "domain": "iwe.release-control.gate-evidence.v1", + "action": action.value, + "artifact_receipt_hash": artifact.resolution_receipt_hash, + "check_suite_receipt_hash": check_suite.receipt_hash, + "target_snapshot_hash": target.receipt_hash, + } + if source_candidate is not None: + gate_evidence["source_candidate_receipt_hash"] = source_candidate.receipt_hash + if additional_gate_evidence_hash is not None: + gate_evidence["additional_evidence_hash"] = _digest( + additional_gate_evidence_hash, + "additional gate evidence", + ) + return ReleaseOperation( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + required_checks=contract.required_checks, + release_id=operation_release_id, + manifest_release_id=manifest.release_id, + action=action, + artifact_digest=artifact.digest, + image_reference=artifact.image_reference, + embedded_manifest_hash=manifest.manifest_hash, + configuration_manifest_hash=target.configuration_manifest_hash, + embedded_layer_digest=artifact.embedded_layer_digest, + artifact_receipt_hash=artifact.resolution_receipt_hash, + artifact_resolver_identity=artifact.resolver_identity, + check_suite_receipt_hash=check_suite.receipt_hash, + source_candidate_receipt_hash=( + source_candidate.receipt_hash if source_candidate is not None else None + ), + pilot_qualification_receipt_hash=pilot_qualification_receipt_hash, + qualified_pilot_applied_receipt_hash=(qualified_pilot_applied_receipt_hash), + rollback_historical_contract_digest=(rollback_historical_contract_digest), + rollback_historical_contract_edition=(rollback_historical_contract_edition), + rollback_historical_required_checks_digest=( + rollback_historical_required_checks_digest + ), + rollback_historical_receipt_hash=rollback_historical_receipt_hash, + rollback_compatibility_receipt_hash=(rollback_compatibility_receipt_hash), + gate_evidence_hash=_hash(gate_evidence, "operation gate evidence"), + target=target.target, + target_profile_digest=target.target_profile_digest, + target_snapshot_hash=target.receipt_hash, + target_source_identity=target.source_identity, + expected_current_digest=target.expected_current_digest, + target_config_digest=target.target_config_digest, + migration_history_digest=target.migration_history_digest, + current_schema=target.current_schema, + migration_class=manifest.migration_class, + schema_min=manifest.schema_min, + schema_max=manifest.schema_max, + evidence_valid_until=valid_until, + required_pilot_operation_fingerprint=required_pilot_operation_fingerprint, + _verified_seal=_RELEASE_OPERATION_SEAL, + ) + + +_PROMOTION_PLAN_SEAL = object() + + +@dataclass(frozen=True) +class PromotionPlan: + release_id: str + artifact_digest: str + embedded_manifest_hash: str + contract_digest: str + contract_edition: str + required_checks: tuple[str, ...] + operations: tuple[ReleaseOperation, ReleaseOperation] + _manifest: EmbeddedManifest = dataclass_field(repr=False) + _configuration_manifest: ReleaseConfigurationManifest = dataclass_field(repr=False) + _artifact: ArtifactDescriptor = dataclass_field(repr=False) + _check_suite: CheckSuiteReceipt = dataclass_field(repr=False) + _source_candidate: SourceCandidateReceipt = dataclass_field(repr=False) + _pilot_target: TargetSnapshot = dataclass_field(repr=False) + _production_target: TargetSnapshot = dataclass_field(repr=False) + _plan_seal: InitVar[object | None] = None + _validated: bool = dataclass_field( + default=False, + init=False, + repr=False, + compare=False, + ) + + def __post_init__(self, _plan_seal: object | None) -> None: + if _plan_seal is not _PROMOTION_PLAN_SEAL: + raise EvidenceRejected("promotion plan requires verified evidence") + object.__setattr__(self, "_validated", True) + + @property + def artifact_build_count(self) -> int: + return 1 + + +def plan_build_once_promotion( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + configuration_manifest: ReleaseConfigurationManifest, + artifact_resolver: VerifiedArtifactResolverPort, + check_suite_resolver: VerifiedCheckSuiteResolverPort, + source_candidate_resolver: VerifiedSourceCandidateResolverPort, + target_resolver: VerifiedTargetStateResolverPort, + clock: TrustedClockPort, +) -> PromotionPlan: + """Plan pilot→production from one verified linux/amd64 OCI manifest.""" + + manifest = _validate_manifest(manifest, contract) + configuration_manifest = _validate_configuration_manifest( + configuration_manifest, manifest + ) + clock = _MonotonicTrustedClock(clock) + artifact = _resolve_artifact( + contract=contract, + manifest=manifest, + purpose=ArtifactPurpose.PROMOTION, + resolver=artifact_resolver, + clock=clock, + ) + check_suite = _resolve_check_suite( + contract=contract, + manifest=manifest, + artifact=artifact, + resolver=check_suite_resolver, + clock=clock, + ) + source_candidate = _resolve_source_candidate( + contract=contract, + manifest=manifest, + artifact=artifact, + check_suite=check_suite, + resolver=source_candidate_resolver, + clock=clock, + ) + promotion_evidence_valid_until = min( + _check_suite_valid_until( + check_suite, + max_age_seconds=contract.check_suite_max_age_seconds, + ), + _utc(source_candidate.verified_at, "source_candidate.verified_at") + + timedelta(seconds=contract.source_candidate_max_age_seconds), + ) + pilot = _resolve_target_snapshot( + contract=contract, + operation_release_id=manifest.release_id, + manifest=manifest, + configuration_manifest=configuration_manifest, + artifact=artifact, + target=TargetName.PILOT, + resolver=target_resolver, + clock=clock, + ) + production = _resolve_target_snapshot( + contract=contract, + operation_release_id=manifest.release_id, + manifest=manifest, + configuration_manifest=configuration_manifest, + artifact=artifact, + target=TargetName.PRODUCTION, + resolver=target_resolver, + clock=clock, + ) + pilot_operation = _operation_from_verified_evidence( + contract=contract, + operation_release_id=manifest.release_id, + action=ReleaseAction.PROMOTE, + manifest=manifest, + artifact=artifact, + check_suite=check_suite, + source_candidate=source_candidate, + target=pilot, + evidence_valid_until=promotion_evidence_valid_until, + ) + production_operation = _operation_from_verified_evidence( + contract=contract, + operation_release_id=manifest.release_id, + action=ReleaseAction.PROMOTE, + manifest=manifest, + artifact=artifact, + check_suite=check_suite, + source_candidate=source_candidate, + target=production, + evidence_valid_until=promotion_evidence_valid_until, + required_pilot_operation_fingerprint=(pilot_operation.operation_fingerprint), + ) + operations = (pilot_operation, production_operation) + if len({operation.artifact_digest for operation in operations}) != 1: + raise EvidenceRejected("pilot and production do not share one artifact digest") + return PromotionPlan( + release_id=manifest.release_id, + artifact_digest=artifact.digest, + embedded_manifest_hash=manifest.manifest_hash, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + required_checks=contract.required_checks, + operations=operations, + _manifest=manifest, + _configuration_manifest=configuration_manifest, + _artifact=artifact, + _check_suite=check_suite, + _source_candidate=source_candidate, + _pilot_target=pilot, + _production_target=production, + _plan_seal=_PROMOTION_PLAN_SEAL, + ) + + +@dataclass(frozen=True) +class HistoricalBuildContractRequest: + repository: str + historical_build_contract_digest: str + previous_release_id: str + previous_manifest_hash: str + source_commit: str + source_tree: str + artifact_digest: str + historical_required_checks: tuple[str, ...] + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "historical_build_contract_digest": (self.historical_build_contract_digest), + "previous_release_id": self.previous_release_id, + "previous_manifest_hash": self.previous_manifest_hash, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "artifact_digest": self.artifact_digest, + "historical_required_checks": list(self.historical_required_checks), + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "historical build contract request") + + +@dataclass(frozen=True) +class HistoricalBuildContractReceipt: + schema_version: int + receipt_id: str + request_digest: str + repository: str + historical_build_contract_digest: str + historical_contract_edition: str + previous_release_id: str + previous_manifest_hash: str + source_commit: str + source_tree: str + artifact_digest: str + historical_required_checks: tuple[str, ...] + archive_complete: bool + immutable: bool + verifier_identity: str + sealed_at: datetime + archive_reference_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "historical_contract.schema_version") + _canonical_uuid(self.receipt_id, "historical_contract.receipt_id") + _bounded_string( + self.repository, + "historical_contract.repository", + maximum=129, + ) + for field_name in ( + "request_digest", + "historical_build_contract_digest", + "previous_manifest_hash", + "artifact_digest", + "archive_reference_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"historical_contract.{field_name}") + _safe_id( + self.historical_contract_edition, + "historical_contract.historical_contract_edition", + ) + _safe_id(self.previous_release_id, "historical_contract.previous_release_id") + _git_oid(self.source_commit, "historical_contract.source_commit") + _git_oid(self.source_tree, "historical_contract.source_tree") + _check_ids( + self.historical_required_checks, + "historical_contract.historical_required_checks", + ) + _boolean(self.archive_complete, "historical_contract.archive_complete") + _boolean(self.immutable, "historical_contract.immutable") + _safe_id(self.verifier_identity, "historical_contract.verifier_identity") + _utc(self.sealed_at, "historical_contract.sealed_at") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "repository": self.repository, + "historical_build_contract_digest": (self.historical_build_contract_digest), + "historical_contract_edition": self.historical_contract_edition, + "previous_release_id": self.previous_release_id, + "previous_manifest_hash": self.previous_manifest_hash, + "source_commit": self.source_commit, + "source_tree": self.source_tree, + "artifact_digest": self.artifact_digest, + "historical_required_checks": list(self.historical_required_checks), + "archive_complete": self.archive_complete, + "immutable": self.immutable, + "verifier_identity": self.verifier_identity, + "sealed_at": _timestamp(self.sealed_at), + "archive_reference_hash": self.archive_reference_hash, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "historical build contract receipt") + + +class VerifiedHistoricalBuildContractResolverPort(Protocol): + def resolve_verified( + self, + request: HistoricalBuildContractRequest, + ) -> HistoricalBuildContractReceipt: + """Verify one immutable archived build contract for the old artifact.""" + + +def _resolve_historical_build_contract( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + artifact: ArtifactDescriptor, + resolver: VerifiedHistoricalBuildContractResolverPort, + clock: TrustedClockPort, +) -> HistoricalBuildContractReceipt: + request = HistoricalBuildContractRequest( + repository=contract.repository, + historical_build_contract_digest=manifest.build_contract_digest, + previous_release_id=manifest.release_id, + previous_manifest_hash=manifest.manifest_hash, + source_commit=manifest.source_commit, + source_tree=manifest.source_tree, + artifact_digest=artifact.digest, + historical_required_checks=tuple(manifest.required_checks), + ) + started_at = _trusted_now(clock) + try: + receipt = resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning( + "historical contract resolver failed closed (%s)", + type(exc).__name__, + ) + raise RollbackRejected("verified historical build contract failed") from exc + now = _trusted_post_call_now( + clock, + started_at, + "historical contract resolution", + ) + if type(receipt) is not HistoricalBuildContractReceipt: + raise RollbackRejected("historical contract resolver returned untyped evidence") + expected = { + "request_digest": request.request_digest, + "repository": request.repository, + "historical_build_contract_digest": (request.historical_build_contract_digest), + "previous_release_id": request.previous_release_id, + "previous_manifest_hash": request.previous_manifest_hash, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "artifact_digest": request.artifact_digest, + "historical_required_checks": request.historical_required_checks, + "archive_complete": True, + "immutable": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise RollbackRejected( + f"historical contract receipt mismatch: {field_name}" + ) + try: + _age_seconds(receipt.sealed_at, now, "historical_contract.sealed_at") + except EvidenceRejected as exc: + raise RollbackRejected(str(exc)) from exc + return receipt + + +@dataclass(frozen=True) +class RollbackCapabilityRequest: + repository: str + contract_digest: str + contract_edition: str + rollback_release_id: str + previous_release_id: str + target: TargetName + previous_good_digest: str + previous_manifest_hash: str + artifact_receipt_hash: str + expected_current_digest: str + target_snapshot_hash: str + current_schema: int + migration_history_digest: str + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "rollback_release_id": self.rollback_release_id, + "previous_release_id": self.previous_release_id, + "target": self.target.value, + "previous_good_digest": self.previous_good_digest, + "previous_manifest_hash": self.previous_manifest_hash, + "artifact_receipt_hash": self.artifact_receipt_hash, + "expected_current_digest": self.expected_current_digest, + "target_snapshot_hash": self.target_snapshot_hash, + "current_schema": self.current_schema, + "migration_history_digest": self.migration_history_digest, + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "rollback capability request") + + +@dataclass(frozen=True) +class RollbackCapabilityReceipt: + schema_version: int + receipt_id: str + request_digest: str + previous_good_digest: str + previous_manifest_hash: str + target: TargetName + target_snapshot_hash: str + expected_current_digest: str + current_schema: int + migration_history_digest: str + can_rollback: bool + can_rollback_checked_at: datetime + artifact_retained: bool + retention_checked_at: datetime + artifact_origin: str + runtime_attestation_valid: bool + runtime_reattested_at: datetime + pilot_operation_fingerprint: str + source_identity: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "rollback_receipt.schema_version") + _canonical_uuid(self.receipt_id, "rollback_receipt.receipt_id") + for field in ( + "request_digest", + "previous_good_digest", + "previous_manifest_hash", + "target_snapshot_hash", + "expected_current_digest", + "migration_history_digest", + "pilot_operation_fingerprint", + "evidence_hash", + ): + _digest(getattr(self, field), f"rollback_receipt.{field}") + if not isinstance(self.target, TargetName): + raise SchemaError("rollback_receipt.target must be a TargetName") + _bounded_int( + self.current_schema, + "rollback_receipt.current_schema", + minimum=0, + maximum=2_147_483_647, + ) + _boolean(self.can_rollback, "rollback_receipt.can_rollback") + _utc(self.can_rollback_checked_at, "rollback_receipt.can_rollback_checked_at") + _boolean(self.artifact_retained, "rollback_receipt.artifact_retained") + _utc(self.retention_checked_at, "rollback_receipt.retention_checked_at") + _bounded_string( + self.artifact_origin, + "rollback_receipt.artifact_origin", + maximum=32, + ) + _boolean( + self.runtime_attestation_valid, + "rollback_receipt.runtime_attestation_valid", + ) + _utc(self.runtime_reattested_at, "rollback_receipt.runtime_reattested_at") + _safe_id(self.source_identity, "rollback_receipt.source_identity") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "previous_good_digest": self.previous_good_digest, + "previous_manifest_hash": self.previous_manifest_hash, + "target": self.target.value, + "target_snapshot_hash": self.target_snapshot_hash, + "expected_current_digest": self.expected_current_digest, + "current_schema": self.current_schema, + "migration_history_digest": self.migration_history_digest, + "can_rollback": self.can_rollback, + "can_rollback_checked_at": _timestamp(self.can_rollback_checked_at), + "artifact_retained": self.artifact_retained, + "retention_checked_at": _timestamp(self.retention_checked_at), + "artifact_origin": self.artifact_origin, + "runtime_attestation_valid": self.runtime_attestation_valid, + "runtime_reattested_at": _timestamp(self.runtime_reattested_at), + "pilot_operation_fingerprint": self.pilot_operation_fingerprint, + "source_identity": self.source_identity, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "rollback capability receipt") + + +class VerifiedRollbackResolverPort(Protocol): + def resolve_verified( + self, + request: RollbackCapabilityRequest, + ) -> RollbackCapabilityReceipt: + """Verify retained artifact, canRollback, runtime, schema, and history.""" + + +def _validate_rollback_receipt( + *, + contract: ReleaseControlContract, + request: RollbackCapabilityRequest, + receipt: RollbackCapabilityReceipt, + now: datetime, +) -> None: + expected = { + "request_digest": request.request_digest, + "previous_good_digest": request.previous_good_digest, + "previous_manifest_hash": request.previous_manifest_hash, + "target": request.target, + "target_snapshot_hash": request.target_snapshot_hash, + "expected_current_digest": request.expected_current_digest, + "current_schema": request.current_schema, + "migration_history_digest": request.migration_history_digest, + } + for field, value in expected.items(): + if getattr(receipt, field) != value: + raise RollbackRejected(f"rollback evidence mismatch: {field}") + if not receipt.can_rollback: + raise RollbackRejected("provider no longer reports canRollback") + if not receipt.artifact_retained or receipt.artifact_origin != "retained": + raise RollbackRejected( + "previous-good artifact is not retained; rebuild is forbidden" + ) + if not receipt.runtime_attestation_valid: + raise RollbackRejected("runtime attestation is invalid") + try: + can_rollback_age = _age_seconds( + receipt.can_rollback_checked_at, + now, + "canRollback observation", + ) + retention_age = _age_seconds( + receipt.retention_checked_at, + now, + "retention observation", + ) + reattestation_age = _age_seconds( + receipt.runtime_reattested_at, + now, + "runtime re-attestation", + ) + except EvidenceRejected as exc: + raise RollbackRejected(str(exc)) from exc + if can_rollback_age > contract.rollback_retention_recheck_seconds: + raise RollbackRejected("canRollback observation is stale") + if retention_age > contract.rollback_retention_recheck_seconds: + raise RollbackRejected("retention observation is stale") + if reattestation_age > contract.rollback_reattestation_seconds: + raise RollbackRejected("runtime re-attestation is stale") + + +@dataclass(frozen=True) +class RollbackCompatibilityRequest: + repository: str + current_contract_digest: str + current_contract_edition: str + historical_contract_digest: str + historical_contract_edition: str + historical_contract_receipt_hash: str + rollback_release_id: str + previous_release_id: str + artifact_digest: str + embedded_manifest_hash: str + migration_class: MigrationClass + schema_min: int + schema_max: int + target_snapshot_hash: str + target_config_digest: str + current_schema: int + migration_history_digest: str + rollback_capability_receipt_hash: str + excluded_verifier_identities: tuple[str, ...] + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "current_contract_digest": self.current_contract_digest, + "current_contract_edition": self.current_contract_edition, + "historical_contract_digest": self.historical_contract_digest, + "historical_contract_edition": self.historical_contract_edition, + "historical_contract_receipt_hash": (self.historical_contract_receipt_hash), + "rollback_release_id": self.rollback_release_id, + "previous_release_id": self.previous_release_id, + "artifact_digest": self.artifact_digest, + "embedded_manifest_hash": self.embedded_manifest_hash, + "migration": { + "class": self.migration_class.value, + "schema_min": self.schema_min, + "schema_max": self.schema_max, + "current_schema": self.current_schema, + "history_digest": self.migration_history_digest, + }, + "target_snapshot_hash": self.target_snapshot_hash, + "target_config_digest": self.target_config_digest, + "rollback_capability_receipt_hash": (self.rollback_capability_receipt_hash), + "excluded_verifier_identities": list(self.excluded_verifier_identities), + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "rollback compatibility request") + + +@dataclass(frozen=True) +class RollbackCompatibilityReceipt: + schema_version: int + receipt_id: str + request_digest: str + current_contract_digest: str + current_contract_edition: str + historical_contract_digest: str + historical_contract_edition: str + historical_contract_receipt_hash: str + rollback_release_id: str + previous_release_id: str + artifact_digest: str + embedded_manifest_hash: str + migration_class: MigrationClass + schema_min: int + schema_max: int + target_snapshot_hash: str + target_config_digest: str + current_schema: int + migration_history_digest: str + rollback_capability_receipt_hash: str + compatible: bool + complete: bool + independent_verifier: bool + verifier_identity: str + verified_at: datetime + external_receipt_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "rollback_compatibility.schema_version") + _canonical_uuid(self.receipt_id, "rollback_compatibility.receipt_id") + for field_name in ( + "request_digest", + "current_contract_digest", + "historical_contract_digest", + "historical_contract_receipt_hash", + "artifact_digest", + "embedded_manifest_hash", + "target_snapshot_hash", + "target_config_digest", + "migration_history_digest", + "rollback_capability_receipt_hash", + "external_receipt_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"rollback_compatibility.{field_name}") + _safe_id( + self.current_contract_edition, + "rollback_compatibility.current_contract_edition", + ) + _safe_id( + self.historical_contract_edition, + "rollback_compatibility.historical_contract_edition", + ) + _safe_id( + self.rollback_release_id, + "rollback_compatibility.rollback_release_id", + ) + _safe_id( + self.previous_release_id, + "rollback_compatibility.previous_release_id", + ) + if not isinstance(self.migration_class, MigrationClass): + raise SchemaError("rollback compatibility migration class is untyped") + for field_name in ("schema_min", "schema_max", "current_schema"): + _bounded_int( + getattr(self, field_name), + f"rollback_compatibility.{field_name}", + minimum=0, + maximum=2_147_483_647, + ) + _boolean(self.compatible, "rollback_compatibility.compatible") + _boolean(self.complete, "rollback_compatibility.complete") + _boolean( + self.independent_verifier, + "rollback_compatibility.independent_verifier", + ) + _safe_id( + self.verifier_identity, + "rollback_compatibility.verifier_identity", + ) + _utc(self.verified_at, "rollback_compatibility.verified_at") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "current_contract_digest": self.current_contract_digest, + "current_contract_edition": self.current_contract_edition, + "historical_contract_digest": self.historical_contract_digest, + "historical_contract_edition": self.historical_contract_edition, + "historical_contract_receipt_hash": (self.historical_contract_receipt_hash), + "rollback_release_id": self.rollback_release_id, + "previous_release_id": self.previous_release_id, + "artifact_digest": self.artifact_digest, + "embedded_manifest_hash": self.embedded_manifest_hash, + "migration_class": self.migration_class.value, + "schema_min": self.schema_min, + "schema_max": self.schema_max, + "target_snapshot_hash": self.target_snapshot_hash, + "target_config_digest": self.target_config_digest, + "current_schema": self.current_schema, + "migration_history_digest": self.migration_history_digest, + "rollback_capability_receipt_hash": (self.rollback_capability_receipt_hash), + "compatible": self.compatible, + "complete": self.complete, + "independent_verifier": self.independent_verifier, + "verifier_identity": self.verifier_identity, + "verified_at": _timestamp(self.verified_at), + "external_receipt_hash": self.external_receipt_hash, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "rollback compatibility receipt") + + +class VerifiedRollbackCompatibilityResolverPort(Protocol): + def resolve_verified( + self, + request: RollbackCompatibilityRequest, + ) -> RollbackCompatibilityReceipt: + """Verify historical artifact compatibility under current rollback policy.""" + + +def _resolve_rollback_compatibility( + *, + contract: ReleaseControlContract, + request: RollbackCompatibilityRequest, + resolver: VerifiedRollbackCompatibilityResolverPort, + clock: TrustedClockPort, +) -> RollbackCompatibilityReceipt: + started_at = _trusted_now(clock) + try: + receipt = resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning( + "rollback compatibility resolver failed closed (%s)", + type(exc).__name__, + ) + raise RollbackRejected("verified rollback compatibility failed") from exc + now = _trusted_post_call_now( + clock, + started_at, + "rollback compatibility resolution", + ) + if type(receipt) is not RollbackCompatibilityReceipt: + raise RollbackRejected("rollback compatibility returned untyped evidence") + expected = { + "request_digest": request.request_digest, + "current_contract_digest": request.current_contract_digest, + "current_contract_edition": request.current_contract_edition, + "historical_contract_digest": request.historical_contract_digest, + "historical_contract_edition": request.historical_contract_edition, + "historical_contract_receipt_hash": (request.historical_contract_receipt_hash), + "rollback_release_id": request.rollback_release_id, + "previous_release_id": request.previous_release_id, + "artifact_digest": request.artifact_digest, + "embedded_manifest_hash": request.embedded_manifest_hash, + "migration_class": request.migration_class, + "schema_min": request.schema_min, + "schema_max": request.schema_max, + "target_snapshot_hash": request.target_snapshot_hash, + "target_config_digest": request.target_config_digest, + "current_schema": request.current_schema, + "migration_history_digest": request.migration_history_digest, + "rollback_capability_receipt_hash": (request.rollback_capability_receipt_hash), + "compatible": True, + "complete": True, + "independent_verifier": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise RollbackRejected(f"rollback compatibility mismatch: {field_name}") + if receipt.verifier_identity in request.excluded_verifier_identities: + raise RollbackRejected("rollback compatibility verifier is not independent") + try: + age = _age_seconds( + receipt.verified_at, + now, + "rollback_compatibility.verified_at", + ) + except EvidenceRejected as exc: + raise RollbackRejected(str(exc)) from exc + if age > contract.rollback_compatibility_max_age_seconds: + raise RollbackRejected("rollback compatibility receipt is stale") + return receipt + + +def plan_rollback( + *, + contract: ReleaseControlContract, + rollback_release_id: str, + previous_manifest: EmbeddedManifest, + previous_configuration_manifest: ReleaseConfigurationManifest, + artifact_resolver: VerifiedArtifactResolverPort, + historical_contract_resolver: VerifiedHistoricalBuildContractResolverPort, + check_suite_resolver: VerifiedCheckSuiteResolverPort, + target_resolver: VerifiedTargetStateResolverPort, + rollback_resolver: VerifiedRollbackResolverPort, + compatibility_resolver: VerifiedRollbackCompatibilityResolverPort, + clock: TrustedClockPort, +) -> ReleaseOperation: + """Plan a no-rebuild rollback from mutually bound, fresh receipts.""" + + rollback_release_id = _safe_id(rollback_release_id, "rollback_release_id") + manifest = _canonical_manifest(previous_manifest) + configuration_manifest = _validate_configuration_manifest( + previous_configuration_manifest, manifest + ) + clock = _MonotonicTrustedClock(clock) + artifact = _resolve_artifact( + contract=contract, + manifest=manifest, + purpose=ArtifactPurpose.ROLLBACK, + resolver=artifact_resolver, + clock=clock, + ) + historical_contract = _resolve_historical_build_contract( + contract=contract, + manifest=manifest, + artifact=artifact, + resolver=historical_contract_resolver, + clock=clock, + ) + check_suite = _resolve_check_suite( + contract=contract, + manifest=manifest, + artifact=artifact, + resolver=check_suite_resolver, + clock=clock, + ) + check_suite_valid_until = _check_suite_valid_until( + check_suite, + max_age_seconds=contract.check_suite_max_age_seconds, + ) + target = _resolve_target_snapshot( + contract=contract, + operation_release_id=rollback_release_id, + manifest=manifest, + configuration_manifest=configuration_manifest, + artifact=artifact, + target=TargetName.PRODUCTION, + resolver=target_resolver, + clock=clock, + ) + request = RollbackCapabilityRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + rollback_release_id=rollback_release_id, + previous_release_id=manifest.release_id, + target=TargetName.PRODUCTION, + previous_good_digest=artifact.digest, + previous_manifest_hash=manifest.manifest_hash, + artifact_receipt_hash=artifact.resolution_receipt_hash, + expected_current_digest=target.expected_current_digest, + target_snapshot_hash=target.receipt_hash, + current_schema=target.current_schema, + migration_history_digest=target.migration_history_digest, + ) + rollback_started_at = _trusted_now(clock) + try: + receipt = rollback_resolver.resolve_verified(request) + except Exception as exc: + LOGGER.warning("rollback resolver failed closed (%s)", type(exc).__name__) + raise RollbackRejected("verified rollback resolver failed") from exc + rollback_resolved_at = _trusted_post_call_now( + clock, + rollback_started_at, + "rollback capability resolution", + ) + if type(receipt) is not RollbackCapabilityReceipt: + raise RollbackRejected("rollback resolver returned an untyped assertion") + _validate_rollback_receipt( + contract=contract, + request=request, + receipt=receipt, + now=rollback_resolved_at, + ) + rollback_evidence_identities = { + artifact.resolver_identity, + historical_contract.verifier_identity, + check_suite.verifier_identity, + target.source_identity, + receipt.source_identity, + } + if len(rollback_evidence_identities) != 5: + raise RollbackRejected("rollback evidence verifiers are not independent") + compatibility_request = RollbackCompatibilityRequest( + repository=contract.repository, + current_contract_digest=contract.contract_digest, + current_contract_edition=contract.contract_edition, + historical_contract_digest=manifest.build_contract_digest, + historical_contract_edition=(historical_contract.historical_contract_edition), + historical_contract_receipt_hash=historical_contract.receipt_hash, + rollback_release_id=rollback_release_id, + previous_release_id=manifest.release_id, + artifact_digest=artifact.digest, + embedded_manifest_hash=manifest.manifest_hash, + migration_class=manifest.migration_class, + schema_min=manifest.schema_min, + schema_max=manifest.schema_max, + target_snapshot_hash=target.receipt_hash, + target_config_digest=target.target_config_digest, + current_schema=target.current_schema, + migration_history_digest=target.migration_history_digest, + rollback_capability_receipt_hash=receipt.receipt_hash, + excluded_verifier_identities=tuple(sorted(rollback_evidence_identities)), + ) + compatibility = _resolve_rollback_compatibility( + contract=contract, + request=compatibility_request, + resolver=compatibility_resolver, + clock=clock, + ) + rollback_evidence_valid_until = min( + check_suite_valid_until, + _utc(receipt.can_rollback_checked_at, "canRollback observation") + + timedelta(seconds=contract.rollback_retention_recheck_seconds), + _utc(receipt.retention_checked_at, "retention observation") + + timedelta(seconds=contract.rollback_retention_recheck_seconds), + _utc(receipt.runtime_reattested_at, "runtime re-attestation") + + timedelta(seconds=contract.rollback_reattestation_seconds), + _utc(compatibility.verified_at, "rollback compatibility verification") + + timedelta(seconds=contract.rollback_compatibility_max_age_seconds), + ) + rollback_gate_hash = _hash( + { + "domain": "iwe.release-control.rollback-gate.v1", + "historical_contract_receipt_hash": historical_contract.receipt_hash, + "rollback_capability_receipt_hash": receipt.receipt_hash, + "rollback_compatibility_receipt_hash": compatibility.receipt_hash, + }, + "rollback gate evidence", + ) + return _operation_from_verified_evidence( + contract=contract, + operation_release_id=rollback_release_id, + action=ReleaseAction.ROLLBACK, + manifest=manifest, + artifact=artifact, + check_suite=check_suite, + source_candidate=None, + rollback_historical_contract_digest=manifest.build_contract_digest, + rollback_historical_contract_edition=( + historical_contract.historical_contract_edition + ), + rollback_historical_required_checks_digest=_hash( + list(historical_contract.historical_required_checks), + "operation.required_checks", + ), + rollback_historical_receipt_hash=historical_contract.receipt_hash, + rollback_compatibility_receipt_hash=compatibility.receipt_hash, + target=target, + evidence_valid_until=rollback_evidence_valid_until, + additional_gate_evidence_hash=rollback_gate_hash, + required_pilot_operation_fingerprint=(receipt.pilot_operation_fingerprint), + ) + + +@dataclass(frozen=True) +class AuthorityEvidence: + schema_version: int + purpose: str + audience: str + repository: str + target: TargetName + contract_digest: str + contract_edition: str + required_checks_digest: str + authority_id: str + release_id: str + operation_fingerprint: str + approver_identity: str + runner_identity: str + issued_at: datetime + expires_at: datetime + max_uses: int + nonce: str + key_id: str + signature: str + + @classmethod + def from_mapping(cls, payload: Any) -> AuthorityEvidence: + obj = _exact_keys( + payload, + { + "schema_version", + "purpose", + "audience", + "repository", + "target", + "contract_digest", + "contract_edition", + "required_checks_digest", + "authority_id", + "release_id", + "operation_fingerprint", + "approver_identity", + "runner_identity", + "issued_at", + "expires_at", + "max_uses", + "nonce", + "key_id", + "signature", + }, + "authority", + ) + try: + target = TargetName(obj["target"]) + except (TypeError, ValueError) as exc: + raise SchemaError("authority.target is unsupported") from exc + return cls( + schema_version=_bounded_int( + obj["schema_version"], "authority.schema_version", minimum=1, maximum=1 + ), + purpose=_bounded_string(obj["purpose"], "authority.purpose", maximum=64), + audience=_bounded_string(obj["audience"], "authority.audience", maximum=64), + repository=_bounded_string( + obj["repository"], "authority.repository", maximum=129 + ), + target=target, + contract_digest=_digest( + obj["contract_digest"], "authority.contract_digest" + ), + contract_edition=_safe_id( + obj["contract_edition"], "authority.contract_edition" + ), + required_checks_digest=_digest( + obj["required_checks_digest"], "authority.required_checks_digest" + ), + authority_id=_canonical_uuid(obj["authority_id"], "authority.authority_id"), + release_id=_safe_id(obj["release_id"], "authority.release_id"), + operation_fingerprint=_digest( + obj["operation_fingerprint"], "authority.operation_fingerprint" + ), + approver_identity=_safe_id( + obj["approver_identity"], "authority.approver_identity" + ), + runner_identity=_safe_id( + obj["runner_identity"], "authority.runner_identity" + ), + issued_at=_parse_utc(obj["issued_at"], "authority.issued_at"), + expires_at=_parse_utc(obj["expires_at"], "authority.expires_at"), + max_uses=_bounded_int( + obj["max_uses"], "authority.max_uses", minimum=1, maximum=1 + ), + nonce=_safe_id(obj["nonce"], "authority.nonce"), + key_id=_safe_id(obj["key_id"], "authority.key_id"), + signature=_bounded_string( + obj["signature"], "authority.signature", maximum=512 + ), + ) + + def signed_payload(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "purpose": self.purpose, + "audience": self.audience, + "repository": self.repository, + "target": self.target.value, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "required_checks_digest": self.required_checks_digest, + "authority_id": self.authority_id, + "release_id": self.release_id, + "operation_fingerprint": self.operation_fingerprint, + "approver_identity": self.approver_identity, + "runner_identity": self.runner_identity, + "issued_at": _timestamp(self.issued_at), + "expires_at": _timestamp(self.expires_at), + "max_uses": self.max_uses, + "nonce": self.nonce, + "key_id": self.key_id, + } + + @property + def evidence_hash(self) -> str: + return _hash( + {"signed_payload": self.signed_payload(), "signature": self.signature}, + "authority evidence", + ) + + +@dataclass(frozen=True) +class AuthorityVerificationReceipt: + schema_version: int + key_id: str + authority_id: str + signer_identity: str + runner_identity: str + signed_payload_digest: str + trust_root_identity: str + signature_valid: bool + independent_signer: bool + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "authority_verification.schema_version") + _safe_id(self.key_id, "authority_verification.key_id") + _canonical_uuid(self.authority_id, "authority_verification.authority_id") + _safe_id(self.signer_identity, "authority_verification.signer_identity") + _safe_id(self.runner_identity, "authority_verification.runner_identity") + _digest( + self.signed_payload_digest, + "authority_verification.signed_payload_digest", + ) + _safe_id( + self.trust_root_identity, + "authority_verification.trust_root_identity", + ) + _boolean(self.signature_valid, "authority_verification.signature_valid") + _boolean(self.independent_signer, "authority_verification.independent_signer") + _digest(self.evidence_hash, "authority_verification.evidence_hash") + + +class AuthorityVerifierPort(Protocol): + def verify( + self, + *, + key_id: str, + signed_payload: bytes, + signature: str, + ) -> AuthorityVerificationReceipt: + """Verify the signature and attest the actual workload identity. + + The returned runner identity must come from trusted runtime evidence such + as workload OIDC, mTLS, or a runner attestation. It must never be copied + from the signed payload or supplied as a caller assertion. + """ + + +def _validate_authority( + *, + contract: ReleaseControlContract, + operation: ReleaseOperation, + authority: AuthorityEvidence, + now: datetime, + verifier: AuthorityVerifierPort, +) -> str: + now = _utc(now, "trusted clock") + if type(authority) is not AuthorityEvidence: + raise AuthorityRejected("authority must use the typed evidence envelope") + try: + _literal(authority.schema_version, 1, "authority.schema_version") + _bounded_string(authority.purpose, "authority.purpose", maximum=64) + _bounded_string(authority.audience, "authority.audience", maximum=64) + _bounded_string(authority.repository, "authority.repository", maximum=129) + if not isinstance(authority.target, TargetName): + raise SchemaError("authority.target must be a TargetName") + _digest(authority.contract_digest, "authority.contract_digest") + _safe_id(authority.contract_edition, "authority.contract_edition") + _digest( + authority.required_checks_digest, + "authority.required_checks_digest", + ) + _canonical_uuid(authority.authority_id, "authority.authority_id") + _safe_id(authority.release_id, "authority.release_id") + _digest(authority.operation_fingerprint, "authority.operation_fingerprint") + _safe_id(authority.approver_identity, "authority.approver_identity") + _safe_id(authority.runner_identity, "authority.runner_identity") + issued = _utc(authority.issued_at, "authority.issued_at") + expires = _utc(authority.expires_at, "authority.expires_at") + _bounded_int(authority.max_uses, "authority.max_uses", minimum=1, maximum=1) + _safe_id(authority.nonce, "authority.nonce") + _safe_id(authority.key_id, "authority.key_id") + _bounded_string(authority.signature, "authority.signature", maximum=512) + except SchemaError as exc: + raise AuthorityRejected(str(exc)) from exc + expected = { + "schema_version": 1, + "purpose": AUTHORITY_PURPOSE, + "audience": AUTHORITY_AUDIENCE, + "repository": contract.repository, + "target": operation.target, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "required_checks_digest": contract.required_checks_digest, + "release_id": operation.release_id, + "operation_fingerprint": operation.operation_fingerprint, + "max_uses": 1, + } + for field, value in expected.items(): + if getattr(authority, field) != value: + raise AuthorityRejected(f"authority scope mismatch: {field}") + if issued > now or now >= expires: + raise AuthorityRejected("authority is not currently valid") + lifetime = (expires - issued).total_seconds() + if not 0 < lifetime <= contract.authority_ttl_seconds: + raise AuthorityRejected("authority lifetime exceeds the contract") + try: + verification = verifier.verify( + key_id=authority.key_id, + signed_payload=canonical_json_bytes(authority.signed_payload()), + signature=authority.signature, + ) + except Exception as exc: + LOGGER.warning("authority verifier failed closed (%s)", type(exc).__name__) + raise AuthorityRejected("authority verification failed") from exc + if type(verification) is not AuthorityVerificationReceipt: + raise AuthorityRejected("authority verifier returned an untyped assertion") + expected_verification = { + "key_id": authority.key_id, + "authority_id": authority.authority_id, + "signer_identity": authority.approver_identity, + "runner_identity": authority.runner_identity, + "signed_payload_digest": _hash( + authority.signed_payload(), + "authority signed payload", + ), + "trust_root_identity": contract.authority_trust_root_identity, + "signature_valid": True, + "independent_signer": True, + } + for field_name, expected_value in expected_verification.items(): + if getattr(verification, field_name) != expected_value: + raise AuthorityRejected(f"authority verification mismatch: {field_name}") + if authority.approver_identity == verification.runner_identity: + raise AuthorityRejected("approver must be independent from the runner") + return _hash( + { + "domain": "iwe.release-control.verified-authority-evidence.v1", + "authority_evidence_hash": authority.evidence_hash, + "verification_evidence_hash": verification.evidence_hash, + "signed_payload_digest": verification.signed_payload_digest, + "trust_root_identity": verification.trust_root_identity, + "authenticated_runner_identity": verification.runner_identity, + }, + "verified authority evidence", + ) + + +def _validate_operation_contract( + operation: ReleaseOperation, + contract: ReleaseControlContract, + *, + allow_production_draft: bool = False, +) -> None: + if type(contract) is not ReleaseControlContract or not contract._validated: + raise ContractError("contract was not produced by strict validation") + if type(operation) is not ReleaseOperation or (operation._planned is not True): + raise EvidenceRejected("operation was not produced by verified planning") + if operation.repository != contract.repository: + raise ContractError("operation repository differs from the contract") + if operation.contract_digest != contract.contract_digest: + raise ContractError( + "operation contract digest differs from the loaded contract" + ) + if operation.contract_edition != contract.contract_edition: + raise ContractError( + "operation contract edition differs from the loaded contract" + ) + if operation.required_checks != contract.required_checks: + raise ContractError("operation required checks differ from the loaded contract") + if ( + operation.action is ReleaseAction.PROMOTE + and operation.target is TargetName.PRODUCTION + and ( + operation.pilot_qualification_receipt_hash is None + or operation.qualified_pilot_applied_receipt_hash is None + ) + and not allow_production_draft + ): + raise EvidenceRejected("production draft requires pilot qualification") + + +class ClaimDisposition(str, Enum): + OWNER = "owner" + DUPLICATE = "duplicate" + + +class OperationState(str, Enum): + CLAIMED_LOCKED = "claimed_locked" + OUTCOME_UNKNOWN = "outcome_unknown" + APPLIED = "applied" + OBSERVED_APPLIED = "observed_applied" + OBSERVED_NOT_APPLIED = "observed_not_applied" + MANUAL_REVIEW = "manual_review" + + +@dataclass(frozen=True) +class LedgerClaimRequest: + external_id: str + authorization_id: str + operation_fingerprint: str + target_key: TargetName + artifact_digest: str + operation_kind: ReleaseAction + contract_digest: str + evidence_hash: str + + @classmethod + def from_operation( + cls, + operation: ReleaseOperation, + authority: AuthorityEvidence, + verified_evidence_hash: str, + ) -> LedgerClaimRequest: + return cls( + external_id=operation_external_id(operation.operation_fingerprint), + authorization_id=authority.authority_id, + operation_fingerprint=operation.operation_fingerprint, + target_key=operation.target, + artifact_digest=operation.artifact_digest, + operation_kind=operation.action, + contract_digest=operation.contract_digest, + evidence_hash=_digest( + verified_evidence_hash, + "verified authority evidence", + ), + ) + + +def operation_external_id(operation_fingerprint: str) -> str: + fingerprint = _digest(operation_fingerprint, "operation_fingerprint") + return f"rc1-{fingerprint.removeprefix('sha256:')}" + + +@dataclass(frozen=True) +class ClaimReceipt: + disposition: ClaimDisposition + external_id: str + authorization_id: str + operation_fingerprint: str + target_key: TargetName + artifact_digest: str + operation_kind: ReleaseAction + contract_digest: str + evidence_hash: str + state: OperationState + state_version: int + fencing_token: int + durable: bool + authority_use_burned: bool + claim_id: str + + +@dataclass(frozen=True) +class NegativeMatrixQuery: + evidence_ref: str + repository: str + contract_digest: str + contract_edition: str + pilot_source_ref: str + production_source_ref: str + + +@dataclass(frozen=True) +class NegativeMatrixReceipt: + """Proves the old branch-based auto-deploy is off on both environments.""" + + evidence_ref: str + repository: str + contract_digest: str + contract_edition: str + pilot_source_ref: str + production_source_ref: str + verified_at: datetime + approver_identity: str + evidence_hash: str + + +@dataclass(frozen=True) +class PilotReceiptQuery: + repository: str + manifest_release_id: str + operation_fingerprint: str + artifact_digest: str + embedded_manifest_hash: str + contract_digest: str + contract_edition: str + required_checks_digest: str + + +@dataclass(frozen=True) +class AppliedReleaseReceipt: + receipt_id: str + claim_id: str + repository: str + release_id: str + manifest_release_id: str + operation_fingerprint: str + action: ReleaseAction + target: TargetName + artifact_digest: str + embedded_manifest_hash: str + contract_digest: str + contract_edition: str + required_checks_digest: str + state: OperationState + state_version: int + fencing_token: int + durable: bool + effect_confirmed_at: datetime + provider_evidence_hash: str + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "receipt_id": self.receipt_id, + "claim_id": self.claim_id, + "repository": self.repository, + "release_id": self.release_id, + "manifest_release_id": self.manifest_release_id, + "operation_fingerprint": self.operation_fingerprint, + "action": self.action.value, + "target": self.target.value, + "artifact_digest": self.artifact_digest, + "embedded_manifest_hash": self.embedded_manifest_hash, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "required_checks_digest": self.required_checks_digest, + "state": self.state.value, + "state_version": self.state_version, + "fencing_token": self.fencing_token, + "durable": self.durable, + "effect_confirmed_at": _timestamp(self.effect_confirmed_at), + "provider_evidence_hash": self.provider_evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "applied release receipt") + + +@dataclass(frozen=True) +class PilotQualificationRequest: + repository: str + contract_digest: str + contract_edition: str + release_id: str + pilot_operation_fingerprint: str + pilot_claim_id: str + pilot_applied_receipt_hash: str + pilot_effect_confirmed_at: datetime + artifact_digest: str + image_reference: str + embedded_manifest_hash: str + target_profile_digest: str + target_snapshot_hash: str + target_config_digest: str + required_signals: tuple[str, ...] + excluded_verifier_identities: tuple[str, ...] + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "pilot_operation_fingerprint": self.pilot_operation_fingerprint, + "pilot_claim_id": self.pilot_claim_id, + "pilot_applied_receipt_hash": self.pilot_applied_receipt_hash, + "pilot_effect_confirmed_at": _timestamp(self.pilot_effect_confirmed_at), + "artifact_digest": self.artifact_digest, + "image_reference": self.image_reference, + "embedded_manifest_hash": self.embedded_manifest_hash, + "target_profile_digest": self.target_profile_digest, + "target_snapshot_hash": self.target_snapshot_hash, + "target_config_digest": self.target_config_digest, + "required_signals": list(self.required_signals), + "excluded_verifier_identities": list(self.excluded_verifier_identities), + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "pilot qualification request") + + +@dataclass(frozen=True) +class PilotSignalResult: + signal_id: str + conclusion: str + observed_at: datetime + provider_reference_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _safe_id(self.signal_id, "pilot_signal.signal_id") + _safe_id(self.conclusion, "pilot_signal.conclusion") + _utc(self.observed_at, "pilot_signal.observed_at") + _digest( + self.provider_reference_hash, + "pilot_signal.provider_reference_hash", + ) + _digest(self.evidence_hash, "pilot_signal.evidence_hash") + + def to_mapping(self) -> dict[str, Any]: + return { + "signal_id": self.signal_id, + "conclusion": self.conclusion, + "observed_at": _timestamp(self.observed_at), + "provider_reference_hash": self.provider_reference_hash, + "evidence_hash": self.evidence_hash, + } + + +@dataclass(frozen=True) +class PilotQualificationReceipt: + schema_version: int + receipt_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + release_id: str + pilot_operation_fingerprint: str + pilot_claim_id: str + pilot_applied_receipt_hash: str + pilot_effect_confirmed_at: datetime + artifact_digest: str + image_reference: str + embedded_manifest_hash: str + target_profile_digest: str + target_snapshot_hash: str + target_config_digest: str + required_signals: tuple[str, ...] + excluded_verifier_identities: tuple[str, ...] + signal_results: tuple[PilotSignalResult, ...] + complete: bool + independent_verifier: bool + verifier_identity: str + verified_at: datetime + external_receipt_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "pilot_qualification.schema_version") + _canonical_uuid(self.receipt_id, "pilot_qualification.receipt_id") + _digest(self.request_digest, "pilot_qualification.request_digest") + _bounded_string( + self.repository, + "pilot_qualification.repository", + maximum=129, + ) + _digest(self.contract_digest, "pilot_qualification.contract_digest") + _safe_id(self.contract_edition, "pilot_qualification.contract_edition") + _safe_id(self.release_id, "pilot_qualification.release_id") + _canonical_uuid(self.pilot_claim_id, "pilot_qualification.pilot_claim_id") + _utc( + self.pilot_effect_confirmed_at, + "pilot_qualification.pilot_effect_confirmed_at", + ) + for field_name in ( + "pilot_operation_fingerprint", + "pilot_applied_receipt_hash", + "artifact_digest", + "embedded_manifest_hash", + "target_profile_digest", + "target_snapshot_hash", + "target_config_digest", + "external_receipt_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"pilot_qualification.{field_name}") + validate_immutable_image_reference(self.image_reference, self.artifact_digest) + _check_ids(self.required_signals, "pilot_qualification.required_signals") + if self.excluded_verifier_identities != tuple( + sorted(set(self.excluded_verifier_identities)) + ): + raise SchemaError( + "pilot qualification excluded identities must be sorted and unique" + ) + for identity in self.excluded_verifier_identities: + _safe_id(identity, "pilot_qualification.excluded_verifier_identities[]") + if not isinstance(self.signal_results, tuple) or not self.signal_results: + raise SchemaError("pilot qualification results must be a non-empty tuple") + if any(type(result) is not PilotSignalResult for result in self.signal_results): + raise SchemaError("pilot qualification contains an untyped signal") + _boolean(self.complete, "pilot_qualification.complete") + _boolean( + self.independent_verifier, + "pilot_qualification.independent_verifier", + ) + _safe_id(self.verifier_identity, "pilot_qualification.verifier_identity") + _utc(self.verified_at, "pilot_qualification.verified_at") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "receipt_id": self.receipt_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "pilot_operation_fingerprint": self.pilot_operation_fingerprint, + "pilot_claim_id": self.pilot_claim_id, + "pilot_applied_receipt_hash": self.pilot_applied_receipt_hash, + "pilot_effect_confirmed_at": _timestamp(self.pilot_effect_confirmed_at), + "artifact_digest": self.artifact_digest, + "image_reference": self.image_reference, + "embedded_manifest_hash": self.embedded_manifest_hash, + "target_profile_digest": self.target_profile_digest, + "target_snapshot_hash": self.target_snapshot_hash, + "target_config_digest": self.target_config_digest, + "required_signals": list(self.required_signals), + "excluded_verifier_identities": list(self.excluded_verifier_identities), + "signal_results": [result.to_mapping() for result in self.signal_results], + "complete": self.complete, + "independent_verifier": self.independent_verifier, + "verifier_identity": self.verifier_identity, + "verified_at": _timestamp(self.verified_at), + "external_receipt_hash": self.external_receipt_hash, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "pilot qualification receipt") + + +class VerifiedPilotQualificationResolverPort(Protocol): + def resolve_verified( + self, + request: PilotQualificationRequest, + ) -> PilotQualificationReceipt: + """Verify post-pilot runtime, readiness, canary, and data signals.""" + + +@dataclass(frozen=True) +class QualificationRecordRequest: + repository: str + contract_digest: str + contract_edition: str + release_id: str + pilot_claim_id: str + pilot_operation_fingerprint: str + pilot_applied_receipt_hash: str + qualification_receipt_hash: str + external_receipt_hash: str + artifact_digest: str + embedded_manifest_hash: str + verifier_identity: str + qualification_verified_at: datetime + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "pilot_claim_id": self.pilot_claim_id, + "pilot_operation_fingerprint": self.pilot_operation_fingerprint, + "pilot_applied_receipt_hash": self.pilot_applied_receipt_hash, + "qualification_receipt_hash": self.qualification_receipt_hash, + "external_receipt_hash": self.external_receipt_hash, + "artifact_digest": self.artifact_digest, + "embedded_manifest_hash": self.embedded_manifest_hash, + "verifier_identity": self.verifier_identity, + "qualification_verified_at": _timestamp(self.qualification_verified_at), + } + + @property + def request_digest(self) -> str: + return _hash(self.to_mapping(), "qualification record request") + + +@dataclass(frozen=True) +class DurableQualificationReceipt: + schema_version: int + record_id: str + request_digest: str + repository: str + contract_digest: str + contract_edition: str + release_id: str + pilot_claim_id: str + pilot_operation_fingerprint: str + pilot_applied_receipt_hash: str + qualification_receipt_hash: str + external_receipt_hash: str + artifact_digest: str + embedded_manifest_hash: str + verifier_identity: str + qualification_verified_at: datetime + recorded_at: datetime + state_version: int + durable: bool + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "durable_qualification.schema_version") + _canonical_uuid(self.record_id, "durable_qualification.record_id") + _digest(self.request_digest, "durable_qualification.request_digest") + _bounded_string( + self.repository, + "durable_qualification.repository", + maximum=129, + ) + _digest(self.contract_digest, "durable_qualification.contract_digest") + _safe_id( + self.contract_edition, + "durable_qualification.contract_edition", + ) + _safe_id(self.release_id, "durable_qualification.release_id") + _safe_id( + self.verifier_identity, + "durable_qualification.verifier_identity", + ) + _canonical_uuid(self.pilot_claim_id, "durable_qualification.pilot_claim_id") + for field_name in ( + "pilot_operation_fingerprint", + "pilot_applied_receipt_hash", + "qualification_receipt_hash", + "external_receipt_hash", + "artifact_digest", + "embedded_manifest_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"durable_qualification.{field_name}") + _utc( + self.qualification_verified_at, + "durable_qualification.qualification_verified_at", + ) + _utc(self.recorded_at, "durable_qualification.recorded_at") + _bounded_int( + self.state_version, + "durable_qualification.state_version", + minimum=1, + maximum=2**63 - 1, + ) + _boolean(self.durable, "durable_qualification.durable") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "record_id": self.record_id, + "request_digest": self.request_digest, + "repository": self.repository, + "contract_digest": self.contract_digest, + "contract_edition": self.contract_edition, + "release_id": self.release_id, + "pilot_claim_id": self.pilot_claim_id, + "pilot_operation_fingerprint": self.pilot_operation_fingerprint, + "pilot_applied_receipt_hash": self.pilot_applied_receipt_hash, + "qualification_receipt_hash": self.qualification_receipt_hash, + "external_receipt_hash": self.external_receipt_hash, + "artifact_digest": self.artifact_digest, + "embedded_manifest_hash": self.embedded_manifest_hash, + "verifier_identity": self.verifier_identity, + "qualification_verified_at": _timestamp(self.qualification_verified_at), + "recorded_at": _timestamp(self.recorded_at), + "state_version": self.state_version, + "durable": self.durable, + "evidence_hash": self.evidence_hash, + } + + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "durable qualification receipt") + + +class QualificationEvidencePort(Protocol): + def record_durable( + self, + request: QualificationRecordRequest, + ) -> DurableQualificationReceipt: + """Append and return an external durable qualification receipt.""" + + +@dataclass(frozen=True) +class ProviderMutationRequest: + operation: ReleaseOperation + image_reference: str + expected_current_artifact_digest: str + expected_target_config_digest: str + expected_current_schema: int + expected_migration_history_digest: str + target_profile_digest: str + target_snapshot_hash: str + external_id: str + claim_id: str + fencing_token: int + idempotency_key: str + not_before: datetime + not_after: datetime + + def __post_init__(self) -> None: + if type(self.operation) is not ReleaseOperation or not self.operation._planned: + raise EvidenceRejected("provider request requires a verified operation") + if self.external_id != operation_external_id( + self.operation.operation_fingerprint + ): + raise LedgerSafetyError("provider request external_id mismatch") + if self.image_reference != self.operation.image_reference: + raise EvidenceRejected("provider request image reference mismatch") + validate_immutable_image_reference( + self.image_reference, + self.operation.artifact_digest, + ) + expected_operation_fields = { + "expected_current_artifact_digest": ( + self.operation.expected_current_digest + ), + "expected_target_config_digest": self.operation.target_config_digest, + "expected_current_schema": self.operation.current_schema, + "expected_migration_history_digest": ( + self.operation.migration_history_digest + ), + "target_profile_digest": self.operation.target_profile_digest, + "target_snapshot_hash": self.operation.target_snapshot_hash, + } + for field_name, expected_value in expected_operation_fields.items(): + if getattr(self, field_name) != expected_value: + raise EvidenceRejected(f"provider request mismatch: {field_name}") + _canonical_uuid(self.claim_id, "provider_request.claim_id") + _bounded_int( + self.fencing_token, + "provider_request.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + expected_key = provider_idempotency_key(self.operation.operation_fingerprint) + if self.idempotency_key != expected_key: + raise LedgerSafetyError( + "provider idempotency key was not derived from operation" + ) + not_before = _utc(self.not_before, "provider_request.not_before") + deadline = _utc(self.not_after, "provider_request.not_after") + if not_before >= deadline: + raise EvidenceRejected("provider mutation window is empty") + if deadline > _utc( + self.operation.evidence_valid_until, + "operation.evidence_valid_until", + ): + raise EvidenceRejected("provider deadline exceeds planning evidence") + + def intent_payload(self) -> dict[str, Any]: + return { + "schema_version": 1, + "domain": "iwe.release-control.provider-intent.v1", + "operation_fingerprint": self.operation.operation_fingerprint, + "external_id": self.external_id, + "claim_id": self.claim_id, + "idempotency_key": self.idempotency_key, + "fencing_token": self.fencing_token, + "not_before": _timestamp(self.not_before), + "not_after": _timestamp(self.not_after), + "image_reference": self.image_reference, + "expected_current_artifact_digest": (self.expected_current_artifact_digest), + "expected_target_config_digest": self.expected_target_config_digest, + "expected_current_schema": self.expected_current_schema, + "expected_migration_history_digest": ( + self.expected_migration_history_digest + ), + "target_profile_digest": self.target_profile_digest, + "target_snapshot_hash": self.target_snapshot_hash, + } + + @property + def intent_hash(self) -> str: + return _hash(self.intent_payload(), "provider mutation intent") + + +@dataclass(frozen=True) +class VerifiedProviderEffect: + """Exact provider-side mutation record shared by apply and reconciliation.""" + + operation_fingerprint: str + external_id: str + claim_id: str + idempotency_key: str + fencing_token: int + not_before: datetime + not_after: datetime + applied_at: datetime + observed_previous_artifact_digest: str + observed_target_config_digest: str + observed_current_schema: int + observed_migration_history_digest: str + artifact_digest: str + image_reference: str + target_profile_digest: str + target_snapshot_hash: str + provider_reference_hash: str + evidence_hash: str + + def __post_init__(self) -> None: + _digest(self.operation_fingerprint, "provider_result.operation_fingerprint") + _safe_id(self.external_id, "provider_result.external_id") + _canonical_uuid(self.claim_id, "provider_result.claim_id") + _digest(self.idempotency_key, "provider_result.idempotency_key") + _bounded_int( + self.fencing_token, + "provider_result.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + _utc(self.not_before, "provider_result.not_before") + _utc(self.not_after, "provider_result.not_after") + _utc(self.applied_at, "provider_result.applied_at") + for field_name in ( + "observed_previous_artifact_digest", + "observed_target_config_digest", + "observed_migration_history_digest", + "artifact_digest", + "target_profile_digest", + "target_snapshot_hash", + "provider_reference_hash", + "evidence_hash", + ): + _digest(getattr(self, field_name), f"provider_effect.{field_name}") + _bounded_int( + self.observed_current_schema, + "provider_effect.observed_current_schema", + minimum=0, + maximum=2_147_483_647, + ) + validate_immutable_image_reference( + self.image_reference, + self.artifact_digest, + ) + + def exactly_matches(self, request: ProviderMutationRequest) -> bool: + return ( + self.operation_fingerprint == request.operation.operation_fingerprint + and self.external_id == request.external_id + and self.claim_id == request.claim_id + and self.idempotency_key == request.idempotency_key + and self.fencing_token == request.fencing_token + and self.not_before == request.not_before + and self.not_after == request.not_after + and request.not_before <= self.applied_at < request.not_after + and self.observed_previous_artifact_digest + == request.expected_current_artifact_digest + and self.observed_target_config_digest + == request.expected_target_config_digest + and self.observed_current_schema == request.expected_current_schema + and self.observed_migration_history_digest + == request.expected_migration_history_digest + and self.artifact_digest == request.operation.artifact_digest + and self.image_reference == request.image_reference + and self.target_profile_digest == request.target_profile_digest + and self.target_snapshot_hash == request.target_snapshot_hash + and _SHA256_RE.fullmatch(self.provider_reference_hash) is not None + and _SHA256_RE.fullmatch(self.evidence_hash) is not None + ) + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": 1, + "operation_fingerprint": self.operation_fingerprint, + "external_id": self.external_id, + "claim_id": self.claim_id, + "idempotency_key": self.idempotency_key, + "fencing_token": self.fencing_token, + "not_before": _timestamp(self.not_before), + "not_after": _timestamp(self.not_after), + "applied_at": _timestamp(self.applied_at), + "observed_previous_artifact_digest": ( + self.observed_previous_artifact_digest + ), + "observed_target_config_digest": self.observed_target_config_digest, + "observed_current_schema": self.observed_current_schema, + "observed_migration_history_digest": ( + self.observed_migration_history_digest + ), + "artifact_digest": self.artifact_digest, + "image_reference": self.image_reference, + "target_profile_digest": self.target_profile_digest, + "target_snapshot_hash": self.target_snapshot_hash, + "provider_reference_hash": self.provider_reference_hash, + "evidence_hash": self.evidence_hash, + } + + +@dataclass(frozen=True) +class ProviderApplyResult(VerifiedProviderEffect): + @property + def receipt_hash(self) -> str: + return _hash(self.to_mapping(), "provider apply receipt") + + +@dataclass(frozen=True) +class ProviderIntentRecordRequest: + external_id: str + claim_id: str + operation_fingerprint: str + expected_state: OperationState + expected_version: int + fencing_token: int + provider_intent_hash: str + not_before: datetime + not_after: datetime + + @classmethod + def from_provider_request( + cls, + request: ProviderMutationRequest, + claim: ClaimReceipt, + ) -> ProviderIntentRecordRequest: + return cls( + external_id=request.external_id, + claim_id=claim.claim_id, + operation_fingerprint=request.operation.operation_fingerprint, + expected_state=OperationState.CLAIMED_LOCKED, + expected_version=claim.state_version, + fencing_token=claim.fencing_token, + provider_intent_hash=request.intent_hash, + not_before=request.not_before, + not_after=request.not_after, + ) + + def __post_init__(self) -> None: + _safe_id(self.external_id, "provider_intent.external_id") + _canonical_uuid(self.claim_id, "provider_intent.claim_id") + _digest(self.operation_fingerprint, "provider_intent.operation_fingerprint") + if self.expected_state is not OperationState.CLAIMED_LOCKED: + raise SchemaError("provider intent must CAS claimed_locked state") + _bounded_int( + self.expected_version, + "provider_intent.expected_version", + minimum=1, + maximum=2**63 - 1, + ) + _bounded_int( + self.fencing_token, + "provider_intent.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + _digest(self.provider_intent_hash, "provider_intent.hash") + not_before = _utc(self.not_before, "provider_intent.not_before") + not_after = _utc(self.not_after, "provider_intent.not_after") + if not_before >= not_after: + raise SchemaError("provider intent window is empty") + + +@dataclass(frozen=True) +class LedgerOutcomeRequest: + external_id: str + claim_id: str + operation_fingerprint: str + expected_state: OperationState + expected_version: int + fencing_token: int + next_state: OperationState + target: TargetName + repository: str + release_id: str + manifest_release_id: str + action: ReleaseAction + artifact_digest: str + embedded_manifest_hash: str + contract_digest: str + contract_edition: str + required_checks_digest: str + evidence_hash: str + + +@dataclass(frozen=True) +class LedgerStateSnapshot: + external_id: str + claim_id: str + operation_fingerprint: str + target: TargetName + artifact_digest: str + contract_digest: str + state: OperationState + state_version: int + fencing_token: int + uncertainty_started_at: datetime + provider_intent_hash: str | None = None + provider_not_before: datetime | None = None + provider_not_after: datetime | None = None + + +class ReleaseLedgerPort(Protocol): + def negative_matrix_receipt( + self, + query: NegativeMatrixQuery, + ) -> NegativeMatrixReceipt | None: + """Return the durable receipt proving old auto-deploy is off, or None.""" + + def pilot_applied_receipt( + self, + query: PilotReceiptQuery, + ) -> AppliedReleaseReceipt | None: + """Return a durable exact pilot receipt, never a latest-by-label guess.""" + + def claim_and_burn(self, request: LedgerClaimRequest) -> ClaimReceipt: + """Atomically append receipt, burn max_uses=1, and acquire target lock.""" + + def record_provider_intent( + self, + request: ProviderIntentRecordRequest, + ) -> LedgerStateSnapshot | None: + """Durably CAS the exact provider request before any mutation call.""" + + def record_outcome( + self, + request: LedgerOutcomeRequest, + ) -> LedgerStateSnapshot | None: + """CAS one runner-visible outcome using state/version/fencing.""" + + def state_snapshot(self, operation_fingerprint: str) -> LedgerStateSnapshot | None: + """Return a versioned state snapshot for auditor reconciliation.""" + + def reconcile_compare_and_swap( + self, + request: ReconciliationCASRequest, + ) -> LedgerStateSnapshot | None: + """Auditor-only atomic transition; None means CAS conflict.""" + + +class ReleaseProviderPort(Protocol): + def apply(self, request: ProviderMutationRequest) -> ProviderApplyResult: + """Atomically enforce current/idempotency/fence/deadline; never retry.""" + + +def provider_idempotency_key(operation_fingerprint: str) -> str: + _digest(operation_fingerprint, "operation_fingerprint") + return _hash( + { + "domain": PROVIDER_IDEMPOTENCY_DOMAIN, + "operation_fingerprint": operation_fingerprint, + }, + "provider idempotency key", + ) + + +def _negative_matrix_query(contract: ReleaseControlContract) -> NegativeMatrixQuery: + return NegativeMatrixQuery( + evidence_ref=_digest( + contract.negative_matrix_evidence_ref, + "contract.cutover.negative_matrix_evidence_ref", + ), + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + pilot_source_ref=contract.pilot_source_ref, + production_source_ref=contract.production_source_ref, + ) + + +def _validate_negative_matrix_receipt( + receipt: NegativeMatrixReceipt | None, + contract: ReleaseControlContract, + now: datetime, +) -> None: + if type(receipt) is not NegativeMatrixReceipt: + raise LedgerSafetyError( + "cutover requires a durable negative-matrix receipt proving the " + "old branch-based auto-deploy is disabled on both environments" + ) + expected = { + "evidence_ref": contract.negative_matrix_evidence_ref, + "repository": contract.repository, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "pilot_source_ref": contract.pilot_source_ref, + "production_source_ref": contract.production_source_ref, + } + for field, value in expected.items(): + if getattr(receipt, field) != value: + raise LedgerSafetyError(f"negative-matrix receipt mismatch: {field}") + _safe_id(receipt.approver_identity, "negative_matrix_receipt.approver_identity") + _digest(receipt.evidence_hash, "negative_matrix_receipt.evidence_hash") + if ( + _age_seconds( + receipt.verified_at, + now, + "negative_matrix_receipt.verified_at", + ) + > contract.negative_matrix_max_age_seconds + ): + raise EvidenceRejected("negative-matrix receipt is stale") + + +def _validate_pilot_receipt( + receipt: AppliedReleaseReceipt | None, + operation: ReleaseOperation, + now: datetime, +) -> None: + if type(receipt) is not AppliedReleaseReceipt: + raise LedgerSafetyError( + "production requires a durable exact pilot APPLIED receipt" + ) + if type(receipt.action) is not ReleaseAction: + raise LedgerSafetyError("pilot APPLIED receipt has an untyped action") + if type(receipt.target) is not TargetName: + raise LedgerSafetyError("pilot APPLIED receipt has an untyped target") + if type(receipt.state) is not OperationState: + raise LedgerSafetyError("pilot APPLIED receipt has an untyped state") + if receipt.durable is not True: + raise LedgerSafetyError("pilot APPLIED receipt is not durable") + if receipt.state not in ( + OperationState.APPLIED, + OperationState.OBSERVED_APPLIED, + ): + raise LedgerSafetyError("pilot receipt does not prove an applied effect") + query = _pilot_receipt_query(operation) + expected = { + "repository": operation.repository, + "release_id": operation.manifest_release_id, + "manifest_release_id": operation.manifest_release_id, + "operation_fingerprint": (operation.required_pilot_operation_fingerprint), + "action": ReleaseAction.PROMOTE, + "target": TargetName.PILOT, + "artifact_digest": operation.artifact_digest, + "embedded_manifest_hash": operation.embedded_manifest_hash, + "contract_digest": query.contract_digest, + "contract_edition": query.contract_edition, + "required_checks_digest": query.required_checks_digest, + "durable": True, + } + for field, value in expected.items(): + if getattr(receipt, field) != value: + raise LedgerSafetyError(f"pilot APPLIED receipt mismatch: {field}") + _canonical_uuid(receipt.receipt_id, "pilot_receipt.receipt_id") + _canonical_uuid(receipt.claim_id, "pilot_receipt.claim_id") + if ( + type(receipt.state_version) is not int + or not 2 <= receipt.state_version <= 2**63 - 1 + ): + raise LedgerSafetyError("pilot receipt state version is impossible") + _age_seconds( + receipt.effect_confirmed_at, + now, + "pilot_receipt.effect_confirmed_at", + ) + _bounded_int( + receipt.fencing_token, + "pilot_receipt.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + _digest(receipt.provider_evidence_hash, "pilot_receipt.provider_evidence_hash") + + +def _pilot_receipt_query(operation: ReleaseOperation) -> PilotReceiptQuery: + if operation.action is ReleaseAction.ROLLBACK: + contract_digest = operation.rollback_historical_contract_digest + contract_edition = operation.rollback_historical_contract_edition + required_checks_digest = operation.rollback_historical_required_checks_digest + else: + contract_digest = operation.contract_digest + contract_edition = operation.contract_edition + required_checks_digest = operation.required_checks_digest + return PilotReceiptQuery( + repository=operation.repository, + manifest_release_id=operation.manifest_release_id, + operation_fingerprint=operation.required_pilot_operation_fingerprint, + artifact_digest=operation.artifact_digest, + embedded_manifest_hash=operation.embedded_manifest_hash, + contract_digest=_digest(contract_digest, "pilot query contract digest"), + contract_edition=_safe_id( + contract_edition, + "pilot query contract edition", + ), + required_checks_digest=_digest( + required_checks_digest, + "pilot query required checks digest", + ), + ) + + +def _validate_pilot_qualification( + *, + contract: ReleaseControlContract, + request: PilotQualificationRequest, + receipt: PilotQualificationReceipt, + now: datetime, +) -> None: + expected = { + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "pilot_operation_fingerprint": request.pilot_operation_fingerprint, + "pilot_claim_id": request.pilot_claim_id, + "pilot_applied_receipt_hash": request.pilot_applied_receipt_hash, + "pilot_effect_confirmed_at": request.pilot_effect_confirmed_at, + "artifact_digest": request.artifact_digest, + "image_reference": request.image_reference, + "embedded_manifest_hash": request.embedded_manifest_hash, + "target_profile_digest": request.target_profile_digest, + "target_snapshot_hash": request.target_snapshot_hash, + "target_config_digest": request.target_config_digest, + "required_signals": request.required_signals, + "excluded_verifier_identities": (request.excluded_verifier_identities), + "complete": True, + "independent_verifier": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise EvidenceRejected(f"pilot qualification mismatch: {field_name}") + if receipt.verifier_identity in request.excluded_verifier_identities: + raise EvidenceRejected("pilot qualification verifier is not independent") + signal_ids = tuple(result.signal_id for result in receipt.signal_results) + if signal_ids != contract.pilot_qualification_required_signals: + raise EvidenceRejected("pilot qualification signals are not exact") + if any(result.conclusion != "success" for result in receipt.signal_results): + raise EvidenceRejected("pilot qualification contains a failed signal") + if len( + {result.provider_reference_hash for result in receipt.signal_results} + ) != len(receipt.signal_results): + raise EvidenceRejected("pilot qualification reuses a signal reference") + if len({result.evidence_hash for result in receipt.signal_results}) != len( + receipt.signal_results + ): + raise EvidenceRejected("pilot qualification reuses signal evidence") + verified_at = _utc(receipt.verified_at, "pilot_qualification.verified_at") + if _age_seconds(verified_at, now, "pilot_qualification.verified_at") > ( + contract.pilot_qualification_max_age_seconds + ): + raise EvidenceRejected("pilot qualification receipt is stale") + if any( + not ( + _utc(request.pilot_effect_confirmed_at, "pilot effect") + <= _utc(result.observed_at, "pilot_signal.observed_at") + <= verified_at + ) + for result in receipt.signal_results + ): + raise EvidenceRejected("pilot signal is not post-effect qualification evidence") + if any( + _age_seconds(result.observed_at, now, "pilot_signal.observed_at") + > contract.pilot_qualification_max_age_seconds + for result in receipt.signal_results + ): + raise EvidenceRejected("pilot qualification contains a stale signal") + + +def _pilot_qualification_valid_until( + receipt: PilotQualificationReceipt, + *, + max_age_seconds: int, +) -> datetime: + """Return the earliest expiry of the wrapper and every pilot signal.""" + + oldest_evidence = min( + _utc(receipt.verified_at, "pilot_qualification.verified_at"), + *( + _utc(result.observed_at, "pilot_signal.observed_at") + for result in receipt.signal_results + ), + ) + return oldest_evidence + timedelta(seconds=max_age_seconds) + + +def _validate_durable_qualification( + *, + request: QualificationRecordRequest, + receipt: DurableQualificationReceipt, + now: datetime, +) -> None: + expected = { + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "pilot_claim_id": request.pilot_claim_id, + "pilot_operation_fingerprint": request.pilot_operation_fingerprint, + "pilot_applied_receipt_hash": request.pilot_applied_receipt_hash, + "qualification_receipt_hash": request.qualification_receipt_hash, + "external_receipt_hash": request.external_receipt_hash, + "artifact_digest": request.artifact_digest, + "embedded_manifest_hash": request.embedded_manifest_hash, + "verifier_identity": request.verifier_identity, + "qualification_verified_at": request.qualification_verified_at, + "durable": True, + } + for field_name, expected_value in expected.items(): + if getattr(receipt, field_name) != expected_value: + raise LedgerSafetyError(f"durable qualification mismatch: {field_name}") + recorded_at = _utc(receipt.recorded_at, "durable_qualification.recorded_at") + if recorded_at < _utc(request.qualification_verified_at, "qualification time"): + raise LedgerSafetyError("qualification was recorded before verification") + try: + _age_seconds(recorded_at, now, "durable_qualification.recorded_at") + except EvidenceRejected as exc: + raise LedgerSafetyError(str(exc)) from exc + + +def finalize_production_operation( + *, + contract: ReleaseControlContract, + plan: PromotionPlan, + ledger: ReleaseLedgerPort, + qualification_resolver: VerifiedPilotQualificationResolverPort, + qualification_evidence: QualificationEvidencePort, + target_resolver: VerifiedTargetStateResolverPort, + clock: TrustedClockPort, +) -> ReleaseOperation: + """Qualify pilot, persist proof, then resolve a fresh production target.""" + + if ( + type(plan) is not PromotionPlan + or not plan._validated + or type(plan.operations) is not tuple + or len(plan.operations) != 2 + ): + raise EvidenceRejected("production finalization requires a promotion plan") + pilot, draft = plan.operations + _validate_operation_contract(pilot, contract) + _validate_operation_contract(draft, contract, allow_production_draft=True) + manifest = _validate_manifest(plan._manifest, contract) + configuration_manifest = _validate_configuration_manifest( + plan._configuration_manifest, manifest + ) + if ( + type(plan._artifact) is not ArtifactDescriptor + or type(plan._check_suite) is not CheckSuiteReceipt + or type(plan._source_candidate) is not SourceCandidateReceipt + or type(plan._pilot_target) is not TargetSnapshot + or type(plan._production_target) is not TargetSnapshot + ): + raise EvidenceRejected("promotion plan contains untyped sealed evidence") + shared_expected = { + "repository": contract.repository, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "required_checks": contract.required_checks, + "release_id": manifest.release_id, + "manifest_release_id": manifest.release_id, + "artifact_digest": plan._artifact.digest, + "image_reference": plan._artifact.image_reference, + "embedded_manifest_hash": manifest.manifest_hash, + "configuration_manifest_hash": configuration_manifest.manifest_hash, + "artifact_receipt_hash": plan._artifact.resolution_receipt_hash, + "artifact_resolver_identity": plan._artifact.resolver_identity, + "check_suite_receipt_hash": plan._check_suite.receipt_hash, + "source_candidate_receipt_hash": plan._source_candidate.receipt_hash, + "migration_class": manifest.migration_class, + "schema_min": manifest.schema_min, + "schema_max": manifest.schema_max, + } + if ( + plan.release_id != manifest.release_id + or plan.artifact_digest != plan._artifact.digest + or plan.embedded_manifest_hash != manifest.manifest_hash + or plan.contract_digest != contract.contract_digest + or plan.contract_edition != contract.contract_edition + or plan.required_checks != contract.required_checks + or any( + getattr(operation, field_name) != expected_value + for operation in (pilot, draft) + for field_name, expected_value in shared_expected.items() + ) + ): + raise EvidenceRejected("promotion plan sealed evidence linkage is invalid") + for operation, target in ( + (pilot, plan._pilot_target), + (draft, plan._production_target), + ): + target_expected = { + "target": target.target, + "configuration_manifest_hash": configuration_manifest.manifest_hash, + "target_profile_digest": target.target_profile_digest, + "target_snapshot_hash": target.receipt_hash, + "target_source_identity": target.source_identity, + "expected_current_digest": target.expected_current_digest, + "target_config_digest": target.target_config_digest, + "migration_history_digest": target.migration_history_digest, + "current_schema": target.current_schema, + } + if any( + getattr(operation, field_name) != expected_value + for field_name, expected_value in target_expected.items() + ): + raise EvidenceRejected("promotion plan target evidence linkage is invalid") + if ( + pilot.action is not ReleaseAction.PROMOTE + or pilot.target is not TargetName.PILOT + or draft.action is not ReleaseAction.PROMOTE + or draft.target is not TargetName.PRODUCTION + or draft.required_pilot_operation_fingerprint != pilot.operation_fingerprint + or draft.pilot_qualification_receipt_hash is not None + or draft.qualified_pilot_applied_receipt_hash is not None + ): + raise EvidenceRejected("promotion plan pilot/production linkage is invalid") + clock = _MonotonicTrustedClock(clock) + pilot_read_started_at = _trusted_now(clock) + try: + pilot_receipt = ledger.pilot_applied_receipt(_pilot_receipt_query(draft)) + except Exception as exc: + LOGGER.warning("pilot receipt read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError( + "cannot verify pilot receipt for qualification" + ) from exc + pilot_read_at = _trusted_post_call_now( + clock, + pilot_read_started_at, + "pilot receipt read", + ) + _validate_pilot_receipt(pilot_receipt, draft, pilot_read_at) + qualification_request = PilotQualificationRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + release_id=draft.manifest_release_id, + pilot_operation_fingerprint=pilot.operation_fingerprint, + pilot_claim_id=pilot_receipt.claim_id, + pilot_applied_receipt_hash=pilot_receipt.receipt_hash, + pilot_effect_confirmed_at=pilot_receipt.effect_confirmed_at, + artifact_digest=pilot.artifact_digest, + image_reference=pilot.image_reference, + embedded_manifest_hash=pilot.embedded_manifest_hash, + target_profile_digest=pilot.target_profile_digest, + target_snapshot_hash=pilot.target_snapshot_hash, + target_config_digest=pilot.target_config_digest, + required_signals=contract.pilot_qualification_required_signals, + excluded_verifier_identities=tuple( + sorted( + { + plan._check_suite.verifier_identity, + plan._artifact.resolver_identity, + plan._source_candidate.reviewer_identity, + plan._source_candidate.verifier_identity, + plan._pilot_target.source_identity, + plan._production_target.source_identity, + } + ) + ), + ) + qualification_started_at = _trusted_now(clock) + try: + qualification = qualification_resolver.resolve_verified(qualification_request) + except Exception as exc: + LOGGER.warning( + "pilot qualification resolver failed closed (%s)", + type(exc).__name__, + ) + raise EvidenceRejected("verified pilot qualification failed") from exc + qualification_resolved_at = _trusted_post_call_now( + clock, + qualification_started_at, + "pilot qualification", + ) + if type(qualification) is not PilotQualificationReceipt: + raise EvidenceRejected("pilot qualification returned an untyped assertion") + _validate_pilot_qualification( + contract=contract, + request=qualification_request, + receipt=qualification, + now=qualification_resolved_at, + ) + record_request = QualificationRecordRequest( + repository=contract.repository, + contract_digest=contract.contract_digest, + contract_edition=contract.contract_edition, + release_id=draft.manifest_release_id, + pilot_claim_id=pilot_receipt.claim_id, + pilot_operation_fingerprint=pilot.operation_fingerprint, + pilot_applied_receipt_hash=pilot_receipt.receipt_hash, + qualification_receipt_hash=qualification.receipt_hash, + external_receipt_hash=qualification.external_receipt_hash, + artifact_digest=pilot.artifact_digest, + embedded_manifest_hash=pilot.embedded_manifest_hash, + verifier_identity=qualification.verifier_identity, + qualification_verified_at=qualification.verified_at, + ) + record_started_at = _trusted_now(clock) + try: + durable_qualification = qualification_evidence.record_durable(record_request) + except Exception as exc: + LOGGER.warning("qualification record failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("pilot qualification was not durably recorded") from exc + post_record_now = _trusted_post_call_now( + clock, + record_started_at, + "qualification recording", + ) + if type(durable_qualification) is not DurableQualificationReceipt: + raise LedgerSafetyError("qualification store returned an untyped receipt") + _validate_pilot_qualification( + contract=contract, + request=qualification_request, + receipt=qualification, + now=post_record_now, + ) + _validate_durable_qualification( + request=record_request, + receipt=durable_qualification, + now=post_record_now, + ) + production_target = _resolve_target_snapshot( + contract=contract, + operation_release_id=plan._manifest.release_id, + manifest=plan._manifest, + configuration_manifest=configuration_manifest, + artifact=plan._artifact, + target=TargetName.PRODUCTION, + resolver=target_resolver, + clock=clock, + ) + if production_target.source_identity == qualification.verifier_identity: + raise EvidenceRejected( + "fresh production target source is not independent from qualification" + ) + if production_target.source_identity != draft.target_source_identity: + raise EvidenceRejected( + "fresh production target resolver identity differs from the planned resolver" + ) + final_now = _trusted_now(clock) + if final_now < post_record_now: + raise EvidenceRejected("trusted clock moved backwards during finalization") + _validate_pilot_qualification( + contract=contract, + request=qualification_request, + receipt=qualification, + now=final_now, + ) + _validate_durable_qualification( + request=record_request, + receipt=durable_qualification, + now=final_now, + ) + if ( + _age_seconds( + production_target.captured_at, + final_now, + "production target snapshot", + ) + > MAX_TARGET_SNAPSHOT_AGE_SECONDS + ): + raise EvidenceRejected("production target snapshot expired during finalization") + evidence_valid_until = min( + _check_suite_valid_until( + plan._check_suite, + max_age_seconds=contract.check_suite_max_age_seconds, + ), + _utc(plan._source_candidate.verified_at, "source_candidate.verified_at") + + timedelta(seconds=contract.source_candidate_max_age_seconds), + _pilot_qualification_valid_until( + qualification, + max_age_seconds=contract.pilot_qualification_max_age_seconds, + ), + ) + final_deadline = min( + evidence_valid_until, + _utc(plan._artifact.resolved_at, "artifact resolved_at") + + timedelta(seconds=MAX_ARTIFACT_RECEIPT_AGE_SECONDS), + _utc(production_target.captured_at, "production target captured_at") + + timedelta(seconds=MAX_TARGET_SNAPSHOT_AGE_SECONDS), + ) + if final_now >= final_deadline: + raise EvidenceRejected("production evidence expired during finalization") + qualification_gate_hash = _hash( + { + "domain": "iwe.release-control.qualified-production-gate.v1", + "pilot_applied_receipt_hash": pilot_receipt.receipt_hash, + "qualification_receipt_hash": qualification.receipt_hash, + "durable_qualification_receipt_hash": (durable_qualification.receipt_hash), + }, + "qualified production gate", + ) + return _operation_from_verified_evidence( + contract=contract, + operation_release_id=plan._manifest.release_id, + action=ReleaseAction.PROMOTE, + manifest=plan._manifest, + artifact=plan._artifact, + check_suite=plan._check_suite, + source_candidate=plan._source_candidate, + pilot_qualification_receipt_hash=durable_qualification.receipt_hash, + qualified_pilot_applied_receipt_hash=pilot_receipt.receipt_hash, + target=production_target, + evidence_valid_until=evidence_valid_until, + additional_gate_evidence_hash=qualification_gate_hash, + required_pilot_operation_fingerprint=pilot.operation_fingerprint, + ) + + +def _validate_claim(receipt: ClaimReceipt, request: LedgerClaimRequest) -> None: + if type(receipt) is not ClaimReceipt: + raise LedgerSafetyError("ledger returned an untyped claim") + if type(receipt.target_key) is not TargetName: + raise LedgerSafetyError("ledger claim has an untyped target") + if type(receipt.operation_kind) is not ReleaseAction: + raise LedgerSafetyError("ledger claim has an untyped operation kind") + for field in ( + "external_id", + "operation_fingerprint", + "target_key", + "artifact_digest", + "operation_kind", + "contract_digest", + ): + if getattr(receipt, field) != getattr(request, field): + raise LedgerSafetyError(f"ledger claim mismatch: {field}") + _canonical_uuid(receipt.authorization_id, "claim.authorization_id") + _digest(receipt.evidence_hash, "claim.evidence_hash") + if receipt.durable is not True or receipt.authority_use_burned is not True: + raise LedgerSafetyError("ledger did not durably claim and burn authority") + _canonical_uuid(receipt.claim_id, "claim.claim_id") + minimum_state_version = 1 if receipt.state is OperationState.CLAIMED_LOCKED else 2 + _bounded_int( + receipt.state_version, + "claim.state_version", + minimum=minimum_state_version, + maximum=2**63 - 1, + ) + _bounded_int( + receipt.fencing_token, + "claim.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + if receipt.disposition is ClaimDisposition.OWNER: + if receipt.authorization_id != request.authorization_id: + raise LedgerSafetyError("ledger claim mismatch: authorization_id") + if receipt.evidence_hash != request.evidence_hash: + raise LedgerSafetyError("ledger claim mismatch: evidence_hash") + if receipt.state is not OperationState.CLAIMED_LOCKED: + raise LedgerSafetyError("owner claim is not claimed_locked") + elif receipt.disposition is ClaimDisposition.DUPLICATE: + if type(receipt.state) is not OperationState: + raise LedgerSafetyError("duplicate claim has an unknown state") + else: + raise LedgerSafetyError("ledger claim disposition is unsupported") + + +def _outcome_request( + *, + claim: ClaimReceipt, + operation: ReleaseOperation, + next_state: OperationState, + evidence_hash: str, +) -> LedgerOutcomeRequest: + return LedgerOutcomeRequest( + external_id=claim.external_id, + claim_id=claim.claim_id, + operation_fingerprint=operation.operation_fingerprint, + expected_state=OperationState.CLAIMED_LOCKED, + expected_version=claim.state_version, + fencing_token=claim.fencing_token, + next_state=next_state, + target=operation.target, + repository=operation.repository, + release_id=operation.release_id, + manifest_release_id=operation.manifest_release_id, + action=operation.action, + artifact_digest=operation.artifact_digest, + embedded_manifest_hash=operation.embedded_manifest_hash, + contract_digest=operation.contract_digest, + contract_edition=operation.contract_edition, + required_checks_digest=operation.required_checks_digest, + evidence_hash=_digest(evidence_hash, "outcome.evidence_hash"), + ) + + +class ExecutionState(str, Enum): + APPLIED = "applied" + OUTCOME_UNKNOWN = "outcome_unknown" + OBSERVED_NOT_APPLIED = "observed_not_applied" + MANUAL_REVIEW = "manual_review" + + +@dataclass(frozen=True) +class ExecutionResult: + state: ExecutionState + operation_fingerprint: str + provider_called: bool + + +def _execution_state_from_durable_state(state: OperationState) -> ExecutionState: + """Return the already-recorded effect instead of a generic duplicate.""" + + if state in (OperationState.APPLIED, OperationState.OBSERVED_APPLIED): + return ExecutionState.APPLIED + if state is OperationState.OBSERVED_NOT_APPLIED: + return ExecutionState.OBSERVED_NOT_APPLIED + if state is OperationState.MANUAL_REVIEW: + return ExecutionState.MANUAL_REVIEW + return ExecutionState.OUTCOME_UNKNOWN + + +def _existing_durable_result( + *, + ledger: ReleaseLedgerPort, + operation: ReleaseOperation, +) -> ExecutionResult | None: + """Return an exact prior outcome without reopening transient mutation gates.""" + + try: + raw_snapshot = ledger.state_snapshot(operation.operation_fingerprint) + except Exception as exc: + LOGGER.warning("ledger state read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("cannot read existing ledger state") from exc + if raw_snapshot is None: + return None + snapshot = _validate_ledger_snapshot(raw_snapshot, operation) + return ExecutionResult( + state=_execution_state_from_durable_state(snapshot.state), + operation_fingerprint=operation.operation_fingerprint, + provider_called=False, + ) + + +def _validate_owner_claim_snapshot( + *, + ledger: ReleaseLedgerPort, + operation: ReleaseOperation, + claim: ClaimReceipt, +) -> None: + """Prove that the owner receipt exactly matches the durable claimed row.""" + + try: + raw_snapshot = ledger.state_snapshot(operation.operation_fingerprint) + except Exception as exc: + LOGGER.warning("owner state read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("cannot verify owner ledger state") from exc + snapshot = _validate_ledger_snapshot(raw_snapshot, operation) + expected = { + "external_id": claim.external_id, + "claim_id": claim.claim_id, + "state": OperationState.CLAIMED_LOCKED, + "state_version": claim.state_version, + "fencing_token": claim.fencing_token, + } + for field, value in expected.items(): + if getattr(snapshot, field) != value: + raise LedgerSafetyError(f"owner ledger state mismatch: {field}") + + +def _validate_duplicate_claim_snapshot( + *, + ledger: ReleaseLedgerPort, + operation: ReleaseOperation, + claim: ClaimReceipt, +) -> LedgerStateSnapshot: + """Return an exact, monotonically reachable state for a duplicate claim.""" + + try: + raw_snapshot = ledger.state_snapshot(operation.operation_fingerprint) + except Exception as exc: + LOGGER.warning("duplicate state read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("cannot verify duplicate ledger state") from exc + snapshot = _validate_ledger_snapshot(raw_snapshot, operation) + expected = { + "external_id": claim.external_id, + "claim_id": claim.claim_id, + "fencing_token": claim.fencing_token, + } + for field, value in expected.items(): + if getattr(snapshot, field) != value: + raise LedgerSafetyError(f"duplicate ledger state mismatch: {field}") + version_delta = snapshot.state_version - claim.state_version + if version_delta == 0: + reachable = snapshot.state is claim.state + elif claim.state is OperationState.CLAIMED_LOCKED: + reachable_by_delta = { + 1: { + OperationState.CLAIMED_LOCKED, + OperationState.OUTCOME_UNKNOWN, + OperationState.APPLIED, + }, + 2: { + OperationState.OUTCOME_UNKNOWN, + OperationState.APPLIED, + OperationState.OBSERVED_APPLIED, + OperationState.OBSERVED_NOT_APPLIED, + OperationState.MANUAL_REVIEW, + }, + 3: { + OperationState.OBSERVED_APPLIED, + OperationState.OBSERVED_NOT_APPLIED, + OperationState.MANUAL_REVIEW, + }, + } + reachable = snapshot.state in reachable_by_delta.get(version_delta, set()) + elif claim.state is OperationState.OUTCOME_UNKNOWN and version_delta == 1: + reachable = snapshot.state in { + OperationState.OBSERVED_APPLIED, + OperationState.OBSERVED_NOT_APPLIED, + OperationState.MANUAL_REVIEW, + } + else: + reachable = False + if not reachable: + raise LedgerSafetyError("duplicate ledger state is not monotonically reachable") + return snapshot + + +def _record_uncertain( + *, + ledger: ReleaseLedgerPort, + claim: ClaimReceipt, + operation: ReleaseOperation, + reason_code: str, +) -> None: + evidence_hash = _hash( + {"reason_code": _safe_id(reason_code, "reason_code")}, + "uncertain outcome", + ) + try: + ledger.record_outcome( + _outcome_request( + claim=claim, + operation=operation, + next_state=OperationState.OUTCOME_UNKNOWN, + evidence_hash=evidence_hash, + ) + ) + except Exception as exc: # noqa: BLE001 - ledger port failure is fail-closed + LOGGER.warning("ledger uncertainty CAS failed closed (%s)", type(exc).__name__) + + +def _temporal_gate_after_claim( + *, + clock: TrustedClockPort, + initial_now: datetime, + authority: AuthorityEvidence, + claim: ClaimReceipt, + operation: ReleaseOperation, + ledger: ReleaseLedgerPort, +) -> datetime | None: + """Recheck trusted time after slow ports and before provider mutation.""" + + try: + current_now = _trusted_now(clock) + except EvidenceRejected: + _record_uncertain( + ledger=ledger, + claim=claim, + operation=operation, + reason_code="pre-provider-clock-unavailable", + ) + return None + planning_deadline = _utc( + operation.evidence_valid_until, + "operation.evidence_valid_until", + ) + authority_deadline = _utc(authority.expires_at, "authority.expires_at") + if ( + current_now < initial_now + or current_now >= planning_deadline + or current_now >= authority_deadline + ): + _record_uncertain( + ledger=ledger, + claim=claim, + operation=operation, + reason_code="pre-provider-evidence-expired", + ) + return None + return current_now + + +def _validate_provider_intent_snapshot( + *, + snapshot: LedgerStateSnapshot | None, + request: ProviderIntentRecordRequest, + operation: ReleaseOperation, +) -> LedgerStateSnapshot: + """Require an exact durable CAS of provider intent before mutation.""" + + validated = _validate_ledger_snapshot(snapshot, operation) + expected = { + "external_id": request.external_id, + "claim_id": request.claim_id, + "operation_fingerprint": request.operation_fingerprint, + "state": OperationState.CLAIMED_LOCKED, + "state_version": request.expected_version + 1, + "fencing_token": request.fencing_token, + "provider_intent_hash": request.provider_intent_hash, + "provider_not_before": request.not_before, + "provider_not_after": request.not_after, + } + for field_name, expected_value in expected.items(): + if getattr(validated, field_name) != expected_value: + raise LedgerSafetyError(f"provider intent mismatch: {field_name}") + return validated + + +def execute_operation( + *, + contract: ReleaseControlContract, + operation: ReleaseOperation, + authority: AuthorityEvidence, + clock: TrustedClockPort, + authority_verifier: AuthorityVerifierPort, + ledger: ReleaseLedgerPort, + provider: ReleaseProviderPort, +) -> ExecutionResult: + """Execute at most once after all contract, pilot, authority, and CAS gates.""" + + _validate_operation_contract(operation, contract) + existing_result = _existing_durable_result(ledger=ledger, operation=operation) + if existing_result is not None: + return existing_result + if not contract.cutover_enabled: + raise CutoverBlocked("cutover is disabled by the repository contract") + clock = _MonotonicTrustedClock(clock) + now = _trusted_now(clock) + negative_matrix_query = _negative_matrix_query(contract) + negative_matrix_read_started_at = now + try: + negative_matrix_receipt = ledger.negative_matrix_receipt(negative_matrix_query) + except Exception as exc: + LOGGER.warning( + "negative-matrix receipt read failed closed (%s)", type(exc).__name__ + ) + raise LedgerSafetyError("cannot verify old auto-deploy is disabled") from exc + now = _trusted_post_call_now( + clock, + negative_matrix_read_started_at, + "negative-matrix receipt read before execution", + ) + _validate_negative_matrix_receipt(negative_matrix_receipt, contract, now) + if now >= _utc(operation.evidence_valid_until, "operation.evidence_valid_until"): + raise EvidenceRejected("verified planning evidence has expired") + if operation.target is TargetName.PRODUCTION: + query = _pilot_receipt_query(operation) + pilot_read_started_at = now + try: + pilot_receipt = ledger.pilot_applied_receipt(query) + except Exception as exc: + LOGGER.warning("pilot receipt read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("cannot verify pilot APPLIED receipt") from exc + now = _trusted_post_call_now( + clock, + pilot_read_started_at, + "pilot receipt read before execution", + ) + _validate_pilot_receipt(pilot_receipt, operation, now) + if ( + operation.qualified_pilot_applied_receipt_hash is not None + and pilot_receipt.receipt_hash + != operation.qualified_pilot_applied_receipt_hash + ): + raise LedgerSafetyError( + "pilot APPLIED receipt differs from the qualified receipt" + ) + verified_authority_evidence_hash = _validate_authority( + contract=contract, + operation=operation, + authority=authority, + now=now, + verifier=authority_verifier, + ) + claim_request = LedgerClaimRequest.from_operation( + operation, + authority, + verified_authority_evidence_hash, + ) + try: + claim = ledger.claim_and_burn(claim_request) + except Exception as exc: + LOGGER.warning("ledger claim failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("durable claim failed before provider call") from exc + _validate_claim(claim, claim_request) + if claim.disposition is ClaimDisposition.OWNER: + _validate_owner_claim_snapshot( + ledger=ledger, + operation=operation, + claim=claim, + ) + else: + duplicate_snapshot = _validate_duplicate_claim_snapshot( + ledger=ledger, + operation=operation, + claim=claim, + ) + return ExecutionResult( + state=_execution_state_from_durable_state(duplicate_snapshot.state), + operation_fingerprint=operation.operation_fingerprint, + provider_called=False, + ) + + provider_not_before = _temporal_gate_after_claim( + clock=clock, + initial_now=now, + authority=authority, + claim=claim, + operation=operation, + ledger=ledger, + ) + if provider_not_before is None: + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=False, + ) + provider_request = ProviderMutationRequest( + operation=operation, + image_reference=operation.image_reference, + expected_current_artifact_digest=operation.expected_current_digest, + expected_target_config_digest=operation.target_config_digest, + expected_current_schema=operation.current_schema, + expected_migration_history_digest=operation.migration_history_digest, + target_profile_digest=operation.target_profile_digest, + target_snapshot_hash=operation.target_snapshot_hash, + external_id=claim.external_id, + claim_id=claim.claim_id, + fencing_token=claim.fencing_token, + idempotency_key=provider_idempotency_key(operation.operation_fingerprint), + not_before=provider_not_before, + not_after=min( + _utc(operation.evidence_valid_until, "operation.evidence_valid_until"), + _utc(authority.expires_at, "authority.expires_at"), + ), + ) + intent_request = ProviderIntentRecordRequest.from_provider_request( + provider_request, + claim, + ) + try: + raw_intent_snapshot = ledger.record_provider_intent(intent_request) + intent_snapshot = _validate_provider_intent_snapshot( + snapshot=raw_intent_snapshot, + request=intent_request, + operation=operation, + ) + except Exception as exc: # noqa: BLE001 - ledger reply is fail-closed + LOGGER.warning("provider intent CAS failed closed (%s)", type(exc).__name__) + _record_uncertain( + ledger=ledger, + claim=claim, + operation=operation, + reason_code="provider-intent-unavailable", + ) + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=False, + ) + claim = replace(claim, state_version=intent_snapshot.state_version) + if ( + _temporal_gate_after_claim( + clock=clock, + initial_now=provider_not_before, + authority=authority, + claim=claim, + operation=operation, + ledger=ledger, + ) + is None + ): + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=False, + ) + try: + provider_result = provider.apply(provider_request) + except Exception as exc: # noqa: BLE001 - provider outcome is intentionally unknown + LOGGER.warning("provider call outcome is unknown (%s)", type(exc).__name__) + _record_uncertain( + ledger=ledger, + claim=claim, + operation=operation, + reason_code="provider-exception", + ) + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=True, + ) + if type( + provider_result + ) is not ProviderApplyResult or not provider_result.exactly_matches( + provider_request + ): + _record_uncertain( + ledger=ledger, + claim=claim, + operation=operation, + reason_code="provider-response-mismatch", + ) + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=True, + ) + outcome = _outcome_request( + claim=claim, + operation=operation, + next_state=OperationState.APPLIED, + evidence_hash=provider_result.receipt_hash, + ) + try: + applied = ledger.record_outcome(outcome) + except Exception as exc: # noqa: BLE001 - ledger port failure is fail-closed + LOGGER.warning("applied receipt CAS failed closed (%s)", type(exc).__name__) + applied = None + applied_is_exact = type(applied) is LedgerStateSnapshot and ( + applied.external_id == claim.external_id + and applied.claim_id == claim.claim_id + and applied.operation_fingerprint == operation.operation_fingerprint + and applied.target is operation.target + and applied.artifact_digest == operation.artifact_digest + and applied.contract_digest == operation.contract_digest + and applied.state is OperationState.APPLIED + and applied.state_version == claim.state_version + 1 + and applied.fencing_token == claim.fencing_token + and applied.provider_intent_hash == intent_snapshot.provider_intent_hash + and applied.provider_not_before == intent_snapshot.provider_not_before + and applied.provider_not_after == intent_snapshot.provider_not_after + ) + if not applied_is_exact: + return ExecutionResult( + state=ExecutionState.OUTCOME_UNKNOWN, + operation_fingerprint=operation.operation_fingerprint, + provider_called=True, + ) + return ExecutionResult( + state=ExecutionState.APPLIED, + operation_fingerprint=operation.operation_fingerprint, + provider_called=True, + ) + + +@dataclass(frozen=True) +class ProviderObservation(VerifiedProviderEffect): + """Read-only view of the same immutable provider effect used by apply.""" + + def exactly_matches_state( + self, + operation: ReleaseOperation, + state: LedgerStateSnapshot, + ) -> bool: + if ( + state.provider_intent_hash is None + or state.provider_not_before is None + or state.provider_not_after is None + ): + return False + try: + request = ProviderMutationRequest( + operation=operation, + image_reference=operation.image_reference, + expected_current_artifact_digest=operation.expected_current_digest, + expected_target_config_digest=operation.target_config_digest, + expected_current_schema=operation.current_schema, + expected_migration_history_digest=operation.migration_history_digest, + target_profile_digest=operation.target_profile_digest, + target_snapshot_hash=operation.target_snapshot_hash, + external_id=state.external_id, + claim_id=state.claim_id, + fencing_token=state.fencing_token, + idempotency_key=provider_idempotency_key( + operation.operation_fingerprint + ), + not_before=state.provider_not_before, + not_after=state.provider_not_after, + ) + except ReleaseControlError: + return False + return ( + request.intent_hash == state.provider_intent_hash + and self.exactly_matches(request) + ) + + +@dataclass(frozen=True) +class ObservationEnvelope: + schema_version: int + snapshot_id: str + source_identity: str + operation_fingerprint: str + target: TargetName + target_profile_digest: str + complete: bool + captured_at: datetime + settled_through: datetime + observations: tuple[ProviderObservation, ...] + evidence_hash: str + + def __post_init__(self) -> None: + _literal(self.schema_version, 1, "observation_envelope.schema_version") + _canonical_uuid(self.snapshot_id, "observation_envelope.snapshot_id") + _safe_id(self.source_identity, "observation_envelope.source_identity") + _digest( + self.operation_fingerprint, "observation_envelope.operation_fingerprint" + ) + if not isinstance(self.target, TargetName): + raise SchemaError("observation_envelope.target must be a TargetName") + _digest( + self.target_profile_digest, + "observation_envelope.target_profile_digest", + ) + _boolean(self.complete, "observation_envelope.complete") + _utc(self.captured_at, "observation_envelope.captured_at") + _utc(self.settled_through, "observation_envelope.settled_through") + if not isinstance(self.observations, tuple) or len(self.observations) > 100: + raise SchemaError( + "observation_envelope.observations must be a bounded tuple" + ) + if any(type(item) is not ProviderObservation for item in self.observations): + raise SchemaError("observation_envelope contains an untyped observation") + _digest(self.evidence_hash, "observation_envelope.evidence_hash") + + def to_mapping(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "snapshot_id": self.snapshot_id, + "source_identity": self.source_identity, + "operation_fingerprint": self.operation_fingerprint, + "target": self.target.value, + "target_profile_digest": self.target_profile_digest, + "complete": self.complete, + "captured_at": _timestamp(self.captured_at), + "settled_through": _timestamp(self.settled_through), + "observations": [item.to_mapping() for item in self.observations], + "evidence_hash": self.evidence_hash, + } + + +class ProviderObserverPort(Protocol): + def observe_verified( + self, + operation: ReleaseOperation, + uncertainty_started_at: datetime, + ) -> Sequence[ObservationEnvelope]: + """Return complete, authenticated, read-only provider snapshots.""" + + +@dataclass(frozen=True) +class ReconciliationCASRequest: + external_id: str + operation_fingerprint: str + expected_state: OperationState + expected_version: int + fencing_token: int + next_state: OperationState + evidence_hash: str + observation_reference_hashes: tuple[str, ...] + + +@dataclass(frozen=True) +class ReconciliationResult: + state: OperationState + resolved: bool + exact_match_count: int + snapshot_count: int + + +def _validate_ledger_snapshot( + snapshot: LedgerStateSnapshot | None, + operation: ReleaseOperation, +) -> LedgerStateSnapshot: + if type(snapshot) is not LedgerStateSnapshot: + raise LedgerSafetyError("uncertain operation has no versioned ledger snapshot") + if type(snapshot.target) is not TargetName: + raise LedgerSafetyError("ledger state has an untyped target") + if type(snapshot.state) is not OperationState: + raise LedgerSafetyError("ledger state has an untyped operation state") + expected = { + "external_id": operation_external_id(operation.operation_fingerprint), + "operation_fingerprint": operation.operation_fingerprint, + "target": operation.target, + "artifact_digest": operation.artifact_digest, + "contract_digest": operation.contract_digest, + } + for field, value in expected.items(): + if getattr(snapshot, field) != value: + raise LedgerSafetyError(f"ledger state mismatch: {field}") + _canonical_uuid(snapshot.claim_id, "ledger.claim_id") + minimum_state_version = 1 if snapshot.state is OperationState.CLAIMED_LOCKED else 2 + _bounded_int( + snapshot.state_version, + "ledger.state_version", + minimum=minimum_state_version, + maximum=2**63 - 1, + ) + _bounded_int( + snapshot.fencing_token, + "ledger.fencing_token", + minimum=1, + maximum=2**63 - 1, + ) + uncertainty_started_at = _utc( + snapshot.uncertainty_started_at, + "ledger.uncertainty_started_at", + ) + intent_values = ( + snapshot.provider_intent_hash, + snapshot.provider_not_before, + snapshot.provider_not_after, + ) + if any(value is None for value in intent_values): + if any(value is not None for value in intent_values): + raise LedgerSafetyError("ledger provider intent is incomplete") + if snapshot.state in ( + OperationState.APPLIED, + OperationState.OBSERVED_APPLIED, + ): + raise LedgerSafetyError("applied ledger state has no provider intent") + else: + _digest(snapshot.provider_intent_hash, "ledger.provider_intent_hash") + not_before = _utc( + snapshot.provider_not_before, + "ledger.provider_not_before", + ) + not_after = _utc(snapshot.provider_not_after, "ledger.provider_not_after") + if not_before < uncertainty_started_at or not_before >= not_after: + raise LedgerSafetyError("ledger provider intent window is invalid") + return snapshot + + +def _validate_observation_envelopes( + *, + operation: ReleaseOperation, + state: LedgerStateSnapshot, + envelopes: Sequence[ObservationEnvelope], + now: datetime, +) -> tuple[tuple[ObservationEnvelope, ...], tuple[ProviderObservation, ...]]: + if not 1 <= len(envelopes) <= 10: + raise EvidenceRejected("reconciliation requires 1..10 bounded snapshots") + typed = tuple(envelopes) + if any(type(envelope) is not ObservationEnvelope for envelope in typed): + raise EvidenceRejected("observer returned an untyped snapshot") + if len({envelope.snapshot_id for envelope in typed}) != len(typed): + raise EvidenceRejected("reconciliation snapshot identities are not unique") + if len({envelope.evidence_hash for envelope in typed}) != len(typed): + raise EvidenceRejected("reconciliation snapshot evidence is not unique") + ordered = tuple(sorted(typed, key=lambda item: item.captured_at)) + if ordered != typed: + raise EvidenceRejected("reconciliation snapshots are not chronological") + minimum_settled = _utc( + state.uncertainty_started_at, + "ledger.uncertainty_started_at", + ) + timedelta(seconds=RECONCILIATION_MIN_SETTLING_SECONDS) + for envelope in typed: + expected = { + "operation_fingerprint": operation.operation_fingerprint, + "target": operation.target, + "target_profile_digest": operation.target_profile_digest, + "complete": True, + } + for field, value in expected.items(): + if getattr(envelope, field) != value: + raise EvidenceRejected(f"observation envelope mismatch: {field}") + if _age_seconds(envelope.captured_at, now, "observation.captured_at") > ( + RECONCILIATION_MAX_SNAPSHOT_AGE_SECONDS + ): + raise EvidenceRejected("observation snapshot is stale") + captured = _utc(envelope.captured_at, "observation.captured_at") + settled = _utc(envelope.settled_through, "observation.settled_through") + if settled > captured or settled < minimum_settled: + raise EvidenceRejected( + "observation snapshot has not crossed the settling horizon" + ) + if any( + _utc(item.applied_at, "provider_observation.applied_at") > captured + for item in envelope.observations + ): + raise EvidenceRejected("provider effect occurred after its snapshot") + observations = tuple(item for envelope in typed for item in envelope.observations) + return typed, observations + + +def _reconciliation_decision( + *, + operation: ReleaseOperation, + state: LedgerStateSnapshot, + envelopes: tuple[ObservationEnvelope, ...], + observations: tuple[ProviderObservation, ...], +) -> tuple[OperationState | None, tuple[str, ...], int]: + contradictory = any( + not item.exactly_matches_state(operation, state) for item in observations + ) + exact = { + item.provider_reference_hash: item + for item in observations + if item.exactly_matches_state(operation, state) + } + references = tuple(sorted(exact)) + if contradictory: + return OperationState.MANUAL_REVIEW, references, len(exact) + if len(exact) == 1: + return OperationState.OBSERVED_APPLIED, references, 1 + if len(exact) > 1: + return OperationState.MANUAL_REVIEW, references, len(exact) + if len(envelopes) < RECONCILIATION_MIN_ZERO_SNAPSHOTS: + return None, (), 0 + interval = (envelopes[-1].captured_at - envelopes[0].captured_at).total_seconds() + if interval < RECONCILIATION_MIN_ZERO_INTERVAL_SECONDS: + return None, (), 0 + mutation_horizon = _utc( + operation.evidence_valid_until, + "operation.evidence_valid_until", + ) + if any( + _utc(envelope.settled_through, "observation.settled_through") < mutation_horizon + for envelope in envelopes + ): + return None, (), 0 + return OperationState.OBSERVED_NOT_APPLIED, (), 0 + + +def reconcile_uncertain_operation( + *, + operation: ReleaseOperation, + ledger: ReleaseLedgerPort, + observer: ProviderObserverPort, + clock: TrustedClockPort, +) -> ReconciliationResult: + """Resolve exact evidence through one version/fencing compare-and-swap.""" + + try: + raw_state = ledger.state_snapshot(operation.operation_fingerprint) + except Exception as exc: + LOGGER.warning("ledger state read failed closed (%s)", type(exc).__name__) + raise LedgerSafetyError("cannot read uncertain ledger state") from exc + state = _validate_ledger_snapshot(raw_state, operation) + if state.state not in ( + OperationState.CLAIMED_LOCKED, + OperationState.OUTCOME_UNKNOWN, + ): + exact_match_count = ( + 1 + if state.state in (OperationState.APPLIED, OperationState.OBSERVED_APPLIED) + else 0 + ) + return ReconciliationResult(state.state, True, exact_match_count, 0) + clock = _MonotonicTrustedClock(clock) + initial_now = _trusted_now(clock) + try: + raw_envelopes = observer.observe_verified( + operation, + state.uncertainty_started_at, + ) + now = _trusted_now(clock) + if now < initial_now: + raise EvidenceRejected( + "trusted clock moved backwards during reconciliation" + ) + envelopes, observations = _validate_observation_envelopes( + operation=operation, + state=state, + envelopes=raw_envelopes, + now=now, + ) + except Exception as exc: # noqa: BLE001 - observer boundary is fail-closed + LOGGER.warning("reconciliation evidence failed closed (%s)", type(exc).__name__) + return ReconciliationResult(state.state, False, 0, 0) + next_state, references, exact_count = _reconciliation_decision( + operation=operation, + state=state, + envelopes=envelopes, + observations=observations, + ) + if next_state is None: + return ReconciliationResult(state.state, False, 0, len(envelopes)) + envelope_hashes = [ + _hash(item.to_mapping(), "observation envelope") for item in envelopes + ] + evidence_hash = _hash( + { + "domain": "iwe.release-control.reconciliation.v1", + "operation_fingerprint": operation.operation_fingerprint, + "expected_state": state.state.value, + "expected_version": state.state_version, + "fencing_token": state.fencing_token, + "next_state": next_state.value, + "envelope_hashes": envelope_hashes, + "observation_reference_hashes": list(references), + }, + "reconciliation evidence", + ) + request = ReconciliationCASRequest( + external_id=state.external_id, + operation_fingerprint=operation.operation_fingerprint, + expected_state=state.state, + expected_version=state.state_version, + fencing_token=state.fencing_token, + next_state=next_state, + evidence_hash=evidence_hash, + observation_reference_hashes=references, + ) + try: + updated = ledger.reconcile_compare_and_swap(request) + except Exception as exc: # noqa: BLE001 - ledger port failure is fail-closed + LOGGER.warning("reconciliation CAS failed closed (%s)", type(exc).__name__) + updated = None + if type(updated) is not LedgerStateSnapshot: + return ReconciliationResult(state.state, False, exact_count, len(envelopes)) + if ( + updated.external_id != state.external_id + or updated.claim_id != state.claim_id + or updated.operation_fingerprint != operation.operation_fingerprint + or updated.target is not operation.target + or updated.artifact_digest != operation.artifact_digest + or updated.contract_digest != operation.contract_digest + or updated.state is not next_state + or updated.state_version != state.state_version + 1 + or updated.fencing_token != state.fencing_token + or updated.provider_intent_hash != state.provider_intent_hash + or updated.provider_not_before != state.provider_not_before + or updated.provider_not_after != state.provider_not_after + ): + return ReconciliationResult(state.state, False, exact_count, len(envelopes)) + return ReconciliationResult(next_state, True, exact_count, len(envelopes)) + + +reconcile_outcome_unknown = reconcile_uncertain_operation + + +def preview_release( + *, + contract: ReleaseControlContract, + manifest: EmbeddedManifest, + configuration_manifest: ReleaseConfigurationManifest, + artifact_reference: str | None = None, +) -> dict[str, Any]: + """Describe consistent declarations without resolving evidence or authorizing. + + This DTO cannot be deserialized into a trusted plan or executable operation. + References are not fetched; even a declared digest still needs verification. + """ + manifest = _validate_manifest(manifest, contract) + configuration = _validate_configuration_manifest(configuration_manifest, manifest) + artifact_digest = None + if artifact_reference is not None: + artifact_digest = _digest( + artifact_reference.rsplit("@", 1)[-1], "preview.artifact_digest" + ) + validate_immutable_image_reference(artifact_reference, artifact_digest) + if artifact_reference != f"{contract.oci_repository}@{artifact_digest}": + raise SchemaError("preview artifact repository does not match contract") + + missing_evidence = [ + "artifact_provenance_and_embedded_manifest", + "trusted_ci_check_suite_and_exact_smoke", + "configuration_references_and_delta_registry_contents", + "delta_classification_and_pilot_decisions", + "actual_pilot_and_production_configuration", + "pilot_qualification", + "independent_single_use_authority", + "negative_matrix", + "durable_ledger_and_claim", + "provider_fencing_idempotency_and_reconciliation", + "runtime_artifact_identity", + "post_deploy_readiness_canary_and_data_invariants", + ] + if artifact_digest is None: + missing_evidence.insert(0, "artifact_identity") + result = { + "schema_version": 1, + "domain": "iwe.release-control.preview.v1", + "status": "preview", + "mutation_authorized": False, + "readiness": "not_verified", + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "cutover_enabled": contract.cutover_enabled, + "embedded_manifest": manifest.to_mapping(), + "embedded_manifest_hash": manifest.manifest_hash, + "configuration_manifest": configuration.to_mapping(), + "configuration_manifest_hash": configuration.manifest_hash, + "artifact": {"reference": artifact_reference, "digest": artifact_digest}, + "proposed_deployments": [ + { + "target": target.value, + "artifact_digest": artifact_digest, + "profile_digest": profile.digest, + } + for target, profile in ( + (TargetName.PILOT, configuration.pilot), + (TargetName.PRODUCTION, configuration.production), + ) + ], + "missing_evidence": missing_evidence, + } + result["preview_digest"] = canonical_sha256(result) + return result + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Inspect WP-562 release declarations") + subparsers = parser.add_subparsers(dest="command", required=True) + validate = subparsers.add_parser("validate-contract") + validate.add_argument("contract", type=Path) + validate.add_argument("--require-cutover-disabled", action="store_true") + preview = subparsers.add_parser("preview-release") + preview.add_argument("contract", type=Path) + preview.add_argument("--embedded-manifest", type=Path, required=True) + preview.add_argument("--configuration-manifest", type=Path, required=True) + preview.add_argument("--artifact-reference") + return parser + + +def _preview_from_files( + args: argparse.Namespace, contract: ReleaseControlContract +) -> dict[str, Any]: + try: + embedded_payload = loads_strict_json(args.embedded_manifest.read_bytes()) + configuration_payload = loads_strict_json( + args.configuration_manifest.read_bytes() + ) + except OSError as exc: + raise SchemaError("cannot read preview manifest inputs") from exc + return preview_release( + contract=contract, + manifest=EmbeddedManifest.from_mapping(embedded_payload), + configuration_manifest=ReleaseConfigurationManifest.from_mapping( + configuration_payload + ), + artifact_reference=args.artifact_reference, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + contract = load_release_control_contract(args.contract) + if args.command == "preview-release": + result = _preview_from_files(args, contract) + else: + if args.require_cutover_disabled and contract.cutover_enabled: + raise ContractError("cutover must remain disabled in this phase") + result = { + "status": "valid", + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "required_checks_digest": contract.required_checks_digest, + "cutover_enabled": contract.cutover_enabled, + } + except (ReleaseControlError, ReleaseManifestError) as exc: + LOGGER.error("release-control input rejected: %s", exc) + return 2 + print(canonical_json_bytes(result).decode("utf-8")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_drift_ci.py b/scripts/release_drift_ci.py new file mode 100755 index 00000000..b391b817 --- /dev/null +++ b/scripts/release_drift_ci.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Carry the drift journal across trusted, serialized GitHub Actions runs. + +The immediately preceding executed run is authoritative, including failed +runs. Missing evidence blocks observation rather than restarting the SLA. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import io +import json +import os +import re +import subprocess +import sys +import zipfile +from pathlib import Path + +ARTIFACT = "release-drift-state" +JOURNAL = "release-drift-manifest.jsonl" +METADATA = "release-drift-state.json" +MAX_BYTES = 32 * 1024 * 1024 +TRUSTED_BRANCH = "new-architecture" +EVENTS = {"push", "workflow_run", "schedule", "workflow_dispatch"} + + +class StateError(RuntimeError): + """The previous durable observation cannot be authenticated or recovered.""" + + +def api(path: str, *, binary: bool = False): + try: + result = subprocess.run( + ["gh", "api", path], capture_output=True, timeout=60, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise StateError("GitHub evidence API unavailable") from exc + if result.returncode: + # stderr can contain URLs or credentials supplied by the host. + raise StateError("GitHub evidence API request failed") + if len(result.stdout) > MAX_BYTES: + raise StateError("GitHub evidence exceeded size limit") + if binary: + return result.stdout + try: + return json.loads(result.stdout) + except (UnicodeError, ValueError) as exc: + raise StateError("Malformed GitHub evidence") from exc + + +def context() -> tuple[str, int]: + repository = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise StateError("Invalid repository identity") + if not run_id.isdecimal() or int(run_id) <= 0: + raise StateError("Invalid run identity") + if os.environ.get("GITHUB_RUN_ATTEMPT") != "1": + raise StateError( + "Start a new workflow dispatch; do not rerun an old observation" + ) + return repository, int(run_id) + + +def preceding_run(runs: list[dict], current: dict, repository: str) -> dict | None: + eligible = [] + for run in runs: + if run["id"] == current["id"]: + continue + if run["head_branch"] != TRUSTED_BRANCH or run["event"] not in EVENTS: + continue + if (run.get("head_repository") or {}).get("full_name") != repository: + continue + if run.get("workflow_id") != current["workflow_id"]: + continue + if run.get("conclusion") == "skipped": + continue + # Cancelled/failed runs must have evidence or fail closed. + if run["status"] in {"queued", "pending", "waiting", "requested"}: + continue + if run["status"] != "completed": + raise StateError("Another observation has not completed") + eligible.append(run) + latest = max(eligible, key=lambda run: run["run_number"], default=None) + if latest and latest["run_number"] > current["run_number"]: + raise StateError( + "A newer run already completed; an old run cannot rewind the journal" + ) + return latest + + +def unpack_state(archive: bytes, *, repository: str, run_id: int) -> bytes: + try: + with zipfile.ZipFile(io.BytesIO(archive)) as bundle: + names = bundle.namelist() + if sorted(names) != sorted([JOURNAL, METADATA]): + raise StateError("Unexpected or duplicate state archive members") + if sum(info.file_size for info in bundle.infolist()) > MAX_BYTES: + raise StateError("State archive exceeds size limit") + journal = bundle.read(JOURNAL) + metadata = json.loads(bundle.read(METADATA)) + except (zipfile.BadZipFile, UnicodeError, ValueError, RuntimeError) as exc: + raise StateError("Invalid state archive") from exc + expected = { + "schema_version": 1, + "repository": repository, + "run_id": run_id, + "sha256": hashlib.sha256(journal).hexdigest(), + } + if metadata != expected: + raise StateError("State provenance or journal checksum mismatch") + return journal + + +def previous_observation(prefix: str, current: dict, repository: str) -> dict | None: + runs = [] + for page in range(1, 11): + batch = api( + f"{prefix}/workflows/{current['workflow_id']}/runs?branch={TRUSTED_BRANCH}&per_page=100&page={page}" + )["workflow_runs"] + runs.extend(batch) + while (prior := preceding_run(runs, current, repository)) is not None: + if prior.get("run_attempt", 1) != 1: + raise StateError( + "Prior observation was rerun; manual recovery is required" + ) + # GitHub can replace a queued run even with cancel-in-progress + # disabled. Only API proof that no job existed permits skipping it. + jobs = api(f"{prefix}/runs/{prior['id']}/jobs?per_page=100") + never_started = ( + jobs["total_count"] == 0 and prior.get("conclusion") == "cancelled" + ) + entries = jobs.get("jobs", []) + activation_skipped = ( + jobs["total_count"] == 1 + and len(entries) == 1 + and entries[0].get("name") == "Release drift observation" + and entries[0].get("conclusion") == "skipped" + and not entries[0].get("steps") + ) + if never_started or activation_skipped: + prior["conclusion"] = "skipped" + continue + return prior + if len(batch) < 100: + return None + raise StateError("Workflow history window exhausted") + + +def validate_journal(path: Path) -> None: + spec = importlib.util.spec_from_file_location( + "release_drift_detector", Path(__file__).with_name("release-drift-detector.py") + ) + detector = importlib.util.module_from_spec(spec) + spec.loader.exec_module(detector) + try: + detector.load_manifest(path) + except detector.DetectorError as exc: + raise StateError("Cannot preserve an invalid detector journal") from exc + + +def restore(directory: Path, *, bootstrap: bool, seed: Path | None = None) -> None: + repository, run_id = context() + prefix = f"repos/{repository}/actions" + current = api(f"{prefix}/runs/{run_id}") + if current["head_branch"] != TRUSTED_BRANCH or current["event"] not in EVENTS: + raise StateError("Observation must execute the trusted production workflow") + if (current.get("head_repository") or {}).get("full_name") != repository: + raise StateError("Current workflow repository mismatch") + # Include failures and cancellations: using only successful runs erases + # overdue observations, whose expected final conclusion is failure. + prior = previous_observation(prefix, current, repository) + if prior is None: + if not bootstrap or current["event"] != "workflow_dispatch": + raise StateError("Initial journal requires an explicit bootstrap dispatch") + if seed is None or not seed.is_file(): + raise StateError("Bootstrap requires the reviewed migrated legacy journal") + validate_journal(seed) + journal = seed.read_bytes() + else: + artifacts = api(f"{prefix}/runs/{prior['id']}/artifacts?per_page=100")[ + "artifacts" + ] + matching = [item for item in artifacts if item["name"] == ARTIFACT] + if len(matching) != 1 or matching[0]["expired"]: + raise StateError("Previous observation has no unique unexpired journal") + item = matching[0] + if item["size_in_bytes"] > MAX_BYTES: + raise StateError("Previous journal exceeds size limit") + archive = api(f"{prefix}/artifacts/{int(item['id'])}/zip", binary=True) + journal = unpack_state(archive, repository=repository, run_id=prior["id"]) + directory.mkdir(parents=True, exist_ok=True) + (directory / JOURNAL).write_bytes(journal) + + +def seal(directory: Path) -> None: + repository, run_id = context() + journal = (directory / JOURNAL).read_bytes() + validate_journal(directory / JOURNAL) + # An equal pair on the first scan legitimately has an empty delta journal. + metadata = { + "schema_version": 1, + "repository": repository, + "run_id": run_id, + "sha256": hashlib.sha256(journal).hexdigest(), + } + (directory / METADATA).write_text(json.dumps(metadata, sort_keys=True) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=["restore", "seal"]) + parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--bootstrap", action="store_true") + parser.add_argument("--seed", type=Path) + args = parser.parse_args() + try: + if args.operation == "restore": + restore(args.directory, bootstrap=args.bootstrap, seed=args.seed) + else: + seal(args.directory) + except (StateError, OSError, KeyError, TypeError, ValueError) as exc: + print(f"Release drift state unavailable: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_release_smoke.py b/scripts/run_release_smoke.py new file mode 100755 index 00000000..46eebc8b --- /dev/null +++ b/scripts/run_release_smoke.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Run the P-18 smoke profile and prove which tests actually completed. + +Collection and execution use separate pytest processes. Their hook receipts, +never terminal counts, are compared against the exact candidate's profile. +This is offline test evidence, not deployment identity or production approval. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import signal +import subprocess +import sys +import time +from collections import Counter +from pathlib import Path +from typing import Any + +TIMEOUT_SECONDS = 120 +REVIEW_AFTER_SECONDS = 60 +PROFILE = "l1-l2" +PROFILE_PATHS = ( + "tests/smoke/", + "tests/test_search_path_independence.py", + "tests/test_db_pool_settings.py", + "tests/test_readiness.py", +) +WORKFLOW_PATH = ".github/workflows/smoke-tests.yml" + + +class SmokeRejected(ValueError): + """Missing or inconsistent smoke evidence must block the release check.""" + + +def _write_json(path: Path, payload: Any) -> None: + path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def _sha256(payload: Any) -> str: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _git(repository: Path, *arguments: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repository), *arguments], + capture_output=True, + text=True, + check=False, + timeout=15, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise SmokeRejected("cannot verify the candidate Git identity") from exc + if result.returncode: + raise SmokeRejected("cannot verify the candidate Git identity") + return result.stdout.strip() + + +def verify_candidate(repository: Path, candidate_commit: str) -> None: + if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", candidate_commit): + raise SmokeRejected("candidate must be a full immutable Git commit") + if _git(repository, "rev-parse", "HEAD") != candidate_commit: + raise SmokeRejected("candidate commit differs from checked-out HEAD") + if _git(repository, "status", "--porcelain", "--untracked-files=normal"): + raise SmokeRejected("candidate worktree is not clean") + + +def _test_environment() -> dict[str, str]: + # No deployment credentials or caller-supplied pytest selection enter tests. + environment = { + name: os.environ[name] + for name in ("PATH", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT") + if name in os.environ + } + environment.update( + { + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTHON_DOTENV_DISABLED": "1", + "TELEGRAM_BOT_TOKEN": "000000000:AAFakeTokenForTests", + "ANTHROPIC_API_KEY": "sk-ant-fake-test-key", + "DATABASE_URL": "postgresql://fake:fake@localhost:5432/fake", + "KNOWLEDGE_MCP_URL": "https://fake-mcp.test/mcp", + "USE_STATE_MACHINE": "false", + } + ) + return environment + + +def _offline_audit(event: str, arguments: tuple[Any, ...]) -> None: + if event in {"socket.connect", "socket.getaddrinfo", "socket.sendto"}: + raise PermissionError("release smoke forbids network access") + if event == "open" and isinstance(arguments[0], (str, bytes, os.PathLike)): + path = Path(os.fsdecode(arguments[0])) + if any(part == ".secrets" or part.startswith(".env") for part in path.parts): + raise PermissionError("release smoke forbids reading environment files") + + +class SmokeObserver: + """Record collection and every test phase, including skips and duplicates.""" + + def __init__(self) -> None: + self.receipt: dict[str, Any] = { + "finished": False, + "exit_code": None, + "collected": [], + "collection_problems": [], + "deselected": [], + "reports": [], + } + + def pytest_collection_finish(self, session: Any) -> None: + self.receipt["collected"] = [item.nodeid for item in session.items] + + def pytest_collectreport(self, report: Any) -> None: + if report.outcome != "passed": + self.receipt["collection_problems"].append( + { + "nodeid": report.nodeid, + "outcome": report.outcome, + } + ) + + def pytest_deselected(self, items: list[Any]) -> None: + self.receipt["deselected"].extend(item.nodeid for item in items) + + def pytest_runtest_logreport(self, report: Any) -> None: + self.receipt["reports"].append( + { + "nodeid": report.nodeid, + "when": report.when, + "outcome": report.outcome, + "wasxfail": hasattr(report, "wasxfail"), + } + ) + + def pytest_sessionfinish(self, session: Any, exitstatus: int) -> None: + self.receipt["finished"] = True + self.receipt["exit_code"] = int(exitstatus) + + +def _worker(request_path: Path, phase: str) -> int: + request = json.loads(request_path.read_text(encoding="utf-8")) + output = request_path.parent + sys.addaudithook(_offline_audit) + import pytest + + # -B blocks writes, but still reads ignored __pycache__ beside candidate code. + sys.pycache_prefix = str(output / f"{phase}-bytecode") + observer = SmokeObserver() + arguments = [ + "-p", + "no:cacheprovider", + "-p", + "pytest_asyncio.plugin", + "--import-mode=importlib", + "-o", + "addopts=", + "-q", + "--tb=short", + *request["paths"], + ] + if phase == "collect": + arguments.append("--collect-only") + else: + arguments.append(f"--junitxml={output / 'smoke-results.xml'}") + try: + return int(pytest.main(arguments, plugins=[observer])) + finally: + _write_json(output / f"{phase}.json", observer.receipt) + + +def _stop_process_group(process: subprocess.Popen[Any]) -> None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + # The child can exit between the timeout and process-group lookup. + print("release smoke process had already stopped", file=sys.stderr) + process.wait() + + +def run_phase( + repository: Path, + request_path: Path, + phase: str, + *, + timeout_seconds: float = TIMEOUT_SECONDS, + review_after_seconds: float = REVIEW_AFTER_SECONDS, +) -> dict[str, Any]: + """Bound the entire child process, including collection and fixture teardown.""" + output = request_path.parent + started = time.monotonic() + review_required = False + with (output / f"{phase}.log").open("wb") as log: + process = subprocess.Popen( + [ + sys.executable, + "-I", + "-B", + str(Path(__file__).resolve()), + "_worker", + str(request_path), + phase, + ], + cwd=repository, + env=_test_environment(), + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + try: + process.wait(timeout=min(review_after_seconds, timeout_seconds)) + except subprocess.TimeoutExpired: + review_required = True + print( + f"::warning::P-18 {phase} reached 50% of timeout_policy; " + "pilot review required, timeout remains unchanged.", + flush=True, + ) + remaining = timeout_seconds - (time.monotonic() - started) + process.wait(timeout=max(remaining, 0)) + except subprocess.TimeoutExpired as exc: + _stop_process_group(process) + raise SmokeRejected(f"{phase} exceeded timeout_policy") from exc + except BaseException: + _stop_process_group(process) + raise + duration = time.monotonic() - started + if duration >= timeout_seconds: + raise SmokeRejected(f"{phase} exceeded timeout_policy") + if duration >= review_after_seconds and not review_required: + review_required = True + print( + f"::warning::P-18 {phase} reached 50% of timeout_policy; " + "pilot review required, timeout remains unchanged.", + flush=True, + ) + try: + receipt = json.loads((output / f"{phase}.json").read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SmokeRejected(f"{phase} did not produce a readable hook receipt") from exc + if not isinstance(receipt, dict): + raise SmokeRejected(f"{phase} produced a malformed hook receipt") + receipt.update( + { + "process_exit_code": process.returncode, + "duration_seconds": duration, + "timeout_review_required": review_required, + } + ) + _write_json(output / f"{phase}.json", receipt) + return receipt + + +def _collected_ids(receipt: dict[str, Any], phase: str) -> list[str]: + if receipt.get("finished") is not True or receipt.get("exit_code") != 0: + raise SmokeRejected(f"{phase} did not finish successfully") + if receipt.get("process_exit_code") != 0: + raise SmokeRejected(f"{phase} process failed") + if receipt.get("collection_problems") != [] or receipt.get("deselected") != []: + raise SmokeRejected(f"{phase} contains collection failures or filtered tests") + ids = receipt.get("collected") + if ( + not isinstance(ids, list) + or not ids + or not all(isinstance(x, str) and x for x in ids) + ): + raise SmokeRejected(f"{phase} has no valid collected test IDs") + if len(ids) != len(set(ids)): + raise SmokeRejected(f"{phase} contains duplicate test IDs") + return ids + + +def verify_execution(expected: list[str], receipt: dict[str, Any]) -> list[str]: + actual_collection = _collected_ids(receipt, "run") + if set(expected) != set(actual_collection): + raise SmokeRejected("run collection differs from the allowed test-ID set") + reports = receipt.get("reports") + if not isinstance(reports, list): + raise SmokeRejected("run has no test-phase reports") + observed: Counter[tuple[str, str]] = Counter() + for report in reports: + if not isinstance(report, dict) or set(report) != { + "nodeid", + "when", + "outcome", + "wasxfail", + }: + raise SmokeRejected("run contains a malformed test-phase report") + nodeid, phase = report["nodeid"], report["when"] + if nodeid not in expected or phase not in {"setup", "call", "teardown"}: + raise SmokeRejected("run contains an unknown test ID or phase") + if report["outcome"] != "passed" or report["wasxfail"] is not False: + raise SmokeRejected("run contains a skipped, failed or xfailed test phase") + observed[nodeid, phase] += 1 + required = Counter( + (nodeid, phase) + for nodeid in expected + for phase in ("setup", "call", "teardown") + ) + if observed != required: + raise SmokeRejected("run contains missing or duplicate test-phase reports") + return sorted(expected) + + +def run_smoke( + repository: Path, output: Path, identity: dict[str, Any] +) -> dict[str, Any]: + """Orchestrate offline evidence; the CLI verifies Git identity before/after.""" + output.mkdir(parents=True, exist_ok=False) + request_path = output / "request.json" + _write_json(request_path, identity) + verdict: dict[str, Any] = { + "schema_version": 1, + **identity, + "status": "rejected", + "timeout_policy_seconds": TIMEOUT_SECONDS, + "review_after_seconds": REVIEW_AFTER_SECONDS, + } + try: + collection = run_phase(repository, request_path, "collect") + allowed = sorted(_collected_ids(collection, "collect")) + baseline = {**identity, "test_ids": allowed} + _write_json(output / "allowed-test-ids.json", baseline) + execution = run_phase(repository, request_path, "run") + executed = verify_execution(allowed, execution) + verdict.update( + { + "status": "passed", + "executed_test_ids": executed, + "allowed_test_ids_digest": _sha256(baseline), + "duration_seconds": execution["duration_seconds"], + "timeout_review_required": execution["timeout_review_required"], + } + ) + except SmokeRejected as exc: + verdict["reason"] = str(exc) + raise + finally: + _write_json(output / "result.json", verdict) + return verdict + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--candidate-commit", required=True) + parser.add_argument("--profile", choices=[PROFILE], required=True) + parser.add_argument("--output-dir", type=Path, required=True) + arguments = parser.parse_args(argv) + repository = Path.cwd().resolve() + output = arguments.output_dir.resolve() + try: + if output == repository or repository in output.parents: + raise SmokeRejected( + "evidence directory must be outside the candidate worktree" + ) + verify_candidate(repository, arguments.candidate_commit) + runner_path = Path(__file__).resolve().relative_to(repository).as_posix() + for required_path in (runner_path, WORKFLOW_PATH, *PROFILE_PATHS): + if not _git(repository, "ls-files", "--", required_path): + raise SmokeRejected( + "smoke runner and profile must belong to the candidate commit" + ) + identity = { + "candidate_commit": arguments.candidate_commit, + "profile": arguments.profile, + "paths": list(PROFILE_PATHS), + "ci_config_commit": arguments.candidate_commit, + "workflow": WORKFLOW_PATH, + "runner_digest": "sha256:" + + hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + } + verdict = run_smoke(repository, output, identity) + try: + verify_candidate(repository, arguments.candidate_commit) + except SmokeRejected as exc: + verdict.update({"status": "rejected", "reason": str(exc)}) + _write_json(output / "result.json", verdict) + raise + except (SmokeRejected, OSError, subprocess.SubprocessError, ValueError) as exc: + print(f"::error::Release smoke rejected: {exc}", file=sys.stderr) + return 1 + print(f"Release smoke passed: {len(verdict['executed_test_ids'])} exact test IDs") + return 0 + + +if __name__ == "__main__": + if ( + len(sys.argv) == 4 + and sys.argv[1] == "_worker" + and sys.argv[3] in {"collect", "run"} + ): + raise SystemExit(_worker(Path(sys.argv[2]), sys.argv[3])) + raise SystemExit(main()) diff --git a/tests/smoke/test_delivery_no_new_direct_senders.py b/tests/smoke/test_delivery_no_new_direct_senders.py index ed34157e..954a8d3e 100644 --- a/tests/smoke/test_delivery_no_new_direct_senders.py +++ b/tests/smoke/test_delivery_no_new_direct_senders.py @@ -130,6 +130,10 @@ def _qualified_owner(tree: ast.AST, call: ast.Call) -> str: ("core/onboarder/x2.py", "_show_topic"), ("core/onboarder/x2.py", "run_step"), ("core/onboarder/x3.py", "_show_x3_offer"), + # WP-562/WP-567: a fixed-recipient operator transport for database/outbox + # failures cannot depend on that same database-backed delivery queue. + # Only this function is exempt; payment handlers remain under the guard. +("core/operator_alerts.py", "_send_operator_alert"), ("core/scheduler.py", "_check_marathon_missed_checkins"), ("core/scheduler.py", "_check_marathon_split_delivery"), ("core/scheduler.py", "_check_retry_storm"), diff --git a/tests/smoke/test_event_outbox_strict_delivery.py b/tests/smoke/test_event_outbox_strict_delivery.py new file mode 100644 index 00000000..59e18e11 --- /dev/null +++ b/tests/smoke/test_event_outbox_strict_delivery.py @@ -0,0 +1,304 @@ +"""The outbox acknowledges delivery only after a successful strict HTTP send.""" + +import asyncio +import hashlib +import hmac +import json +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture +def gateway(monkeypatch): + from helpers import dual_write + + monkeypatch.setattr(dual_write, "EVENT_GATEWAY_ENABLED", True) + monkeypatch.setattr( + dual_write, "EVENT_GATEWAY_URL", "https://gateway.example.invalid" + ) + monkeypatch.setattr(dual_write, "EVENT_GATEWAY_HMAC_KEY", "fixture-signing-key") + monkeypatch.setattr(dual_write, "EVENT_GATEWAY_HMAC_KEY_ID", "fixture-key") + return dual_write + + +@pytest.fixture +def event(): + return { + "source": "aist-bot", + "external_id": "payment-fixture-1", + "event_type": "payment_received", + "schema_version": "v1", + "occurred_at": datetime(2026, 9, 12, tzinfo=timezone.utc), + "account_id": None, + "payload": {"amount": 100, "currency": "RUB"}, + } + + +def fake_session(gateway, monkeypatch, *, status=200, failure=None): + response = MagicMock() + response.status = status + response.text = AsyncMock(return_value="private-response") + response.json = AsyncMock( + return_value=( + {"inserted": True, "id": "fixture-event-42"} + if status == 201 + else {"inserted": False, "idempotent": True} + ) + ) + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=response) + session = MagicMock() + session.response = response + session.post.return_value = context + if failure is not None: + session.post.side_effect = failure + getter = MagicMock(return_value=session) + monkeypatch.setattr(gateway, "_get_session", getter) + return getter, session + + +def test_disabled_strict_writer_refuses_without_http(gateway, event, monkeypatch): + getter, session = fake_session(gateway, monkeypatch) + monkeypatch.setattr(gateway, "EVENT_GATEWAY_ENABLED", False) + + with pytest.raises(RuntimeError, match="gateway is disabled"): + asyncio.run(gateway.post_event_or_raise(**event)) + + getter.assert_not_called() + session.post.assert_not_called() + + +def test_disabled_best_effort_writer_remains_noop(gateway, event, monkeypatch): + getter, session = fake_session(gateway, monkeypatch) + monkeypatch.setattr(gateway, "EVENT_GATEWAY_ENABLED", False) + + asyncio.run(gateway.post_event(**event)) + + getter.assert_not_called() + session.post.assert_not_called() + + +@pytest.mark.parametrize( + "status", [101, 199, 202, 204, 300, 302, 304, 307, 308, 400, 429, 500] +) +def test_strict_writer_refuses_undocumented_status_without_following_redirects( + gateway, + event, + monkeypatch, + status, +): + _, session = fake_session(gateway, monkeypatch, status=status) + + with pytest.raises(RuntimeError, match=f"POST failed: HTTP {status}") as rejected: + asyncio.run(gateway.post_event_or_raise(**event)) + + assert "private-response" not in str(rejected.value) + session.response.text.assert_not_awaited() + assert session.post.call_args.kwargs["allow_redirects"] is False + assert session.post.call_count == 1 + + +@pytest.mark.parametrize("status", [200, 201]) +def test_strict_writer_acknowledges_direct_success(gateway, event, monkeypatch, status): + _, session = fake_session(gateway, monkeypatch, status=status) + + asyncio.run(gateway.post_event_or_raise(**event)) + + assert session.post.call_count == 1 + assert session.post.call_args.args == ("https://gateway.example.invalid/events",) + sent = session.post.call_args.kwargs + assert sent["allow_redirects"] is False + assert json.loads(sent["data"])["external_id"] == event["external_id"] + headers = sent["headers"] + assert headers["X-IWE-Signature-Version"] == "v1" + assert headers["X-IWE-Key-Id"] == "fixture-key" + canonical = ( + f"v1\naist-bot\nfixture-key\n{headers['X-IWE-Timestamp']}\n".encode() + + sent["data"] + ) + expected = hmac.new(b"fixture-signing-key", canonical, hashlib.sha256).hexdigest() + assert headers["X-IWE-Signature"] == f"sha256={expected}" + + +@pytest.mark.parametrize( + "status,acknowledgement", + [ + (200, None), + (200, []), + (200, {}), + (200, {"inserted": True, "id": "fixture-id"}), + (200, {"inserted": 0, "idempotent": True}), + (200, {"inserted": False, "idempotent": False}), + (200, {"inserted": False, "idempotent": True, "error": "private-response"}), + (201, {"inserted": False, "id": "fixture-id"}), + (201, {"inserted": True}), + (201, {"inserted": True, "id": None}), + (201, {"inserted": True, "id": " "}), + ], +) +def test_strict_writer_rejects_false_success_acknowledgement( + gateway, + event, + monkeypatch, + status, + acknowledgement, +): + _, session = fake_session(gateway, monkeypatch, status=status) + session.response.json.return_value = acknowledgement + + with pytest.raises(RuntimeError, match="invalid acknowledgement") as rejected: + asyncio.run(gateway.post_event_or_raise(**event)) + + assert "private-response" not in str(rejected.value) + session.response.json.assert_awaited_once() + + +def test_malformed_json_acknowledgement_does_not_expose_response( + gateway, + event, + monkeypatch, +): + _, session = fake_session(gateway, monkeypatch) + session.response.json.side_effect = ValueError("private-response") + + with pytest.raises(RuntimeError, match="invalid acknowledgement") as rejected: + asyncio.run(gateway.post_event_or_raise(**event)) + + assert "private-response" not in str(rejected.value) + assert rejected.value.__suppress_context__ is True + + +@pytest.mark.parametrize( + "failure", [ConnectionError("transport"), TimeoutError("timeout")] +) +def test_strict_writer_propagates_transport_failure( + gateway, event, monkeypatch, failure +): + _, session = fake_session(gateway, monkeypatch, failure=failure) + + with pytest.raises(type(failure), match=str(failure)): + asyncio.run(gateway.post_event_or_raise(**event)) + + assert session.post.call_count == 1 + + +def install_outbox(event, monkeypatch): + from core import scheduler + from db.queries import event_outbox + + row = { + "id": 17, + "attempts": 0, + "delivered_at": None, + **{ + key: event[key] + for key in ( + "external_id", + "event_type", + "occurred_at", + "account_id", + "payload", + ) + }, + } + pool = MagicMock() + connection = pool.acquire.return_value.__aenter__.return_value + connection.transaction = MagicMock() + + async def pending(_connection, batch): + assert batch == 20 + return [row] if row["delivered_at"] is None else [] + + async def delivered(_connection, row_id): + assert row_id == row["id"] + row["delivered_at"] = "fixture-delivered" + + async def failed(_connection, row_id, reason): + assert row_id == row["id"] + row["attempts"] += 1 + row["last_error"] = reason + + monkeypatch.setattr(scheduler, "get_pool", AsyncMock(return_value=pool)) + monkeypatch.setattr( + event_outbox, "fetch_pending_outbox", AsyncMock(side_effect=pending) + ) + mark_delivered = AsyncMock(side_effect=delivered) + mark_failed = AsyncMock(side_effect=failed) + monkeypatch.setattr(event_outbox, "mark_delivered", mark_delivered) + monkeypatch.setattr(event_outbox, "mark_failed", mark_failed) + return scheduler, row, mark_delivered, mark_failed + + +def test_disabled_drain_retains_row_until_enabled_success(gateway, event, monkeypatch): + _, session = fake_session(gateway, monkeypatch) + scheduler, row, mark_delivered, mark_failed = install_outbox(event, monkeypatch) + monkeypatch.setattr(gateway, "EVENT_GATEWAY_ENABLED", False) + + asyncio.run(scheduler._drain_event_outbox()) + + session.post.assert_not_called() + mark_delivered.assert_not_awaited() + assert mark_failed.await_count == 1 + assert row["delivered_at"] is None + assert row["attempts"] == 1 + assert "gateway is disabled" in row["last_error"] + + monkeypatch.setattr(gateway, "EVENT_GATEWAY_ENABLED", True) + asyncio.run(scheduler._drain_event_outbox()) + + assert session.post.call_count == 1 + assert mark_delivered.await_count == 1 + assert mark_failed.await_count == 1 + assert row["delivered_at"] == "fixture-delivered" + assert ( + json.loads(session.post.call_args.kwargs["data"])["external_id"] + == row["external_id"] + ) + + +@pytest.mark.parametrize("status", [202, 204, 302, 304, 429, 500]) +def test_failed_http_drain_leaves_delivery_pending(gateway, event, monkeypatch, status): + _, session = fake_session(gateway, monkeypatch, status=status) + scheduler, row, mark_delivered, mark_failed = install_outbox(event, monkeypatch) + + asyncio.run(scheduler._drain_event_outbox()) + + assert session.post.call_count == 1 + mark_delivered.assert_not_awaited() + assert mark_failed.await_count == 1 + assert row["delivered_at"] is None + assert row["attempts"] == 1 + assert "private-response" not in row["last_error"] + session.response.text.assert_not_awaited() + + +def test_false_200_acknowledgement_keeps_outbox_pending(gateway, event, monkeypatch): + _, session = fake_session(gateway, monkeypatch) + session.response.json.return_value = {"error": "private-response"} + scheduler, row, mark_delivered, mark_failed = install_outbox(event, monkeypatch) + + asyncio.run(scheduler._drain_event_outbox()) + + mark_delivered.assert_not_awaited() + assert mark_failed.await_count == 1 + assert row["delivered_at"] is None + assert row["attempts"] == 1 + assert "private-response" not in row["last_error"] + + +def test_cancelled_drain_does_not_record_delivery_or_failure( + gateway, event, monkeypatch +): + _, session = fake_session(gateway, monkeypatch, failure=asyncio.CancelledError()) + scheduler, row, mark_delivered, mark_failed = install_outbox(event, monkeypatch) + + with pytest.raises(asyncio.CancelledError): + asyncio.run(scheduler._drain_event_outbox()) + + assert session.post.call_count == 1 + mark_delivered.assert_not_awaited() + mark_failed.assert_not_awaited() + assert row["delivered_at"] is None + assert row["attempts"] == 0 diff --git a/tests/smoke/test_operator_alerts.py b/tests/smoke/test_operator_alerts.py new file mode 100644 index 00000000..e73f7e6b --- /dev/null +++ b/tests/smoke/test_operator_alerts.py @@ -0,0 +1,299 @@ +"""Operator recovery remains available when normal delivery/storage is broken.""" + +import asyncio +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +import core.notification_service as delivery +import core.operator_alerts as alerts +import db.queries.event_outbox as outbox +import handlers.subscription_stars as stars +from core import scheduler +from db import connection +from handlers import payments + +pytestmark = pytest.mark.asyncio +DEVELOPER_ID = 90001 +USER_ID = 12345 +CHARGE_ID = "test-charge-567" + + +@pytest.fixture +def bot(monkeypatch): + monkeypatch.setattr(alerts, "DEVELOPER_CHAT_ID", DEVELOPER_ID) + # No database or regular delivery is available for this entire test module. + monkeypatch.setattr( + connection, "get_pool", AsyncMock(side_effect=ConnectionError("db unavailable")) + ) + monkeypatch.setattr( + delivery, + "enqueue", + AsyncMock(side_effect=AssertionError("emergency used queue")), + ) + return SimpleNamespace( + send_message=AsyncMock(), session=SimpleNamespace(close=AsyncMock()) + ) + + +def payment_message(bot, payload): + return SimpleNamespace( + bot=bot, + chat=SimpleNamespace(id=USER_ID), + successful_payment=SimpleNamespace( + invoice_payload=payload, + telegram_payment_charge_id=CHARGE_ID, + total_amount=100, + is_first_recurring=True, + is_recurring=True, + ), + answer=AsyncMock(), + ) + + +@pytest.mark.parametrize( + ("sender", "fields", "fragment"), + [ + ( + alerts.alert_recurring_donation_failure, + {"chat_id": USER_ID, "charge_id": CHARGE_ID}, + "Recurring donation НЕ сохранён", + ), + ( + alerts.alert_stars_subscription_failure, + { + "chat_id": USER_ID, + "charge_id": CHARGE_ID, + "stars_amount": 100, + "tariff_key": "1m", + }, + "Stars-подписка НЕ сохранена", + ), + (alerts.alert_event_outbox_stuck, {"stuck_count": 7}, "7 событий"), + ], +) +async def test_operator_delivery_is_fixed_plain_bounded_and_database_independent( + bot, sender, fields, fragment +): + assert await sender(bot, **fields) is True + + sent = bot.send_message.await_args + assert sent.args[0] == DEVELOPER_ID + assert fragment in sent.args[1] + assert sent.kwargs == { + "parse_mode": None, + "protect_content": True, + "request_timeout": 5, + } + if "charge_id" in fields: + assert CHARGE_ID in sent.args[1] + assert str(USER_ID) in sent.args[1] + connection.get_pool.assert_not_awaited() + delivery.enqueue.assert_not_awaited() + bot.session.close.assert_not_awaited() + + +async def test_missing_operator_recipient_never_sends_to_payment_user(bot, monkeypatch): + monkeypatch.setattr(alerts, "DEVELOPER_CHAT_ID", 0) + assert ( + await alerts.alert_recurring_donation_failure( + bot, chat_id=USER_ID, charge_id=CHARGE_ID + ) + is False + ) + bot.send_message.assert_not_awaited() + + +async def test_transport_failure_does_not_log_exception_payload(bot, caplog): + bot.send_message.side_effect = RuntimeError("private-connection-details") + with caplog.at_level(logging.ERROR): + assert await alerts.alert_event_outbox_stuck(bot, stuck_count=2) is False + assert "RuntimeError" in caplog.text + assert "private-connection-details" not in caplog.text + bot.session.close.assert_not_awaited() + + +async def test_transport_timeout_is_bounded_even_if_client_ignores_timeout( + bot, monkeypatch +): + cancelled = asyncio.Event() + + async def stalled_send(*args, **kwargs): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + monkeypatch.setattr(alerts, "SEND_TIMEOUT_SECONDS", 0.01) + bot.send_message.side_effect = stalled_send + result = await asyncio.wait_for( + alerts.alert_event_outbox_stuck(bot, stuck_count=2), timeout=1 + ) + assert result is False + assert cancelled.is_set() + + +async def test_caller_cancellation_is_not_swallowed(bot): + bot.send_message.side_effect = asyncio.CancelledError + with pytest.raises(asyncio.CancelledError): + await alerts.alert_event_outbox_stuck(bot, stuck_count=2) + + +async def test_total_database_failure_still_alerts_and_answers_donation_user( + bot, monkeypatch, caplog +): + message = payment_message(bot, "sub_12345_100") + monkeypatch.setattr( + payments, + "get_intern", + AsyncMock(side_effect=ConnectionError("private-language")), + ) + save = AsyncMock(side_effect=ConnectionError("private-storage")) + monkeypatch.setattr(payments, "save_subscription", save) + grant = AsyncMock() + monkeypatch.setattr(payments, "upsert_subscription_grant", grant) + + with caplog.at_level(logging.WARNING): + await payments.on_successful_payment(message) + + save.assert_awaited_once() + bot.send_message.assert_awaited_once() + assert CHARGE_ID in bot.send_message.await_args.args[1] + assert "private-" not in bot.send_message.await_args.args[1] + assert "private-" not in caplog.text + grant.assert_not_awaited() + message.answer.assert_awaited_once() + assert "обработка занимает больше времени" in message.answer.await_args.args[0] + + +async def test_failed_operator_send_does_not_hide_donation_failure_from_user( + bot, monkeypatch +): + message = payment_message(bot, "sub_12345_100") + monkeypatch.setattr(payments, "get_intern", AsyncMock(return_value=None)) + monkeypatch.setattr( + payments, "save_subscription", AsyncMock(side_effect=ConnectionError("db down")) + ) + bot.send_message.side_effect = RuntimeError("operator unavailable") + + await payments.on_successful_payment(message) + + bot.send_message.assert_awaited_once() + message.answer.assert_awaited_once() + assert "обработка занимает больше времени" in message.answer.await_args.args[0] + + +async def test_saved_donation_with_failed_confirmation_does_not_raise_storage_alert( + bot, monkeypatch +): + message = payment_message(bot, "sub_12345_100") + message.answer.side_effect = RuntimeError("user unavailable") + monkeypatch.setattr( + payments, "get_intern", AsyncMock(return_value={"language": "ru"}) + ) + save = AsyncMock() + monkeypatch.setattr(payments, "save_subscription", save) + grant = AsyncMock() + monkeypatch.setattr(payments, "upsert_subscription_grant", grant) + + await payments.on_successful_payment(message) + + save.assert_awaited_once() + grant.assert_awaited_once() + message.answer.assert_awaited_once() + bot.send_message.assert_not_awaited() + + +async def test_stars_exhausted_retries_send_one_alert_with_recovery_details( + bot, monkeypatch, caplog +): + message = payment_message(bot, "stars_sub_12345_1m") + monkeypatch.setattr(stars, "resolve_ory_id_from_chat", AsyncMock(return_value=None)) + save = AsyncMock(side_effect=ConnectionError("private-stars-storage")) + monkeypatch.setattr(stars, "save_subscription_with_outbox", save) + sleep = AsyncMock() + monkeypatch.setattr(stars.asyncio, "sleep", sleep) + + with caplog.at_level(logging.WARNING): + await stars.on_successful_stars_sub(message) + + assert save.await_count == 3 + assert [call.args[0] for call in sleep.await_args_list] == [2, 4] + bot.send_message.assert_awaited_once() + text = bot.send_message.await_args.args[1] + assert CHARGE_ID in text + assert "100 XTR" in text + assert "tariff=1m" in text + assert "private-stars-storage" not in text + assert "private-stars-storage" not in caplog.text + message.answer.assert_awaited_once() + assert "обрабатывается дольше обычного" in message.answer.await_args.args[0] + + +async def test_stars_retry_success_does_not_send_false_storage_alert(bot, monkeypatch): + message = payment_message(bot, "stars_sub_12345_1m") + monkeypatch.setattr(stars, "resolve_ory_id_from_chat", AsyncMock(return_value=None)) + save = AsyncMock(side_effect=[ConnectionError("temporary"), None]) + monkeypatch.setattr(stars, "save_subscription_with_outbox", save) + monkeypatch.setattr(stars.asyncio, "sleep", AsyncMock()) + + await stars.on_successful_stars_sub(message) + + assert save.await_count == 2 + bot.send_message.assert_not_awaited() + assert "Подписка активирована" in message.answer.await_args.args[0] + assert save.await_args_list[0].kwargs == save.await_args_list[1].kwargs + + +async def test_stars_saved_before_failed_confirmation_does_not_raise_storage_alert( + bot, monkeypatch +): + message = payment_message(bot, "stars_sub_12345_1m") + message.answer.side_effect = RuntimeError("user unavailable") + monkeypatch.setattr(stars, "resolve_ory_id_from_chat", AsyncMock(return_value=None)) + save = AsyncMock() + monkeypatch.setattr(stars, "save_subscription_with_outbox", save) + + with pytest.raises(RuntimeError, match="user unavailable"): + await stars.on_successful_stars_sub(message) + + save.assert_awaited_once() + bot.send_message.assert_not_awaited() + + +async def test_outbox_watch_retries_failed_alert_and_cools_down_after_ack( + bot, monkeypatch +): + monkeypatch.setattr(scheduler, "_bot_token", "000000000:AAFakeTokenForTests") + monkeypatch.setattr(scheduler, "DEVELOPER_CHAT_ID", DEVELOPER_ID) + monkeypatch.setattr(scheduler, "_last_outbox_watch_alert_ts", 0) + monkeypatch.setattr(outbox, "count_stuck_outbox", AsyncMock(return_value=7)) + create_bot = Mock(return_value=bot) + monkeypatch.setattr(scheduler, "Bot", create_bot) + bot.send_message.side_effect = [RuntimeError("down"), None] + + await scheduler._watch_event_outbox() + assert scheduler._last_outbox_watch_alert_ts == 0 + bot.session.close.assert_awaited_once() + + await scheduler._watch_event_outbox() + assert scheduler._last_outbox_watch_alert_ts > 0 + assert bot.send_message.await_count == 2 + assert bot.session.close.await_count == 2 + + await scheduler._watch_event_outbox() + assert create_bot.call_count == 2 + assert bot.send_message.await_count == 2 + + +async def test_empty_outbox_never_constructs_alert_bot(bot, monkeypatch): + monkeypatch.setattr(outbox, "count_stuck_outbox", AsyncMock(return_value=0)) + create_bot = Mock(side_effect=AssertionError("unexpected alert")) + monkeypatch.setattr(scheduler, "Bot", create_bot) + + await scheduler._watch_event_outbox() + + create_bot.assert_not_called() + bot.send_message.assert_not_awaited() diff --git a/tests/smoke/test_wp567_grant_cutover.py b/tests/smoke/test_wp567_grant_cutover.py new file mode 100644 index 00000000..2b09bc46 --- /dev/null +++ b/tests/smoke/test_wp567_grant_cutover.py @@ -0,0 +1,39 @@ +"""Offline checks for migration 048's exact-signature ownership guard.""" + +import asyncio +import importlib.util +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +MIGRATION_PATH = ( + Path(__file__).resolve().parents[2] / "db/migrations/048_wp567_grant_cutover.py" +) +SPEC = importlib.util.spec_from_file_location("wp567_grant_cutover", MIGRATION_PATH) +migration = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(migration) + + +@pytest.mark.parametrize("owner_already_moved", [True, False]) +def test_exact_signature_parameter_controls_ownership_cutover(owner_already_moved): + pool = MagicMock() + connection = pool.acquire.return_value.__aenter__.return_value + connection.fetchval = AsyncMock(return_value=owner_already_moved) + connection.execute = AsyncMock() + + applied = asyncio.run(migration.migrate_if_needed(pool)) + + connection.fetchval.assert_awaited_once_with( + "SELECT proowner::regrole::text = 'rewards_points_engine_owner' " + "FROM pg_proc WHERE oid = $1::text::regprocedure", + "public.compute_effective_amount_v4(uuid, bigint, text, jsonb, timestamp with time zone)", + ) + if owner_already_moved: + assert applied is False + assert pool.acquire.call_count == 1 + connection.execute.assert_not_awaited() + else: + assert applied is True + assert pool.acquire.call_count == 2 + connection.execute.assert_awaited_once_with(migration.MIGRATION_SQL) diff --git a/tests/test_branch_auto_deploy.py b/tests/test_branch_auto_deploy.py new file mode 100644 index 00000000..41e897b1 --- /dev/null +++ b/tests/test_branch_auto_deploy.py @@ -0,0 +1,325 @@ +"""Source observations must never become unsupported release authorization.""" + +import json +import subprocess +from datetime import datetime + +import pytest + +from scripts import check_branch_auto_deploy as observer + +TARGET = observer.Target( + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", +) +DEPLOYMENT_ID = "44444444-4444-4444-8444-444444444444" +DIGEST = "sha256:" + "a" * 64 +IMAGE = "ghcr.io/example/bot@" + DIGEST + + +def response(): + return { + "data": { + "environment": { + "id": TARGET.environment_id, + "projectId": TARGET.project_id, + "configEtag": "opaque-config-revision-1", + }, + "serviceInstance": { + "serviceId": TARGET.service_id, + "environmentId": TARGET.environment_id, + "source": {"image": IMAGE, "repo": None}, + "activeDeployments": [ + { + **TARGET.variables(), + "id": DEPLOYMENT_ID, + "status": "SUCCESS", + } + ], + }, + "serviceInstanceAutoDeployStatus": {"enabled": False}, + "deploymentTriggers": { + "pageInfo": {"hasNextPage": False}, + "edges": [], + }, + } + } + + +def arguments(): + return [ + "--project", + TARGET.project_id, + "--environment", + TARGET.environment_id, + "--service", + TARGET.service_id, + ] + + +def invoke_fixture(tmp_path, capsys, payload): + fixture = tmp_path / "response.json" + fixture.write_text(json.dumps(payload)) + code = observer.main([*arguments(), "--input", str(fixture)]) + captured = capsys.readouterr() + assert captured.err == "" + return code, json.loads(captured.out) + + +def no_subprocess(*args, **kwargs): + pytest.fail("offline observation must not start a subprocess") + + +def test_offline_pin_is_configuration_evidence_only(tmp_path, capsys, monkeypatch): + monkeypatch.setattr(observer.subprocess, "run", no_subprocess) + code, report = invoke_fixture(tmp_path, capsys, response()) + assert code == 1 + assert report["evidence_status"] == "available" + assert report["configured_source_ready"] is True + assert report["configured_source"]["configured_registry_digest"] == DIGEST + assert report["active_deployments"] == [{"id": DEPLOYMENT_ID, "status": "SUCCESS"}] + assert report["observation_mode"] == "offline_fixture" + assert report["observed_at"] is None + assert report["readiness"] == "not_ready" + assert report["image_auto_updates"] == "unknown" + assert report["runtime_artifact_verified"] is False + assert report["single_writer_ready"] is False + assert "image_auto_updates_unverified" in report["blockers"] + + +def test_live_uses_only_the_selected_read_only_query(capsys, monkeypatch): + calls = [] + + def fake_run(command, **options): + calls.append(command) + assert command[:3] == ["railway", "api", observer.QUERY] + assert json.loads(command[4]) == TARGET.variables() + assert command[3] == "--variables" + assert options == {"capture_output": True, "check": True, "timeout": 30} + assert "mutation" not in command[2] + assert not any( + name in command[2].split() + for name in ("meta", "config", "variables", "logs", "registryCredentials") + ) + return subprocess.CompletedProcess(command, 0, json.dumps(response()).encode()) + + monkeypatch.setattr(observer.subprocess, "run", fake_run) + assert observer.main([*arguments(), "--live"]) == 1 + report = json.loads(capsys.readouterr().out) + assert len(calls) == 1 + assert report["observation_mode"] == "live_read_only" + assert ( + datetime.fromisoformat(report["observed_at"]).utcoffset().total_seconds() == 0 + ) + assert report["runtime_artifact_verified"] is False + + +@pytest.mark.parametrize( + "image", + [ + "ghcr.io/example/bot:latest", + "ghcr.io/example/bot:" + "a" * 40, + "ghcr.io/example/bot@sha256:abc123", + "example/bot@" + DIGEST, + "ghcr.io/example/bot@sha256:" + "A" * 64, + ], +) +def test_tags_and_incomplete_pins_never_supply_digest(image): + payload = response() + payload["data"]["serviceInstance"]["source"]["image"] = image + report = observer.observe(payload, TARGET) + assert report["configured_source_ready"] is False + assert report["configured_source"]["configured_registry_digest"] is None + assert "registry_digest_not_pinned" in report["blockers"] + + +def test_tag_plus_digest_still_pins_the_registry_artifact(): + assert ( + observer.configured_registry_digest("ghcr.io/example/bot:release-1@" + DIGEST) + == DIGEST + ) + + +def test_repository_connection_without_trigger_is_not_image_promotion(): + payload = response() + payload["data"]["serviceInstance"]["source"] = { + "image": None, + "repo": "example/bot", + } + report = observer.observe(payload, TARGET) + assert report["configured_source_ready"] is False + assert "repository_source_configured" in report["blockers"] + assert report["branch_auto_deploy"]["enabled"] is False + + +@pytest.mark.parametrize("enabled, add_trigger", [(True, False), (False, True)]) +def test_either_old_deploy_channel_blocks_source_readiness(enabled, add_trigger): + payload = response() + payload["data"]["serviceInstanceAutoDeployStatus"]["enabled"] = enabled + if add_trigger: + payload["data"]["deploymentTriggers"]["edges"] = [ + { + "node": { + **TARGET.variables(), + "id": "trigger-1", + "branch": "pilot", + "repository": "example/bot", + "provider": "github", + } + } + ] + report = observer.observe(payload, TARGET) + assert report["configured_source_ready"] is False + reason = ( + "branch_auto_deploy_enabled" + if enabled + else "branch_deployment_triggers_present" + ) + assert reason in report["blockers"] + + +@pytest.mark.parametrize("status", ["SKIPPED", "FAILED", "CRASHED", "DEPLOYING"]) +def test_nonrunning_deployment_is_gated_even_with_configured_digest(status): + payload = response() + payload["data"]["serviceInstance"]["activeDeployments"][0]["status"] = status + report = observer.observe(payload, TARGET) + assert "active_deployment_not_successful" in report["blockers"] + assert report["runtime_artifact_verified"] is False + + +def test_skipped_build_and_unrequested_metadata_cannot_be_runtime_proof( + tmp_path, capsys +): + payload = response() + payload["data"]["serviceInstance"]["activeDeployments"][0]["meta"] = { + "imageDigest": DIGEST, + "skippedBuild": True, + "variables": "private-sentinel", + } + code, report = invoke_fixture(tmp_path, capsys, payload) + assert code == 1 + assert "private-sentinel" not in json.dumps(report) + assert "imageDigest" not in json.dumps(report) + assert report["runtime_artifact_verified"] is False + assert "runtime_artifact_unverified" in report["blockers"] + + +@pytest.mark.parametrize("count", [0, 2]) +def test_empty_or_overlapping_active_deployments_do_not_pass(count): + payload = response() + active = payload["data"]["serviceInstance"]["activeDeployments"] + active[:] = [{**active[0], "id": f"deployment-{n}"} for n in range(count)] + report = observer.observe(payload, TARGET) + assert "active_deployment_not_unique" in report["blockers"] + + +@pytest.mark.parametrize("field", ["projectId", "environmentId", "serviceId"]) +def test_deployment_from_another_target_is_unavailable(field): + payload = response() + payload["data"]["serviceInstance"]["activeDeployments"][0][field] = "wrong-target" + with pytest.raises(observer.EvidenceUnavailable, match="target_identity_mismatch"): + observer.observe(payload, TARGET) + + +@pytest.mark.parametrize( + "path", + [ + ("environment",), + ("environment", "configEtag"), + ("environment", "projectId"), + ("serviceInstance",), + ("serviceInstance", "environmentId"), + ("serviceInstance", "source"), + ("serviceInstance", "activeDeployments"), + ("serviceInstanceAutoDeployStatus", "enabled"), + ("deploymentTriggers", "edges"), + ("deploymentTriggers", "pageInfo"), + ], +) +@pytest.mark.parametrize("remove", [False, True]) +def test_missing_or_null_required_evidence_fails_closed(tmp_path, capsys, path, remove): + payload = response() + node = payload["data"] + for key in path[:-1]: + node = node[key] + if remove: + del node[path[-1]] + else: + node[path[-1]] = None + code, report = invoke_fixture(tmp_path, capsys, payload) + assert code == 2 + assert report["readiness"] == "evidence_unavailable" + assert report["configured_source_ready"] is False + + +@pytest.mark.parametrize("value", ["false", 0, [], {}]) +def test_boolean_coercion_cannot_disable_auto_deploy(value): + payload = response() + payload["data"]["serviceInstanceAutoDeployStatus"]["enabled"] = value + with pytest.raises(observer.EvidenceUnavailable, match="invalid_boolean"): + observer.observe(payload, TARGET) + + +def test_incomplete_trigger_page_cannot_hide_a_push_trigger(): + payload = response() + payload["data"]["deploymentTriggers"]["pageInfo"]["hasNextPage"] = True + with pytest.raises( + observer.EvidenceUnavailable, match="incomplete_trigger_listing" + ): + observer.observe(payload, TARGET) + + +def test_api_unsupported_and_partial_data_are_not_silently_accepted(tmp_path, capsys): + payload = response() + payload["errors"] = [{"message": "Unsupported field; private-sentinel"}] + code, report = invoke_fixture(tmp_path, capsys, payload) + assert code == 2 + assert report["blockers"] == ["railway_api_errors"] + assert "private-sentinel" not in json.dumps(report) + + +@pytest.mark.parametrize( + "failure", + [ + FileNotFoundError("private-sentinel"), + subprocess.CalledProcessError(1, "railway", stderr="private-sentinel"), + subprocess.TimeoutExpired("railway", 30, output="private-sentinel"), + ], +) +def test_cli_failures_never_print_private_diagnostics(capsys, monkeypatch, failure): + def fail(*args, **kwargs): + raise failure + + monkeypatch.setattr(observer.subprocess, "run", fail) + assert observer.main([*arguments(), "--live"]) == 2 + captured = capsys.readouterr() + assert "private-sentinel" not in captured.out + captured.err + assert json.loads(captured.out)["blockers"] == ["railway_read_failed"] + + +@pytest.mark.parametrize("raw", [b"not-json private-sentinel", b"\xff", b"[[]]"]) +def test_invalid_fixture_is_unavailable_without_raw_echo(tmp_path, capsys, raw): + path = tmp_path / "invalid.json" + path.write_bytes(raw) + assert observer.main([*arguments(), "--input", str(path)]) == 2 + captured = capsys.readouterr() + assert "private-sentinel" not in captured.out + captured.err + assert json.loads(captured.out)["evidence_status"] == "unavailable" + + +def test_missing_file_is_unavailable(tmp_path, capsys): + assert observer.main([*arguments(), "--input", str(tmp_path / "missing.json")]) == 2 + assert json.loads(capsys.readouterr().out)["blockers"] == ["fixture_unavailable"] + + +def test_mode_and_target_must_be_explicit_before_subprocess(monkeypatch): + monkeypatch.setattr(observer.subprocess, "run", no_subprocess) + for args in ( + ["--live"], + arguments(), + [*arguments(), "--live", "--input", "fixture"], + ): + with pytest.raises(SystemExit) as error: + observer.main(args) + assert error.value.code == 2 diff --git a/tests/test_release_control.py b/tests/test_release_control.py new file mode 100644 index 00000000..2ca28a54 --- /dev/null +++ b/tests/test_release_control.py @@ -0,0 +1,4512 @@ +from __future__ import annotations + +import hashlib +import json +import socket +import subprocess +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import fields, replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from release_manifest import ReleaseManifestError +from scripts import release_control as release_cli +from scripts.release_control import ( + AUTHORITY_AUDIENCE, + AUTHORITY_PURPOSE, + OCI_INDEX_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE, + AppliedReleaseReceipt, + ArtifactDescriptor, + ArtifactPurpose, + ArtifactResolutionReceipt, + ArtifactResolutionRequest, + AuthorityEvidence, + AuthorityRejected, + AuthorityVerificationReceipt, + CheckResult, + CheckSuiteReceipt, + CheckSuiteVerificationRequest, + ClaimDisposition, + ClaimReceipt, + ContractError, + CutoverBlocked, + DurableQualificationReceipt, + EmbeddedManifest, + EvidenceRejected, + ExecutionState, + HistoricalBuildContractReceipt, + LedgerClaimRequest, + LedgerOutcomeRequest, + LedgerSafetyError, + LedgerStateSnapshot, + MigrationClass, + NegativeMatrixReceipt, + ObservationEnvelope, + OperationState, + PilotQualificationReceipt, + PilotSignalResult, + ProviderApplyResult, + ProviderMutationRequest, + ProviderObservation, + QualificationRecordRequest, + ReconciliationCASRequest, + ReleaseAction, + ReleaseConfigurationManifest, + ReleaseControlContract, + ReleaseOperation, + RollbackCapabilityReceipt, + RollbackCompatibilityReceipt, + RollbackRejected, + SchemaError, + SourceCandidateReceipt, + SourceCandidateVerificationRequest, + TargetName, + TargetSnapshotReceipt, + TargetSnapshotRequest, + canonical_json_bytes, + canonical_sha256, + execute_operation, + finalize_production_operation, + load_release_control_contract, + loads_strict_json, + operation_external_id, + plan_build_once_promotion, + plan_rollback, + provider_idempotency_key, + reconcile_uncertain_operation, + validate_immutable_image_reference, + validate_release_control_contract, +) + +REPO_ROOT = Path(__file__).parents[1] +CONTRACT_PATH = REPO_ROOT / ".github" / "release-control-contract.json" +NOW = datetime(2026, 9, 3, 12, 0, tzinfo=timezone.utc) +RUNNER = "release-runner" +APPROVER = "pilot-approver" +AUTHORITY_ID = "10000000-0000-4000-8000-000000000001" +CLAIM_ID = "20000000-0000-4000-8000-000000000001" +PILOT_RECEIPT_ID = "30000000-0000-4000-8000-000000000001" + + +def _sha(character: str) -> str: + return "sha256:" + character * 64 + + +DIGEST_A = _sha("a") +DIGEST_B = _sha("b") +DIGEST_C = _sha("c") +DIGEST_D = _sha("d") +DIGEST_E = _sha("e") +DIGEST_F = _sha("f") +NEGATIVE_MATRIX_EVIDENCE_REF = _sha("9") +SOURCE_COMMIT = "1" * 40 +SOURCE_TREE = "2" * 40 + + +def _contract(*, cutover: bool): + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["cutover"]["enabled"] = cutover + if cutover: + payload["cutover"]["negative_matrix_evidence_ref"] = ( + NEGATIVE_MATRIX_EVIDENCE_REF + ) + return validate_release_control_contract(payload) + + +def _manifest(contract, **overrides: object) -> EmbeddedManifest: + payload: dict[str, object] = { + "schema_version": 1, + "release_id": "release-2026-09-03-01", + "source_commit": SOURCE_COMMIT, + "source_tree": SOURCE_TREE, + "build_contract_digest": contract.contract_digest, + "migration_class": "expand", + "schema_min": 4, + "schema_max": 6, + "required_checks": list(contract.required_checks), + "platform": {"os": "linux", "architecture": "amd64"}, + } + payload.update(overrides) + return EmbeddedManifest.from_mapping(payload) + + +def _configuration_manifest(manifest, **overrides): + def reference(path, digest): + return {"repository_commit": SOURCE_COMMIT, "path": path, "digest": digest} + + payload = { + "schema_version": 1, + "release_id": manifest.release_id, + "embedded_manifest_hash": manifest.manifest_hash, + "governance_repository": "example/governance", + "configuration_profiles": { + "pilot": reference("profiles/pilot.json", DIGEST_D), + "production": reference("profiles/production.json", DIGEST_E), + }, + "environment_delta_registry": reference("profiles/deltas.json", DIGEST_F), + } + payload.update(overrides) + return ReleaseConfigurationManifest.from_mapping(payload) + + +class FakeClock: + def __init__(self, now: datetime = NOW) -> None: + self.now = now + self.calls = 0 + + def now_utc(self) -> datetime: + self.calls += 1 + return self.now + + +class FailingClock: + def now_utc(self) -> datetime: + raise RuntimeError("trusted time unavailable") + + +class SequenceClock: + def __init__(self, *results: datetime | Exception) -> None: + self.results = results + self.calls = 0 + + def now_utc(self) -> datetime: + result = self.results[self.calls] + self.calls += 1 + if isinstance(result, Exception): + raise result + return result + + +class FakeArtifactResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "40000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "artifact_digest": DIGEST_A, + "digest_kind": "oci_manifest", + "media_type": OCI_MANIFEST_MEDIA_TYPE, + "platform_os": "linux", + "platform_architecture": "amd64", + "image_reference": f"{request.oci_repository}@{DIGEST_A}", + "embedded_layer_digest": DIGEST_B, + "embedded_content_hash": request.embedded_manifest_hash, + "provenance": ( + "build_once" + if request.purpose is ArtifactPurpose.PROMOTION + else "retained" + ), + "resolver_identity": "artifact-verifier", + "resolved_at": NOW - timedelta(seconds=5), + "evidence_hash": DIGEST_C, + } + values.update(self.overrides) + if values.pop("return_untyped", False): + return values + return ArtifactResolutionReceipt(**values) + + +class FakeTargetResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def snapshot_verified(self, request): + self.calls += 1 + self.requests.append(request) + is_pilot = request.target is TargetName.PILOT + values = { + "schema_version": 1, + "snapshot_id": ( + "50000000-0000-4000-8000-000000000001" + if is_pilot + else "50000000-0000-4000-8000-000000000002" + ), + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "operation_release_id": request.operation_release_id, + "candidate_release_id": request.candidate_release_id, + "target": request.target, + "candidate_artifact_digest": request.candidate_artifact_digest, + "candidate_manifest_hash": request.candidate_manifest_hash, + "configuration_manifest_hash": request.configuration_manifest.manifest_hash, + "environment_delta_registry_digest": request.configuration_manifest.environment_delta_registry.digest, + "target_profile_digest": DIGEST_D if is_pilot else DIGEST_E, + "current_artifact_digest": DIGEST_B if is_pilot else DIGEST_C, + "current_manifest_hash": DIGEST_F, + "target_config_digest": DIGEST_D, + "current_schema": 5, + "migration_history_digest": DIGEST_E, + "migration_class": request.migration_class, + "schema_compatible": True, + "migration_allowed": True, + "complete": True, + "captured_at": NOW - timedelta(seconds=5), + "source_identity": "target-state-verifier", + "evidence_hash": DIGEST_F, + } + values.update(self.overrides) + if values.pop("return_untyped", False): + return values + return TargetSnapshotReceipt(**values) + + +class ForgedCheckSuiteReceipt(CheckSuiteReceipt): + pass + + +def _check_results( + check_ids, + *, + conclusion: str = "success", +) -> tuple[CheckResult, ...]: + return tuple( + CheckResult( + check_id=check_id, + conclusion=conclusion, + completed_at=NOW - timedelta(seconds=10), + suite_reference_hash=DIGEST_C, + provider_reference_hash=_sha(format(index % 16, "x")), + evidence_hash=_sha(format((index + 8) % 16, "x")), + ) + for index, check_id in enumerate(check_ids) + ) + + +class FakeCheckSuiteResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + results = _check_results(request.required_checks) + values = { + "schema_version": 1, + "receipt_id": "45000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "embedded_manifest_hash": request.embedded_manifest_hash, + "artifact_digest": request.artifact_digest, + "required_checks": request.required_checks, + "forbidden_paths": request.forbidden_paths, + "complete": True, + "forbidden_paths_absent": True, + "suite_reference_hash": DIGEST_C, + "results": results, + "verified_at": NOW - timedelta(seconds=5), + "verifier_identity": "check-suite-verifier", + "evidence_hash": DIGEST_F, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ForgedCheckSuiteReceipt if return_subclass else CheckSuiteReceipt + return receipt_type(**values) + + +class ForgedSourceCandidateReceipt(SourceCandidateReceipt): + pass + + +class FakeSourceCandidateResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "46000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "embedded_manifest_hash": request.embedded_manifest_hash, + "artifact_digest": request.artifact_digest, + "candidate_ref": request.candidate_ref, + "base_ref": request.base_ref, + "check_suite_receipt_hash": request.check_suite_receipt_hash, + "candidate_head_commit": request.source_commit, + "candidate_head_tree": request.source_tree, + "base_commit": "5" * 40, + "base_tree": "6" * 40, + "reviewed_source_commit": request.source_commit, + "reviewed_source_tree": request.source_tree, + "reviewed_base_commit": "5" * 40, + "reviewed_base_tree": "6" * 40, + "candidate_ref_reachable": True, + "independent_review": True, + "complete": True, + "reviewer_identity": "candidate-reviewer", + "verifier_identity": "candidate-verifier", + "review_reference_hash": DIGEST_E, + "verified_at": NOW - timedelta(seconds=5), + "evidence_hash": DIGEST_F, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ( + ForgedSourceCandidateReceipt if return_subclass else SourceCandidateReceipt + ) + return receipt_type(**values) + + +class ForgedPilotQualificationReceipt(PilotQualificationReceipt): + pass + + +def _pilot_signal_results(signal_ids, *, conclusion="success"): + return tuple( + PilotSignalResult( + signal_id=signal_id, + conclusion=conclusion, + observed_at=NOW - timedelta(seconds=10), + provider_reference_hash=_sha(format(index % 16, "x")), + evidence_hash=_sha(format((index + 8) % 16, "x")), + ) + for index, signal_id in enumerate(signal_ids) + ) + + +class FakePilotQualificationResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "47000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "pilot_operation_fingerprint": request.pilot_operation_fingerprint, + "pilot_claim_id": request.pilot_claim_id, + "pilot_applied_receipt_hash": request.pilot_applied_receipt_hash, + "pilot_effect_confirmed_at": request.pilot_effect_confirmed_at, + "artifact_digest": request.artifact_digest, + "image_reference": request.image_reference, + "embedded_manifest_hash": request.embedded_manifest_hash, + "target_profile_digest": request.target_profile_digest, + "target_snapshot_hash": request.target_snapshot_hash, + "target_config_digest": request.target_config_digest, + "required_signals": request.required_signals, + "excluded_verifier_identities": (request.excluded_verifier_identities), + "signal_results": _pilot_signal_results(request.required_signals), + "complete": True, + "independent_verifier": True, + "verifier_identity": "pilot-qualification-verifier", + "verified_at": NOW - timedelta(seconds=5), + "external_receipt_hash": DIGEST_E, + "evidence_hash": DIGEST_F, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ( + ForgedPilotQualificationReceipt + if return_subclass + else PilotQualificationReceipt + ) + return receipt_type(**values) + + +class ForgedDurableQualificationReceipt(DurableQualificationReceipt): + pass + + +class FakeQualificationEvidence: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def record_durable(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "record_id": "48000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "contract_digest": request.contract_digest, + "contract_edition": request.contract_edition, + "release_id": request.release_id, + "pilot_claim_id": request.pilot_claim_id, + "pilot_operation_fingerprint": request.pilot_operation_fingerprint, + "pilot_applied_receipt_hash": request.pilot_applied_receipt_hash, + "qualification_receipt_hash": request.qualification_receipt_hash, + "external_receipt_hash": request.external_receipt_hash, + "artifact_digest": request.artifact_digest, + "embedded_manifest_hash": request.embedded_manifest_hash, + "verifier_identity": request.verifier_identity, + "qualification_verified_at": request.qualification_verified_at, + "recorded_at": NOW, + "state_version": 1, + "durable": True, + "evidence_hash": DIGEST_E, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ( + ForgedDurableQualificationReceipt + if return_subclass + else DurableQualificationReceipt + ) + return receipt_type(**values) + + +class ForgedHistoricalBuildContractReceipt(HistoricalBuildContractReceipt): + pass + + +class FakeHistoricalBuildContractResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "49000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "repository": request.repository, + "historical_build_contract_digest": ( + request.historical_build_contract_digest + ), + "historical_contract_edition": "historical-contract-v1", + "previous_release_id": request.previous_release_id, + "previous_manifest_hash": request.previous_manifest_hash, + "source_commit": request.source_commit, + "source_tree": request.source_tree, + "artifact_digest": request.artifact_digest, + "historical_required_checks": request.historical_required_checks, + "archive_complete": True, + "immutable": True, + "verifier_identity": "historical-contract-verifier", + "sealed_at": NOW - timedelta(days=30), + "archive_reference_hash": DIGEST_D, + "evidence_hash": DIGEST_E, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ( + ForgedHistoricalBuildContractReceipt + if return_subclass + else HistoricalBuildContractReceipt + ) + return receipt_type(**values) + + +class FakeRollbackResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "60000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "previous_good_digest": request.previous_good_digest, + "previous_manifest_hash": request.previous_manifest_hash, + "target": request.target, + "target_snapshot_hash": request.target_snapshot_hash, + "expected_current_digest": request.expected_current_digest, + "current_schema": request.current_schema, + "migration_history_digest": request.migration_history_digest, + "can_rollback": True, + "can_rollback_checked_at": NOW - timedelta(minutes=5), + "artifact_retained": True, + "retention_checked_at": NOW - timedelta(minutes=5), + "artifact_origin": "retained", + "runtime_attestation_valid": True, + "runtime_reattested_at": NOW - timedelta(minutes=5), + "pilot_operation_fingerprint": DIGEST_E, + "source_identity": "rollback-verifier", + "evidence_hash": DIGEST_F, + } + values.update(self.overrides) + if values.pop("return_untyped", False): + return values + return RollbackCapabilityReceipt(**values) + + +class ForgedRollbackCompatibilityReceipt(RollbackCompatibilityReceipt): + pass + + +class FakeRollbackCompatibilityResolver: + def __init__(self, **overrides: object) -> None: + self.overrides = overrides + self.calls = 0 + self.requests = [] + + def resolve_verified(self, request): + self.calls += 1 + self.requests.append(request) + values = { + "schema_version": 1, + "receipt_id": "61000000-0000-4000-8000-000000000001", + "request_digest": request.request_digest, + "current_contract_digest": request.current_contract_digest, + "current_contract_edition": request.current_contract_edition, + "historical_contract_digest": request.historical_contract_digest, + "historical_contract_edition": request.historical_contract_edition, + "historical_contract_receipt_hash": ( + request.historical_contract_receipt_hash + ), + "rollback_release_id": request.rollback_release_id, + "previous_release_id": request.previous_release_id, + "artifact_digest": request.artifact_digest, + "embedded_manifest_hash": request.embedded_manifest_hash, + "migration_class": request.migration_class, + "schema_min": request.schema_min, + "schema_max": request.schema_max, + "target_snapshot_hash": request.target_snapshot_hash, + "target_config_digest": request.target_config_digest, + "current_schema": request.current_schema, + "migration_history_digest": request.migration_history_digest, + "rollback_capability_receipt_hash": ( + request.rollback_capability_receipt_hash + ), + "compatible": True, + "complete": True, + "independent_verifier": True, + "verifier_identity": "rollback-compatibility-verifier", + "verified_at": NOW - timedelta(seconds=5), + "external_receipt_hash": DIGEST_C, + "evidence_hash": DIGEST_D, + } + values.update(self.overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + receipt_type = ( + ForgedRollbackCompatibilityReceipt + if return_subclass + else RollbackCompatibilityReceipt + ) + return receipt_type(**values) + + +def _plan( + contract, + *, + artifact_resolver=None, + check_suite_resolver=None, + source_candidate_resolver=None, + target_resolver=None, + manifest=None, + configuration_manifest=None, + clock=None, +): + embedded_manifest = manifest or _manifest(contract) + return plan_build_once_promotion( + contract=contract, + manifest=embedded_manifest, + configuration_manifest=( + configuration_manifest + if configuration_manifest is not None + else _configuration_manifest(embedded_manifest) + ), + artifact_resolver=artifact_resolver or FakeArtifactResolver(), + check_suite_resolver=check_suite_resolver or FakeCheckSuiteResolver(), + source_candidate_resolver=( + source_candidate_resolver or FakeSourceCandidateResolver() + ), + target_resolver=target_resolver or FakeTargetResolver(), + clock=clock or FakeClock(), + ) + + +def _signature(key_id: str, signed_payload: bytes) -> str: + return hashlib.sha256(key_id.encode() + b"\0" + signed_payload).hexdigest() + + +def _authority(operation, **overrides: object) -> AuthorityEvidence: + signed = { + "schema_version": 1, + "purpose": AUTHORITY_PURPOSE, + "audience": AUTHORITY_AUDIENCE, + "repository": operation.repository, + "target": operation.target.value, + "contract_digest": operation.contract_digest, + "contract_edition": operation.contract_edition, + "required_checks_digest": operation.required_checks_digest, + "authority_id": AUTHORITY_ID, + "release_id": operation.release_id, + "operation_fingerprint": operation.operation_fingerprint, + "approver_identity": APPROVER, + "runner_identity": RUNNER, + "issued_at": (NOW - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + "expires_at": (NOW + timedelta(minutes=29)).isoformat().replace("+00:00", "Z"), + "max_uses": 1, + "nonce": "nonce-01", + "key_id": "pilot-key-01", + } + signed.update( + {key: value for key, value in overrides.items() if key != "signature"} + ) + signed_bytes = canonical_json_bytes(signed) + signed["signature"] = overrides.get( + "signature", _signature(signed["key_id"], signed_bytes) + ) + return AuthorityEvidence.from_mapping(signed) + + +class FakeAuthorityVerifier: + def __init__(self, **overrides: object) -> None: + self.calls = 0 + self.payloads: list[bytes] = [] + self.overrides = overrides + + def verify(self, *, key_id: str, signed_payload: bytes, signature: str): + self.calls += 1 + self.payloads.append(signed_payload) + payload = json.loads(signed_payload) + values = { + "schema_version": 1, + "key_id": key_id, + "authority_id": payload["authority_id"], + "signer_identity": payload["approver_identity"], + # A production verifier obtains this from workload OIDC, mTLS, or + # runner attestation; it must not trust the signed payload claim. + "runner_identity": RUNNER, + "signed_payload_digest": canonical_sha256(payload), + "trust_root_identity": "release-authority-root", + "signature_valid": signature == _signature(key_id, signed_payload), + "independent_signer": ( + payload["approver_identity"] != payload["runner_identity"] + ), + "evidence_hash": DIGEST_D, + } + values.update(self.overrides) + if values.pop("return_untyped", False): + return values + return AuthorityVerificationReceipt(**values) + + +class FakeLedger: + def __init__(self) -> None: + self._lock = threading.Lock() + self.claim_calls = 0 + self.intent_calls = 0 + self.owner_count = 0 + self.outcome_calls = 0 + self.reconcile_calls = 0 + self.pilot_read_calls = 0 + self.states: dict[str, LedgerStateSnapshot] = {} + self.claims: dict[str, ClaimReceipt] = {} + self.pilot_receipt: AppliedReleaseReceipt | None = None + self.force_outcome_conflict = False + self.force_intent_conflict = False + self.force_reconcile_conflict = False + self.last_claim_request: LedgerClaimRequest | None = None + self.last_intent_request = None + self.last_outcome_request: LedgerOutcomeRequest | None = None + self.last_reconcile_request: ReconciliationCASRequest | None = None + self.last_pilot_query = None + self.negative_matrix_calls = 0 + self.negative_matrix_verified_at: datetime | None = None + self.force_missing_negative_matrix = False + self.negative_matrix_override: NegativeMatrixReceipt | None = None + self.last_negative_matrix_query = None + + def pilot_applied_receipt(self, query): + self.pilot_read_calls += 1 + self.last_pilot_query = query + return self.pilot_receipt + + def negative_matrix_receipt(self, query): + self.negative_matrix_calls += 1 + self.last_negative_matrix_query = query + if self.force_missing_negative_matrix: + return None + if self.negative_matrix_override is not None: + return self.negative_matrix_override + return NegativeMatrixReceipt( + evidence_ref=query.evidence_ref, + repository=query.repository, + contract_digest=query.contract_digest, + contract_edition=query.contract_edition, + pilot_source_ref=query.pilot_source_ref, + production_source_ref=query.production_source_ref, + verified_at=self.negative_matrix_verified_at or NOW, + approver_identity="approver-negative-matrix", + evidence_hash=DIGEST_F, + ) + + def claim_and_burn(self, request: LedgerClaimRequest) -> ClaimReceipt: + with self._lock: + self.claim_calls += 1 + self.last_claim_request = request + existing = self.claims.get(request.operation_fingerprint) + if existing is not None: + state = self.states[request.operation_fingerprint] + return replace( + existing, + disposition=ClaimDisposition.DUPLICATE, + state=state.state, + state_version=state.state_version, + ) + fencing = self.owner_count + 10 + receipt = ClaimReceipt( + disposition=ClaimDisposition.OWNER, + external_id=request.external_id, + authorization_id=request.authorization_id, + operation_fingerprint=request.operation_fingerprint, + target_key=request.target_key, + artifact_digest=request.artifact_digest, + operation_kind=request.operation_kind, + contract_digest=request.contract_digest, + evidence_hash=request.evidence_hash, + state=OperationState.CLAIMED_LOCKED, + state_version=1, + fencing_token=fencing, + durable=True, + authority_use_burned=True, + claim_id=CLAIM_ID, + ) + self.owner_count += 1 + self.claims[request.operation_fingerprint] = receipt + self.states[request.operation_fingerprint] = LedgerStateSnapshot( + external_id=request.external_id, + claim_id=CLAIM_ID, + operation_fingerprint=request.operation_fingerprint, + target=request.target_key, + artifact_digest=request.artifact_digest, + contract_digest=request.contract_digest, + state=OperationState.CLAIMED_LOCKED, + state_version=1, + fencing_token=fencing, + uncertainty_started_at=NOW - timedelta(minutes=2), + ) + return receipt + + def record_provider_intent(self, request): + with self._lock: + self.intent_calls += 1 + self.last_intent_request = request + state = self.states.get(request.operation_fingerprint) + if self.force_intent_conflict or state is None: + return None + if ( + state.external_id != request.external_id + or state.claim_id != request.claim_id + or state.state is not request.expected_state + or state.state_version != request.expected_version + or state.fencing_token != request.fencing_token + ): + return None + updated = replace( + state, + state_version=state.state_version + 1, + provider_intent_hash=request.provider_intent_hash, + provider_not_before=request.not_before, + provider_not_after=request.not_after, + ) + self.states[request.operation_fingerprint] = updated + return updated + + def record_outcome(self, request: LedgerOutcomeRequest): + with self._lock: + self.outcome_calls += 1 + self.last_outcome_request = request + state = self.states.get(request.operation_fingerprint) + if self.force_outcome_conflict or state is None: + return None + if ( + state.state is not request.expected_state + or state.state_version != request.expected_version + or state.fencing_token != request.fencing_token + or state.claim_id != request.claim_id + ): + return None + updated = replace( + state, + state=request.next_state, + state_version=state.state_version + 1, + ) + self.states[request.operation_fingerprint] = updated + if ( + request.next_state is OperationState.APPLIED + and request.target is TargetName.PILOT + ): + self.pilot_receipt = AppliedReleaseReceipt( + receipt_id=PILOT_RECEIPT_ID, + claim_id=request.claim_id, + repository=request.repository, + release_id=request.release_id, + manifest_release_id=request.manifest_release_id, + operation_fingerprint=request.operation_fingerprint, + action=request.action, + target=request.target, + artifact_digest=request.artifact_digest, + embedded_manifest_hash=request.embedded_manifest_hash, + contract_digest=request.contract_digest, + contract_edition=request.contract_edition, + required_checks_digest=request.required_checks_digest, + state=OperationState.APPLIED, + state_version=updated.state_version, + fencing_token=updated.fencing_token, + durable=True, + effect_confirmed_at=NOW, + provider_evidence_hash=request.evidence_hash, + ) + return updated + + def state_snapshot(self, operation_fingerprint: str): + return self.states.get(operation_fingerprint) + + def reconcile_compare_and_swap(self, request: ReconciliationCASRequest): + with self._lock: + self.reconcile_calls += 1 + self.last_reconcile_request = request + state = self.states.get(request.operation_fingerprint) + if self.force_reconcile_conflict or state is None: + return None + if ( + state.state is not request.expected_state + or state.state_version != request.expected_version + or state.fencing_token != request.fencing_token + ): + return None + updated = replace( + state, + state=request.next_state, + state_version=state.state_version + 1, + ) + self.states[request.operation_fingerprint] = updated + outcome = self.last_outcome_request + claim = self.claims.get(request.operation_fingerprint) + if ( + request.next_state is OperationState.OBSERVED_APPLIED + and state.target is TargetName.PILOT + and outcome is not None + and claim is not None + ): + self.pilot_receipt = AppliedReleaseReceipt( + receipt_id=PILOT_RECEIPT_ID, + claim_id=claim.claim_id, + repository=outcome.repository, + release_id=outcome.release_id, + manifest_release_id=outcome.manifest_release_id, + operation_fingerprint=outcome.operation_fingerprint, + action=outcome.action, + target=outcome.target, + artifact_digest=outcome.artifact_digest, + embedded_manifest_hash=outcome.embedded_manifest_hash, + contract_digest=outcome.contract_digest, + contract_edition=outcome.contract_edition, + required_checks_digest=outcome.required_checks_digest, + state=OperationState.OBSERVED_APPLIED, + state_version=updated.state_version, + fencing_token=updated.fencing_token, + durable=True, + effect_confirmed_at=NOW, + provider_evidence_hash=request.evidence_hash, + ) + return updated + + def seed_uncertain( + self, + operation, + *, + state: OperationState = OperationState.OUTCOME_UNKNOWN, + version: int = 3, + fencing: int = 19, + with_provider_intent: bool = True, + intent_not_before: datetime | None = None, + intent_not_after: datetime | None = None, + ) -> None: + not_before = intent_not_before or NOW - timedelta(seconds=90) + not_after = intent_not_after or operation.evidence_valid_until + provider_request = ProviderMutationRequest( + operation=operation, + image_reference=operation.image_reference, + expected_current_artifact_digest=operation.expected_current_digest, + expected_target_config_digest=operation.target_config_digest, + expected_current_schema=operation.current_schema, + expected_migration_history_digest=operation.migration_history_digest, + target_profile_digest=operation.target_profile_digest, + target_snapshot_hash=operation.target_snapshot_hash, + external_id=operation_external_id(operation.operation_fingerprint), + claim_id=CLAIM_ID, + fencing_token=fencing, + idempotency_key=provider_idempotency_key(operation.operation_fingerprint), + not_before=not_before, + not_after=not_after, + ) + self.states[operation.operation_fingerprint] = LedgerStateSnapshot( + external_id=operation_external_id(operation.operation_fingerprint), + claim_id=CLAIM_ID, + operation_fingerprint=operation.operation_fingerprint, + target=operation.target, + artifact_digest=operation.artifact_digest, + contract_digest=operation.contract_digest, + state=state, + state_version=version, + fencing_token=fencing, + uncertainty_started_at=NOW - timedelta(minutes=2), + provider_intent_hash=( + provider_request.intent_hash if with_provider_intent else None + ), + provider_not_before=not_before if with_provider_intent else None, + provider_not_after=not_after if with_provider_intent else None, + ) + + +class ClaimTamperingLedger(FakeLedger): + def __init__(self, **overrides: object) -> None: + super().__init__() + self.overrides = overrides + + def claim_and_burn(self, request: LedgerClaimRequest) -> ClaimReceipt: + receipt = super().claim_and_burn(request) + return replace(receipt, **self.overrides) + + +class IntentTamperingLedger(FakeLedger): + def __init__(self, **overrides: object) -> None: + super().__init__() + self.overrides = overrides + + def record_provider_intent(self, request): + snapshot = super().record_provider_intent(request) + if snapshot is None: + return None + return replace(snapshot, **self.overrides) + + +class OutcomeTamperingLedger(FakeLedger): + def __init__(self, **overrides: object) -> None: + super().__init__() + self.overrides = overrides + + def record_outcome(self, request: LedgerOutcomeRequest): + snapshot = super().record_outcome(request) + if snapshot is None: + return None + return replace(snapshot, **self.overrides) + + +class ReconcileTamperingLedger(FakeLedger): + def __init__(self, **overrides: object) -> None: + super().__init__() + self.overrides = overrides + + def reconcile_compare_and_swap(self, request: ReconciliationCASRequest): + snapshot = super().reconcile_compare_and_swap(request) + if snapshot is None: + return None + return replace(snapshot, **self.overrides) + + +class LostOutcomeReplyLedger(FakeLedger): + def __init__(self) -> None: + super().__init__() + self.reply_lost = False + + def record_outcome(self, request: LedgerOutcomeRequest): + snapshot = super().record_outcome(request) + if ( + snapshot is not None + and request.next_state is OperationState.APPLIED + and not self.reply_lost + ): + self.reply_lost = True + raise TimeoutError("outcome reply lost after durable CAS") + return snapshot + + +class LostReconciliationReplyLedger(FakeLedger): + def __init__(self) -> None: + super().__init__() + self.reply_lost = False + + def reconcile_compare_and_swap(self, request: ReconciliationCASRequest): + snapshot = super().reconcile_compare_and_swap(request) + if snapshot is not None and not self.reply_lost: + self.reply_lost = True + raise TimeoutError("reconciliation reply lost after durable CAS") + return snapshot + + +class RacingDuplicateEvidenceLedger(FakeLedger): + """Model another valid claimant winning between state lookup and claim.""" + + def __init__(self, operation: ReleaseOperation) -> None: + super().__init__() + self.operation = operation + self.snapshot_calls = 0 + + def claim_and_burn(self, request: LedgerClaimRequest) -> ClaimReceipt: + if request.operation_fingerprint not in self.claims: + winner_request = replace( + request, + authorization_id="10000000-0000-4000-8000-000000000002", + evidence_hash=DIGEST_A, + ) + super().claim_and_burn(winner_request) + return super().claim_and_burn(request) + + def state_snapshot(self, operation_fingerprint: str): + self.snapshot_calls += 1 + snapshot = super().state_snapshot(operation_fingerprint) + if ( + self.snapshot_calls == 2 + and snapshot is not None + and snapshot.state is OperationState.CLAIMED_LOCKED + ): + not_before = NOW - timedelta(seconds=1) + provider_request = ProviderMutationRequest( + operation=self.operation, + image_reference=self.operation.image_reference, + expected_current_artifact_digest=( + self.operation.expected_current_digest + ), + expected_target_config_digest=self.operation.target_config_digest, + expected_current_schema=self.operation.current_schema, + expected_migration_history_digest=( + self.operation.migration_history_digest + ), + target_profile_digest=self.operation.target_profile_digest, + target_snapshot_hash=self.operation.target_snapshot_hash, + external_id=snapshot.external_id, + claim_id=snapshot.claim_id, + fencing_token=snapshot.fencing_token, + idempotency_key=provider_idempotency_key( + self.operation.operation_fingerprint + ), + not_before=not_before, + not_after=self.operation.evidence_valid_until, + ) + self.states[operation_fingerprint] = replace( + snapshot, + state=OperationState.APPLIED, + state_version=snapshot.state_version + 2, + provider_intent_hash=provider_request.intent_hash, + provider_not_before=provider_request.not_before, + provider_not_after=provider_request.not_after, + ) + snapshot = self.states[operation_fingerprint] + return snapshot + + +class ForgedProviderApplyResult(ProviderApplyResult): + pass + + +class FakeProvider: + def __init__( + self, + *, + failure: Exception | None = None, + result_overrides: dict[str, object] | None = None, + ) -> None: + self._lock = threading.Lock() + self.calls = 0 + self.failure = failure + self.result_overrides = result_overrides or {} + self.keys: list[str] = [] + self.requests: list[ProviderMutationRequest] = [] + self.results: list[ProviderApplyResult] = [] + + def apply(self, request: ProviderMutationRequest): + with self._lock: + self.calls += 1 + self.keys.append(request.idempotency_key) + self.requests.append(request) + if self.failure is not None: + raise self.failure + operation = request.operation + values = { + "operation_fingerprint": operation.operation_fingerprint, + "external_id": request.external_id, + "claim_id": request.claim_id, + "idempotency_key": request.idempotency_key, + "fencing_token": request.fencing_token, + "not_before": request.not_before, + "not_after": request.not_after, + "applied_at": request.not_before, + "observed_previous_artifact_digest": operation.expected_current_digest, + "observed_target_config_digest": operation.target_config_digest, + "observed_current_schema": operation.current_schema, + "observed_migration_history_digest": (operation.migration_history_digest), + "artifact_digest": operation.artifact_digest, + "image_reference": request.image_reference, + "target_profile_digest": operation.target_profile_digest, + "target_snapshot_hash": operation.target_snapshot_hash, + "provider_reference_hash": DIGEST_E, + "evidence_hash": DIGEST_F, + } + values.update(self.result_overrides) + return_untyped = values.pop("return_untyped", False) + return_subclass = values.pop("return_subclass", False) + if return_untyped: + return values + result_type = ( + ForgedProviderApplyResult if return_subclass else ProviderApplyResult + ) + result = result_type(**values) + self.results.append(result) + return result + + +class FakeObserver: + def __init__(self, envelopes) -> None: + self.envelopes = envelopes + self.calls = 0 + self.uncertainty_started_at = None + + def observe_verified(self, operation, uncertainty_started_at): + del operation + self.calls += 1 + self.uncertainty_started_at = uncertainty_started_at + return self.envelopes + + +def _pilot_receipt(operation, **overrides: object) -> AppliedReleaseReceipt: + is_rollback = operation.action is ReleaseAction.ROLLBACK + values = { + "receipt_id": PILOT_RECEIPT_ID, + "claim_id": CLAIM_ID, + "repository": operation.repository, + "release_id": operation.manifest_release_id, + "manifest_release_id": operation.manifest_release_id, + "operation_fingerprint": (operation.required_pilot_operation_fingerprint), + "action": ReleaseAction.PROMOTE, + "target": TargetName.PILOT, + "artifact_digest": operation.artifact_digest, + "embedded_manifest_hash": operation.embedded_manifest_hash, + "contract_digest": ( + operation.rollback_historical_contract_digest + if is_rollback + else operation.contract_digest + ), + "contract_edition": ( + operation.rollback_historical_contract_edition + if is_rollback + else operation.contract_edition + ), + "required_checks_digest": ( + operation.rollback_historical_required_checks_digest + if is_rollback + else operation.required_checks_digest + ), + "state": OperationState.APPLIED, + "state_version": 2, + "fencing_token": 10, + "durable": True, + "effect_confirmed_at": NOW - timedelta(seconds=20), + "provider_evidence_hash": DIGEST_F, + } + values.update(overrides) + return AppliedReleaseReceipt(**values) + + +def _qualified_production( + contract, + *, + plan=None, + ledger=None, + qualification_resolver=None, + qualification_evidence=None, + target_resolver=None, + clock=None, +): + promotion_plan = plan or _plan(contract) + qualification_ledger = ledger + if qualification_ledger is None: + qualification_ledger = FakeLedger() + qualification_ledger.pilot_receipt = _pilot_receipt( + promotion_plan.operations[1] + ) + return finalize_production_operation( + contract=contract, + plan=promotion_plan, + ledger=qualification_ledger, + qualification_resolver=( + qualification_resolver or FakePilotQualificationResolver() + ), + qualification_evidence=(qualification_evidence or FakeQualificationEvidence()), + target_resolver=target_resolver or FakeTargetResolver(), + clock=clock or FakeClock(), + ) + + +def _execute( + *, + contract, + operation, + ledger=None, + provider=None, + authority=None, + clock=None, + verifier=None, +): + return execute_operation( + contract=contract, + operation=operation, + authority=authority or _authority(operation), + clock=clock or FakeClock(), + authority_verifier=verifier or FakeAuthorityVerifier(), + ledger=ledger or FakeLedger(), + provider=provider or FakeProvider(), + ) + + +def _observation(operation, *, reference=DIGEST_A, **overrides: object): + values = { + "operation_fingerprint": operation.operation_fingerprint, + "external_id": operation_external_id(operation.operation_fingerprint), + "claim_id": CLAIM_ID, + "idempotency_key": provider_idempotency_key(operation.operation_fingerprint), + "fencing_token": 19, + "not_before": NOW - timedelta(seconds=90), + "not_after": operation.evidence_valid_until, + "applied_at": NOW - timedelta(seconds=60), + "observed_previous_artifact_digest": operation.expected_current_digest, + "observed_target_config_digest": operation.target_config_digest, + "observed_current_schema": operation.current_schema, + "observed_migration_history_digest": operation.migration_history_digest, + "artifact_digest": operation.artifact_digest, + "image_reference": operation.image_reference, + "target_profile_digest": operation.target_profile_digest, + "target_snapshot_hash": operation.target_snapshot_hash, + "provider_reference_hash": reference, + "evidence_hash": DIGEST_F, + } + values.update(overrides) + return ProviderObservation(**values) + + +def _envelope( + operation, + index: int, + *, + captured_at: datetime | None = None, + observations=(), + **overrides: object, +): + captured = captured_at or NOW - timedelta(seconds=10) + values = { + "schema_version": 1, + "snapshot_id": f"70000000-0000-4000-8000-{index:012d}", + "source_identity": "provider-observer", + "operation_fingerprint": operation.operation_fingerprint, + "target": operation.target, + "target_profile_digest": operation.target_profile_digest, + "complete": True, + "captured_at": captured, + "settled_through": captured - timedelta(seconds=1), + "observations": tuple(observations), + "evidence_hash": _sha(format(index % 16, "x")), + } + values.update(overrides) + return ObservationEnvelope(**values) + + +def test_contract_and_manifest_are_bound_by_exact_machine_check_ids() -> None: + contract = load_release_control_contract(CONTRACT_PATH) + plan = _plan(contract) + + assert contract.cutover_enabled is False + assert plan.contract_digest == contract.contract_digest + assert plan.contract_edition == contract.contract_edition + assert plan.required_checks == contract.required_checks + assert plan.operations[0].required_checks == contract.required_checks + + +def test_contract_rejects_extra_or_missing_fields() -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["artifact"]["unexpected"] = True + with pytest.raises(ContractError, match="extra"): + validate_release_control_contract(payload) + + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + del payload["rollback"]["require_can_rollback"] + with pytest.raises(ContractError, match="missing"): + validate_release_control_contract(payload) + + +@pytest.mark.parametrize( + ("section", "field", "value"), + [ + ("authority", "purpose", "other-purpose"), + ("authority", "audience", "other-audience"), + ("authority", "required_signed_fields", ["operation_fingerprint"]), + ("ledger", "target_version_cas_required", False), + ("ledger", "raw_evidence_forbidden", False), + ("ledger", "claim_field_allowlist", ["operation_fingerprint"]), + ("promotion", "provider_idempotency_required", False), + ("promotion", "provider_fencing_required", False), + ("promotion", "provider_deadline_required", False), + ("promotion", "check_suite_receipt_required", False), + ("promotion", "check_suite_max_age_seconds", 0), + ("promotion", "source_candidate_receipt_required", False), + ("promotion", "independent_review_required", False), + ("promotion", "pilot_qualification_receipt_required", False), + ("promotion", "pilot_qualification_max_age_seconds", 0), + ("promotion", "pilot_qualification_independent_verifier_required", False), + ("promotion", "pilot_qualification_required_signals", ["readiness"]), + ("promotion", "zero_settlement_observations", 1), + ("promotion", "zero_settlement_min_interval_seconds", 0), + ("promotion", "reconciliation_settling_seconds", 0), + ("rollback", "historical_build_contract_receipt_required", False), + ("rollback", "compatibility_receipt_required", False), + ("rollback", "compatibility_max_age_seconds", 0), + ("rollback", "independent_verifier_required", False), + ], +) +def test_contract_requires_explicit_authority_ledger_and_provider_safety( + section, + field, + value, +) -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload[section][field] = value + + with pytest.raises(ContractError): + validate_release_control_contract(payload) + + +def test_validated_contract_cannot_be_cloned_to_bypass_cutover() -> None: + contract = _contract(cutover=False) + + with pytest.raises(ContractError, match="strict repository validation"): + replace(contract, cutover_enabled=True) + + values = { + item.name: getattr(contract, item.name) + for item in fields(ReleaseControlContract) + if not item.name.startswith("_") + } + values["cutover_enabled"] = True + with pytest.raises(ContractError, match="strict repository validation"): + ReleaseControlContract(**values) + + +@pytest.mark.parametrize( + ("path", "value"), + [ + (("repository", "owner"), "Owner"), + (("repository", "owner"), ".."), + (("source_refs", "pilot"), "topic/.hidden"), + (("source_refs", "pilot"), "topic/release.lock"), + (("source_refs", "pilot"), "x" * 256), + ], +) +def test_contract_rejects_noncanonical_bounded_references(path, value) -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + section, field = path + payload[section][field] = value + + with pytest.raises(ContractError): + validate_release_control_contract(payload) + + +def test_contract_rejects_unsafe_or_overlapping_forbidden_paths() -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["forbidden_paths"] = ["../sitecustomize.py"] + with pytest.raises(ContractError, match="relative"): + validate_release_control_contract(payload) + + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["forbidden_paths"] = [payload["protected_paths"][0]] + with pytest.raises(ContractError, match="overlap"): + validate_release_control_contract(payload) + + +def test_contract_protected_path_capacity_supports_tcb_growth() -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["protected_paths"] = sorted( + ["scripts/release_control.py"] + + [f"protected/path-{index:03d}" for index in range(255)] + ) + + contract = validate_release_control_contract(payload) + + assert len(contract.protected_paths) == 256 + + payload["protected_paths"].append("protected/path-over-cap") + payload["protected_paths"].sort() + with pytest.raises(ContractError, match="1..256"): + validate_release_control_contract(payload) + + +def test_manifest_schema_version_and_contract_checks_are_literal() -> None: + contract = _contract(cutover=False) + with pytest.raises(ReleaseManifestError, match="must equal 1"): + _manifest(contract, schema_version=2) + + mismatched = _manifest(contract, required_checks=[contract.required_checks[0]]) + with pytest.raises(SchemaError, match="exactly match"): + _plan(contract, manifest=mismatched) + + +def test_resolver_receipt_between_trusted_clock_samples_is_not_future() -> None: + contract = _contract(cutover=False) + resolved_at = NOW + timedelta(seconds=1) + clock = SequenceClock( + NOW, + NOW + timedelta(seconds=2), + *([NOW + timedelta(seconds=2)] * 8), + ) + + plan = _plan( + contract, + artifact_resolver=FakeArtifactResolver(resolved_at=resolved_at), + clock=clock, + ) + + assert plan._artifact.resolved_at == resolved_at + assert clock.calls == 10 + + +def test_resolver_window_rejects_a_backwards_trusted_clock() -> None: + contract = _contract(cutover=False) + artifact_resolver = FakeArtifactResolver() + + with pytest.raises(EvidenceRejected, match="moved backwards"): + _plan( + contract, + artifact_resolver=artifact_resolver, + clock=SequenceClock(NOW, NOW - timedelta(microseconds=1)), + ) + + assert artifact_resolver.calls == 1 + + +def test_planning_rejects_a_backward_jump_between_resolver_windows() -> None: + contract = _contract(cutover=False) + check_suite_resolver = FakeCheckSuiteResolver() + + with pytest.raises(EvidenceRejected, match="across release stages"): + _plan( + contract, + check_suite_resolver=check_suite_resolver, + clock=SequenceClock( + NOW, + NOW + timedelta(seconds=10), + NOW - timedelta(seconds=100), + ), + ) + + assert check_suite_resolver.calls == 0 + + +def test_manifest_from_another_contract_is_rejected_even_with_same_checks() -> None: + contract = _contract(cutover=False) + foreign = _manifest(contract, build_contract_digest=DIGEST_A) + + with pytest.raises(SchemaError, match="build contract digest"): + _plan(contract, manifest=foreign) + + +@pytest.mark.parametrize("migration_class", [item.value for item in MigrationClass]) +def test_all_literal_migration_classes_are_planned(migration_class) -> None: + contract = _contract(cutover=False) + schema_range = ( + {"schema_min": 0, "schema_max": 0} + if migration_class == MigrationClass.NONE.value + else {} + ) + manifest = _manifest( + contract, + migration_class=migration_class, + **schema_range, + ) + + target_resolver = ( + FakeTargetResolver(current_schema=0) + if migration_class == MigrationClass.NONE.value + else None + ) + operation = _plan( + contract, + manifest=manifest, + target_resolver=target_resolver, + ).operations[0] + + assert operation.migration_class.value == migration_class + + +@pytest.mark.parametrize( + ("migration_class", "schema_min", "schema_max"), + [ + (MigrationClass.NONE, 1, 1), + (MigrationClass.EXPAND, 0, 0), + ], +) +def test_runner_revalidates_forged_migration_semantics( + migration_class: MigrationClass, + schema_min: int, + schema_max: int, +) -> None: + contract = _contract(cutover=False) + forged = replace( + _manifest(contract), + migration_class=migration_class, + schema_min=schema_min, + schema_max=schema_max, + ) + + with pytest.raises(SchemaError, match="canonical revalidation"): + _plan(contract, manifest=forged) + + +def test_manifest_rejects_self_and_final_digest_fields() -> None: + contract = _contract(cutover=False) + payload = _manifest(contract).to_mapping() + for field in ("manifest_hash", "artifact_digest"): + invalid = dict(payload) + invalid[field] = DIGEST_A + with pytest.raises(ReleaseManifestError): + EmbeddedManifest.from_mapping(invalid) + + +def test_release_manifest_helpers_are_reexported_without_second_schema() -> None: + assert canonical_sha256({"b": 2, "a": 1}) == ( + "sha256:43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777" + ) + with pytest.raises(ReleaseManifestError, match="duplicate JSON key"): + loads_strict_json('{"a":1,"a":2}') + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"media_type": OCI_INDEX_MEDIA_TYPE}, "media_type"), + ({"digest_kind": "oci_index"}, "digest_kind"), + ({"platform_architecture": "arm64"}, "platform_architecture"), + ( + {"image_reference": f"registry.example.invalid/team/app:latest@{DIGEST_A}"}, + "mutable tag", + ), + ( + {"image_reference": f"https://registry.example.invalid/app@{DIGEST_A}"}, + "digest-only", + ), + ({"embedded_content_hash": DIGEST_B}, "embedded_content_hash"), + ({"request_digest": DIGEST_B}, "request_digest"), + ({"contract_digest": DIGEST_B}, "contract_digest"), + ({"provenance": "retained"}, "provenance"), + ({"resolved_at": NOW - timedelta(seconds=901)}, "stale"), + ({"return_untyped": True}, "untyped"), + ], +) +def test_unverified_or_mismatched_artifact_receipts_cannot_plan( + overrides, message +) -> None: + contract = _contract(cutover=False) + with pytest.raises(EvidenceRejected, match=message): + _plan(contract, artifact_resolver=FakeArtifactResolver(**overrides)) + + +def test_artifact_receipt_must_use_the_contract_oci_repository() -> None: + contract = _contract(cutover=False) + resolver = FakeArtifactResolver( + image_reference=f"registry.example.invalid/other/app@{DIGEST_A}" + ) + + with pytest.raises(EvidenceRejected, match="contract OCI repository"): + _plan(contract, artifact_resolver=resolver) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"request_digest": DIGEST_A}, "request_digest"), + ({"repository": "other/repository"}, "repository"), + ({"source_commit": "3" * 40}, "source_commit"), + ({"source_tree": "4" * 40}, "source_tree"), + ({"embedded_manifest_hash": DIGEST_B}, "embedded_manifest_hash"), + ({"artifact_digest": DIGEST_B}, "artifact_digest"), + ({"contract_digest": DIGEST_B}, "contract_digest"), + ({"complete": False}, "complete"), + ({"forbidden_paths_absent": False}, "forbidden_paths_absent"), + ({"verified_at": NOW - timedelta(seconds=3601)}, "stale"), + ({"return_untyped": True}, "untyped"), + ({"return_subclass": True}, "untyped"), + ], +) +def test_check_suite_receipt_is_exact_typed_and_fresh(overrides, message) -> None: + contract = _contract(cutover=False) + + with pytest.raises(EvidenceRejected, match=message): + _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(**overrides), + ) + + +@pytest.mark.parametrize("result_kind", ["missing", "extra", "duplicate", "failed"]) +def test_check_suite_requires_exactly_one_success_per_required_check( + result_kind, +) -> None: + contract = _contract(cutover=False) + check_ids = list(contract.required_checks) + if result_kind == "missing": + results = _check_results(check_ids[:-1]) + elif result_kind == "extra": + results = _check_results([*check_ids, "zz-extra-check"]) + elif result_kind == "duplicate": + results = _check_results([check_ids[0], *check_ids]) + else: + results = _check_results(check_ids, conclusion="failure") + + with pytest.raises(EvidenceRejected): + _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(results=results), + ) + + +def test_check_suite_rejects_fresh_wrapper_around_stale_or_mixed_run_results() -> None: + contract = _contract(cutover=False) + stale_results = tuple( + replace( + result, + completed_at=NOW - timedelta(seconds=3601), + ) + for result in _check_results(contract.required_checks) + ) + with pytest.raises(EvidenceRejected, match="stale result"): + _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(results=stale_results), + ) + + mixed_results = list(_check_results(contract.required_checks)) + mixed_results[-1] = replace( + mixed_results[-1], + suite_reference_hash=DIGEST_D, + ) + with pytest.raises(EvidenceRejected, match="different suites"): + _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(results=tuple(mixed_results)), + ) + + +def test_check_suite_receipt_hash_is_bound_into_both_operations() -> None: + contract = _contract(cutover=False) + first = _plan(contract) + second = _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(evidence_hash=DIGEST_A), + ) + + assert first.operations[0].check_suite_receipt_hash == ( + first.operations[1].check_suite_receipt_hash + ) + assert first.operations[0].operation_fingerprint != ( + second.operations[0].operation_fingerprint + ) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"request_digest": DIGEST_A}, "request_digest"), + ({"source_commit": "3" * 40}, "source_commit"), + ({"source_tree": "4" * 40}, "source_tree"), + ({"artifact_digest": DIGEST_B}, "artifact_digest"), + ({"candidate_ref": "other-candidate"}, "candidate_ref"), + ({"base_ref": "other-base"}, "base_ref"), + ({"candidate_head_commit": "3" * 40}, "candidate_head_commit"), + ({"reviewed_source_commit": "3" * 40}, "reviewed_source_commit"), + ({"reviewed_base_commit": "7" * 40}, "base snapshot"), + ({"reviewed_base_tree": "8" * 40}, "base snapshot"), + ({"candidate_ref_reachable": False}, "candidate_ref_reachable"), + ({"independent_review": False}, "independent_review"), + ({"complete": False}, "complete"), + ({"reviewer_identity": "candidate-verifier"}, "independent"), + ({"reviewer_identity": "check-suite-verifier"}, "independent"), + ({"verified_at": NOW - timedelta(seconds=3601)}, "stale"), + ({"return_untyped": True}, "untyped"), + ({"return_subclass": True}, "untyped"), + ], +) +def test_source_candidate_receipt_is_exact_independent_typed_and_fresh( + overrides, + message, +) -> None: + contract = _contract(cutover=False) + + with pytest.raises(EvidenceRejected, match=message): + _plan( + contract, + source_candidate_resolver=FakeSourceCandidateResolver(**overrides), + ) + + +def test_source_candidate_receipt_is_bound_into_both_promotion_operations() -> None: + contract = _contract(cutover=False) + first = _plan(contract) + second = _plan( + contract, + source_candidate_resolver=FakeSourceCandidateResolver(evidence_hash=DIGEST_A), + ) + + assert first.operations[0].source_candidate_receipt_hash == ( + first.operations[1].source_candidate_receipt_hash + ) + assert first.operations[0].operation_fingerprint != ( + second.operations[0].operation_fingerprint + ) + + +def test_artifact_descriptor_cannot_be_built_from_raw_caller_assertions() -> None: + with pytest.raises(EvidenceRejected, match="verified resolver"): + ArtifactDescriptor() + assert not hasattr(ArtifactDescriptor, "from_mapping") + + +def test_embedded_layer_receipt_is_bound_into_operation_fingerprint() -> None: + contract = _contract(cutover=False) + first = _plan(contract).operations[0] + second = _plan( + contract, + artifact_resolver=FakeArtifactResolver(embedded_layer_digest=DIGEST_C), + ).operations[0] + + assert first.embedded_layer_digest != second.embedded_layer_digest + assert first.operation_fingerprint != second.operation_fingerprint + + +@pytest.mark.parametrize( + "reference", + [ + f"registry.example.invalid:5000/team/app@{DIGEST_A}", + f"registry.example.invalid/team/app@{DIGEST_A}", + ], +) +def test_oci_parser_accepts_only_bounded_digest_references(reference) -> None: + assert validate_immutable_image_reference(reference, DIGEST_A) == reference + + +@pytest.mark.parametrize( + "reference", + [ + f"registry.example.invalid/team/app:tag@{DIGEST_A}", + f"registry.example.invalid:99999/team/app@{DIGEST_A}", + f"Registry.example.invalid/team/app@{DIGEST_A}", + f"registry.example.invalid//app@{DIGEST_A}", + f"registry.example.invalid/team/app@{DIGEST_B}", + "r" * 513, + ], +) +def test_oci_parser_rejects_tags_bad_ports_case_gaps_mismatch_and_overflow( + reference, +) -> None: + with pytest.raises(SchemaError): + validate_immutable_image_reference(reference, DIGEST_A) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"request_digest": DIGEST_A}, "request_digest"), + ({"candidate_artifact_digest": DIGEST_B}, "candidate_artifact_digest"), + ({"candidate_manifest_hash": DIGEST_B}, "candidate_manifest_hash"), + ({"target": TargetName.PRODUCTION}, "target"), + ({"complete": False}, "compatible"), + ({"schema_compatible": False}, "compatible"), + ({"migration_allowed": False}, "compatible"), + ({"current_schema": 7}, "current_schema"), + ({"captured_at": NOW - timedelta(seconds=301)}, "stale"), + ({"return_untyped": True}, "untyped"), + ], +) +def test_target_schema_and_migration_receipts_fail_closed(overrides, message) -> None: + contract = _contract(cutover=False) + with pytest.raises(EvidenceRejected, match=message): + _plan(contract, target_resolver=FakeTargetResolver(**overrides)) + + +def test_target_snapshot_hash_schema_and_history_are_bound_into_operation() -> None: + contract = _contract(cutover=False) + first = _plan(contract).operations[0] + second = _plan( + contract, + target_resolver=FakeTargetResolver(migration_history_digest=DIGEST_A), + ).operations[0] + + assert first.current_schema == 5 + assert first.migration_history_digest == DIGEST_E + assert first.operation_fingerprint != second.operation_fingerprint + + +def test_build_once_plan_uses_one_exact_digest_for_pilot_and_production() -> None: + contract = _contract(cutover=False) + plan = _plan(contract) + + assert plan.artifact_build_count == 1 + assert tuple(item.target for item in plan.operations) == ( + TargetName.PILOT, + TargetName.PRODUCTION, + ) + assert {item.artifact_digest for item in plan.operations} == {DIGEST_A} + assert plan.operations[0].required_pilot_operation_fingerprint is None + assert plan.operations[1].required_pilot_operation_fingerprint == ( + plan.operations[0].operation_fingerprint + ) + assert all( + operation.qualified_pilot_applied_receipt_hash is None + for operation in plan.operations + ) + repeated = _plan(contract) + assert tuple(item.operation_fingerprint for item in plan.operations) == tuple( + item.operation_fingerprint for item in repeated.operations + ) + + +def test_unqualified_production_draft_cannot_execute() -> None: + contract = _contract(cutover=True) + draft = _plan(contract).operations[1] + ledger = FakeLedger() + provider = FakeProvider() + + with pytest.raises(EvidenceRejected, match="qualification"): + _execute( + contract=contract, + operation=draft, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_production_finalizer_revalidates_sealed_plan_linkage() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + object.__setattr__(plan, "artifact_digest", DIGEST_B) + + with pytest.raises(EvidenceRejected, match="sealed evidence linkage"): + _qualified_production(contract, plan=plan) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"request_digest": DIGEST_A}, "request_digest"), + ({"pilot_applied_receipt_hash": DIGEST_A}, "receipt_hash"), + ( + { + "artifact_digest": DIGEST_B, + "image_reference": (f"registry.example.invalid/team/app@{DIGEST_B}"), + }, + "artifact_digest", + ), + ({"target_config_digest": DIGEST_B}, "target_config_digest"), + ({"complete": False}, "complete"), + ({"independent_verifier": False}, "independent_verifier"), + ({"verifier_identity": "candidate-reviewer"}, "not independent"), + ({"verifier_identity": "artifact-verifier"}, "not independent"), + ({"verifier_identity": "target-state-verifier"}, "not independent"), + ({"verified_at": NOW - timedelta(seconds=301)}, "stale"), + ({"return_untyped": True}, "untyped"), + ({"return_subclass": True}, "untyped"), + ], +) +def test_pilot_qualification_receipt_is_exact_typed_fresh_and_independent( + overrides, + message, +) -> None: + contract = _contract(cutover=True) + + with pytest.raises(EvidenceRejected, match=message): + _qualified_production( + contract, + qualification_resolver=FakePilotQualificationResolver(**overrides), + ) + + +@pytest.mark.parametrize("result_kind", ["missing", "extra", "duplicate", "failed"]) +def test_pilot_qualification_requires_exact_successful_signals(result_kind) -> None: + contract = _contract(cutover=True) + signal_ids = list(contract.pilot_qualification_required_signals) + if result_kind == "missing": + results = _pilot_signal_results(signal_ids[:-1]) + elif result_kind == "extra": + results = _pilot_signal_results([*signal_ids, "zz-extra-signal"]) + elif result_kind == "duplicate": + results = _pilot_signal_results([signal_ids[0], *signal_ids]) + else: + results = _pilot_signal_results(signal_ids, conclusion="failure") + + with pytest.raises(EvidenceRejected): + _qualified_production( + contract, + qualification_resolver=FakePilotQualificationResolver( + signal_results=results + ), + ) + + +@pytest.mark.parametrize( + "state", + [ + OperationState.OUTCOME_UNKNOWN, + OperationState.OBSERVED_NOT_APPLIED, + OperationState.MANUAL_REVIEW, + ], +) +def test_non_applied_reconciliation_states_cannot_qualify_production(state) -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(plan.operations[1], state=state) + + with pytest.raises(LedgerSafetyError, match="applied effect"): + _qualified_production(contract, plan=plan, ledger=ledger) + + +def test_durable_observed_applied_pilot_can_be_qualified_without_retry() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt( + plan.operations[1], + state=OperationState.OBSERVED_APPLIED, + ) + + operation = _qualified_production(contract, plan=plan, ledger=ledger) + + assert operation.pilot_qualification_receipt_hash is not None + assert operation.qualified_pilot_applied_receipt_hash == ( + ledger.pilot_receipt.receipt_hash + ) + + +def test_pilot_qualification_receipt_hash_changes_production_fingerprint() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + first = _qualified_production(contract, plan=plan) + second = _qualified_production( + contract, + plan=plan, + qualification_resolver=FakePilotQualificationResolver(evidence_hash=DIGEST_A), + ) + + assert first.pilot_qualification_receipt_hash != ( + second.pilot_qualification_receipt_hash + ) + assert first.operation_fingerprint != second.operation_fingerprint + + +def test_production_execution_requires_the_pilot_receipt_that_was_qualified() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + qualified_receipt = _pilot_receipt( + plan.operations[1], + effect_confirmed_at=NOW - timedelta(seconds=20), + ) + ledger.pilot_receipt = qualified_receipt + operation = _qualified_production(contract, plan=plan, ledger=ledger) + provider = FakeProvider() + + assert operation.qualified_pilot_applied_receipt_hash == ( + qualified_receipt.receipt_hash + ) + ledger.pilot_receipt = replace( + qualified_receipt, + receipt_id="30000000-0000-4000-8000-000000000002", + claim_id="20000000-0000-4000-8000-000000000002", + effect_confirmed_at=NOW, + ) + + with pytest.raises(LedgerSafetyError, match="qualified receipt"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_pilot_qualification_rejects_fresh_wrapper_around_stale_signal() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt( + plan.operations[1], + effect_confirmed_at=NOW - timedelta(minutes=10), + ) + stale_results = tuple( + replace( + result, + observed_at=NOW - timedelta(seconds=301), + ) + for result in _pilot_signal_results( + contract.pilot_qualification_required_signals + ) + ) + + with pytest.raises(EvidenceRejected, match="stale signal"): + _qualified_production( + contract, + plan=plan, + ledger=ledger, + qualification_resolver=FakePilotQualificationResolver( + signal_results=stale_results + ), + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"request_digest": DIGEST_A}, + {"pilot_claim_id": "20000000-0000-4000-8000-000000000002"}, + {"qualification_receipt_hash": DIGEST_A}, + {"external_receipt_hash": DIGEST_A}, + {"artifact_digest": DIGEST_B}, + {"verifier_identity": "different-verifier"}, + {"durable": False}, + {"recorded_at": NOW + timedelta(seconds=1)}, + {"state_version": 0}, + {"return_untyped": True}, + {"return_subclass": True}, + ], +) +def test_durable_qualification_receipt_must_exactly_echo_verified_record( + overrides, +) -> None: + contract = _contract(cutover=True) + target_resolver = FakeTargetResolver() + + with pytest.raises(LedgerSafetyError): + _qualified_production( + contract, + qualification_evidence=FakeQualificationEvidence(**overrides), + target_resolver=target_resolver, + ) + + assert target_resolver.calls == 0 + + +def test_production_target_is_resolved_fresh_after_long_pilot_qualification() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + later = NOW + timedelta(minutes=6) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt( + plan.operations[1], + effect_confirmed_at=later - timedelta(seconds=30), + ) + signals = tuple( + replace(result, observed_at=later - timedelta(seconds=10)) + for result in _pilot_signal_results( + contract.pilot_qualification_required_signals + ) + ) + target_resolver = FakeTargetResolver(captured_at=later - timedelta(seconds=2)) + + final = _qualified_production( + contract, + plan=plan, + ledger=ledger, + qualification_resolver=FakePilotQualificationResolver( + signal_results=signals, + verified_at=later - timedelta(seconds=5), + ), + qualification_evidence=FakeQualificationEvidence( + recorded_at=later - timedelta(seconds=1) + ), + target_resolver=target_resolver, + clock=FakeClock(later), + ) + + assert plan.operations[1].evidence_valid_until < later + assert final.target_snapshot_hash != plan.operations[1].target_snapshot_hash + assert final.evidence_valid_until > later + assert target_resolver.calls == 1 + + +def test_fresh_production_target_source_is_independent_from_qualification() -> None: + contract = _contract(cutover=True) + + with pytest.raises(EvidenceRejected, match="target source is not independent"): + _qualified_production( + contract, + target_resolver=FakeTargetResolver( + source_identity="pilot-qualification-verifier" + ), + ) + + +def test_fresh_production_target_uses_the_planned_resolver_identity() -> None: + contract = _contract(cutover=True) + + with pytest.raises(EvidenceRejected, match="differs from the planned resolver"): + _qualified_production( + contract, + target_resolver=FakeTargetResolver( + source_identity="replacement-target-verifier" + ), + ) + + +def test_finalization_rejects_a_changed_production_profile_before_execution() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + provider = FakeProvider() + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(plan.operations[1]) + + with pytest.raises(EvidenceRejected, match="target_profile_digest"): + operation = _qualified_production( + contract, + plan=plan, + ledger=ledger, + target_resolver=FakeTargetResolver(target_profile_digest=DIGEST_A), + ) + _execute( + contract=contract, operation=operation, ledger=ledger, provider=provider + ) + + assert provider.calls == 0 + + +@pytest.mark.parametrize("target", [TargetName.PILOT, TargetName.PRODUCTION]) +def test_planning_requires_the_manifest_profile_from_the_verified_target( + target, +) -> None: + contract = _contract(cutover=True) + provider = FakeProvider() + + class ChangedProfile(FakeTargetResolver): + def snapshot_verified(self, request): + receipt = super().snapshot_verified(request) + return ( + replace(receipt, target_profile_digest=DIGEST_A) + if request.target is target + else receipt + ) + + resolver = ChangedProfile() + with pytest.raises(EvidenceRejected, match="target_profile_digest"): + plan = _plan(contract, target_resolver=resolver) + _execute(contract=contract, operation=plan.operations[0], provider=provider) + + assert resolver.requests[-1].target is target + assert provider.calls == 0 + + +@pytest.mark.parametrize( + "field", ["configuration_manifest_hash", "environment_delta_registry_digest"] +) +def test_finalization_rejects_changed_configuration_evidence(field) -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + provider = FakeProvider() + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(plan.operations[1]) + + with pytest.raises(EvidenceRejected, match=field): + operation = _qualified_production( + contract, + plan=plan, + ledger=ledger, + target_resolver=FakeTargetResolver(**{field: DIGEST_A}), + ) + _execute( + contract=contract, operation=operation, ledger=ledger, provider=provider + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +@pytest.mark.parametrize("field", ["release_id", "embedded_manifest_hash"]) +def test_configuration_manifest_must_belong_to_the_candidate_before_resolving( + field, +) -> None: + contract = _contract(cutover=True) + manifest = _manifest(contract) + configuration = _configuration_manifest( + manifest, **{field: "other-release" if field == "release_id" else DIGEST_A} + ) + artifact = FakeArtifactResolver() + with pytest.raises(EvidenceRejected, match="does not match embedded manifest"): + _plan( + contract, configuration_manifest=configuration, artifact_resolver=artifact + ) + assert artifact.calls == 0 + + +def test_a_raw_configuration_mapping_is_not_verified_evidence() -> None: + contract = _contract(cutover=True) + configuration = _configuration_manifest(_manifest(contract)).to_mapping() + artifact = FakeArtifactResolver() + + with pytest.raises(SchemaError, match="strictly validated"): + _plan( + contract, configuration_manifest=configuration, artifact_resolver=artifact + ) + + assert artifact.calls == 0 + + +@pytest.mark.parametrize( + "reference", ["pilot", "production", "environment_delta_registry"] +) +def test_finalization_rejects_replacement_of_any_planned_configuration_reference( + reference, +) -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + original = plan._configuration_manifest + changed = replace( + original, **{reference: replace(getattr(original, reference), digest=DIGEST_A)} + ) + object.__setattr__(plan, "_configuration_manifest", changed) + qualification = FakePilotQualificationResolver() + target = FakeTargetResolver() + + with pytest.raises(EvidenceRejected, match="sealed evidence linkage"): + _qualified_production( + contract, + plan=plan, + qualification_resolver=qualification, + target_resolver=target, + ) + + assert qualification.calls == 0 + assert target.calls == 0 + + +@pytest.mark.parametrize( + "reference", ["pilot", "production", "environment_delta_registry"] +) +def test_new_configuration_reference_invalidates_previous_operation_authority( + reference, +) -> None: + contract = _contract(cutover=True) + first = _plan(contract) + original = first._configuration_manifest + # Even identical content at a different accepted commit is a new manifest. + changed = replace( + original, + **{ + reference: replace(getattr(original, reference), repository_commit="3" * 40) + }, + ) + second = _plan(contract, configuration_manifest=changed) + operation = second.operations[0] + provider = FakeProvider() + ledger = FakeLedger() + + assert operation.artifact_digest == first.operations[0].artifact_digest + assert operation.configuration_manifest_hash == changed.manifest_hash + assert operation.operation_fingerprint != first.operations[0].operation_fingerprint + with pytest.raises(AuthorityRejected, match="operation_fingerprint"): + _execute( + contract=contract, + operation=operation, + authority=_authority(first.operations[0]), + provider=provider, + ledger=ledger, + ) + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_fresh_production_baseline_preserves_configuration_and_executes_with_new_authority() -> ( + None +): + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(plan.operations[1]) + resolver = FakeTargetResolver( + current_artifact_digest=DIGEST_F, + current_manifest_hash=DIGEST_C, + target_config_digest=DIGEST_B, + captured_at=NOW, + ) + operation = _qualified_production( + contract, plan=plan, ledger=ledger, target_resolver=resolver + ) + provider = FakeProvider() + + result = _execute( + contract=contract, operation=operation, ledger=ledger, provider=provider + ) + + assert result.state is ExecutionState.APPLIED + assert provider.calls == 1 + assert operation.expected_current_digest == DIGEST_F + assert operation.target_config_digest == DIGEST_B + assert operation.target_profile_digest == plan.operations[1].target_profile_digest + assert ( + operation.configuration_manifest_hash + == plan._configuration_manifest.manifest_hash + ) + assert operation.target_snapshot_hash != plan.operations[1].target_snapshot_hash + assert resolver.requests[0].configuration_manifest == plan._configuration_manifest + + +@pytest.mark.parametrize( + "overrides", + [ + {"target_profile_digest": DIGEST_A}, + {"environment_delta_registry_digest": DIGEST_A}, + ], +) +def test_rollback_uses_the_previous_releases_fixed_configuration(overrides) -> None: + contract = _contract(cutover=True) + rollback = FakeRollbackResolver() + + with pytest.raises( + EvidenceRejected, + match="target_profile_digest|environment_delta_registry_digest", + ): + _rollback(contract, target=FakeTargetResolver(**overrides), rollback=rollback) + + assert rollback.calls == 0 + + +def test_oldest_check_result_bounds_final_production_execution_deadline() -> None: + contract = _contract(cutover=True) + check_results = list(_check_results(contract.required_checks)) + check_results[0] = replace( + check_results[0], + completed_at=NOW - timedelta(seconds=3599), + ) + plan = _plan( + contract, + check_suite_resolver=FakeCheckSuiteResolver(results=tuple(check_results)), + ) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(plan.operations[1]) + operation = _qualified_production(contract, plan=plan, ledger=ledger) + provider = FakeProvider() + + assert operation.evidence_valid_until == NOW + timedelta(seconds=1) + with pytest.raises(EvidenceRejected, match="expired"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=FakeClock(NOW + timedelta(seconds=2)), + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_oldest_pilot_signal_bounds_production_execution_deadline() -> None: + contract = _contract(cutover=True) + plan = _plan(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt( + plan.operations[1], + effect_confirmed_at=NOW - timedelta(seconds=400), + ) + signal_results = list( + _pilot_signal_results(contract.pilot_qualification_required_signals) + ) + signal_results[0] = replace( + signal_results[0], + observed_at=NOW - timedelta(seconds=299), + ) + operation = _qualified_production( + contract, + plan=plan, + ledger=ledger, + qualification_resolver=FakePilotQualificationResolver( + signal_results=tuple(signal_results) + ), + ) + provider = FakeProvider() + + assert operation.evidence_valid_until == NOW + timedelta(seconds=1) + with pytest.raises(EvidenceRejected, match="expired"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=FakeClock(NOW + timedelta(seconds=2)), + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_correspondence_digest_has_no_artifact_or_target_input_slot() -> None: + request_types = ( + ArtifactResolutionRequest, + CheckSuiteVerificationRequest, + QualificationRecordRequest, + SourceCandidateVerificationRequest, + TargetSnapshotRequest, + ) + assert all( + "correspondence_digest" not in {item.name for item in fields(request_type)} + for request_type in request_types + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("contract_digest", DIGEST_A), + ("contract_edition", "different-edition"), + ("required_checks", ("different-check",)), + ("artifact_digest", DIGEST_B), + ("embedded_layer_digest", DIGEST_C), + ("artifact_resolver_identity", "other-artifact-verifier"), + ("gate_evidence_hash", DIGEST_C), + ("target_profile_digest", DIGEST_A), + ("target_source_identity", "other-target-verifier"), + ("expected_current_digest", DIGEST_A), + ("target_config_digest", DIGEST_A), + ("migration_history_digest", DIGEST_A), + ("current_schema", 4), + ("migration_class", MigrationClass.DATA_REWRITE), + ("schema_max", 7), + ("evidence_valid_until", "2026-09-03T13:00:00Z"), + ("required_pilot_operation_fingerprint", DIGEST_A), + ], +) +def test_operation_fingerprint_binds_contract_release_target_and_migration( + field, value +) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + payload = json.loads(canonical_json_bytes(operation.fingerprint_payload())) + paths = { + "contract_digest": ("contract", "digest"), + "contract_edition": ("contract", "edition"), + "required_checks": ("contract", "required_checks"), + "artifact_digest": ("artifact", "digest"), + "embedded_layer_digest": ("artifact", "embedded_layer_digest"), + "artifact_resolver_identity": ("artifact", "resolver_identity"), + "gate_evidence_hash": (None, "gate_evidence_hash"), + "target_profile_digest": ("target", "profile_digest"), + "target_source_identity": ("target", "source_identity"), + "expected_current_digest": ("target", "expected_current_digest"), + "target_config_digest": ("target", "target_config_digest"), + "migration_history_digest": ("target", "migration_history_digest"), + "current_schema": ("target", "current_schema"), + "migration_class": ("migration", "class"), + "schema_max": ("migration", "schema_max"), + "evidence_valid_until": (None, "evidence_valid_until"), + "required_pilot_operation_fingerprint": ( + None, + "required_pilot_operation_fingerprint", + ), + } + section, key = paths[field] + if isinstance(value, tuple): + value = list(value) + if isinstance(value, MigrationClass): + value = value.value + if section is None: + payload[key] = value + else: + payload[section][key] = value + + assert canonical_sha256(payload) != operation.operation_fingerprint + + +def test_cutover_false_blocks_every_execution_before_authority_ledger_or_provider() -> ( + None +): + contract = _contract(cutover=False) + operation = _plan(contract).operations[0] + verifier = FakeAuthorityVerifier() + ledger = FakeLedger() + provider = FakeProvider() + + with pytest.raises(CutoverBlocked): + _execute( + contract=contract, + operation=operation, + verifier=verifier, + ledger=ledger, + provider=provider, + ) + + assert verifier.calls == 0 + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_execute_rejects_operation_from_another_contract_before_provider() -> None: + live = _contract(cutover=True) + foreign = _contract(cutover=False) + operation = _plan(live).operations[0] + provider = FakeProvider() + + with pytest.raises(ContractError, match="digest"): + _execute(contract=foreign, operation=operation, provider=provider) + + assert provider.calls == 0 + + +@pytest.mark.parametrize( + "receipt_overrides", + [ + None, + {"artifact_digest": DIGEST_B}, + {"embedded_manifest_hash": DIGEST_B}, + {"contract_digest": DIGEST_B}, + {"contract_edition": "old-edition"}, + {"required_checks_digest": DIGEST_B}, + {"operation_fingerprint": DIGEST_B}, + {"state": OperationState.OUTCOME_UNKNOWN}, + {"state_version": 1}, + {"durable": False}, + {"action": "promote"}, + {"target": "pilot"}, + {"state": "applied"}, + {"durable": 1}, + ], +) +def test_production_requires_exact_durable_pilot_applied_receipt( + receipt_overrides, +) -> None: + contract = _contract(cutover=True) + operation = _qualified_production(contract) + ledger = FakeLedger() + provider = FakeProvider() + if receipt_overrides is not None: + ledger.pilot_receipt = _pilot_receipt(operation, **receipt_overrides) + + with pytest.raises(LedgerSafetyError, match="pilot"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_exact_pilot_receipt_allows_production_to_reuse_same_artifact() -> None: + contract = _contract(cutover=True) + operation = _qualified_production(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(operation) + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert result.state is ExecutionState.APPLIED + assert provider.calls == 1 + + +def test_execute_operation_requires_negative_matrix_receipt_when_cutover_enabled() -> ( + None +): + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + ledger.force_missing_negative_matrix = True + provider = FakeProvider() + + with pytest.raises(LedgerSafetyError, match="negative-matrix"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.negative_matrix_calls == 1 + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + {"evidence_ref": DIGEST_B}, + {"repository": "other/repository"}, + {"contract_digest": DIGEST_B}, + {"contract_edition": "other-edition"}, + {"pilot_source_ref": "other-pilot-ref"}, + {"production_source_ref": "other-production-ref"}, + ], +) +def test_negative_matrix_receipt_mismatch_is_rejected(overrides) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + values = { + "evidence_ref": contract.negative_matrix_evidence_ref, + "repository": contract.repository, + "contract_digest": contract.contract_digest, + "contract_edition": contract.contract_edition, + "pilot_source_ref": contract.pilot_source_ref, + "production_source_ref": contract.production_source_ref, + } + values.update(overrides) + ledger = FakeLedger() + ledger.negative_matrix_override = NegativeMatrixReceipt( + evidence_ref=values["evidence_ref"], + repository=values["repository"], + contract_digest=values["contract_digest"], + contract_edition=values["contract_edition"], + pilot_source_ref=values["pilot_source_ref"], + production_source_ref=values["production_source_ref"], + verified_at=NOW, + approver_identity="approver-negative-matrix", + evidence_hash=DIGEST_F, + ) + provider = FakeProvider() + + with pytest.raises(LedgerSafetyError, match="negative-matrix receipt mismatch"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_stale_negative_matrix_receipt_is_rejected() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + ledger.negative_matrix_verified_at = NOW - timedelta( + seconds=contract.negative_matrix_max_age_seconds + 1 + ) + provider = FakeProvider() + + with pytest.raises(EvidenceRejected, match="negative-matrix receipt is stale"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_fresh_negative_matrix_receipt_allows_execution() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert result.state is ExecutionState.APPLIED + assert ledger.negative_matrix_calls == 1 + + +def test_contract_requires_negative_matrix_evidence_ref_when_cutover_enabled() -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["cutover"]["enabled"] = True + payload["cutover"]["negative_matrix_evidence_ref"] = None + + with pytest.raises(ContractError, match="negative_matrix_evidence_ref"): + validate_release_control_contract(payload) + + +def test_contract_rejects_malformed_negative_matrix_evidence_ref() -> None: + payload = loads_strict_json(CONTRACT_PATH.read_bytes()) + payload["cutover"]["enabled"] = True + payload["cutover"]["negative_matrix_evidence_ref"] = "not-a-digest" + + with pytest.raises(ContractError): + validate_release_control_contract(payload) + + +def test_pilot_receipt_release_identity_must_be_exact() -> None: + contract = _contract(cutover=True) + operation = _qualified_production(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(operation, release_id="different-release") + provider = FakeProvider() + + with pytest.raises(LedgerSafetyError, match="release_id"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + {"purpose": "other-purpose"}, + {"audience": "other-audience"}, + {"repository": "other/repository"}, + {"target": "pilot"}, + {"contract_digest": DIGEST_A}, + {"contract_edition": "old-edition"}, + {"required_checks_digest": DIGEST_A}, + {"runner_identity": "other-runner"}, + {"approver_identity": RUNNER}, + ], +) +def test_authority_scope_is_domain_separated_and_exact(overrides) -> None: + contract = _contract(cutover=True) + operation = _qualified_production(contract) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(operation) + provider = FakeProvider() + + with pytest.raises(AuthorityRejected): + _execute( + contract=contract, + operation=operation, + authority=_authority(operation, **overrides), + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_forged_or_expired_authority_has_zero_mutation_calls() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + for authority, clock in ( + (_authority(operation, signature="forged"), FakeClock()), + ( + _authority( + operation, + issued_at=(NOW - timedelta(minutes=31)) + .isoformat() + .replace("+00:00", "Z"), + expires_at=(NOW - timedelta(minutes=1)) + .isoformat() + .replace("+00:00", "Z"), + ), + FakeClock(), + ), + ): + ledger = FakeLedger() + provider = FakeProvider() + with pytest.raises(AuthorityRejected): + _execute( + contract=contract, + operation=operation, + authority=authority, + clock=clock, + ledger=ledger, + provider=provider, + ) + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_unavailable_trusted_clock_has_zero_authority_ledger_or_provider_calls() -> ( + None +): + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + verifier = FakeAuthorityVerifier() + ledger = FakeLedger() + provider = FakeProvider() + + with pytest.raises(EvidenceRejected, match="clock"): + _execute( + contract=contract, + operation=operation, + clock=FailingClock(), + verifier=verifier, + ledger=ledger, + provider=provider, + ) + + assert verifier.calls == 0 + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_authority_verifier_receives_canonical_domain_separated_bytes() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + verifier = FakeAuthorityVerifier() + + _execute(contract=contract, operation=operation, verifier=verifier) + + signed = json.loads(verifier.payloads[0]) + assert signed["purpose"] == AUTHORITY_PURPOSE + assert signed["audience"] == AUTHORITY_AUDIENCE + assert signed["repository"] == contract.repository + assert signed["contract_digest"] == contract.contract_digest + assert signed["target"] == "pilot" + + +@pytest.mark.parametrize( + "overrides", + [ + {"key_id": "other-key"}, + {"authority_id": "10000000-0000-4000-8000-000000000002"}, + {"signer_identity": "other-signer"}, + {"runner_identity": "other-runner"}, + {"signed_payload_digest": DIGEST_B}, + {"trust_root_identity": "foreign-authority-root"}, + {"signature_valid": False}, + {"independent_signer": False}, + {"return_untyped": True}, + ], +) +def test_authority_verification_receipt_must_be_independent_and_exact( + overrides, +) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider() + + with pytest.raises(AuthorityRejected): + _execute( + contract=contract, + operation=operation, + verifier=FakeAuthorityVerifier(**overrides), + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_authority_parser_rejects_extra_fields_and_noncanonical_uuid() -> None: + operation = _plan(_contract(cutover=True)).operations[0] + payload = _authority(operation).signed_payload() + payload.update({"signature": "valid", "unexpected": True}) + with pytest.raises(SchemaError, match="extra"): + AuthorityEvidence.from_mapping(payload) + + payload.pop("unexpected") + payload["authority_id"] = "NOT-A-UUID" + with pytest.raises(SchemaError, match="UUID"): + AuthorityEvidence.from_mapping(payload) + + +def test_ledger_claim_boundary_contains_only_bounded_opaque_values() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + authority = _authority(operation) + ledger = FakeLedger() + + _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + ) + + assert {field.name for field in fields(LedgerClaimRequest)} == { + "external_id", + "authorization_id", + "operation_fingerprint", + "target_key", + "artifact_digest", + "operation_kind", + "contract_digest", + "evidence_hash", + } + assert ledger.last_claim_request is not None + assert len(ledger.last_claim_request.external_id) == 68 + assert ledger.last_claim_request.evidence_hash != authority.evidence_hash + assert not hasattr(ledger.last_claim_request, "signature") + assert not hasattr(ledger.last_claim_request, "image_reference") + + +def test_provider_idempotency_is_derived_only_from_operation_fingerprint() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + provider = FakeProvider() + + _execute(contract=contract, operation=operation, provider=provider) + + assert provider.keys == [provider_idempotency_key(operation.operation_fingerprint)] + assert provider.keys[0] != CLAIM_ID + assert provider.requests[0].external_id == operation_external_id( + operation.operation_fingerprint + ) + assert provider.requests[0].claim_id == CLAIM_ID + assert provider.requests[0].fencing_token == 10 + + +def test_provider_request_binds_atomic_target_snapshot_and_trusted_time_window() -> ( + None +): + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + provider = FakeProvider() + ledger = FakeLedger() + + result = _execute( + contract=contract, + operation=operation, + provider=provider, + ledger=ledger, + clock=SequenceClock(NOW, NOW, NOW, NOW), + ) + + request = provider.requests[0] + assert result.state is ExecutionState.APPLIED + assert request.not_before == NOW + assert request.not_before < request.not_after + assert request.expected_current_artifact_digest == ( + operation.expected_current_digest + ) + assert request.expected_target_config_digest == operation.target_config_digest + assert request.expected_current_schema == operation.current_schema + assert request.expected_migration_history_digest == ( + operation.migration_history_digest + ) + assert request.target_snapshot_hash == operation.target_snapshot_hash + assert ledger.intent_calls == 1 + assert ledger.last_intent_request.provider_intent_hash == request.intent_hash + assert ledger.last_intent_request.not_before == request.not_before + assert ledger.last_intent_request.not_after == request.not_after + assert ledger.last_outcome_request is not None + assert ledger.last_outcome_request.evidence_hash == provider.results[0].receipt_hash + + +def test_provider_is_never_called_without_an_exact_durable_intent() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + ledger.force_intent_conflict = True + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert ledger.intent_calls == 1 + assert provider.calls == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + {"provider_intent_hash": DIGEST_B}, + {"provider_not_before": NOW - timedelta(seconds=1)}, + {"provider_not_after": NOW + timedelta(seconds=1)}, + {"state": OperationState.OUTCOME_UNKNOWN}, + {"state_version": 1}, + {"state_version": 3}, + {"fencing_token": 11}, + ], +) +def test_provider_intent_cas_must_return_the_exact_next_snapshot(overrides) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = IntentTamperingLedger(**overrides) + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert provider.calls == 0 + + +def test_slow_provider_intent_cas_rechecks_deadline_before_mutation() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=SequenceClock(NOW, NOW, NOW, operation.evidence_valid_until), + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert ledger.intent_calls == 1 + assert provider.calls == 0 + + +def test_release_operation_cannot_be_constructed_without_verified_plan_seal() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + values = { + item.name: getattr(operation, item.name) + for item in fields(ReleaseOperation) + if not item.name.startswith("_") + } + + with pytest.raises(EvidenceRejected, match="verified planning"): + ReleaseOperation(**values) + with pytest.raises(EvidenceRejected, match="verified planning"): + replace(operation, artifact_digest=DIGEST_B) + + +@pytest.mark.parametrize( + "overrides", + [ + {"disposition": "owner"}, + {"external_id": "rc1-" + "b" * 64}, + {"authorization_id": "10000000-0000-4000-8000-000000000002"}, + {"operation_fingerprint": DIGEST_B}, + {"target_key": TargetName.PRODUCTION}, + {"target_key": "pilot"}, + {"artifact_digest": DIGEST_B}, + {"operation_kind": ReleaseAction.ROLLBACK}, + {"operation_kind": "promote"}, + {"contract_digest": DIGEST_B}, + {"evidence_hash": DIGEST_B}, + {"state": OperationState.OUTCOME_UNKNOWN}, + {"state_version": 0}, + {"state_version": 2}, + {"fencing_token": 0}, + {"fencing_token": 11}, + {"durable": False}, + {"authority_use_burned": False}, + {"claim_id": "not-a-uuid"}, + {"claim_id": "20000000-0000-4000-8000-000000000002"}, + ], +) +def test_claim_must_echo_durable_burned_fenced_receipt_before_provider( + overrides, +) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = ClaimTamperingLedger(**overrides) + provider = FakeProvider() + + with pytest.raises((LedgerSafetyError, SchemaError)): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 1 + assert provider.calls == 0 + + +def test_twenty_duplicate_claims_have_one_owner_and_one_provider_call() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + authority = _authority(operation) + ledger = FakeLedger() + provider = FakeProvider() + verifier = FakeAuthorityVerifier() + + def attempt(): + return _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + provider=provider, + verifier=verifier, + ).state + + with ThreadPoolExecutor(max_workers=20) as pool: + states = list(pool.map(lambda _: attempt(), range(20))) + + assert set(states) <= {ExecutionState.APPLIED, ExecutionState.OUTCOME_UNKNOWN} + assert ExecutionState.APPLIED in states + retry = _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + provider=provider, + verifier=verifier, + ) + assert retry.state is ExecutionState.APPLIED + assert retry.provider_called is False + assert ledger.owner_count == 1 + assert provider.calls == 1 + + +def test_retry_returns_durable_applied_after_outcome_cas_reply_is_lost() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = LostOutcomeReplyLedger() + provider = FakeProvider() + + first = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + second = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert first.state is ExecutionState.OUTCOME_UNKNOWN + assert second.state is ExecutionState.APPLIED + assert second.provider_called is False + assert provider.calls == 1 + assert ( + ledger.states[operation.operation_fingerprint].state is OperationState.APPLIED + ) + + +def test_retry_returns_durable_applied_after_transient_evidence_expires() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = LostOutcomeReplyLedger() + provider = FakeProvider() + verifier = FakeAuthorityVerifier() + + first = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + verifier=verifier, + ) + expired_clock = FakeClock(operation.evidence_valid_until + timedelta(seconds=1)) + second = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + verifier=verifier, + clock=expired_clock, + ) + + assert first.state is ExecutionState.OUTCOME_UNKNOWN + assert second.state is ExecutionState.APPLIED + assert second.provider_called is False + assert expired_clock.calls == 0 + assert verifier.calls == 1 + assert ledger.claim_calls == 1 + assert provider.calls == 1 + + +def test_existing_claimed_lock_blocks_a_second_owner_and_provider_call() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation, state=OperationState.CLAIMED_LOCKED, version=1) + provider = FakeProvider() + verifier = FakeAuthorityVerifier() + clock = FakeClock() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + verifier=verifier, + clock=clock, + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert ledger.claim_calls == 0 + assert provider.calls == 0 + assert verifier.calls == 0 + assert clock.calls == 0 + + +def test_raced_duplicate_preserves_winning_authority_evidence() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = RacingDuplicateEvidenceLedger(operation) + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert result.state is ExecutionState.APPLIED + assert result.provider_called is False + assert ledger.last_claim_request is not None + assert ledger.claims[operation.operation_fingerprint].authorization_id != ( + ledger.last_claim_request.authorization_id + ) + assert ledger.claims[operation.operation_fingerprint].evidence_hash == DIGEST_A + assert ledger.last_claim_request.evidence_hash != DIGEST_A + assert provider.calls == 0 + + +@pytest.mark.parametrize("state_version", [1, 2]) +def test_forged_duplicate_applied_never_returns_false_success( + state_version: int, +) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = ClaimTamperingLedger( + disposition=ClaimDisposition.DUPLICATE, + state=OperationState.APPLIED, + state_version=state_version, + ) + provider = FakeProvider() + + with pytest.raises((LedgerSafetyError, SchemaError)): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert provider.calls == 0 + + +@pytest.mark.parametrize("failure", [TimeoutError("timeout"), RuntimeError("crash")]) +def test_timeout_or_caught_crash_is_unknown_and_never_retried(failure) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + authority = _authority(operation) + ledger = FakeLedger() + provider = FakeProvider(failure=failure) + + first = _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + provider=provider, + ) + second = _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + provider=provider, + ) + + assert first.state is ExecutionState.OUTCOME_UNKNOWN + assert second.state is ExecutionState.OUTCOME_UNKNOWN + assert provider.calls == 1 + assert ( + ledger.states[operation.operation_fingerprint].state + is OperationState.OUTCOME_UNKNOWN + ) + + +def test_failed_applied_cas_returns_unknown_not_false_success() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + ledger.force_outcome_conflict = True + + result = _execute(contract=contract, operation=operation, ledger=ledger) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + + +@pytest.mark.parametrize( + "overrides", + [ + {"external_id": "rc1-" + "b" * 64}, + {"operation_fingerprint": DIGEST_B}, + {"target": TargetName.PRODUCTION}, + {"artifact_digest": DIGEST_B}, + {"contract_digest": DIGEST_B}, + {"state": OperationState.OUTCOME_UNKNOWN}, + {"state_version": 4}, + {"fencing_token": 11}, + ], +) +def test_applied_cas_must_return_the_exact_next_snapshot(overrides) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = OutcomeTamperingLedger(**overrides) + + result = _execute(contract=contract, operation=operation, ledger=ledger) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + + +@pytest.mark.parametrize( + "overrides", + [ + {"operation_fingerprint": DIGEST_B}, + {"external_id": "rc1-" + "b" * 64}, + {"claim_id": "20000000-0000-4000-8000-000000000002"}, + {"idempotency_key": DIGEST_B}, + {"fencing_token": 11}, + {"not_before": NOW - timedelta(seconds=1)}, + {"not_after": NOW + timedelta(seconds=1)}, + {"applied_at": NOW - timedelta(seconds=1)}, + {"applied_at": NOW + timedelta(hours=1)}, + {"observed_previous_artifact_digest": DIGEST_C}, + {"observed_target_config_digest": DIGEST_C}, + {"observed_current_schema": 4}, + {"observed_migration_history_digest": DIGEST_C}, + {"artifact_digest": DIGEST_B}, + {"image_reference": (f"registry.example.invalid/other/app@{DIGEST_A}")}, + {"target_profile_digest": DIGEST_B}, + {"target_snapshot_hash": DIGEST_B}, + {"provider_reference_hash": "invalid"}, + {"evidence_hash": "invalid"}, + {"return_untyped": True}, + {"return_subclass": True}, + ], +) +def test_mismatched_provider_response_is_unknown_and_never_retried(overrides) -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider(result_overrides=overrides) + + first = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + second = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert first.state is ExecutionState.OUTCOME_UNKNOWN + assert second.state is ExecutionState.OUTCOME_UNKNOWN + assert provider.calls == 1 + + +def test_expired_verified_plan_evidence_blocks_provider() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + later = NOW + timedelta(minutes=6) + authority = _authority( + operation, + issued_at=(later - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + expires_at=(later + timedelta(minutes=29)).isoformat().replace("+00:00", "Z"), + ) + ledger = FakeLedger() + provider = FakeProvider() + + with pytest.raises(EvidenceRejected, match="expired"): + _execute( + contract=contract, + operation=operation, + authority=authority, + clock=FakeClock(later), + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_plan_evidence_expiring_after_claim_is_unknown_without_provider_call() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider() + clock = SequenceClock(NOW, NOW, NOW + timedelta(minutes=5)) + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=clock, + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert clock.calls == 3 + assert ledger.outcome_calls == 1 + assert ledger.states[operation.operation_fingerprint].state is ( + OperationState.OUTCOME_UNKNOWN + ) + assert provider.calls == 0 + + +def test_authority_expiring_after_claim_is_unknown_without_provider_call() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + authority = _authority( + operation, + expires_at=(NOW + timedelta(seconds=1)).isoformat().replace("+00:00", "Z"), + ) + ledger = FakeLedger() + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + authority=authority, + ledger=ledger, + provider=provider, + clock=SequenceClock(NOW, NOW, NOW + timedelta(seconds=2)), + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert ledger.states[operation.operation_fingerprint].state is ( + OperationState.OUTCOME_UNKNOWN + ) + assert provider.calls == 0 + + +def test_trusted_clock_failure_after_claim_is_unknown_without_provider_call() -> None: + contract = _contract(cutover=True) + operation = _plan(contract).operations[0] + ledger = FakeLedger() + provider = FakeProvider() + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=SequenceClock(NOW, NOW, RuntimeError("trusted time lost")), + ) + + assert result.state is ExecutionState.OUTCOME_UNKNOWN + assert result.provider_called is False + assert ledger.states[operation.operation_fingerprint].state is ( + OperationState.OUTCOME_UNKNOWN + ) + assert provider.calls == 0 + + +def test_single_zero_snapshot_never_finalizes_not_applied() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + envelope = _envelope(operation, 1) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver([envelope]), + clock=FakeClock(), + ) + + assert result.resolved is False + assert result.state is OperationState.OUTCOME_UNKNOWN + assert ledger.reconcile_calls == 0 + + +def test_reconciliation_rechecks_freshness_after_slow_observer() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + observer = FakeObserver( + [_envelope(operation, 1, observations=[_observation(operation)])] + ) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=observer, + clock=SequenceClock(NOW, NOW + timedelta(minutes=6)), + ) + + assert result.resolved is False + assert result.state is OperationState.OUTCOME_UNKNOWN + assert ledger.reconcile_calls == 0 + + +def test_zero_observations_do_not_finalize_while_mutation_window_is_open() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + envelopes = [ + _envelope(operation, 1, captured_at=NOW - timedelta(seconds=50)), + _envelope(operation, 2, captured_at=NOW - timedelta(seconds=10)), + ] + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver(envelopes), + clock=FakeClock(), + ) + + assert NOW < operation.evidence_valid_until + assert result.resolved is False + assert result.state is OperationState.OUTCOME_UNKNOWN + assert ledger.reconcile_calls == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + {"target": "pilot"}, + {"state": "outcome_unknown"}, + ], +) +def test_reconciliation_rejects_untyped_ledger_state(overrides) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + ledger.states[operation.operation_fingerprint] = replace( + ledger.states[operation.operation_fingerprint], + **overrides, + ) + observer = FakeObserver([_envelope(operation, 1)]) + + with pytest.raises(LedgerSafetyError, match="untyped"): + reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=observer, + clock=FakeClock(), + ) + + assert observer.calls == 0 + + +def test_two_fresh_complete_settled_zero_snapshots_finalize_by_cas() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation, version=7, fencing=41) + after_mutation_horizon = operation.evidence_valid_until + timedelta(minutes=1) + envelopes = [ + _envelope( + operation, + 1, + captured_at=after_mutation_horizon - timedelta(seconds=50), + settled_through=after_mutation_horizon - timedelta(seconds=50), + ), + _envelope( + operation, + 2, + captured_at=after_mutation_horizon - timedelta(seconds=10), + settled_through=after_mutation_horizon - timedelta(seconds=10), + ), + ] + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver(envelopes), + clock=FakeClock(after_mutation_horizon), + ) + + assert result.resolved is True + assert result.state is OperationState.OBSERVED_NOT_APPLIED + assert ledger.last_reconcile_request.expected_version == 7 + assert ledger.last_reconcile_request.fencing_token == 41 + + +def test_claimed_locked_crash_is_resolved_only_by_auditor_observations() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation, state=OperationState.CLAIMED_LOCKED) + after_mutation_horizon = operation.evidence_valid_until + timedelta(minutes=1) + envelopes = [ + _envelope( + operation, + 1, + captured_at=after_mutation_horizon - timedelta(seconds=50), + settled_through=after_mutation_horizon - timedelta(seconds=50), + ), + _envelope( + operation, + 2, + captured_at=after_mutation_horizon - timedelta(seconds=10), + settled_through=after_mutation_horizon - timedelta(seconds=10), + ), + ] + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver(envelopes), + clock=FakeClock(after_mutation_horizon), + ) + + assert result.state is OperationState.OBSERVED_NOT_APPLIED + assert result.resolved is True + assert ledger.last_reconcile_request.expected_state is OperationState.CLAIMED_LOCKED + + +@pytest.mark.parametrize( + "envelopes_factory", + [ + lambda operation: [_envelope(operation, 1, complete=False)], + lambda operation: [ + _envelope(operation, 1, captured_at=NOW - timedelta(seconds=301)) + ], + lambda operation: [ + _envelope( + operation, + 1, + settled_through=NOW - timedelta(seconds=70), + ) + ], + lambda operation: [ + _envelope(operation, 1), + _envelope(operation, 1, captured_at=NOW - timedelta(seconds=5)), + ], + lambda operation: [ + _envelope(operation, 1), + _envelope( + operation, + 2, + captured_at=NOW - timedelta(seconds=5), + evidence_hash=_sha("1"), + ), + ], + ], +) +def test_incomplete_stale_unsettled_or_replayed_snapshots_do_not_cas( + envelopes_factory, +) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver(envelopes_factory(operation)), + clock=FakeClock(), + ) + + assert result.resolved is False + assert ledger.reconcile_calls == 0 + + +@pytest.mark.parametrize( + "overrides", + [ + {"operation_fingerprint": DIGEST_B}, + {"target": TargetName.PRODUCTION}, + {"target_profile_digest": DIGEST_B}, + ], +) +def test_observation_envelope_scope_mismatch_never_reconciles(overrides) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver([_envelope(operation, 1, **overrides)]), + clock=FakeClock(), + ) + + assert result.resolved is False + assert ledger.reconcile_calls == 0 + + +@pytest.mark.parametrize( + ("observations", "expected", "count"), + [ + ( + lambda operation: [_observation(operation)], + OperationState.OBSERVED_APPLIED, + 1, + ), + ( + lambda operation: [ + _observation(operation, reference=DIGEST_A), + _observation(operation, reference=DIGEST_B), + ], + OperationState.MANUAL_REVIEW, + 2, + ), + ( + lambda operation: [ + _observation( + operation, + artifact_digest=DIGEST_B, + image_reference=f"registry.example.invalid/team/app@{DIGEST_B}", + ) + ], + OperationState.MANUAL_REVIEW, + 0, + ), + ], +) +def test_reconciliation_exact_one_many_or_contradiction( + observations, expected, count +) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + envelope = _envelope(operation, 1, observations=observations(operation)) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver([envelope]), + clock=FakeClock(), + ) + + assert result.resolved is True + assert result.state is expected + assert result.exact_match_count == count + + +def test_apply_and_reconciliation_share_one_provider_effect_schema() -> None: + assert tuple(item.name for item in fields(ProviderObservation)) == tuple( + item.name for item in fields(ProviderApplyResult) + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("operation_fingerprint", DIGEST_B), + ("external_id", "other-external-id"), + ("claim_id", "20000000-0000-4000-8000-000000000002"), + ("idempotency_key", DIGEST_B), + ("fencing_token", 20), + ("not_before", NOW - timedelta(seconds=121)), + ("not_after", NOW + timedelta(days=1)), + ("applied_at", NOW - timedelta(seconds=100)), + ("observed_previous_artifact_digest", DIGEST_A), + ("observed_target_config_digest", DIGEST_A), + ("observed_current_schema", 4), + ("observed_migration_history_digest", DIGEST_A), + ("artifact_digest", DIGEST_B), + ( + "image_reference", + f"registry.example.invalid/other/app@{DIGEST_A}", + ), + ("target_profile_digest", DIGEST_A), + ("target_snapshot_hash", DIGEST_A), + ], +) +def test_partial_or_drifted_provider_effect_never_reconciles_as_applied( + field, + value, +) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + overrides = {field: value} + if field == "artifact_digest": + overrides["image_reference"] = f"registry.example.invalid/team/app@{DIGEST_B}" + observation = _observation(operation, **overrides) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver([_envelope(operation, 1, observations=[observation])]), + clock=FakeClock(), + ) + + assert result.state is not OperationState.OBSERVED_APPLIED + assert ledger.states[operation.operation_fingerprint].state is not ( + OperationState.OBSERVED_APPLIED + ) + + +def test_reconciliation_cannot_widen_the_durable_provider_window() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + durable_deadline = NOW + timedelta(seconds=10) + ledger.seed_uncertain(operation, intent_not_after=durable_deadline) + widened = _observation( + operation, + applied_at=NOW + timedelta(seconds=20), + not_after=operation.evidence_valid_until, + ) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver( + [ + _envelope( + operation, + 1, + captured_at=NOW + timedelta(seconds=30), + observations=[widened], + ) + ] + ), + clock=FakeClock(NOW + timedelta(seconds=40)), + ) + + assert result.state is OperationState.MANUAL_REVIEW + assert result.exact_match_count == 0 + + +def test_observed_effect_without_durable_provider_intent_requires_review() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation, with_provider_intent=False) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver( + [_envelope(operation, 1, observations=[_observation(operation)])] + ), + clock=FakeClock(), + ) + + assert result.state is OperationState.MANUAL_REVIEW + assert result.exact_match_count == 0 + + +def test_reconciliation_cas_conflict_leaves_original_state() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = FakeLedger() + ledger.seed_uncertain(operation) + ledger.force_reconcile_conflict = True + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver( + [_envelope(operation, 1, observations=[_observation(operation)])] + ), + clock=FakeClock(), + ) + + assert result.resolved is False + assert result.state is OperationState.OUTCOME_UNKNOWN + assert ( + ledger.states[operation.operation_fingerprint].state + is OperationState.OUTCOME_UNKNOWN + ) + + +def test_retry_returns_terminal_reconciliation_after_cas_reply_is_lost() -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = LostReconciliationReplyLedger() + ledger.seed_uncertain(operation) + observer = FakeObserver( + [_envelope(operation, 1, observations=[_observation(operation)])] + ) + + first = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=observer, + clock=FakeClock(), + ) + second = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=observer, + clock=FakeClock(), + ) + + assert first.resolved is False + assert first.state is OperationState.OUTCOME_UNKNOWN + assert second.resolved is True + assert second.state is OperationState.OBSERVED_APPLIED + assert second.exact_match_count == 1 + assert observer.calls == 1 + + +@pytest.mark.parametrize( + "overrides", + [ + {"external_id": "rc1-" + "b" * 64}, + {"operation_fingerprint": DIGEST_B}, + {"target": TargetName.PRODUCTION}, + {"artifact_digest": DIGEST_B}, + {"contract_digest": DIGEST_B}, + {"state": OperationState.OUTCOME_UNKNOWN}, + {"state_version": 5}, + {"fencing_token": 20}, + ], +) +def test_reconciliation_cas_requires_exact_next_versioned_snapshot(overrides) -> None: + operation = _plan(_contract(cutover=False)).operations[0] + ledger = ReconcileTamperingLedger(**overrides) + ledger.seed_uncertain(operation) + + result = reconcile_uncertain_operation( + operation=operation, + ledger=ledger, + observer=FakeObserver( + [_envelope(operation, 1, observations=[_observation(operation)])] + ), + clock=FakeClock(), + ) + + assert result.resolved is False + assert result.state is OperationState.OUTCOME_UNKNOWN + + +def _rollback( + contract, + *, + artifact=None, + historical_contract=None, + check_suite=None, + target=None, + rollback=None, + compatibility=None, + manifest=None, + configuration_manifest=None, +): + embedded_manifest = manifest or _manifest(contract) + return plan_rollback( + contract=contract, + rollback_release_id="rollback-2026-09-03-01", + previous_manifest=embedded_manifest, + previous_configuration_manifest=( + configuration_manifest + if configuration_manifest is not None + else _configuration_manifest(embedded_manifest) + ), + artifact_resolver=artifact or FakeArtifactResolver(), + historical_contract_resolver=( + historical_contract or FakeHistoricalBuildContractResolver() + ), + check_suite_resolver=check_suite or FakeCheckSuiteResolver(), + target_resolver=target or FakeTargetResolver(), + rollback_resolver=rollback or FakeRollbackResolver(), + compatibility_resolver=(compatibility or FakeRollbackCompatibilityResolver()), + clock=FakeClock(), + ) + + +def test_oldest_check_result_bounds_pilot_and_rollback_execution_deadlines() -> None: + contract = _contract(cutover=True) + check_results = list(_check_results(contract.required_checks)) + check_results[0] = replace( + check_results[0], + completed_at=NOW - timedelta(seconds=3599), + ) + resolver = FakeCheckSuiteResolver(results=tuple(check_results)) + pilot_operation = _plan(contract, check_suite_resolver=resolver).operations[0] + rollback_operation = _rollback( + contract, + check_suite=FakeCheckSuiteResolver(results=tuple(check_results)), + ) + + for operation in (pilot_operation, rollback_operation): + ledger = FakeLedger() + if operation.target is TargetName.PRODUCTION: + ledger.pilot_receipt = _pilot_receipt(operation) + provider = FakeProvider() + + assert operation.evidence_valid_until == NOW + timedelta(seconds=1) + with pytest.raises(EvidenceRejected, match="expired"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + clock=FakeClock(NOW + timedelta(seconds=2)), + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +def test_rollback_reuses_verified_retained_previous_good_digest() -> None: + contract = _contract(cutover=False) + operation = _rollback(contract) + + assert operation.action is ReleaseAction.ROLLBACK + assert operation.artifact_digest == DIGEST_A + assert operation.current_schema == 5 + assert operation.migration_history_digest == DIGEST_E + assert operation.required_pilot_operation_fingerprint == DIGEST_E + assert operation.qualified_pilot_applied_receipt_hash is None + assert operation.rollback_historical_contract_digest == contract.contract_digest + assert operation.rollback_historical_contract_edition == "historical-contract-v1" + assert operation.rollback_historical_receipt_hash is not None + assert operation.rollback_compatibility_receipt_hash is not None + + +def test_rollback_accepts_sealed_historical_build_under_evolved_contract() -> None: + contract = _contract(cutover=False) + historical_manifest = _manifest( + contract, + build_contract_digest=DIGEST_B, + required_checks=["historical-check"], + ) + + operation = _rollback(contract, manifest=historical_manifest) + + assert operation.contract_digest == contract.contract_digest + assert operation.required_checks == contract.required_checks + assert operation.rollback_historical_contract_digest == DIGEST_B + assert operation.embedded_manifest_hash == historical_manifest.manifest_hash + + +def test_evolved_rollback_queries_exact_historical_pilot_receipt_scope() -> None: + contract = _contract(cutover=True) + historical_manifest = _manifest( + contract, + build_contract_digest=DIGEST_B, + required_checks=["historical-check"], + ) + operation = _rollback(contract, manifest=historical_manifest) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(operation) + + result = _execute( + contract=contract, + operation=operation, + ledger=ledger, + ) + + assert result.state is ExecutionState.APPLIED + assert ledger.last_pilot_query.contract_digest == DIGEST_B + assert ledger.last_pilot_query.contract_edition == "historical-contract-v1" + assert ledger.last_pilot_query.required_checks_digest == ( + operation.rollback_historical_required_checks_digest + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"request_digest": DIGEST_A}, + {"historical_build_contract_digest": DIGEST_A}, + {"previous_manifest_hash": DIGEST_A}, + {"artifact_digest": DIGEST_B}, + {"historical_required_checks": ("other-check",)}, + {"archive_complete": False}, + {"immutable": False}, + {"verifier_identity": "check-suite-verifier"}, + {"sealed_at": NOW + timedelta(seconds=1)}, + {"return_untyped": True}, + {"return_subclass": True}, + ], +) +def test_historical_build_contract_receipt_is_exact_typed_and_sealed( + overrides, +) -> None: + with pytest.raises(RollbackRejected): + _rollback( + _contract(cutover=False), + historical_contract=FakeHistoricalBuildContractResolver(**overrides), + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"request_digest": DIGEST_A}, + {"current_contract_digest": DIGEST_A}, + {"historical_contract_digest": DIGEST_A}, + {"historical_contract_receipt_hash": DIGEST_A}, + {"artifact_digest": DIGEST_B}, + {"target_snapshot_hash": DIGEST_A}, + {"target_config_digest": DIGEST_A}, + {"current_schema": 4}, + {"migration_history_digest": DIGEST_A}, + {"rollback_capability_receipt_hash": DIGEST_A}, + {"compatible": False}, + {"complete": False}, + {"independent_verifier": False}, + {"verifier_identity": "rollback-verifier"}, + {"verifier_identity": "artifact-verifier"}, + {"verifier_identity": "target-state-verifier"}, + {"verified_at": NOW - timedelta(seconds=1801)}, + {"return_untyped": True}, + {"return_subclass": True}, + ], +) +def test_rollback_compatibility_is_exact_fresh_and_independent(overrides) -> None: + with pytest.raises(RollbackRejected): + _rollback( + _contract(cutover=False), + compatibility=FakeRollbackCompatibilityResolver(**overrides), + ) + + +def test_rollback_capability_receipt_is_bound_into_operation_fingerprint() -> None: + contract = _contract(cutover=False) + first = _rollback(contract) + second = _rollback( + contract, + rollback=FakeRollbackResolver(evidence_hash=DIGEST_A), + ) + + assert first.gate_evidence_hash != second.gate_evidence_hash + assert first.operation_fingerprint != second.operation_fingerprint + + +def test_rollback_binds_verified_pilot_fingerprint_and_requires_exact_receipt() -> None: + contract = _contract(cutover=True) + operation = _rollback( + contract, + rollback=FakeRollbackResolver(pilot_operation_fingerprint=DIGEST_B), + ) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt( + operation, + operation_fingerprint=DIGEST_C, + ) + provider = FakeProvider() + + with pytest.raises(LedgerSafetyError, match="operation_fingerprint"): + _execute( + contract=contract, + operation=operation, + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"request_digest": DIGEST_A}, "request_digest"), + ({"previous_good_digest": DIGEST_B}, "previous_good_digest"), + ({"previous_manifest_hash": DIGEST_B}, "previous_manifest_hash"), + ({"target": TargetName.PILOT}, "target"), + ({"target_snapshot_hash": DIGEST_B}, "target_snapshot_hash"), + ({"expected_current_digest": DIGEST_A}, "expected_current_digest"), + ({"current_schema": 4}, "current_schema"), + ({"migration_history_digest": DIGEST_A}, "migration_history_digest"), + ({"can_rollback": False}, "canRollback"), + ({"artifact_retained": False}, "retained"), + ({"artifact_origin": "rebuilt"}, "rebuild"), + ({"runtime_attestation_valid": False}, "attestation"), + ({"source_identity": "artifact-verifier"}, "independent"), + ({"source_identity": "target-state-verifier"}, "independent"), + ({"can_rollback_checked_at": NOW - timedelta(seconds=3601)}, "stale"), + ({"retention_checked_at": NOW - timedelta(seconds=3601)}, "stale"), + ({"runtime_reattested_at": NOW - timedelta(seconds=1801)}, "stale"), + ({"return_untyped": True}, "untyped"), + ], +) +def test_rollback_capability_equality_and_freshness_gates_fail_closed( + overrides, message +) -> None: + with pytest.raises(RollbackRejected, match=message): + _rollback( + _contract(cutover=False), + rollback=FakeRollbackResolver(**overrides), + ) + + +def test_rollback_rejects_rebuilt_artifact_at_resolver_boundary() -> None: + with pytest.raises(EvidenceRejected, match="provenance"): + _rollback( + _contract(cutover=False), + artifact=FakeArtifactResolver(provenance="build_once"), + ) + + +def test_rollback_rejects_incompatible_current_schema_or_migration_history() -> None: + contract = _contract(cutover=False) + for overrides in ( + {"current_schema": 7}, + {"schema_compatible": False}, + {"migration_allowed": False}, + ): + with pytest.raises(EvidenceRejected): + _rollback(contract, target=FakeTargetResolver(**overrides)) + + +@pytest.mark.parametrize( + "field", + [ + "can_rollback_checked_at", + "retention_checked_at", + "runtime_reattested_at", + ], +) +def test_rollback_rejects_future_dated_re_attestation(field) -> None: + with pytest.raises(RollbackRejected, match="future"): + _rollback( + _contract(cutover=False), + rollback=FakeRollbackResolver(**{field: NOW + timedelta(seconds=1)}), + ) + + +def test_rollback_re_attestation_deadline_is_enforced_again_at_execution() -> None: + contract = _contract(cutover=True) + operation = _rollback( + contract, + target=FakeTargetResolver(captured_at=NOW), + rollback=FakeRollbackResolver( + can_rollback_checked_at=NOW - timedelta(seconds=3599), + retention_checked_at=NOW - timedelta(seconds=3599), + ), + ) + later = NOW + timedelta(seconds=2) + authority = _authority( + operation, + issued_at=(NOW - timedelta(seconds=1)).isoformat().replace("+00:00", "Z"), + expires_at=(NOW + timedelta(minutes=29)).isoformat().replace("+00:00", "Z"), + ) + ledger = FakeLedger() + ledger.pilot_receipt = _pilot_receipt(operation) + provider = FakeProvider() + + assert operation.evidence_valid_until == NOW + timedelta(seconds=1) + with pytest.raises(EvidenceRejected, match="expired"): + _execute( + contract=contract, + operation=operation, + authority=authority, + clock=FakeClock(later), + ledger=ledger, + provider=provider, + ) + + assert ledger.claim_calls == 0 + assert provider.calls == 0 + + +@pytest.fixture +def preview_inputs(tmp_path): + contract = load_release_control_contract(CONTRACT_PATH) + manifest = _manifest(contract) + configuration = _configuration_manifest(manifest) + paths = { + "contract": tmp_path / "contract.json", + "embedded": tmp_path / "embedded.json", + "configuration": tmp_path / "configuration.json", + } + paths["contract"].write_bytes(CONTRACT_PATH.read_bytes()) + paths["embedded"].write_text(json.dumps(manifest.to_mapping())) + paths["configuration"].write_text(json.dumps(configuration.to_mapping())) + return paths + + +def _preview_arguments(paths, artifact_reference=None): + args = [ + "preview-release", + str(paths["contract"]), + "--embedded-manifest", + str(paths["embedded"]), + "--configuration-manifest", + str(paths["configuration"]), + ] + if artifact_reference is not None: + args.extend(["--artifact-reference", artifact_reference]) + return args + + +@pytest.mark.parametrize("artifact_digest", [None, DIGEST_A]) +def test_preview_is_deterministic_unverified_and_has_no_effects( + preview_inputs, artifact_digest, capsys, monkeypatch +): + def forbidden(*args, **kwargs): + pytest.fail("preview must not resolve or execute anything") + + monkeypatch.setattr(release_cli, "plan_build_once_promotion", forbidden) + monkeypatch.setattr(release_cli, "execute_operation", forbidden) + monkeypatch.setattr(socket, "socket", forbidden) + contract = load_release_control_contract(preview_inputs["contract"]) + reference = ( + f"{contract.oci_repository}@{artifact_digest}" if artifact_digest else None + ) + args = _preview_arguments(preview_inputs, reference) + original_files = {path: path.read_bytes() for path in preview_inputs.values()} + + assert release_cli.main(args) == 0 + output = capsys.readouterr().out + preview = json.loads(output) + assert preview["mutation_authorized"] is False + assert preview["readiness"] == "not_verified" + assert "independent_single_use_authority" in preview["missing_evidence"] + assert "actual_pilot_and_production_configuration" in preview["missing_evidence"] + assert preview["proposed_deployments"] == [ + { + "target": "pilot", + "artifact_digest": artifact_digest, + "profile_digest": DIGEST_D, + }, + { + "target": "production", + "artifact_digest": artifact_digest, + "profile_digest": DIGEST_E, + }, + ] + assert ("artifact_identity" in preview["missing_evidence"]) is ( + artifact_digest is None + ) + assert "operation_fingerprint" not in preview + assert "_validated" not in output + preview_digest = preview.pop("preview_digest") + assert preview_digest == canonical_sha256(preview) + assert {path: path.read_bytes() for path in original_files} == original_files + assert set(preview_inputs["contract"].parent.iterdir()) == set(original_files) + + for path in preview_inputs.values(): + payload = json.loads(path.read_bytes()) + path.write_text(json.dumps(payload, sort_keys=True, indent=3)) + assert release_cli.main(args) == 0 + assert capsys.readouterr().out == output + + +@pytest.mark.parametrize( + ("document", "field", "value"), + [ + ("embedded", "build_contract_digest", DIGEST_F), + ("embedded", "required_checks", ["some-other-check"]), + ("configuration", "release_id", "different-release"), + ("configuration", "embedded_manifest_hash", DIGEST_F), + ("configuration", "_validated", True), + ("configuration", "approval", "pretend-approved"), + ], +) +def test_preview_rejects_mismatched_or_injected_declarations_without_output( + preview_inputs, document, field, value, capsys +): + path = preview_inputs[document] + payload = json.loads(path.read_bytes()) + payload[field] = value + path.write_text(json.dumps(payload)) + + assert release_cli.main(_preview_arguments(preview_inputs)) == 2 + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize( + "reference", + [ + "registry.example.invalid/team/app:latest", + f"different.example.invalid/team/app@{DIGEST_A}", + f"registry.example.invalid/team/app@{SOURCE_COMMIT}", + ], +) +def test_preview_rejects_mutable_or_unexpected_artifact( + preview_inputs, reference, capsys +): + assert release_cli.main(_preview_arguments(preview_inputs, reference)) == 2 + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize( + "bad_input", ['{"schema_version":1,"schema_version":1}', "NaN", "{"] +) +def test_preview_uses_strict_json_parser(preview_inputs, bad_input, capsys): + preview_inputs["configuration"].write_text(bad_input) + assert release_cli.main(_preview_arguments(preview_inputs)) == 2 + assert capsys.readouterr().out == "" + + +def test_preview_missing_input_has_no_partial_output(preview_inputs, capsys): + preview_inputs["configuration"].unlink() + assert release_cli.main(_preview_arguments(preview_inputs)) == 2 + assert capsys.readouterr().out == "" + + +def test_preview_runs_as_isolated_cli(preview_inputs): + result = subprocess.run( + [ + sys.executable, + "-I", + "-B", + str(REPO_ROOT / "scripts/release_control.py"), + *_preview_arguments(preview_inputs), + ], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["mutation_authorized"] is False + assert result.stderr == "" diff --git a/tests/test_release_drift_ci.py b/tests/test_release_drift_ci.py new file mode 100644 index 00000000..11fcdeab --- /dev/null +++ b/tests/test_release_drift_ci.py @@ -0,0 +1,269 @@ +"""The observer must preserve failed/overdue evidence without resetting time.""" + +import hashlib +import importlib.util +import io +import json +import zipfile +from pathlib import Path + +import pytest + +SPEC = importlib.util.spec_from_file_location( + "release_drift_ci", Path(__file__).parents[1] / "scripts/release_drift_ci.py" +) +ci = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(ci) +REPO = "example/bot" + + +def run(number, **overrides): + return { + "id": number * 10, + "run_number": number, + "workflow_id": 7, + "head_branch": "new-architecture", + "head_repository": {"full_name": REPO}, + "event": "schedule", + "status": "completed", + "conclusion": "success", + **overrides, + } + + +def archive( + journal=b'{"first_seen_unmatched_at":"2026-09-09T00:00:00Z"}\n', **overrides +): + stream = io.BytesIO() + metadata = { + "schema_version": 1, + "repository": REPO, + "run_id": 20, + "sha256": hashlib.sha256(journal).hexdigest(), + **overrides, + } + with zipfile.ZipFile(stream, "w") as bundle: + bundle.writestr(ci.JOURNAL, journal) + bundle.writestr(ci.METADATA, json.dumps(metadata)) + return stream.getvalue() + + +def environment(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", REPO) + monkeypatch.setenv("GITHUB_RUN_ID", "30") + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "1") + + +def test_failed_overdue_run_remains_authoritative(): + previous = run(2, conclusion="failure") + assert ci.preceding_run([run(1), previous, run(3)], run(3), REPO) == previous + + +def test_wrong_branch_fork_and_workflow_cannot_supply_state(): + candidates = [run(1), run(2, head_branch="pilot"), run(2, workflow_id=8)] + candidates.append(run(2, head_repository={"full_name": "attacker/bot"})) + assert ci.preceding_run(candidates, run(3), REPO) == run(1) + + +def test_skipped_activation_runs_do_not_prevent_first_bootstrap(): + assert ci.preceding_run([run(1, conclusion="skipped")], run(2), REPO) is None + + +def test_overlapping_observation_blocks(): + with pytest.raises(ci.StateError, match="not completed"): + ci.preceding_run([run(2, status="in_progress")], run(3), REPO) + + +def test_roundtrip_preserves_original_sla_bytes(): + expected = b'{"first_seen_unmatched_at":"2026-09-09T00:00:00Z"}\n' + assert ci.unpack_state(archive(expected), repository=REPO, run_id=20) == expected + + +@pytest.mark.parametrize( + "change", [{"run_id": 30}, {"repository": "attacker/bot"}, {"sha256": "0" * 64}] +) +def test_state_provenance_and_checksum_mismatch_rejected(change): + with pytest.raises(ci.StateError, match="mismatch"): + ci.unpack_state(archive(**change), repository=REPO, run_id=20) + + +def test_zip_path_traversal_rejected_without_extraction(tmp_path): + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as bundle: + bundle.writestr("../outside", "unsafe") + with pytest.raises(ci.StateError): + ci.unpack_state(data.getvalue(), repository=REPO, run_id=20) + assert not (tmp_path.parent / "outside").exists() + + +def test_empty_first_scan_can_be_sealed_and_recovered(tmp_path, monkeypatch): + environment(monkeypatch) + (tmp_path / ci.JOURNAL).write_bytes(b"") + ci.seal(tmp_path) + metadata = json.loads((tmp_path / ci.METADATA).read_text()) + assert metadata["sha256"] == hashlib.sha256(b"").hexdigest() + assert ci.unpack_state(archive(b""), repository=REPO, run_id=20) == b"" + + +def test_rerun_cannot_rewind_journal(monkeypatch): + environment(monkeypatch) + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "2") + with pytest.raises(ci.StateError, match="do not rerun"): + ci.context() + + +def test_initialization_requires_explicit_manual_dispatch(tmp_path, monkeypatch): + environment(monkeypatch) + monkeypatch.setattr( + ci, + "api", + lambda path: run(3) if path.endswith("runs/30") else {"workflow_runs": []}, + ) + with pytest.raises(ci.StateError, match="explicit bootstrap"): + ci.restore(tmp_path, bootstrap=True) + assert not (tmp_path / ci.JOURNAL).exists() + + +def test_bootstrap_cannot_erase_missing_previous_state(tmp_path, monkeypatch): + environment(monkeypatch) + + def fake_api(path): + if path.endswith("runs/30"): + return run(3, event="workflow_dispatch") + if "/jobs" in path: + return {"total_count": 1, "jobs": []} + if "artifacts" in path: + return {"artifacts": []} + return {"workflow_runs": [run(2, conclusion="failure")]} + + monkeypatch.setattr(ci, "api", fake_api) + with pytest.raises(ci.StateError, match="no unique unexpired"): + ci.restore(tmp_path, bootstrap=True) + assert not (tmp_path / ci.JOURNAL).exists() + + +def test_restore_uses_failed_run_not_last_success(tmp_path, monkeypatch): + environment(monkeypatch) + + def fake_api(path, *, binary=False): + if binary: + assert path.endswith("artifacts/99/zip") + return archive() + if path.endswith("runs/30"): + return run(3) + if "/jobs" in path: + return {"total_count": 1, "jobs": []} + if "runs/20/artifacts" in path: + return { + "artifacts": [ + { + "id": 99, + "name": ci.ARTIFACT, + "expired": False, + "size_in_bytes": 500, + } + ] + } + return {"workflow_runs": [run(1), run(2, conclusion="failure")]} + + monkeypatch.setattr(ci, "api", fake_api) + ci.restore(tmp_path, bootstrap=False) + assert b"2026-09-09T00:00:00Z" in (tmp_path / ci.JOURNAL).read_bytes() + + +@pytest.mark.parametrize("job_count, expected_number", [(0, 1), (1, 2)]) +def test_only_never_started_cancelled_run_can_be_skipped( + monkeypatch, job_count, expected_number +): + def fake_api(path): + if "/jobs" in path: + return {"total_count": job_count} + return {"workflow_runs": [run(2, conclusion="cancelled"), run(1)]} + + monkeypatch.setattr(ci, "api", fake_api) + previous = ci.previous_observation("repos/example/bot/actions", run(3), REPO) + assert previous["run_number"] == expected_number + + +def test_restore_walks_past_skipped_page(monkeypatch): + def fake_api(path): + if "/jobs" in path: + return {"total_count": 1, "jobs": []} + if "page=2" in path: + return {"workflow_runs": [run(1)]} + return { + "workflow_runs": [run(n, conclusion="skipped") for n in range(101, 1, -1)] + } + + monkeypatch.setattr(ci, "api", fake_api) + assert ci.previous_observation("prefix", run(102), REPO)["run_number"] == 1 + + +def test_job_skipped_inside_successful_run_allows_bootstrap(monkeypatch): + def fake_api(path): + if "/jobs" in path: + return { + "total_count": 1, + "jobs": [ + { + "name": "Release drift observation", + "conclusion": "skipped", + "steps": [], + } + ], + } + return {"workflow_runs": [run(1)]} + + monkeypatch.setattr(ci, "api", fake_api) + assert ci.previous_observation("prefix", run(2), REPO) is None + + +def test_out_of_order_execution_cannot_rewind_state(): + with pytest.raises(ci.StateError, match="newer run already completed"): + ci.preceding_run([run(2), run(4)], run(3), REPO) + + +def test_future_queued_run_does_not_block_current_observer(): + assert ci.preceding_run([run(2), run(4, status="queued")], run(3), REPO) == run(2) + + +def test_corrupted_journal_cannot_be_sealed(tmp_path, monkeypatch): + environment(monkeypatch) + (tmp_path / ci.JOURNAL).write_text('{"partial":') + with pytest.raises(ci.StateError, match="invalid detector journal"): + ci.seal(tmp_path) + assert not (tmp_path / ci.METADATA).exists() + + +def test_bootstrap_cannot_silently_start_without_legacy_journal(tmp_path, monkeypatch): + environment(monkeypatch) + monkeypatch.setattr( + ci, + "api", + lambda path: ( + run(3, event="workflow_dispatch") + if path.endswith("runs/30") + else {"workflow_runs": []} + ), + ) + with pytest.raises(ci.StateError, match="reviewed migrated legacy journal"): + ci.restore(tmp_path, bootstrap=True) + assert not (tmp_path / ci.JOURNAL).exists() + + +def test_bootstrap_preserves_reviewed_journal_bytes(tmp_path, monkeypatch): + environment(monkeypatch) + monkeypatch.setattr( + ci, + "api", + lambda path: ( + run(3, event="workflow_dispatch") + if path.endswith("runs/30") + else {"workflow_runs": []} + ), + ) + seed = tmp_path / "seed.jsonl" + # Empty is a valid reviewed baseline only when no delta has been observed. + seed.write_bytes(b"") + ci.restore(tmp_path / "state", bootstrap=True, seed=seed) + assert (tmp_path / "state" / ci.JOURNAL).read_bytes() == seed.read_bytes() diff --git a/tests/test_release_drift_detector.py b/tests/test_release_drift_detector.py new file mode 100644 index 00000000..76e3e245 --- /dev/null +++ b/tests/test_release_drift_detector.py @@ -0,0 +1,528 @@ +"""Offline acceptance tests for the release drift evidence and CLI contract.""" + +import datetime +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/release-drift-detector.py" +SPEC = importlib.util.spec_from_file_location("release_drift_detector", SCRIPT) +detector = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(detector) + + +class DetectorAcceptanceTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="wp562-detector-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.manifest = self.root / "drift.jsonl" + self.environment = { + **os.environ, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + } + self.git("init", "-b", "production") + self.git("config", "user.name", "Offline Test") + self.git("config", "user.email", "offline@example.invalid") + self.git("config", "core.hooksPath", str(self.root / "no-hooks")) + self.commit("base.txt", "base\n", "baseline") + self.baseline = self.git("rev-parse", "HEAD").strip() + self.git("branch", "pilot") + + def git(self, *args): + return subprocess.run( + ["git", "-C", str(self.repo), *args], + check=True, + capture_output=True, + text=True, + env=self.environment, + timeout=10, + ).stdout + + def commit(self, filename, contents, subject): + (self.repo / filename).write_text(contents, encoding="utf-8") + self.git("add", "--", filename) + self.git("commit", "-m", subject) + return self.git("rev-parse", "HEAD").strip() + + def diverge(self): + self.git("checkout", "pilot") + return self.commit("feature.txt", "feature\n", "feature") + + def command(self, *extra, fetch=False): + command = [ + sys.executable, + str(SCRIPT), + "--repo", + str(self.repo), + "--manifest", + str(self.manifest), + "--pilot-ref", + "pilot", + "--production-ref", + "production", + "--json", + ] + if not fetch: + command.append("--no-fetch") + return [*command, *extra] + + def run_detector(self, *extra, fetch=False, environment=None): + return subprocess.run( + self.command(*extra, fetch=fetch), + text=True, + capture_output=True, + timeout=15, + env=environment or self.environment, + check=False, + ) + + def report(self, *extra, expected=0, **kwargs): + result = self.run_detector(*extra, **kwargs) + self.assertEqual(result.returncode, expected, result.stderr) + return json.loads(result.stdout) + + def age_manifest(self): + old = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=26) + ).isoformat() + records = [json.loads(line) for line in self.manifest.read_text().splitlines()] + for row in records: + row["first_seen_unmatched_at"] = old + row["sla_deadline_at"] = ( + detector.timestamp(old) + datetime.timedelta(hours=24) + ).isoformat() + self.manifest.write_text("".join(json.dumps(row) + "\n" for row in records)) + return old + + def fake_transport(self, exit_code): + executable = self.root / "iwe-tg" + marker = self.root / "sent.txt" + executable.write_text( + f"#!/bin/sh\nprintf '%s\\n' \"$@\" > '{marker}'\nexit {exit_code}\n" + ) + executable.chmod(0o755) + environment = {**self.environment, "PATH": f"{self.root}:{os.environ['PATH']}"} + environment.pop("TELEGRAM_ENV", None) + return environment, marker + + def test_explicit_snapshots_need_no_remote_and_output_matches(self): + self.diverge() + before_refs = self.git("show-ref") + before_status = self.git("status", "--porcelain") + output = self.root / "report.json" + report = self.report("--output", str(output)) + self.assertEqual(report, json.loads(output.read_text())) + self.assertEqual(report["status"], "drift") + self.assertEqual(report["summary"]["tree_difference"]["real_delta_count"], 1) + self.assertEqual( + report["snapshots"]["pilot"]["commit"], + self.git("rev-parse", "pilot").strip(), + ) + self.assertEqual(self.git("show-ref"), before_refs) + self.assertEqual(self.git("status", "--porcelain"), before_status) + self.assertEqual(report["alert_delivery"], "not_requested") + + def test_equal_trees_with_different_shas_are_clean_and_close_with_evidence(self): + feature = self.diverge() + self.report() + self.git("checkout", "production") + self.git("cherry-pick", feature) + self.git("commit", "--amend", "-m", "independent production history") + report = self.report() + self.assertNotEqual( + report["snapshots"]["pilot"]["commit"], + report["snapshots"]["production"]["commit"], + ) + self.assertEqual(report["status"], "clean") + self.assertIsNone(report["oldest_deadline_at"]) + self.assertGreater(len(report["records"]), 0) + for row in report["records"]: + self.assertEqual(row["status"], "resolved") + self.assertTrue(detector.has_resolution_evidence(row)) + + def test_patch_id_preserves_sla_after_sha_rewrite(self): + original_sha = self.diverge() + first = self.report() + old = self.age_manifest() + self.git("commit", "--amend", "-m", "same patch under a new SHA") + report = self.report(expected=3) + before = next( + row for row in first["records"] if row["direction"] == "pilot_only" + ) + after = next( + row for row in report["records"] if row["direction"] == "pilot_only" + ) + self.assertEqual(before["delta_id"], after["delta_id"]) + self.assertNotEqual(original_sha, after["source_sha"]) + self.assertEqual(after["first_seen_unmatched_at"], old) + self.assertEqual(report["oldest_deadline_at"], after["sla_deadline_at"]) + + def test_legacy_sha_clock_survives_rewrite_without_mapping(self): + original = self.diverge() + first = self.report() + legacy = next( + row for row in first["records"] if row["direction"] == "pilot_only" + ) + legacy["schema_version"] = 1 + legacy["delta_id"] = f"pilot_only:{original}" + del legacy["patch_id"] + self.manifest.write_text(json.dumps(legacy) + "\n") + old = self.age_manifest() + self.git("commit", "--amend", "-m", "rewritten legacy") + report = self.report(expected=3) + records = [row for row in report["records"] if row["direction"] == "pilot_only"] + self.assertTrue(all(row["first_seen_unmatched_at"] == old for row in records)) + self.assertTrue(all(row["status"] == "unmatched" for row in records)) + + def test_missing_commit_does_not_resolve_when_trees_still_differ(self): + self.diverge() + first = self.report() + original = next( + row for row in first["records"] if row["direction"] == "pilot_only" + ) + self.age_manifest() + self.git("reset", "--hard", self.baseline) + self.commit("other.txt", "unrelated\n", "new unrelated change") + report = self.report(expected=3) + retained = next( + row for row in report["records"] if row["delta_id"] == original["delta_id"] + ) + self.assertEqual(retained["status"], "unmatched") + self.assertEqual(retained["observation"], "not_observed_resolution_unproven") + + def test_merge_only_change_cannot_hide_behind_git_cherry(self): + self.git("checkout", "-b", "side", self.baseline) + side = self.commit("side.txt", "side\n", "side change") + self.git("checkout", "pilot") + main = self.commit("main.txt", "main\n", "main change") + self.git("merge", "--no-ff", "--no-commit", "side") + self.commit("merge-only.txt", "merge resolution\n", "merge with extra change") + self.git("checkout", "production") + self.git("cherry-pick", main, side) + self.assertFalse( + any( + line.startswith("+") + for line in self.git("cherry", "production", "pilot").splitlines() + ) + ) + report = self.report() + self.assertEqual(report["status"], "drift") + self.assertEqual(report["summary"]["tree_difference"]["real_delta_count"], 1) + self.assertTrue( + any( + row["direction"] == "tree_difference" and row["status"] == "unmatched" + for row in report["records"] + ) + ) + + def test_reverted_patch_still_has_tree_drift(self): + feature = self.diverge() + self.git("checkout", "production") + self.git("cherry-pick", feature) + self.git("revert", "--no-edit", "HEAD") + self.assertFalse( + any( + line.startswith("+") + for line in self.git("cherry", "production", "pilot").splitlines() + ) + ) + report = self.report() + self.assertEqual(report["status"], "drift") + self.assertEqual(report["summary"]["tree_difference"]["real_delta_count"], 1) + + def test_corrupt_manifest_fails_without_append(self): + self.diverge() + self.report() + with self.manifest.open("a") as target: + target.write('{"delta_id":') + before = self.manifest.read_bytes() + result = self.run_detector() + self.assertEqual(result.returncode, 1) + self.assertIn("Invalid manifest record", result.stderr) + self.assertEqual(self.manifest.read_bytes(), before) + + def test_truncated_line_after_complete_json_is_not_appended_to(self): + self.diverge() + self.report() + self.manifest.write_bytes(self.manifest.read_bytes().rstrip(b"\n")) + before = self.manifest.read_bytes() + result = self.run_detector() + self.assertEqual(result.returncode, 1) + self.assertIn("terminating newline", result.stderr) + self.assertEqual(self.manifest.read_bytes(), before) + + def test_legacy_resolution_without_evidence_cannot_restart_clock(self): + self.diverge() + self.report() + old = self.age_manifest() + records = [json.loads(line) for line in self.manifest.read_text().splitlines()] + for row in records: + row["status"] = "resolved" + self.manifest.write_text("".join(json.dumps(row) + "\n" for row in records)) + self.git("commit", "--amend", "-m", "new SHA after false legacy resolution") + report = self.report(expected=3) + self.assertTrue( + all(row["first_seen_unmatched_at"] == old for row in report["records"]) + ) + self.assertTrue(all(row["status"] == "unmatched" for row in report["records"])) + + def test_proven_tree_convergence_allows_new_drift_window(self): + feature = self.diverge() + self.report() + old = self.age_manifest() + self.git("checkout", "production") + self.git("cherry-pick", feature) + self.report() + self.git("revert", "--no-edit", "HEAD") + report = self.report() + self.assertEqual(report["overdue"], []) + self.assertTrue( + all( + detector.timestamp(row["first_seen_unmatched_at"]) + > detector.timestamp(old) + for row in report["records"] + ) + ) + + def test_semantically_invalid_json_is_not_treated_as_empty_history(self): + self.diverge() + row = self.report()["records"][0] + for field, value in ( + ("status", "approved"), + ("first_seen_unmatched_at", None), + ("sla_deadline_at", "2026-01-01T00:00:00"), + ("direction", "unknown"), + ("schema_version", 99), + ): + with self.subTest(field=field): + self.manifest.write_text(json.dumps({**row, field: value}) + "\n") + before = self.manifest.read_bytes() + result = self.run_detector() + self.assertEqual(result.returncode, 1) + self.assertEqual(self.manifest.read_bytes(), before) + + def test_sla_clock_cannot_advance_without_resolution_evidence(self): + self.diverge() + row = self.report()["records"][0] + newer = {**row} + for key in ("first_seen_unmatched_at", "sla_deadline_at", "last_rechecked_at"): + newer[key] = ( + detector.timestamp(row[key]) + datetime.timedelta(hours=1) + ).isoformat() + self.manifest.write_text(json.dumps(row) + "\n" + json.dumps(newer) + "\n") + result = self.run_detector() + self.assertEqual(result.returncode, 1) + self.assertIn("SLA clock advanced", result.stderr) + + def test_fetch_error_without_alert_never_calls_transport(self): + environment, marker = self.fake_transport(0) + result = self.run_detector(fetch=True, environment=environment) + self.assertEqual(result.returncode, 1) + self.assertIn("git fetch failed", result.stderr) + self.assertFalse(marker.exists()) + self.assertFalse(self.manifest.exists()) + + def test_overdue_delivery_failure_is_visible_in_output_and_exit(self): + self.diverge() + self.report() + self.age_manifest() + environment, marker = self.fake_transport(3) + report = self.report("--alert", expected=2, environment=environment) + self.assertEqual(report["alert_delivery"], "failed") + self.assertGreater(len(report["overdue"]), 0) + self.assertTrue(marker.exists()) + + def test_successful_delivery_keeps_overdue_exit_code(self): + self.diverge() + self.report() + self.age_manifest() + environment, marker = self.fake_transport(0) + report = self.report("--alert", expected=3, environment=environment) + self.assertEqual(report["alert_delivery"], "delivered") + self.assertTrue(marker.exists()) + self.assertIn("--source\nrelease-drift-detector\n--\n", marker.read_text()) + + def test_blocked_test_transport_is_not_claimed_as_delivered(self): + self.diverge() + self.report() + self.age_manifest() + environment, marker = self.fake_transport(0) + environment.update(TELEGRAM_ENV="test", ALLOW_LIVE_TELEGRAM_IN_TEST="0") + report = self.report("--alert", expected=2, environment=environment) + self.assertEqual(report["alert_delivery"], "failed") + self.assertFalse(marker.exists()) + + def test_missing_transport_is_not_success(self): + with ( + mock.patch.object(detector.shutil, "which", return_value=None), + self.assertRaisesRegex(detector.DetectorError, "not installed"), + ): + detector.send_alert("synthetic", 1) + + def test_git_timeout_is_bounded_and_does_not_leak_command_output(self): + with mock.patch.object( + detector.subprocess, + "run", + side_effect=subprocess.TimeoutExpired("git", 0.1), + ) as run: + with self.assertRaisesRegex(detector.DetectorError, "timed out"): + detector.Git(self.repo, 0.1).run("fetch", "origin") + self.assertEqual(run.call_args.kwargs["timeout"], 0.1) + + def test_append_is_flushed_before_fsync(self): + observed = [] + real_fsync = os.fsync + + def inspect_fsync(fd): + observed.append(self.manifest.read_text()) + real_fsync(fd) + + with ( + mock.patch.object(detector.os, "fsync", side_effect=inspect_fsync), + detector.manifest_lock(self.manifest), + ): + detector.append_manifest(self.manifest, [{"delta_id": "synthetic"}]) + self.assertEqual(json.loads(observed[0]), {"delta_id": "synthetic"}) + + def test_overlapping_runs_keep_one_first_seen_and_valid_jsonl(self): + self.diverge() + processes = [ + subprocess.Popen( + self.command(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=self.environment, + ) + for _ in range(3) + ] + for process in processes: + stdout, stderr = process.communicate(timeout=15) + self.assertEqual(process.returncode, 0, stderr) + self.assertEqual(json.loads(stdout)["status"], "drift") + rows = [json.loads(line) for line in self.manifest.read_text().splitlines()] + clocks = {} + for row in rows: + clocks.setdefault(row["delta_id"], set()).add( + row["first_seen_unmatched_at"] + ) + self.assertTrue(all(len(values) == 1 for values in clocks.values())) + + def legacy_history(self): + first_seen = "2026-09-01T00:00:00+00:00" + now = "2026-09-05T00:00:00+00:00" + row = detector.delta_record( + "pilot_only:legacy", + "pilot_only", + self.baseline, + "synthetic legacy", + first_seen, + first_seen, + ) + row["schema_version"] = 1 + falsely_resolved = {**row, "status": "resolved", "last_rechecked_at": now} + reset = { + **row, + "first_seen_unmatched_at": now, + "last_rechecked_at": now, + "sla_deadline_at": "2026-09-06T00:00:00+00:00", + } + source = self.root / "legacy.jsonl" + source.write_text( + "".join(json.dumps(item) + "\n" for item in (row, falsely_resolved, reset)) + ) + return source, first_seen + + def test_explicit_legacy_migration_preserves_earliest_clock_and_source(self): + source, first_seen = self.legacy_history() + original = source.read_bytes() + with self.assertRaisesRegex(detector.DetectorError, "SLA clock advanced"): + detector.load_manifest(source) + report = self.report("--migrate-legacy-manifest", str(source)) + self.assertEqual(report["status"], "migrated") + self.assertEqual(source.read_bytes(), original) + self.assertEqual( + report["provenance"]["source_sha256"], hashlib.sha256(original).hexdigest() + ) + migrated = detector.load_manifest(self.manifest)["pilot_only:legacy"] + self.assertEqual(migrated["schema_version"], 2) + self.assertEqual(migrated["first_seen_unmatched_at"], first_seen) + self.assertEqual(migrated["sla_deadline_at"], "2026-09-02T00:00:00+00:00") + self.assertEqual(migrated["status"], "unmatched") + self.assertEqual(migrated["classification"], "unknown") + + def test_migration_reopens_legacy_resolved_record(self): + source, first_seen = self.legacy_history() + rows = source.read_text().splitlines() + source.write_text("\n".join(rows[:2]) + "\n") + detector.migrate_legacy_manifest(source, self.manifest) + row = detector.load_manifest(self.manifest)["pilot_only:legacy"] + self.assertEqual(row["status"], "unmatched") + self.assertEqual(row["first_seen_unmatched_at"], first_seen) + self.assertEqual(row["provenance"]["reason"], "legacy_resolution_unproven") + + def test_migration_refuses_same_path_existing_destination_and_source_report(self): + source, _ = self.legacy_history() + before = source.read_bytes() + with self.assertRaisesRegex(detector.DetectorError, "distinct destination"): + detector.migrate_legacy_manifest(source, source) + self.manifest.write_text("existing\n") + with self.assertRaisesRegex(detector.DetectorError, "already exists"): + detector.migrate_legacy_manifest(source, self.manifest) + result = self.run_detector( + "--migrate-legacy-manifest", str(source), "--output", str(source) + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(source.read_bytes(), before) + self.assertEqual(self.manifest.read_text(), "existing\n") + + def test_migration_rejects_corruption_nonlegacy_and_missing_fields(self): + source, _ = self.legacy_history() + valid = source.read_text().splitlines()[0] + record = json.loads(valid) + variants = [ + valid.rstrip("\n"), + '{"broken":\n', + json.dumps({**record, "schema_version": 2}) + "\n", + json.dumps({**record, "first_seen_unmatched_at": "2026-09-01T00:00:00"}) + + "\n", + '{"schema_version":1,"schema_version":1}\n', + '{"x":NaN}\n', + json.dumps({"schema_version": 1}) + "\n", + ] + for malformed in variants: + with self.subTest(malformed=malformed): + source.write_text(malformed) + with self.assertRaises(detector.DetectorError): + detector.migrate_legacy_manifest(source, self.manifest) + self.assertFalse(self.manifest.exists()) + + def test_migration_never_uses_git_or_alert_transport(self): + source, _ = self.legacy_history() + environment, marker = self.fake_transport(0) + report = self.report( + "--migrate-legacy-manifest", + str(source), + "--alert", + "--repo", + str(self.root / "missing-repo"), + fetch=True, + environment=environment, + ) + self.assertEqual(report["status"], "migrated") + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_manifest.py b/tests/test_release_manifest.py new file mode 100644 index 00000000..9f2469f3 --- /dev/null +++ b/tests/test_release_manifest.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import pytest + +from release_manifest import ( + EmbeddedManifest, + ReleaseConfigurationManifest, + ReleaseManifestError, + canonical_sha256, + loads_strict_json, +) + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": 1, + "release_id": "release-2026-09-03-01", + "source_commit": "1" * 40, + "source_tree": "2" * 40, + "build_contract_digest": "sha256:" + "3" * 64, + "migration_class": "expand", + "schema_min": 4, + "schema_max": 6, + "required_checks": ["build", "test"], + "platform": {"os": "linux", "architecture": "amd64"}, + } + payload.update(overrides) + return payload + + +def test_manifest_round_trip_has_one_canonical_hash() -> None: + manifest = EmbeddedManifest.from_mapping(_payload()) + + assert manifest.to_mapping() == _payload() + assert manifest.manifest_hash == canonical_sha256(_payload()) + + +@pytest.mark.parametrize("schema_version", [0, 2, 999, True]) +def test_manifest_rejects_unknown_schema_versions(schema_version: object) -> None: + with pytest.raises(ReleaseManifestError): + EmbeddedManifest.from_mapping(_payload(schema_version=schema_version)) + + +@pytest.mark.parametrize( + "check_id", + ["SECRET_TOKEN=value", "secret value", "../secret", "x\ud800"], +) +def test_manifest_rejects_unsafe_or_free_form_check_ids(check_id: str) -> None: + with pytest.raises(ReleaseManifestError): + EmbeddedManifest.from_mapping(_payload(required_checks=[check_id])) + + +def test_manifest_rejects_duplicate_json_keys() -> None: + with pytest.raises(ReleaseManifestError): + loads_strict_json('{"schema_version":1,"schema_version":1}') + + +def test_manifest_rejects_extra_self_digest() -> None: + with pytest.raises(ReleaseManifestError): + EmbeddedManifest.from_mapping(_payload(manifest_hash="sha256:" + "a" * 64)) + + +@pytest.mark.parametrize( + ("migration_class", "schema_min", "schema_max"), + [ + ("none", 1, 1), + ("none", 0, 1), + ("expand", 0, 0), + ("contract", 0, 0), + ("data_rewrite", 0, 0), + ], +) +def test_manifest_rejects_semantically_impossible_migration_ranges( + migration_class: str, + schema_min: int, + schema_max: int, +) -> None: + with pytest.raises(ReleaseManifestError, match="migration|no-migration"): + EmbeddedManifest.from_mapping( + _payload( + migration_class=migration_class, + schema_min=schema_min, + schema_max=schema_max, + ) + ) + + +def _configuration_payload(): + embedded = EmbeddedManifest.from_mapping(_payload()) + + def reference(path, character): + return { + "repository_commit": "1" * 40, + "path": path, + "digest": "sha256:" + character * 64, + } + + return { + "schema_version": 1, + "release_id": embedded.release_id, + "embedded_manifest_hash": embedded.manifest_hash, + "governance_repository": "example/governance", + "configuration_profiles": { + "pilot": reference("profiles/pilot.json", "a"), + "production": reference("profiles/production.json", "b"), + }, + "environment_delta_registry": reference("profiles/deltas.json", "c"), + } + + +def test_configuration_manifest_round_trip_is_outside_the_embedded_image() -> None: + payload = _configuration_payload() + embedded = EmbeddedManifest.from_mapping(_payload()) + configuration = ReleaseConfigurationManifest.from_mapping(payload) + + assert configuration.to_mapping() == payload + assert configuration.manifest_hash == canonical_sha256(payload) + assert configuration.embedded_manifest_hash == embedded.manifest_hash + assert "configuration_profiles" not in embedded.to_mapping() + + +@pytest.mark.parametrize("field", list(_configuration_payload())) +def test_configuration_manifest_rejects_every_missing_field(field) -> None: + payload = _configuration_payload() + del payload[field] + with pytest.raises(ReleaseManifestError): + ReleaseConfigurationManifest.from_mapping(payload) + + +@pytest.mark.parametrize("target", ["pilot", "production"]) +def test_configuration_manifest_requires_both_profiles(target) -> None: + payload = _configuration_payload() + del payload["configuration_profiles"][target] + with pytest.raises(ReleaseManifestError): + ReleaseConfigurationManifest.from_mapping(payload) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("schema_version", True), + ("schema_version", 2), + ("embedded_manifest_hash", "latest"), + ("governance_repository", "https://token@example.test/repo"), + ("governance_repository", "../repo"), + ("runtime_artifact_verified", True), + ("approved_by", "caller-assertion"), + ], +) +def test_configuration_manifest_rejects_invalid_fields_and_authority_claims( + field, value +) -> None: + payload = _configuration_payload() + payload[field] = value + with pytest.raises(ReleaseManifestError): + ReleaseConfigurationManifest.from_mapping(payload) + + +@pytest.mark.parametrize( + "reference", ["pilot", "production", "environment_delta_registry"] +) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("repository_commit", "main"), + ("digest", "latest"), + ("path", "../secrets"), + ("path", "/absolute/path"), + ("path", "profiles//pilot.json"), + ("path", "profiles/./pilot.json"), + ("path", "profiles\\pilot.json"), + ("credential", "forbidden"), + ], +) +def test_every_configuration_reference_is_immutable_and_bounded( + reference, field, value +) -> None: + payload = _configuration_payload() + owner = ( + payload + if reference == "environment_delta_registry" + else payload["configuration_profiles"] + ) + owner[reference][field] = value + with pytest.raises(ReleaseManifestError): + ReleaseConfigurationManifest.from_mapping(payload) diff --git a/tests/test_release_smoke.py b/tests/test_release_smoke.py new file mode 100644 index 00000000..a5514a33 --- /dev/null +++ b/tests/test_release_smoke.py @@ -0,0 +1,441 @@ +"""Observe actual pytest subprocesses; malformed evidence must fail closed.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import py_compile +import subprocess +import sys +import textwrap +import time +from copy import deepcopy +from pathlib import Path + +import pytest + +RUNNER_PATH = Path(__file__).resolve().parents[1] / "scripts/run_release_smoke.py" +SPEC = importlib.util.spec_from_file_location("release_smoke_under_test", RUNNER_PATH) +smoke = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(smoke) + + +@pytest.fixture +def project(tmp_path): + repository = tmp_path / "candidate" + repository.mkdir() + (repository / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + output = tmp_path / "evidence" + identity = { + "candidate_commit": "1" * 40, + "ci_config_commit": "1" * 40, + "profile": "fixture", + "paths": ["test_sample.py"], + } + return repository, output, identity + + +def write_test(project, source, conftest=""): + repository, _, _ = project + (repository / "test_sample.py").write_text( + textwrap.dedent(source), encoding="utf-8" + ) + if conftest: + (repository / "conftest.py").write_text( + textwrap.dedent(conftest), encoding="utf-8" + ) + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def test_records_exact_executed_ids_and_candidate_profile(project): + write_test( + project, + """ + import pytest + @pytest.mark.parametrize('number', [1, 2]) + def test_double(number): + assert number * 2 in (2, 4) + """, + ) + repository, output, identity = project + result = smoke.run_smoke(repository, output, identity) + assert result["status"] == "passed" + assert result["executed_test_ids"] == [ + "test_sample.py::test_double[1]", + "test_sample.py::test_double[2]", + ] + baseline = read_json(output / "allowed-test-ids.json") + assert baseline == {**identity, "test_ids": result["executed_test_ids"]} + assert result["allowed_test_ids_digest"] == smoke._sha256(baseline) + assert result["timeout_policy_seconds"] == 120 + assert result["review_after_seconds"] == 60 + assert len(read_json(output / "run.json")["reports"]) == 6 + + +@pytest.mark.parametrize( + "source", + [ + "import pytest\n@pytest.mark.skip(reason='offline')\ndef test_example(): pass", + "import pytest\ndef test_example(): pytest.skip('offline')", + "import pytest\n@pytest.mark.xfail\ndef test_example(): assert False", + "import pytest\n@pytest.mark.xfail\ndef test_example(): assert 2 + 2 == 4", + "def test_example(): assert 2 + 2 == 5", + ( + "import pytest\n@pytest.fixture\ndef broken(): raise RuntimeError('fixture')\n" + "def test_example(broken): pass" + ), + ( + "import pytest\n@pytest.fixture\ndef broken():\n yield\n raise RuntimeError('teardown')\n" + "def test_example(broken): assert 2 + 2 == 4" + ), + ], +) +def test_rejects_every_non_successful_test_phase(project, source): + write_test(project, source) + with pytest.raises(smoke.SmokeRejected): + smoke.run_smoke(*project) + assert read_json(project[1] / "result.json")["status"] == "rejected" + + +@pytest.mark.parametrize( + "source", + [ + "def broken syntax", + "raise RuntimeError('collection failed')", + "import pytest\npytest.skip('module skipped', allow_module_level=True)", + "# zero collected tests", + "import os\nos._exit(0)", + ], +) +def test_collection_failure_skip_empty_or_missing_receipt_blocks_run(project, source): + write_test(project, source) + with pytest.raises(smoke.SmokeRejected): + smoke.run_smoke(*project) + assert not (project[1] / "run.json").exists() + assert read_json(project[1] / "result.json")["status"] == "rejected" + + +@pytest.mark.parametrize("change", ["missing", "extra", "duplicate", "filtered"]) +def test_run_collection_must_equal_independent_baseline(project, change): + mutation = { + "missing": "items.pop()", + "extra": "items[-1]._nodeid += '-unexpected'", + "duplicate": "items.append(items[-1])", + "filtered": "config.hook.pytest_deselected(items=[items.pop()])", + }[change] + write_test( + project, + """ + def test_one(): assert 2 + 2 == 4 + def test_two(): assert 'smoke'.upper() == 'SMOKE' + """, + f""" + def pytest_collection_modifyitems(config, items): + if not config.option.collectonly: + {mutation} + """, + ) + with pytest.raises(smoke.SmokeRejected): + smoke.run_smoke(*project) + assert len(read_json(project[1] / "allowed-test-ids.json")["test_ids"]) == 2 + assert read_json(project[1] / "result.json")["status"] == "rejected" + + +def test_duplicate_baseline_is_rejected(project): + write_test( + project, + "def test_one(): assert 2 + 2 == 4", + """ + def pytest_collection_modifyitems(items): + items.append(items[0]) + """, + ) + with pytest.raises(smoke.SmokeRejected, match="duplicate"): + smoke.run_smoke(*project) + assert not (project[1] / "run.json").exists() + + +def test_collected_but_not_executed_test_does_not_pass(project): + write_test( + project, + "def test_one(): assert 2 + 2 == 4", + """ + def pytest_runtest_protocol(item, nextitem): + return True + """, + ) + with pytest.raises(smoke.SmokeRejected, match="missing"): + smoke.run_smoke(*project) + execution = read_json(project[1] / "run.json") + assert execution["exit_code"] == 0 + assert execution["reports"] == [] + + +@pytest.mark.parametrize("mutation", ["duplicate", "missing", "unknown", "skipped"]) +def test_independent_phase_receipt_validation_rejects_false_green(project, mutation): + write_test(project, "def test_one(): assert 2 + 2 == 4") + result = smoke.run_smoke(*project) + receipt = read_json(project[1] / "run.json") + if mutation == "duplicate": + receipt["reports"].append(deepcopy(receipt["reports"][0])) + elif mutation == "missing": + receipt["reports"].pop() + elif mutation == "unknown": + receipt["reports"][0]["nodeid"] = "test_sample.py::unknown" + else: + receipt["reports"][0]["outcome"] = "skipped" + with pytest.raises(smoke.SmokeRejected): + smoke.verify_execution(result["executed_test_ids"], receipt) + + +def test_timeout_stops_worker_and_emits_review_signal(project, capsys): + write_test( + project, + """ + import time + def test_slow(): + time.sleep(10) + raise AssertionError('deadline was not enforced') + """, + ) + repository, output, identity = project + output.mkdir() + request = output / "request.json" + smoke._write_json(request, identity) + started = time.monotonic() + with pytest.raises(smoke.SmokeRejected, match="timeout_policy"): + smoke.run_phase( + repository, request, "run", timeout_seconds=0.8, review_after_seconds=0.4 + ) + assert time.monotonic() - started < 3 + assert "::warning::" in capsys.readouterr().out + assert not (output / "run.json").exists() + assert smoke.TIMEOUT_SECONDS == 120 + + +def test_slow_success_signals_review_without_changing_policy(project, capsys): + write_test( + project, + """ + import time + def test_slow(): + time.sleep(0.3) + assert 'test'.startswith('t') + """, + ) + repository, output, identity = project + output.mkdir() + request = output / "request.json" + smoke._write_json(request, identity) + receipt = smoke.run_phase( + repository, + request, + "run", + timeout_seconds=4, + review_after_seconds=0.1, + ) + assert receipt["process_exit_code"] == 0 + assert receipt["timeout_review_required"] is True + assert "timeout remains unchanged" in capsys.readouterr().out + + +def test_worker_excludes_credentials_and_pytest_filters(project, monkeypatch): + monkeypatch.setenv("RELEASE_SMOKE_SECRET_FIXTURE", "must-not-reach-child") + monkeypatch.setenv("PYTEST_ADDOPTS", "-k nonexistent") + write_test( + project, + """ + import os + def test_environment(): + assert 'RELEASE_SMOKE_SECRET_FIXTURE' not in os.environ + assert 'PYTEST_ADDOPTS' not in os.environ + assert os.environ['DATABASE_URL'] == 'postgresql://fake:fake@localhost:5432/fake' + """, + ) + assert smoke.run_smoke(*project)["status"] == "passed" + + +def test_worker_blocks_network_and_environment_file_reads(project): + write_test( + project, + """ + import pathlib + import socket + import pytest + def test_offline(): + with pytest.raises(PermissionError, match='network'): + socket.getaddrinfo('example.invalid', 443) + with pytest.raises(PermissionError, match='environment'): + pathlib.Path('.env').read_text() + """, + ) + assert smoke.run_smoke(*project)["status"] == "passed" + + +def test_worker_does_not_load_ignored_bytecode_instead_of_candidate_source(project): + repository, _, _ = project + (repository / "pytest.ini").write_text( + "[pytest]\npythonpath = .\n", encoding="utf-8" + ) + helper = repository / "helper.py" + helper.write_text("ANSWER = 1\n", encoding="utf-8") + poison = repository / "poison.py" + poison.write_text("ANSWER = 0\n", encoding="utf-8") + source_time = helper.stat().st_mtime_ns + os.utime(poison, ns=(source_time, source_time)) + cached = Path(importlib.util.cache_from_source(str(helper))) + cached.parent.mkdir() + py_compile.compile(str(poison), cfile=str(cached), dfile=str(helper), doraise=True) + write_test( + project, + """ + import helper + def test_source(): + assert helper.ANSWER == 1 + """, + ) + assert smoke.run_smoke(*project)["status"] == "passed" + + +def test_existing_output_directory_cannot_reuse_stale_evidence(project): + write_test(project, "def test_one(): assert 2 + 2 == 4") + smoke.run_smoke(*project) + with pytest.raises(FileExistsError): + smoke.run_smoke(*project) + + +def test_cli_does_not_accept_a_floating_ref_or_unknown_profile(project): + repository, output, _ = project + with pytest.raises(smoke.SmokeRejected, match="immutable"): + smoke.verify_candidate(repository, "pilot") + with pytest.raises(SystemExit) as rejected: + smoke.main( + [ + "--candidate-commit", + "1" * 40, + "--profile", + "empty", + "--output-dir", + str(output), + ] + ) + assert rejected.value.code == 2 + + +def test_cli_rejects_commit_mismatch_and_dirty_tree(project, monkeypatch): + repository, _, _ = project + monkeypatch.setattr(smoke, "_git", lambda *_args: "2" * 40) + with pytest.raises(smoke.SmokeRejected, match="HEAD"): + smoke.verify_candidate(repository, "1" * 40) + + def dirty_git(_repo, *arguments): + return "1" * 40 if arguments[0] == "rev-parse" else " M test_sample.py" + + monkeypatch.setattr(smoke, "_git", dirty_git) + with pytest.raises(smoke.SmokeRejected, match="clean"): + smoke.verify_candidate(repository, "1" * 40) + + +@pytest.mark.parametrize( + "failure", [OSError("unavailable"), subprocess.TimeoutExpired("git", 15)] +) +def test_git_verification_failure_is_a_rejected_candidate( + project, monkeypatch, failure +): + def unavailable(*_args, **_kwargs): + raise failure + + monkeypatch.setattr(smoke.subprocess, "run", unavailable) + with pytest.raises(smoke.SmokeRejected, match="Git identity"): + smoke.verify_candidate(project[0], "1" * 40) + + +def test_cli_binds_a_real_clean_candidate_and_rejects_later_source_changes(tmp_path): + repository = tmp_path / "candidate" + repository.mkdir() + runner = repository / "scripts/run_release_smoke.py" + runner.parent.mkdir() + runner.write_bytes(RUNNER_PATH.read_bytes()) + workflow = repository / smoke.WORKFLOW_PATH + workflow.parent.mkdir(parents=True) + workflow.write_text("name: fixture\n", encoding="utf-8") + tracked = ["scripts/run_release_smoke.py", smoke.WORKFLOW_PATH] + for relative in smoke.PROFILE_PATHS: + path = repository / relative + if relative.endswith("/"): + path = path / "test_fixture.py" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("def test_fixture(): assert 2 + 2 == 4\n", encoding="utf-8") + tracked.append(path.relative_to(repository).as_posix()) + environment = smoke._test_environment() + environment.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null"}) + + def git(*arguments): + return subprocess.run( + [ + "git", + "-c", + "core.hooksPath=/dev/null", + "-c", + "user.name=Smoke fixture", + "-c", + "user.email=smoke@example.invalid", + *arguments, + ], + cwd=repository, + env=environment, + capture_output=True, + text=True, + check=True, + timeout=15, + ).stdout.strip() + + git("init", "-q") + git("add", "--", *tracked) + git("commit", "-q", "-m", "test fixture") + candidate = git("rev-parse", "HEAD") + output = tmp_path / "evidence" + command = [ + sys.executable, + "-I", + "-B", + str(runner), + "--candidate-commit", + candidate, + "--profile", + smoke.PROFILE, + "--output-dir", + str(output), + ] + result = subprocess.run( + command, + cwd=repository, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert result.returncode == 0, result.stderr + receipt = read_json(output / "result.json") + assert receipt["candidate_commit"] == candidate + assert receipt["ci_config_commit"] == candidate + assert len(receipt["executed_test_ids"]) == 4 + (repository / smoke.PROFILE_PATHS[1]).write_text("# changed\n", encoding="utf-8") + command[-1] = str(tmp_path / "changed-evidence") + rejected = subprocess.run( + command, + cwd=repository, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert rejected.returncode == 1 + assert "worktree is not clean" in rejected.stderr + assert not (tmp_path / "changed-evidence").exists()