diff --git a/.github/workflows/profile-consistency.yml b/.github/workflows/profile-consistency.yml index febbe2d..a037bfa 100644 --- a/.github/workflows/profile-consistency.yml +++ b/.github/workflows/profile-consistency.yml @@ -1,15 +1,26 @@ name: Profile consistency on: + schedule: + - cron: "19 7 * * *" + workflow_dispatch: pull_request: paths: - ".github/workflows/profile-consistency.yml" - "LAUNCH_PLAN.md" - "REPOSITORY_LIFECYCLE.md" - "profile/README.md" + - "production-lifecycle-admissions.json" + - "production-lifecycle-policy.json" - "README.md" - "repository-lifecycle.yml" - "scripts/check_profile.py" + - "scripts/validate_production_lifecycle.py" + - "schemas/production-lifecycle-admissions.schema.json" + - "schemas/production-lifecycle-evidence-manifest.schema.json" + - "schemas/production-lifecycle-evidence-summary.schema.json" + - "schemas/production-lifecycle-policy.schema.json" + - "tests/test_production_lifecycle.py" push: branches: [main] paths: @@ -17,9 +28,17 @@ on: - "LAUNCH_PLAN.md" - "REPOSITORY_LIFECYCLE.md" - "profile/README.md" + - "production-lifecycle-admissions.json" + - "production-lifecycle-policy.json" - "README.md" - "repository-lifecycle.yml" - "scripts/check_profile.py" + - "scripts/validate_production_lifecycle.py" + - "schemas/production-lifecycle-admissions.schema.json" + - "schemas/production-lifecycle-evidence-manifest.schema.json" + - "schemas/production-lifecycle-evidence-summary.schema.json" + - "schemas/production-lifecycle-policy.schema.json" + - "tests/test_production_lifecycle.py" permissions: contents: read @@ -30,5 +49,48 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - name: Test Production lifecycle admission failures + run: python3 -m unittest discover -s tests -p 'test_*.py' + - name: Refuse Production release-ledger rollback + if: github.event_name == 'pull_request' || github.event_name == 'push' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if git cat-file -e "$BASE_SHA:production-lifecycle-admissions.json"; then + git show "$BASE_SHA:production-lifecycle-admissions.json" > "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" + elif git cat-file -e "$BASE_SHA:production-lifecycle-policy.json"; then + echo "REFUSED: the trusted base has policy but no admission ledger" >&2 + exit 1 + else + printf '{"admissions":[]}\n' > "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" + fi + python3 scripts/validate_production_lifecycle.py --previous-admissions "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" - name: Validate product truth and links run: python3 scripts/check_profile.py + + report-expired-admission: + needs: validate-profile + if: >- + ${{ always() && needs.validate-profile.result == 'failure' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: File or update the Production lifecycle failure + env: + GH_TOKEN: ${{ github.token }} + run: | + TITLE="Production lifecycle admission needs attention" + BODY="The scheduled evidence-gated lifecycle check failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + An admission record or its current evidence is invalid. Consumers derive Production from the latest exact signed release and must fail closed." + EXISTING=$(gh issue list --repo "${{ github.repository }}" --state open \ + --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "${{ github.repository }}" --body "$BODY" + else + gh issue create --repo "${{ github.repository }}" --title "$TITLE" --body "$BODY" + fi diff --git a/REPOSITORY_LIFECYCLE.md b/REPOSITORY_LIFECYCLE.md index f68bec1..e0d18ff 100644 --- a/REPOSITORY_LIFECYCLE.md +++ b/REPOSITORY_LIFECYCLE.md @@ -1,6 +1,6 @@ # OpenAdapt Repository Lifecycle Registry -Last reviewed: 2026-07-27 +Last reviewed: 2026-08-18 This public registry separates the product from experiments and records the intended lifecycle of organization repositories. It does not authorize moving @@ -14,6 +14,7 @@ The machine-readable source is [`repository-lifecycle.yml`](repository-lifecycle | Status | Meaning | |--------|---------| +| **Production** | Exact latest release in the signed Production channel with an active, unexpired, non-revoked, independently attested acceptance admission | | **Beta** | Active product surface with compatibility intent, but not a blanket production-readiness claim | | **Experimental** | Active prototype or optional component with no production support promise | | **Research** | Evidence-generating work, not required by the product runtime | @@ -24,7 +25,56 @@ The machine-readable source is [`repository-lifecycle.yml`](repository-lifecycle | **Deprecated** | Superseded; migration fixes only, no new integrations | | **Archived** | Historical and read-only | -## Current Product Boundary +## Production Admission + +Production is a derived per-release state. It is not a static repository label. +A person cannot create it by changing a table or repository description. The canonical +[`production-lifecycle-policy.json`](production-lifecycle-policy.json) names +the seven eligible targets and their required release artifacts. The +[`production-lifecycle-admissions.json`](production-lifecycle-admissions.json) +file contains only admissions that pass the machine validator. + +Each admission binds the exact public package release or deployment identity, +the complete required artifact inventory and hashes, and an independently +attested remote-safe evidence summary. The summary binds the private Cloud +acceptance certificate by schema, digest, and signer-provenance digest. It does +not publish the private certificate or its location. The summary also binds an +immutable public evidence-manifest URL and digest. A private Cloud deployment +uses an opaque release identity and digest. It does not require a public source +or artifact URL. Public artifacts use a pinned authority. The validator checks +current PyPI metadata, immutable GitHub release metadata, or content-addressed +managed evidence before it derives Production. + +The signer-provenance digest uses the domain `OpenAdapt production certificate +signer provenance v1\0`. Its canonical input is the normalized, verified Cloud +GitHub provenance plus the qualification admission signer-registry digest and +revision. The private v2 evidence identity must bind the exact target release +or deployment and its complete artifact inventory. Each target has a distinct +claim scope. Evidence for one target cannot admit a different target. The +public evidence manifest must bind the same target, scope, policy, release, +artifact inventory, evidence identity, qualification, failure taxonomy, +reliability counts, oracle, trial inventory, and immutable retention record. + +A qualified workflow is one exact compiled workflow version that passed its +declared qualification contract on its bound execution environment. The signed +qualification identity binds the workflow bundle, runtime release, dependency +set, environment, input schema, policy, required identity checks, required +effect checks, and verification rules. A run gate must reject an absent, +expired, revoked, or mismatched qualification. A new workflow version or a +change to a bound input requires a new qualification. A Production runtime must +accept only these exact qualified workflow identities. + +Each target has an append-only hash chain of signed Production release +identities. The highest sequence is current. A new release cannot reuse an old +release identity. If the latest admission expires or is revoked, Production is +empty for that target. The validator does not fall back to an older release. +Static Production membership is forbidden. Consumers derive current Production +at read time from the signed admission, its expiry, and its revocation state. + +## Baseline Lifecycle + +The baseline applies when a target does not have a current Production +admission. The admission overlay does not rewrite repository history. | Repository | Lifecycle | Role | |------------|-----------|------| diff --git a/production-lifecycle-admissions.json b/production-lifecycle-admissions.json new file mode 100644 index 0000000..a47eb8a --- /dev/null +++ b/production-lifecycle-admissions.json @@ -0,0 +1,6 @@ +{ + "$schema": "schemas/production-lifecycle-admissions.schema.json", + "schema_version": "openadapt.production-lifecycle-admissions/v1", + "policy_sha256": "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11", + "admissions": [] +} diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json new file mode 100644 index 0000000..dbd02cf --- /dev/null +++ b/production-lifecycle-policy.json @@ -0,0 +1,121 @@ +{ + "$schema": "schemas/production-lifecycle-policy.schema.json", + "schema_version": "openadapt.production-lifecycle-policy/v1", + "revision": 1, + "maximum_admission_days": 30, + "summary_authority": { + "repository": "OpenAdaptAI/openadapt-evals", + "workflow": ".github/workflows/production-lifecycle-evidence.yml", + "source_ref": "refs/heads/main", + "oidc_issuer": "https://token.actions.githubusercontent.com", + "certificate_identity": "https://github.com/OpenAdaptAI/openadapt-evals/.github/workflows/production-lifecycle-evidence.yml@refs/heads/main", + "summary_schema_version": "openadapt.production-lifecycle-evidence-summary/v1", + "private_certificate_schema_version": "openadapt.execute-live-acceptance-record/v2", + "evidence_manifest_schema_version": "openadapt.production-acceptance/v1", + "acceptance_policy_sha256": "sha256:9b1fe55bc6796ae0a46960ca4aa335d88de60b0562c383afa1e85fa0a0c204b8", + "release_identity_schema_version": "openadapt.monotonic-production-release/v1", + "production_channel": "production", + "signer_provenance_digest_domain": "OpenAdapt production certificate signer provenance v1\u0000" + }, + "targets": [ + { + "id": "agent", + "display_name": "OpenAdapt Agent", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-agent", + "source_repository": "OpenAdaptAI/openadapt-agent", + "release_kind": "public_package", + "required_claim_scope": "qualified_agent_bridge_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-agent", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} + }, + { + "id": "capture", + "display_name": "OpenAdapt Capture", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-capture", + "source_repository": "OpenAdaptAI/openadapt-capture", + "release_kind": "public_package", + "required_claim_scope": "qualified_native_recorder_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-capture", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} + }, + { + "id": "cloud", + "display_name": "OpenAdapt Cloud", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-cloud", + "source_repository": "OpenAdaptAI/openadapt-cloud", + "release_kind": "private_deployment", + "required_claim_scope": "qualified_workflow_control_plane_deployment", + "required_artifact_kinds": [], + "package_index_project": null, + "artifact_authority_by_kind": {} + }, + { + "id": "desktop", + "display_name": "OpenAdapt Desktop", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-desktop", + "source_repository": "OpenAdaptAI/openadapt-desktop", + "release_kind": "public_package", + "required_claim_scope": "qualified_native_workflow_desktop_release", + "required_artifact_kinds": [ + "linux-installer", + "macos-installer", + "sdist", + "wheel", + "windows-installer" + ], + "package_index_project": "openadapt-desktop", + "artifact_authority_by_kind": { + "linux-installer": "github_release", + "macos-installer": "github_release", + "sdist": "pypi", + "wheel": "pypi", + "windows-installer": "github_release" + } + }, + { + "id": "docs", + "display_name": "OpenAdapt Documentation", + "lifecycle_scope": "public_surface", + "lifecycle_subject": "docs.openadapt.ai", + "source_repository": "OpenAdaptAI/openadapt-ops", + "release_kind": "public_deployment", + "required_claim_scope": "production_documentation_deployment", + "required_artifact_kinds": ["deployment-manifest", "site-archive"], + "package_index_project": null, + "artifact_authority_by_kind": { + "deployment-manifest": "managed_evidence", + "site-archive": "managed_evidence" + } + }, + { + "id": "flow", + "display_name": "OpenAdapt Flow", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "release_kind": "public_package", + "required_claim_scope": "qualified_workflow_runtime_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-flow", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} + }, + { + "id": "openadapt", + "display_name": "OpenAdapt", + "lifecycle_scope": "repository", + "lifecycle_subject": "OpenAdapt", + "source_repository": "OpenAdaptAI/OpenAdapt", + "release_kind": "public_package", + "required_claim_scope": "qualified_workflow_launcher_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"} + } + ] +} diff --git a/repository-lifecycle.yml b/repository-lifecycle.yml index 55731c9..02e56db 100644 --- a/repository-lifecycle.yml +++ b/repository-lifecycle.yml @@ -1,5 +1,5 @@ -schema_version: 1 -reviewed_on: 2026-07-27 +schema_version: 2 +reviewed_on: 2026-08-18 canonical_product: launcher: OpenAdapt engine: openadapt-flow @@ -25,6 +25,7 @@ public_metadata: Beta local bridge exposing governed OpenAdapt workflows as MCP tools and Agent Skills; halts remain halts. lifecycle: + production: [] beta: - OpenAdapt - openadapt-flow @@ -70,3 +71,7 @@ lifecycle: - openadapt-gitbook - OpenReflector - OpenAdapter +public_surfaces: + production: [] + beta: + - docs.openadapt.ai diff --git a/schemas/production-lifecycle-admissions.schema.json b/schemas/production-lifecycle-admissions.schema.json new file mode 100644 index 0000000..fffe032 --- /dev/null +++ b/schemas/production-lifecycle-admissions.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-admissions.schema.json", + "title": "OpenAdapt Production lifecycle admissions", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "policy_sha256", "admissions"], + "properties": { + "$schema": {"const": "schemas/production-lifecycle-admissions.schema.json"}, + "schema_version": {"const": "openadapt.production-lifecycle-admissions/v1"}, + "policy_sha256": {"$ref": "#/$defs/digest"}, + "admissions": {"type": "array", "items": {"$ref": "#/$defs/admission"}} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"}, + "releaseIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "sequence", "previous_admission_sha256"], + "properties": { + "schema_version": {"const": "openadapt.monotonic-production-release/v1"}, + "channel": {"const": "production"}, + "sequence": {"type": "integer", "minimum": 1}, + "previous_admission_sha256": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "authority", "url", "sha256", "size_bytes"], + "properties": { + "name": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$"}, + "kind": {"type": "string", "minLength": 1}, + "authority": {"enum": ["pypi", "github_release", "managed_evidence"]}, + "url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1} + } + }, + "publicPackage": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "version", "tag", "source_commit", "immutable_release_url", "artifacts"], + "properties": { + "kind": {"const": "public_package"}, + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"}, + "tag": {"type": "string", "minLength": 1}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "immutable_release_url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "artifacts": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/artifact"}} + } + }, + "publicDeployment": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "deployment_id", "deployment_sha256", "source_commit", "immutable_release_url", "artifacts"], + "properties": { + "kind": {"const": "public_deployment"}, + "deployment_id": {"type": "string", "minLength": 1}, + "deployment_sha256": {"$ref": "#/$defs/digest"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "immutable_release_url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "artifacts": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/artifact"}} + } + }, + "privateDeployment": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "deployment_release_id", "deployment_release_sha256", "manifest_sha256"], + "properties": { + "kind": {"const": "private_deployment"}, + "deployment_release_id": {"type": "string", "minLength": 1}, + "deployment_release_sha256": {"$ref": "#/$defs/digest"}, + "manifest_sha256": {"$ref": "#/$defs/digest"} + } + }, + "acceptanceEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "summary_url", + "summary_sha256", + "attestation_bundle_url", + "attestation_bundle_sha256", + "authority_source_commit" + ], + "properties": { + "summary_url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "summary_sha256": {"$ref": "#/$defs/digest"}, + "attestation_bundle_url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "attestation_bundle_sha256": {"$ref": "#/$defs/digest"}, + "authority_source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"} + } + }, + "admission": { + "type": "object", + "additionalProperties": false, + "required": [ + "admission_id", + "target", + "claim_scope", + "release_identity", + "policy_revision", + "release", + "acceptance_evidence", + "issued_at", + "expires_at", + "revoked_at" + ], + "properties": { + "admission_id": {"type": "string", "minLength": 1}, + "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "release_identity": {"$ref": "#/$defs/releaseIdentity"}, + "policy_revision": {"type": "integer", "minimum": 1}, + "release": { + "oneOf": [ + {"$ref": "#/$defs/publicPackage"}, + {"$ref": "#/$defs/publicDeployment"}, + {"$ref": "#/$defs/privateDeployment"} + ] + }, + "acceptance_evidence": {"$ref": "#/$defs/acceptanceEvidence"}, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "revoked_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]} + } + } + } +} diff --git a/schemas/production-lifecycle-evidence-manifest.schema.json b/schemas/production-lifecycle-evidence-manifest.schema.json new file mode 100644 index 0000000..d0fa2c3 --- /dev/null +++ b/schemas/production-lifecycle-evidence-manifest.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-evidence-manifest.schema.json", + "title": "OpenAdapt Production acceptance evidence manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "target", + "claim_scope", + "verdict", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "target_release_sha256", + "target_artifact_inventory_sha256", + "evidence_identity_sha256", + "source_evidence", + "qualification", + "failure_taxonomy_counts", + "reliability", + "retention" + ], + "properties": { + "schema_version": {"const": "openadapt.production-acceptance/v1"}, + "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "verdict": {"const": "accepted"}, + "acceptance_policy_sha256": {"$ref": "#/$defs/digest"}, + "lifecycle_policy_sha256": {"$ref": "#/$defs/digest"}, + "target_release_sha256": {"$ref": "#/$defs/digest"}, + "target_artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, + "source_evidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_result_sha256", + "certificate_sha256", + "campaign_sha256", + "qualification_admission_sha256", + "attestation_sha256", + "attestation_bundle_sha256" + ], + "properties": { + "source_result_sha256": {"$ref": "#/$defs/digest"}, + "certificate_sha256": {"$ref": "#/$defs/digest"}, + "campaign_sha256": {"$ref": "#/$defs/digest"}, + "qualification_admission_sha256": {"$ref": "#/$defs/digest"}, + "attestation_sha256": {"$ref": "#/$defs/digest"}, + "attestation_bundle_sha256": {"$ref": "#/$defs/digest"} + } + }, + "qualification": { + "type": "object", + "additionalProperties": false, + "required": [ + "campaign_contract_sha256", + "campaign_outcomes_sha256", + "oracle_contract_sha256", + "task_count", + "condition_count", + "required_trial_count", + "observed_trial_count", + "minimum_trials_per_condition", + "excluded_trial_count", + "task_condition_inventory_sha256" + ], + "properties": { + "campaign_contract_sha256": {"$ref": "#/$defs/digest"}, + "campaign_outcomes_sha256": {"$ref": "#/$defs/digest"}, + "oracle_contract_sha256": {"$ref": "#/$defs/digest"}, + "task_count": {"type": "integer", "minimum": 1}, + "condition_count": {"type": "integer", "minimum": 1}, + "required_trial_count": {"type": "integer", "minimum": 3}, + "observed_trial_count": {"type": "integer", "minimum": 3}, + "minimum_trials_per_condition": {"type": "integer", "minimum": 3}, + "excluded_trial_count": {"const": 0}, + "task_condition_inventory_sha256": {"$ref": "#/$defs/digest"} + } + }, + "failure_taxonomy_counts": {"$ref": "#/$defs/failureTaxonomy"}, + "reliability": {"$ref": "#/$defs/reliability"}, + "retention": {"$ref": "#/$defs/retention"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "millisecondTimestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"}, + "count": {"type": "integer", "minimum": 0}, + "failureTaxonomy": { + "type": "object", + "additionalProperties": false, + "required": [ + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_record" + ], + "properties": { + "collateral_effect": {"$ref": "#/$defs/count"}, + "duplicate_effect": {"$ref": "#/$defs/count"}, + "healthy_path_model_call": {"$ref": "#/$defs/count"}, + "operator_intervention": {"$ref": "#/$defs/count"}, + "over_halt": {"$ref": "#/$defs/count"}, + "platform_failure": {"$ref": "#/$defs/count"}, + "safe_halt": {"$ref": "#/$defs/count"}, + "silent_incorrect_success": {"$ref": "#/$defs/count"}, + "uncertain_delivery": {"$ref": "#/$defs/count"}, + "verified": {"$ref": "#/$defs/count"}, + "wrong_record": {"$ref": "#/$defs/count"} + } + }, + "reliability": { + "type": "object", + "additionalProperties": false, + "required": [ + "silent_incorrect_success_count", + "over_halt_count", + "wrong_record_count", + "duplicate_effect_count", + "collateral_effect_count", + "operator_intervention_count", + "uncertain_delivery_count", + "model_call_count" + ], + "properties": { + "silent_incorrect_success_count": {"$ref": "#/$defs/count"}, + "over_halt_count": {"$ref": "#/$defs/count"}, + "wrong_record_count": {"$ref": "#/$defs/count"}, + "duplicate_effect_count": {"$ref": "#/$defs/count"}, + "collateral_effect_count": {"$ref": "#/$defs/count"}, + "operator_intervention_count": {"$ref": "#/$defs/count"}, + "uncertain_delivery_count": {"$ref": "#/$defs/count"}, + "model_call_count": {"$ref": "#/$defs/count"} + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": [ + "receipt_id", + "ciphertext_sha256", + "candidate_sha256", + "private_envelope_sha256", + "store_attestation_sha256", + "storage_identity_sha256", + "object_version_sha256", + "private_locator_version_sha256", + "kms_key_identity_sha256", + "uploader_identity_sha256", + "retention_mode", + "retention_until", + "retained_at", + "upload_verified", + "head_verified", + "object_lock_verified", + "private_locator_recorded", + "acceptance_verified_at", + "provenance_attestation" + ], + "properties": { + "receipt_id": {"type": "string", "pattern": "^retention:[0-9a-f]{32}$"}, + "ciphertext_sha256": {"$ref": "#/$defs/digest"}, + "candidate_sha256": {"$ref": "#/$defs/digest"}, + "private_envelope_sha256": {"$ref": "#/$defs/digest"}, + "store_attestation_sha256": {"$ref": "#/$defs/digest"}, + "storage_identity_sha256": {"$ref": "#/$defs/digest"}, + "object_version_sha256": {"$ref": "#/$defs/digest"}, + "private_locator_version_sha256": {"$ref": "#/$defs/digest"}, + "kms_key_identity_sha256": {"$ref": "#/$defs/digest"}, + "uploader_identity_sha256": {"$ref": "#/$defs/digest"}, + "retention_mode": {"const": "COMPLIANCE"}, + "retention_until": {"$ref": "#/$defs/millisecondTimestamp"}, + "retained_at": {"$ref": "#/$defs/millisecondTimestamp"}, + "upload_verified": {"const": true}, + "head_verified": {"const": true}, + "object_lock_verified": {"const": true}, + "private_locator_recorded": {"const": true}, + "acceptance_verified_at": {"$ref": "#/$defs/millisecondTimestamp"}, + "provenance_attestation": {"const": "github-artifact-attestation-v4"} + } + } + } +} diff --git a/schemas/production-lifecycle-evidence-summary.schema.json b/schemas/production-lifecycle-evidence-summary.schema.json new file mode 100644 index 0000000..a0980c7 --- /dev/null +++ b/schemas/production-lifecycle-evidence-summary.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-evidence-summary.schema.json", + "title": "OpenAdapt remote-safe Production lifecycle evidence summary", + "description": "An attested public projection. It binds a private acceptance certificate by digest and signer provenance only.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "target", + "verdict", + "claim_scope", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "release_identity", + "release_sha256", + "artifact_inventory_sha256", + "evidence_identity_sha256", + "private_certificate_binding", + "evidence_manifest", + "issued_at", + "expires_at", + "revoked_at" + ], + "properties": { + "schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v1"}, + "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "verdict": {"const": "accepted"}, + "claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "acceptance_policy_sha256": {"$ref": "#/$defs/digest"}, + "lifecycle_policy_sha256": {"$ref": "#/$defs/digest"}, + "release_identity": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "sequence", "previous_admission_sha256"], + "properties": { + "schema_version": {"const": "openadapt.monotonic-production-release/v1"}, + "channel": {"const": "production"}, + "sequence": {"type": "integer", "minimum": 1}, + "previous_admission_sha256": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + } + } + }, + "release_sha256": {"$ref": "#/$defs/digest"}, + "artifact_inventory_sha256": {"$ref": "#/$defs/digest"}, + "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, + "private_certificate_binding": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "sha256", + "signer_provenance_sha256", + "evidence_identity_sha256", + "target", + "target_release_sha256", + "target_artifact_inventory_sha256" + ], + "properties": { + "schema_version": {"const": "openadapt.execute-live-acceptance-record/v2"}, + "sha256": {"$ref": "#/$defs/digest"}, + "signer_provenance_sha256": { + "description": "SHA-256 over the domain-separated canonical normalized verified Cloud provenance plus the admission signer-registry digest and revision.", + "$ref": "#/$defs/digest" + }, + "evidence_identity_sha256": {"$ref": "#/$defs/digest"}, + "target": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "target_release_sha256": {"$ref": "#/$defs/digest"}, + "target_artifact_inventory_sha256": {"$ref": "#/$defs/digest"} + } + }, + "evidence_manifest": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "url", "sha256"], + "properties": { + "schema_version": {"const": "openadapt.production-acceptance/v1"}, + "url": {"type": "string", "format": "uri", "pattern": "^https://"}, + "sha256": {"$ref": "#/$defs/digest"} + } + }, + "issued_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"$ref": "#/$defs/timestamp"}, + "revoked_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "timestamp": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"} + } +} diff --git a/schemas/production-lifecycle-policy.schema.json b/schemas/production-lifecycle-policy.schema.json new file mode 100644 index 0000000..9f199ff --- /dev/null +++ b/schemas/production-lifecycle-policy.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/.github/blob/main/schemas/production-lifecycle-policy.schema.json", + "title": "OpenAdapt Production lifecycle policy", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schema_version", + "revision", + "maximum_admission_days", + "summary_authority", + "targets" + ], + "properties": { + "$schema": {"const": "schemas/production-lifecycle-policy.schema.json"}, + "schema_version": {"const": "openadapt.production-lifecycle-policy/v1"}, + "revision": {"type": "integer", "minimum": 1}, + "maximum_admission_days": {"type": "integer", "minimum": 1, "maximum": 30}, + "summary_authority": { + "type": "object", + "additionalProperties": false, + "required": [ + "repository", + "workflow", + "source_ref", + "oidc_issuer", + "certificate_identity", + "summary_schema_version", + "private_certificate_schema_version", + "evidence_manifest_schema_version", + "acceptance_policy_sha256", + "release_identity_schema_version", + "production_channel", + "signer_provenance_digest_domain" + ], + "properties": { + "repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "workflow": {"type": "string", "pattern": "^\\.github/workflows/[A-Za-z0-9._-]+\\.ya?ml$"}, + "source_ref": {"const": "refs/heads/main"}, + "oidc_issuer": {"const": "https://token.actions.githubusercontent.com"}, + "certificate_identity": {"type": "string", "format": "uri"}, + "summary_schema_version": {"const": "openadapt.production-lifecycle-evidence-summary/v1"}, + "private_certificate_schema_version": {"const": "openadapt.execute-live-acceptance-record/v2"}, + "evidence_manifest_schema_version": {"const": "openadapt.production-acceptance/v1"}, + "acceptance_policy_sha256": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "release_identity_schema_version": {"const": "openadapt.monotonic-production-release/v1"}, + "production_channel": {"const": "production"}, + "signer_provenance_digest_domain": {"const": "OpenAdapt production certificate signer provenance v1\u0000"} + } + }, + "targets": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "display_name", + "lifecycle_scope", + "lifecycle_subject", + "source_repository", + "release_kind", + "required_claim_scope", + "required_artifact_kinds", + "package_index_project", + "artifact_authority_by_kind" + ], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,63}$"}, + "display_name": {"type": "string", "minLength": 1}, + "lifecycle_scope": {"enum": ["repository", "public_surface"]}, + "lifecycle_subject": {"type": "string", "minLength": 1}, + "source_repository": {"type": "string", "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$"}, + "release_kind": {"enum": ["public_package", "private_deployment", "public_deployment"]}, + "required_claim_scope": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,127}$"}, + "package_index_project": { + "oneOf": [ + {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]+$"}, + {"type": "null"} + ] + }, + "artifact_authority_by_kind": { + "type": "object", + "additionalProperties": {"enum": ["pypi", "github_release", "managed_evidence"]} + }, + "required_artifact_kinds": { + "type": "array", + "uniqueItems": true, + "items": {"type": "string", "minLength": 1} + } + } + } + } + } +} diff --git a/scripts/check_profile.py b/scripts/check_profile.py old mode 100644 new mode 100755 index 7fffedd..b0770c6 --- a/scripts/check_profile.py +++ b/scripts/check_profile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the public profile without making network requests.""" +"""Check the public profile and its evidence-gated lifecycle.""" from __future__ import annotations @@ -8,6 +8,7 @@ from pathlib import Path from urllib.parse import unquote, urlsplit +from validate_production_lifecycle import LifecycleError, validate_files ROOT = Path(__file__).resolve().parents[1] PROFILE = ROOT / "profile" / "README.md" @@ -52,6 +53,7 @@ LIFECYCLE_GROUP_RE = re.compile(r"^ ([a-z_]+):(?: \[\])?$") LIFECYCLE_REPOSITORY_RE = re.compile(r"^ - (\S+)$") EXPECTED_LIFECYCLE_GROUPS = { + "production", "beta", "experimental", "research", @@ -126,6 +128,11 @@ def main() -> int: if missing_links: errors.append(f"profile/README.md is missing required links: {missing_links}") + try: + validate_files(ROOT) + except LifecycleError as exc: + errors.append(f"Production lifecycle refused: {exc}") + lifecycle_text = LIFECYCLE_DATA.read_text(encoding="utf-8") pinned_section = lifecycle_text.split(" pinned_repositories:\n", maxsplit=1) if len(pinned_section) != 2: @@ -138,7 +145,9 @@ def main() -> int: elif line and not line.startswith(" "): break if tuple(pinned) != EXPECTED_PINNED_REPOSITORIES: - errors.append("repository-lifecycle.yml product pins do not match the public contract") + errors.append( + "repository-lifecycle.yml product pins do not match the public contract" + ) lifecycle_section = lifecycle_text.split("lifecycle:\n", maxsplit=1) if len(lifecycle_section) != 2: @@ -164,9 +173,13 @@ def main() -> int: errors.append( "repository-lifecycle.yml lifecycle groups do not match the public schema" ) - repositories = [repository for values in groups.values() for repository in values] + repositories = [ + repository for values in groups.values() for repository in values + ] duplicates = sorted( - repository for repository in set(repositories) if repositories.count(repository) > 1 + repository + for repository in set(repositories) + if repositories.count(repository) > 1 ) if duplicates: errors.append( @@ -201,7 +214,9 @@ def main() -> int: text = source.read_text(encoding="utf-8") links = LINK_RE.findall(text) if text.count("](") != len(links): - errors.append(f"{source.relative_to(ROOT)} has malformed Markdown link syntax") + errors.append( + f"{source.relative_to(ROOT)} has malformed Markdown link syntax" + ) for destination in links: if error := check_link(source, destination): errors.append(f"{source.relative_to(ROOT)}: {error}") diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py new file mode 100755 index 0000000..81a822b --- /dev/null +++ b/scripts/validate_production_lifecycle.py @@ -0,0 +1,1675 @@ +#!/usr/bin/env python3 +"""Validate evidence-gated Production lifecycle assignments. + +The public lifecycle is a derived state. A repository or public surface can +appear in the Production group only while one exact, independently attested +admission is active. The admission binds a release, its artifacts, and a +remote-safe acceptance summary. The summary binds the private acceptance +certificate by digest only; it never publishes the private certificate or its +location. + +No network request occurs while the admissions list is empty. A Production +admission fails closed unless the referenced summary, attestation bundle, and +evidence manifest can all be fetched, hashed, and verified. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import tempfile +import urllib.error +import urllib.request +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlsplit + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PATH = ROOT / "production-lifecycle-policy.json" +ADMISSIONS_PATH = ROOT / "production-lifecycle-admissions.json" +LIFECYCLE_PATH = ROOT / "repository-lifecycle.yml" + +POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v1" +ADMISSIONS_SCHEMA = "openadapt.production-lifecycle-admissions/v1" +SUMMARY_SCHEMA = "openadapt.production-lifecycle-evidence-summary/v1" +RELEASE_IDENTITY_SCHEMA = "openadapt.monotonic-production-release/v1" +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") +TARGET_ID = re.compile(r"^[a-z][a-z0-9-]{1,63}$") +ARTIFACT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$") +MILLISECOND_UTC = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$" +) +GROUP_RE = re.compile(r"^ ([a-z_]+):(?: \[\])?$") +REPOSITORY_RE = re.compile(r"^ - (\S+)$") +MAX_REMOTE_BYTES = 2 * 1024 * 1024 +TARGET_RELEASE_DIGEST_DOMAIN = b"OpenAdapt production lifecycle target release v1\0" +ARTIFACT_INVENTORY_DIGEST_DOMAIN = ( + b"OpenAdapt production lifecycle artifact inventory v1\0" +) +FAILURE_TAXONOMY_KEYS = { + "collateral_effect", + "duplicate_effect", + "healthy_path_model_call", + "operator_intervention", + "over_halt", + "platform_failure", + "safe_halt", + "silent_incorrect_success", + "uncertain_delivery", + "verified", + "wrong_record", +} +RELIABILITY_TO_TAXONOMY = { + "silent_incorrect_success_count": "silent_incorrect_success", + "over_halt_count": "over_halt", + "wrong_record_count": "wrong_record", + "duplicate_effect_count": "duplicate_effect", + "collateral_effect_count": "collateral_effect", + "operator_intervention_count": "operator_intervention", + "uncertain_delivery_count": "uncertain_delivery", +} +RELIABILITY_KEYS = {*RELIABILITY_TO_TAXONOMY, "model_call_count"} +RETENTION_DIGEST_KEYS = { + "ciphertext_sha256", + "candidate_sha256", + "private_envelope_sha256", + "store_attestation_sha256", + "storage_identity_sha256", + "object_version_sha256", + "private_locator_version_sha256", + "kms_key_identity_sha256", + "uploader_identity_sha256", +} +EXPECTED_AUTHORITY = { + "repository": "OpenAdaptAI/openadapt-evals", + "workflow": ".github/workflows/production-lifecycle-evidence.yml", + "source_ref": "refs/heads/main", + "oidc_issuer": "https://token.actions.githubusercontent.com", + "certificate_identity": ( + "https://github.com/OpenAdaptAI/openadapt-evals/.github/workflows/" + "production-lifecycle-evidence.yml@refs/heads/main" + ), + "summary_schema_version": SUMMARY_SCHEMA, + "private_certificate_schema_version": "openadapt.execute-live-acceptance-record/v2", + "evidence_manifest_schema_version": ("openadapt.production-acceptance/v1"), + "acceptance_policy_sha256": ( + "sha256:9b1fe55bc6796ae0a46960ca4aa335d88de60b0562c383afa1e85fa0a0c204b8" + ), + "release_identity_schema_version": RELEASE_IDENTITY_SCHEMA, + "production_channel": "production", + "signer_provenance_digest_domain": ( + "OpenAdapt production certificate signer provenance v1\0" + ), +} +EXPECTED_TARGETS = { + "agent": { + "display_name": "OpenAdapt Agent", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-agent", + "source_repository": "OpenAdaptAI/openadapt-agent", + "release_kind": "public_package", + "required_claim_scope": "qualified_agent_bridge_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-agent", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + }, + "capture": { + "display_name": "OpenAdapt Capture", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-capture", + "source_repository": "OpenAdaptAI/openadapt-capture", + "release_kind": "public_package", + "required_claim_scope": "qualified_native_recorder_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-capture", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + }, + "cloud": { + "display_name": "OpenAdapt Cloud", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-cloud", + "source_repository": "OpenAdaptAI/openadapt-cloud", + "release_kind": "private_deployment", + "required_claim_scope": "qualified_workflow_control_plane_deployment", + "required_artifact_kinds": [], + "package_index_project": None, + "artifact_authority_by_kind": {}, + }, + "desktop": { + "display_name": "OpenAdapt Desktop", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-desktop", + "source_repository": "OpenAdaptAI/openadapt-desktop", + "release_kind": "public_package", + "required_claim_scope": "qualified_native_workflow_desktop_release", + "required_artifact_kinds": [ + "linux-installer", + "macos-installer", + "sdist", + "wheel", + "windows-installer", + ], + "package_index_project": "openadapt-desktop", + "artifact_authority_by_kind": { + "linux-installer": "github_release", + "macos-installer": "github_release", + "sdist": "pypi", + "wheel": "pypi", + "windows-installer": "github_release", + }, + }, + "docs": { + "display_name": "OpenAdapt Documentation", + "lifecycle_scope": "public_surface", + "lifecycle_subject": "docs.openadapt.ai", + "source_repository": "OpenAdaptAI/openadapt-ops", + "release_kind": "public_deployment", + "required_claim_scope": "production_documentation_deployment", + "required_artifact_kinds": ["deployment-manifest", "site-archive"], + "package_index_project": None, + "artifact_authority_by_kind": { + "deployment-manifest": "managed_evidence", + "site-archive": "managed_evidence", + }, + }, + "flow": { + "display_name": "OpenAdapt Flow", + "lifecycle_scope": "repository", + "lifecycle_subject": "openadapt-flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "release_kind": "public_package", + "required_claim_scope": "qualified_workflow_runtime_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt-flow", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + }, + "openadapt": { + "display_name": "OpenAdapt", + "lifecycle_scope": "repository", + "lifecycle_subject": "OpenAdapt", + "source_repository": "OpenAdaptAI/OpenAdapt", + "release_kind": "public_package", + "required_claim_scope": "qualified_workflow_launcher_release", + "required_artifact_kinds": ["sdist", "wheel"], + "package_index_project": "openadapt", + "artifact_authority_by_kind": {"sdist": "pypi", "wheel": "pypi"}, + }, +} + + +class LifecycleError(ValueError): + """The lifecycle state is not supported by its evidence.""" + + +def _closed(value: object, keys: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != keys: + actual = sorted(value) if isinstance(value, dict) else type(value).__name__ + raise LifecycleError( + f"{label} must contain exactly {sorted(keys)}; got {actual}" + ) + return value + + +def _nonempty(value: object, label: str) -> str: + if not isinstance(value, str) or not value or value != value.strip(): + raise LifecycleError(f"{label} must be a non-empty trimmed string") + return value + + +def _digest(value: object, label: str) -> str: + if not isinstance(value, str) or SHA256.fullmatch(value) is None: + raise LifecycleError(f"{label} must be a lowercase sha256 digest") + return value + + +def _timestamp(value: object, label: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z") or "." in value: + raise LifecycleError(f"{label} must be a whole-second UTC timestamp") + try: + parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as exc: + raise LifecycleError(f"{label} is not a valid UTC timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() != timedelta(0): + raise LifecycleError(f"{label} must use UTC") + return parsed.astimezone(timezone.utc) + + +def _millisecond_timestamp(value: object, label: str) -> datetime: + """Parse a retained-evidence timestamp. + + The private acceptance certificate records retention times in canonical + UTC form with millisecond precision (``2026-08-18T12:00:00.000Z``). The + exported evidence manifest copies those values verbatim, so this accepts + exactly that form. Any other precision, offset, or separator is refused. + """ + + if not isinstance(value, str) or MILLISECOND_UTC.fullmatch(value) is None: + raise LifecycleError(f"{label} must be a millisecond UTC timestamp") + try: + parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as exc: + raise LifecycleError(f"{label} is not a valid UTC timestamp") from exc + return parsed.astimezone(timezone.utc) + + +def _canonical_digest(value: object) -> str: + payload = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return "sha256:" + hashlib.sha256(payload).hexdigest() + + +def _domain_digest(domain: bytes, value: object) -> str: + payload = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return "sha256:" + hashlib.sha256(domain + payload).hexdigest() + + +def _target_release_digest( + target: str, claim_scope: str, release: Mapping[str, Any] +) -> str: + return _domain_digest( + TARGET_RELEASE_DIGEST_DOMAIN, + {"target": target, "claim_scope": claim_scope, "release": release}, + ) + + +def _artifact_inventory_digest( + target: str, claim_scope: str, artifacts: Sequence[Mapping[str, Any]] +) -> str: + return _domain_digest( + ARTIFACT_INVENTORY_DIGEST_DOMAIN, + {"target": target, "claim_scope": claim_scope, "artifacts": artifacts}, + ) + + +def _bytes_digest(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def _file_digest(path: Path) -> str: + return _bytes_digest(path.read_bytes()) + + +def _load_json(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LifecycleError(f"{label} is missing or invalid: {exc}") from exc + if not isinstance(value, dict): + raise LifecycleError(f"{label} must be a JSON object") + return value + + +def _parse_group(text: str, heading: str) -> dict[str, list[str]]: + section = text.split(f"{heading}:\n", maxsplit=1) + if len(section) != 2: + raise LifecycleError(f"repository-lifecycle.yml is missing {heading}") + groups: dict[str, list[str]] = {} + active: str | None = None + for line in section[1].splitlines(): + if match := GROUP_RE.fullmatch(line): + active = match.group(1) + groups[active] = [] + elif match := REPOSITORY_RE.fullmatch(line): + if active is None: + raise LifecycleError(f"{heading} contains a subject outside a group") + groups[active].append(match.group(1)) + elif line and not line.startswith(" "): + break + return groups + + +def load_lifecycle( + path: Path = LIFECYCLE_PATH, +) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise LifecycleError(f"repository-lifecycle.yml is missing: {exc}") from exc + return _parse_group(text, "lifecycle"), _parse_group(text, "public_surfaces") + + +def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: + policy = _closed( + value, + { + "$schema", + "schema_version", + "revision", + "maximum_admission_days", + "summary_authority", + "targets", + }, + "production lifecycle policy", + ) + if policy["schema_version"] != POLICY_SCHEMA: + raise LifecycleError("production lifecycle policy schema is not supported") + if ( + not isinstance(policy["revision"], int) + or isinstance(policy["revision"], bool) + or policy["revision"] < 1 + ): + raise LifecycleError("production lifecycle policy revision must be positive") + maximum_days = policy["maximum_admission_days"] + if ( + not isinstance(maximum_days, int) + or isinstance(maximum_days, bool) + or not 1 <= maximum_days <= 30 + ): + raise LifecycleError("maximum_admission_days must be between 1 and 30") + authority = _closed( + policy["summary_authority"], + { + "repository", + "workflow", + "source_ref", + "oidc_issuer", + "certificate_identity", + "summary_schema_version", + "private_certificate_schema_version", + "evidence_manifest_schema_version", + "acceptance_policy_sha256", + "release_identity_schema_version", + "production_channel", + "signer_provenance_digest_domain", + }, + "summary authority", + ) + if authority["summary_schema_version"] != SUMMARY_SCHEMA: + raise LifecycleError("summary authority schema is not supported") + if authority["source_ref"] != "refs/heads/main": + raise LifecycleError("summary authority must use refs/heads/main") + if authority["oidc_issuer"] != "https://token.actions.githubusercontent.com": + raise LifecycleError("summary authority OIDC issuer is not approved") + repository = _nonempty(authority["repository"], "summary authority repository") + workflow = _nonempty(authority["workflow"], "summary authority workflow") + expected_identity = f"https://github.com/{repository}/{workflow}@refs/heads/main" + if authority["certificate_identity"] != expected_identity: + raise LifecycleError("summary authority certificate identity is not exact") + _nonempty( + authority["private_certificate_schema_version"], + "private certificate schema version", + ) + if authority["evidence_manifest_schema_version"] != ( + "openadapt.production-acceptance/v1" + ): + raise LifecycleError("evidence manifest schema is not supported") + if authority["signer_provenance_digest_domain"] != ( + "OpenAdapt production certificate signer provenance v1\0" + ): + raise LifecycleError("signer provenance digest domain is not supported") + if authority != EXPECTED_AUTHORITY: + raise LifecycleError("summary authority differs from the pinned trust root") + + targets_value = policy["targets"] + if not isinstance(targets_value, list) or not targets_value: + raise LifecycleError("production lifecycle policy must declare targets") + targets: dict[str, dict[str, Any]] = {} + memberships: set[tuple[str, str]] = set() + for index, item in enumerate(targets_value): + target = _closed( + item, + { + "id", + "display_name", + "lifecycle_scope", + "lifecycle_subject", + "source_repository", + "release_kind", + "required_claim_scope", + "required_artifact_kinds", + "package_index_project", + "artifact_authority_by_kind", + }, + f"target {index}", + ) + target_id = _nonempty(target["id"], f"target {index} id") + if TARGET_ID.fullmatch(target_id) is None or target_id in targets: + raise LifecycleError(f"target id is invalid or duplicate: {target_id!r}") + scope = target["lifecycle_scope"] + if scope not in {"repository", "public_surface"}: + raise LifecycleError(f"target {target_id} lifecycle_scope is invalid") + subject = _nonempty(target["lifecycle_subject"], f"target {target_id} subject") + membership = (scope, subject) + if membership in memberships: + raise LifecycleError(f"lifecycle subject is duplicate: {membership}") + memberships.add(membership) + _nonempty(target["display_name"], f"target {target_id} display name") + source_repository = _nonempty( + target["source_repository"], f"target {target_id} source repository" + ) + if not source_repository.startswith("OpenAdaptAI/"): + raise LifecycleError( + f"target {target_id} source repository is not first-party" + ) + release_kind = target["release_kind"] + if release_kind not in { + "public_package", + "private_deployment", + "public_deployment", + }: + raise LifecycleError(f"target {target_id} release kind is invalid") + claim_scope = _nonempty( + target["required_claim_scope"], + f"target {target_id} required claim scope", + ) + if re.fullmatch(r"^[a-z][a-z0-9_]{2,127}$", claim_scope) is None: + raise LifecycleError(f"target {target_id} required claim scope is invalid") + kinds = target["required_artifact_kinds"] + if not isinstance(kinds, list) or not all( + isinstance(kind, str) and kind for kind in kinds + ): + raise LifecycleError( + f"target {target_id} required artifact kinds are invalid" + ) + if kinds != sorted(set(kinds)): + raise LifecycleError( + f"target {target_id} required artifact kinds must be unique and sorted" + ) + if release_kind == "private_deployment" and kinds: + raise LifecycleError( + f"private target {target_id} cannot require public artifacts" + ) + if release_kind != "private_deployment" and not kinds: + raise LifecycleError(f"public target {target_id} must require artifacts") + package_project = target["package_index_project"] + if package_project is not None and ( + not isinstance(package_project, str) + or re.fullmatch(r"^[a-z0-9][a-z0-9._-]+$", package_project) is None + ): + raise LifecycleError(f"target {target_id} package project is invalid") + authorities = target["artifact_authority_by_kind"] + if not isinstance(authorities, dict) or set(authorities) != set(kinds): + raise LifecycleError( + f"target {target_id} artifact authority map differs from artifact kinds" + ) + if not all( + value in {"pypi", "github_release", "managed_evidence"} + for value in authorities.values() + ): + raise LifecycleError(f"target {target_id} artifact authority is invalid") + if ("pypi" in authorities.values()) != (package_project is not None): + raise LifecycleError( + f"target {target_id} package project does not match PyPI authority" + ) + targets[target_id] = target + actual_targets = { + target_id: {key: value for key, value in target.items() if key != "id"} + for target_id, target in targets.items() + } + if actual_targets != EXPECTED_TARGETS: + raise LifecycleError("production target map differs from the pinned target map") + return policy, targets + + +def _clean_https_url(value: object, label: str) -> str: + url = _nonempty(value, label) + parsed = urlsplit(url) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise LifecycleError(f"{label} must be a clean HTTPS URL") + return url + + +def _validate_url( + value: object, digest: str, label: str, source_commit: str | None = None +) -> str: + url = _clean_https_url(value, label) + parsed = urlsplit(url) + path_parts = [part for part in parsed.path.split("/") if part] + digest_hex = digest.removeprefix("sha256:") + digest_bound = any(digest_hex == part or digest_hex in part for part in path_parts) + commit_bound = source_commit is not None and source_commit in path_parts + if not digest_bound and not commit_bound: + raise LifecycleError(f"{label} is not bound to its digest or source commit") + return url + + +def _validate_artifacts( + value: object, + *, + required_kinds: Sequence[str], + authority_by_kind: Mapping[str, str], + source_repository: str, + label: str, +) -> list[dict[str, Any]]: + if not isinstance(value, list): + raise LifecycleError(f"{label} must be a list") + artifacts: list[dict[str, Any]] = [] + identities: list[tuple[str, str]] = [] + for index, item in enumerate(value): + artifact = _closed( + item, + {"name", "kind", "authority", "url", "sha256", "size_bytes"}, + f"{label} item {index}", + ) + name = _nonempty(artifact["name"], f"{label} item {index} name") + if ARTIFACT_NAME.fullmatch(name) is None: + raise LifecycleError(f"{label} item {index} name is invalid") + kind = _nonempty(artifact["kind"], f"{label} item {index} kind") + artifact_authority = artifact["authority"] + if artifact_authority != authority_by_kind.get(kind): + raise LifecycleError(f"{label} item {index} authority differs from policy") + digest = _digest(artifact["sha256"], f"{label} item {index} digest") + artifact_url = _clean_https_url(artifact["url"], f"{label} item {index} URL") + parsed_artifact_url = urlsplit(artifact_url) + if artifact_authority == "pypi" and ( + parsed_artifact_url.netloc != "files.pythonhosted.org" + ): + raise LifecycleError(f"{label} item {index} is not a PyPI artifact") + if artifact_authority == "github_release": + expected_prefix = f"/repos/{source_repository}/releases/assets/" + asset_id = parsed_artifact_url.path.removeprefix(expected_prefix) + if ( + parsed_artifact_url.netloc != "api.github.com" + or not parsed_artifact_url.path.startswith(expected_prefix) + or not asset_id.isdigit() + ): + raise LifecycleError( + f"{label} item {index} is not an exact GitHub release asset" + ) + if artifact_authority == "managed_evidence" and ( + parsed_artifact_url.netloc != "evidence.openadapt.ai" + or digest.removeprefix("sha256:") + not in [part for part in parsed_artifact_url.path.split("/") if part] + ): + raise LifecycleError( + f"{label} item {index} is not content-addressed managed evidence" + ) + size = artifact["size_bytes"] + if not isinstance(size, int) or isinstance(size, bool) or size < 1: + raise LifecycleError(f"{label} item {index} size must be positive") + identities.append((kind, name)) + artifacts.append(artifact) + if identities != sorted(set(identities)): + raise LifecycleError(f"{label} must be unique and sorted by kind then name") + present = {kind for kind, _ in identities} + missing = sorted(set(required_kinds) - present) + if missing: + raise LifecycleError(f"{label} is missing required artifact kinds: {missing}") + return artifacts + + +def _validate_release_identity( + value: object, authority: Mapping[str, Any], label: str +) -> dict[str, Any]: + identity = _closed( + value, + {"schema_version", "channel", "sequence", "previous_admission_sha256"}, + label, + ) + if identity["schema_version"] != authority["release_identity_schema_version"]: + raise LifecycleError(f"{label} schema is not supported") + if identity["channel"] != authority["production_channel"]: + raise LifecycleError(f"{label} channel is not Production") + sequence = identity["sequence"] + if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 1: + raise LifecycleError(f"{label} sequence must be a positive integer") + previous = identity["previous_admission_sha256"] + if previous is not None: + _digest(previous, f"{label} predecessor") + return identity + + +def _validate_release(value: object, target: Mapping[str, Any]) -> dict[str, Any]: + target_id = target["id"] + kind = target["release_kind"] + if kind == "public_package": + release = _closed( + value, + { + "kind", + "version", + "tag", + "source_commit", + "immutable_release_url", + "artifacts", + }, + f"admission {target_id} release", + ) + if ( + release["kind"] != kind + or not isinstance(release["version"], str) + or SEMVER.fullmatch(release["version"]) is None + ): + raise LifecycleError( + f"admission {target_id} public package version is invalid" + ) + if release["tag"] not in {f"v{release['version']}", release["version"]}: + raise LifecycleError( + f"admission {target_id} release tag does not match version" + ) + source_commit = release["source_commit"] + if not isinstance(source_commit, str) or HEX40.fullmatch(source_commit) is None: + raise LifecycleError(f"admission {target_id} source commit is invalid") + source_digest = ( + "sha256:" + hashlib.sha256(source_commit.encode("ascii")).hexdigest() + ) + release_url = _validate_url( + release["immutable_release_url"], + source_digest, + f"admission {target_id} immutable release URL", + source_commit, + ) + parsed_release = urlsplit(release_url) + expected_prefix = f"/{target['source_repository']}/commit/{source_commit}" + if ( + parsed_release.netloc != "github.com" + or parsed_release.path.rstrip("/") != expected_prefix + ): + raise LifecycleError( + f"admission {target_id} immutable release URL is not the exact source commit" + ) + _validate_artifacts( + release["artifacts"], + required_kinds=target["required_artifact_kinds"], + authority_by_kind=target["artifact_authority_by_kind"], + source_repository=target["source_repository"], + label=f"admission {target_id} artifacts", + ) + return release + if kind == "public_deployment": + release = _closed( + value, + { + "kind", + "deployment_id", + "deployment_sha256", + "source_commit", + "immutable_release_url", + "artifacts", + }, + f"admission {target_id} release", + ) + if release["kind"] != kind: + raise LifecycleError( + f"admission {target_id} release kind differs from policy" + ) + _nonempty(release["deployment_id"], f"admission {target_id} deployment id") + _digest( + release["deployment_sha256"], f"admission {target_id} deployment digest" + ) + source_commit = release["source_commit"] + if not isinstance(source_commit, str) or HEX40.fullmatch(source_commit) is None: + raise LifecycleError(f"admission {target_id} source commit is invalid") + release_url = _validate_url( + release["immutable_release_url"], + release["deployment_sha256"], + f"admission {target_id} immutable release URL", + source_commit, + ) + parsed_release = urlsplit(release_url) + expected_prefix = f"/{target['source_repository']}/commit/{source_commit}" + if ( + parsed_release.netloc != "github.com" + or parsed_release.path.rstrip("/") != expected_prefix + ): + raise LifecycleError( + f"admission {target_id} immutable release URL is not the exact source commit" + ) + _validate_artifacts( + release["artifacts"], + required_kinds=target["required_artifact_kinds"], + authority_by_kind=target["artifact_authority_by_kind"], + source_repository=target["source_repository"], + label=f"admission {target_id} artifacts", + ) + return release + release = _closed( + value, + { + "kind", + "deployment_release_id", + "deployment_release_sha256", + "manifest_sha256", + }, + f"admission {target_id} release", + ) + if release["kind"] != "private_deployment": + raise LifecycleError(f"admission {target_id} release kind differs from policy") + _nonempty( + release["deployment_release_id"], f"admission {target_id} deployment release id" + ) + _digest( + release["deployment_release_sha256"], + f"admission {target_id} deployment release digest", + ) + _digest( + release["manifest_sha256"], f"admission {target_id} deployment manifest digest" + ) + return release + + +def _fetch_json_object( + url: str, label: str, fetch: Callable[[str], bytes] +) -> dict[str, Any]: + try: + payload = fetch(url) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise LifecycleError(f"{label} could not be fetched: {exc}") from exc + try: + value = json.loads(payload) + except json.JSONDecodeError as exc: + raise LifecycleError(f"{label} is not JSON") from exc + if not isinstance(value, dict): + raise LifecycleError(f"{label} must be a JSON object") + return value + + +def _verify_artifact_authorities( + release: Mapping[str, Any], + target: Mapping[str, Any], + fetch: Callable[[str], bytes], +) -> None: + artifacts = release.get("artifacts", []) + pypi_artifacts = [item for item in artifacts if item["authority"] == "pypi"] + if pypi_artifacts: + project = target["package_index_project"] + version = release["version"] + metadata_url = ( + f"https://pypi.org/pypi/{quote(project, safe='')}/" + f"{quote(version, safe='')}/json" + ) + metadata = _fetch_json_object(metadata_url, "PyPI release metadata", fetch) + if metadata.get("info", {}).get("version") != version: + raise LifecycleError("PyPI release metadata version differs") + files = metadata.get("urls") + if not isinstance(files, list): + raise LifecycleError("PyPI release metadata files are invalid") + for artifact in pypi_artifacts: + matches = [ + item + for item in files + if isinstance(item, dict) + and item.get("filename") == artifact["name"] + and item.get("url") == artifact["url"] + and item.get("size") == artifact["size_bytes"] + and item.get("digests", {}).get("sha256") + == artifact["sha256"].removeprefix("sha256:") + and item.get("yanked") is False + ] + if len(matches) != 1: + raise LifecycleError( + f"PyPI does not verify exact artifact {artifact['name']}" + ) + + github_artifacts = [ + item for item in artifacts if item["authority"] == "github_release" + ] + if github_artifacts: + repository = target["source_repository"] + tag = release["tag"] + metadata_url = ( + f"https://api.github.com/repos/{repository}/releases/tags/" + f"{quote(tag, safe='')}" + ) + metadata = _fetch_json_object(metadata_url, "GitHub release metadata", fetch) + if ( + metadata.get("tag_name") != tag + or metadata.get("draft") is not False + or metadata.get("prerelease") is not False + or metadata.get("immutable") is not True + ): + raise LifecycleError("GitHub release is not an exact immutable release") + assets = metadata.get("assets") + if not isinstance(assets, list): + raise LifecycleError("GitHub release assets are invalid") + for artifact in github_artifacts: + matches = [ + item + for item in assets + if isinstance(item, dict) + and item.get("name") == artifact["name"] + and item.get("url") == artifact["url"] + and item.get("size") == artifact["size_bytes"] + and item.get("digest") == artifact["sha256"] + and item.get("state") == "uploaded" + ] + if len(matches) != 1: + raise LifecycleError( + f"GitHub does not verify exact artifact {artifact['name']}" + ) + + managed_artifacts = [ + item for item in artifacts if item["authority"] == "managed_evidence" + ] + for artifact in managed_artifacts: + digest_hex = artifact["sha256"].removeprefix("sha256:") + metadata_url = ( + "https://evidence.openadapt.ai/api/v1/objects/sha256/" + digest_hex + ) + metadata = _fetch_json_object( + metadata_url, "managed evidence object metadata", fetch + ) + metadata = _closed( + metadata, + { + "schema_version", + "exists", + "artifact_url", + "sha256", + "size_bytes", + "object_version_sha256", + "head_verified", + }, + "managed evidence object metadata", + ) + expected = { + "schema_version": "openadapt.managed-artifact-head/v1", + "exists": True, + "artifact_url": artifact["url"], + "sha256": artifact["sha256"], + "size_bytes": artifact["size_bytes"], + "head_verified": True, + } + if any(metadata.get(key) != value for key, value in expected.items()): + raise LifecycleError( + f"managed evidence does not verify exact artifact {artifact['name']}" + ) + _digest( + metadata.get("object_version_sha256"), + f"managed evidence artifact {artifact['name']} object version", + ) + + +def _fetch_url(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-production-lifecycle-validator/1"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > MAX_REMOTE_BYTES: + raise LifecycleError("remote lifecycle evidence exceeds the size limit") + body = response.read(MAX_REMOTE_BYTES + 1) + if len(body) > MAX_REMOTE_BYTES: + raise LifecycleError("remote lifecycle evidence exceeds the size limit") + return body + + +def _verify_attestation( + summary: bytes, + bundle: bytes, + authority: Mapping[str, Any], + source_commit: str, +) -> None: + with tempfile.TemporaryDirectory(prefix="openadapt-lifecycle-") as directory: + root = Path(directory) + summary_path = root / "production-lifecycle-evidence-summary.json" + bundle_path = root / "production-lifecycle-evidence-summary.sigstore.json" + summary_path.write_bytes(summary) + bundle_path.write_bytes(bundle) + command = [ + "gh", + "attestation", + "verify", + str(summary_path), + "--repo", + authority["repository"], + "--bundle", + str(bundle_path), + "--cert-identity", + authority["certificate_identity"], + "--cert-oidc-issuer", + authority["oidc_issuer"], + "--deny-self-hosted-runners", + "--format", + "json", + ] + try: + completed = subprocess.run( + command, check=False, capture_output=True, text=True + ) + except OSError as exc: + raise LifecycleError( + f"summary attestation verifier could not run: {exc}" + ) from exc + if completed.returncode != 0: + detail = ( + completed.stderr or completed.stdout or "verification failed" + ).strip() + raise LifecycleError( + f"summary attestation is invalid: {detail.splitlines()[-1]}" + ) + try: + result = json.loads(completed.stdout) + if not isinstance(result, list) or len(result) != 1: + raise LifecycleError( + "summary attestation must contain one verified statement" + ) + verification = result[0]["verificationResult"] + certificate = verification["signature"]["certificate"] + statement = verification["statement"] + workflow_sha = certificate["githubWorkflowSHA"] + source_digest = certificate["sourceRepositoryDigest"] + workflow_ref = certificate["githubWorkflowRef"] + workflow_repository = certificate["githubWorkflowRepository"] + dependencies = statement["predicate"]["buildDefinition"][ + "resolvedDependencies" + ] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise LifecycleError( + "summary attestation verifier returned an invalid statement" + ) from exc + if workflow_sha != source_commit or source_digest != source_commit: + raise LifecycleError("summary attestation source commit differs") + if ( + workflow_ref != authority["source_ref"] + or workflow_repository != authority["repository"] + ): + raise LifecycleError("summary attestation workflow identity differs") + expected_dependency = { + "uri": f"git+https://github.com/{authority['repository']}@{authority['source_ref']}", + "digest": {"gitCommit": source_commit}, + } + if dependencies != [expected_dependency]: + raise LifecycleError("summary attestation source dependency differs") + + +def _count(value: object, label: str, minimum: int = 0) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value < minimum: + raise LifecycleError(f"{label} must be an integer of at least {minimum}") + return value + + +def _validate_manifest( + value: object, + *, + admission: Mapping[str, Any], + summary: Mapping[str, Any], + authority: Mapping[str, Any], + now: datetime, +) -> None: + target_id = admission["target"] + manifest = _closed( + value, + { + "schema_version", + "target", + "claim_scope", + "verdict", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "target_release_sha256", + "target_artifact_inventory_sha256", + "evidence_identity_sha256", + "source_evidence", + "qualification", + "failure_taxonomy_counts", + "reliability", + "retention", + }, + f"admission {target_id} evidence manifest", + ) + expected_bindings = { + "schema_version": authority["evidence_manifest_schema_version"], + "target": target_id, + "claim_scope": admission["claim_scope"], + "verdict": "accepted", + "acceptance_policy_sha256": summary["acceptance_policy_sha256"], + "lifecycle_policy_sha256": summary["lifecycle_policy_sha256"], + "target_release_sha256": summary["release_sha256"], + "target_artifact_inventory_sha256": summary["artifact_inventory_sha256"], + "evidence_identity_sha256": summary["evidence_identity_sha256"], + } + for key, expected in expected_bindings.items(): + if manifest[key] != expected: + raise LifecycleError( + f"admission {target_id} evidence manifest {key} differs" + ) + + source = _closed( + manifest["source_evidence"], + { + "source_result_sha256", + "certificate_sha256", + "campaign_sha256", + "qualification_admission_sha256", + "attestation_sha256", + "attestation_bundle_sha256", + }, + f"admission {target_id} source evidence", + ) + certificate = summary["private_certificate_binding"] + if source["certificate_sha256"] != certificate["sha256"]: + raise LifecycleError(f"admission {target_id} source evidence differs") + for key in ( + "source_result_sha256", + "certificate_sha256", + "campaign_sha256", + "qualification_admission_sha256", + "attestation_sha256", + "attestation_bundle_sha256", + ): + _digest(source[key], f"admission {target_id} source evidence {key}") + + qualification = _closed( + manifest["qualification"], + { + "campaign_contract_sha256", + "campaign_outcomes_sha256", + "oracle_contract_sha256", + "task_count", + "condition_count", + "required_trial_count", + "observed_trial_count", + "minimum_trials_per_condition", + "excluded_trial_count", + "task_condition_inventory_sha256", + }, + f"admission {target_id} qualification", + ) + for key in ( + "campaign_contract_sha256", + "campaign_outcomes_sha256", + "oracle_contract_sha256", + "task_condition_inventory_sha256", + ): + _digest(qualification[key], f"admission {target_id} qualification {key}") + _count(qualification["task_count"], f"admission {target_id} task count", 1) + condition_count = _count( + qualification["condition_count"], + f"admission {target_id} condition count", + 1, + ) + required_trials = _count( + qualification["required_trial_count"], + f"admission {target_id} required trial count", + 3, + ) + observed_trials = _count( + qualification["observed_trial_count"], + f"admission {target_id} observed trial count", + required_trials, + ) + minimum_trials = _count( + qualification["minimum_trials_per_condition"], + f"admission {target_id} minimum trials per condition", + 3, + ) + if qualification["excluded_trial_count"] != 0: + raise LifecycleError(f"admission {target_id} excludes qualification trials") + if required_trials < condition_count * 3: + raise LifecycleError(f"admission {target_id} required trial total is too small") + if observed_trials < condition_count * minimum_trials: + raise LifecycleError(f"admission {target_id} observed trial total is too small") + + taxonomy = _closed( + manifest["failure_taxonomy_counts"], + FAILURE_TAXONOMY_KEYS, + f"admission {target_id} failure taxonomy", + ) + for key, value_count in taxonomy.items(): + _count(value_count, f"admission {target_id} failure taxonomy {key}") + if sum(taxonomy.values()) != observed_trials: + raise LifecycleError( + f"admission {target_id} failure taxonomy does not account for every trial" + ) + reliability = _closed( + manifest["reliability"], + RELIABILITY_KEYS, + f"admission {target_id} reliability", + ) + for reliability_key, taxonomy_key in RELIABILITY_TO_TAXONOMY.items(): + count = _count( + reliability[reliability_key], + f"admission {target_id} reliability {reliability_key}", + ) + if count != taxonomy[taxonomy_key]: + raise LifecycleError( + f"admission {target_id} reliability and taxonomy differ" + ) + _count( + reliability["model_call_count"], + f"admission {target_id} reliability model_call_count", + ) + for key in FAILURE_TAXONOMY_KEYS - {"verified", "safe_halt"}: + if taxonomy[key] != 0: + raise LifecycleError( + f"admission {target_id} Production failure count {key} is nonzero" + ) + + retention = _closed( + manifest["retention"], + { + "receipt_id", + *RETENTION_DIGEST_KEYS, + "retention_mode", + "retention_until", + "retained_at", + "upload_verified", + "head_verified", + "object_lock_verified", + "private_locator_recorded", + "acceptance_verified_at", + "provenance_attestation", + }, + f"admission {target_id} retention", + ) + receipt = retention["receipt_id"] + if ( + not isinstance(receipt, str) + or re.fullmatch(r"retention:[0-9a-f]{32}", receipt) is None + ): + raise LifecycleError(f"admission {target_id} retention receipt is invalid") + for key in RETENTION_DIGEST_KEYS: + _digest(retention[key], f"admission {target_id} retention {key}") + if retention["retention_mode"] != "COMPLIANCE": + raise LifecycleError(f"admission {target_id} retention mode is invalid") + for key in ( + "upload_verified", + "head_verified", + "object_lock_verified", + "private_locator_recorded", + ): + if retention[key] is not True: + raise LifecycleError(f"admission {target_id} retention {key} is not true") + if retention["provenance_attestation"] != "github-artifact-attestation-v4": + raise LifecycleError(f"admission {target_id} retention provenance is invalid") + accepted_at = _millisecond_timestamp( + retention["acceptance_verified_at"], + f"admission {target_id} acceptance verification time", + ) + retained_at = _millisecond_timestamp( + retention["retained_at"], f"admission {target_id} retained time" + ) + retention_until = _millisecond_timestamp( + retention["retention_until"], f"admission {target_id} retention end" + ) + if not accepted_at <= retained_at < retention_until: + raise LifecycleError(f"admission {target_id} retention chronology is invalid") + admission_issued_at = _timestamp( + admission["issued_at"], f"admission {target_id} issued_at" + ) + if retained_at > admission_issued_at or retained_at > now: + raise LifecycleError( + f"admission {target_id} retention verification is in the future" + ) + duration = retention_until - retained_at + if not timedelta(days=365) <= duration <= timedelta(days=3650): + raise LifecycleError(f"admission {target_id} retention duration is invalid") + if retention_until <= now: + raise LifecycleError(f"admission {target_id} retained evidence is expired") + + +def _validate_remote_summary( + admission: Mapping[str, Any], + release: Mapping[str, Any], + authority: Mapping[str, Any], + policy_sha256: str, + now: datetime, + *, + fetch: Callable[[str], bytes], + verify_attestation: Callable[[bytes, bytes, Mapping[str, Any], str], None], +) -> None: + target_id = admission["target"] + reference = _closed( + admission["acceptance_evidence"], + { + "summary_url", + "summary_sha256", + "attestation_bundle_url", + "attestation_bundle_sha256", + "authority_source_commit", + }, + f"admission {target_id} acceptance evidence", + ) + summary_digest = _digest( + reference["summary_sha256"], f"admission {target_id} summary digest" + ) + bundle_digest = _digest( + reference["attestation_bundle_sha256"], + f"admission {target_id} attestation digest", + ) + authority_commit = reference["authority_source_commit"] + if ( + not isinstance(authority_commit, str) + or HEX40.fullmatch(authority_commit) is None + ): + raise LifecycleError( + f"admission {target_id} authority source commit is invalid" + ) + summary_url = _validate_url( + reference["summary_url"], + summary_digest, + f"admission {target_id} summary URL", + authority_commit, + ) + bundle_url = _validate_url( + reference["attestation_bundle_url"], + bundle_digest, + f"admission {target_id} attestation bundle URL", + authority_commit, + ) + try: + summary_bytes = fetch(summary_url) + bundle_bytes = fetch(bundle_url) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise LifecycleError( + f"admission {target_id} evidence could not be fetched: {exc}" + ) from exc + if _bytes_digest(summary_bytes) != summary_digest: + raise LifecycleError(f"admission {target_id} summary digest changed") + if _bytes_digest(bundle_bytes) != bundle_digest: + raise LifecycleError(f"admission {target_id} attestation bundle digest changed") + verify_attestation(summary_bytes, bundle_bytes, authority, authority_commit) + try: + summary_value = json.loads(summary_bytes) + except json.JSONDecodeError as exc: + raise LifecycleError(f"admission {target_id} summary is not JSON") from exc + summary = _closed( + summary_value, + { + "schema_version", + "target", + "verdict", + "claim_scope", + "acceptance_policy_sha256", + "lifecycle_policy_sha256", + "release_identity", + "release_sha256", + "artifact_inventory_sha256", + "evidence_identity_sha256", + "private_certificate_binding", + "evidence_manifest", + "issued_at", + "expires_at", + "revoked_at", + }, + f"admission {target_id} remote-safe summary", + ) + if summary["schema_version"] != authority["summary_schema_version"]: + raise LifecycleError(f"admission {target_id} summary schema is not supported") + if summary["target"] != target_id or summary["verdict"] != "accepted": + raise LifecycleError( + f"admission {target_id} summary did not accept the exact target" + ) + if summary["claim_scope"] != admission["claim_scope"]: + raise LifecycleError(f"admission {target_id} signed claim scope differs") + if summary["acceptance_policy_sha256"] != authority["acceptance_policy_sha256"]: + raise LifecycleError( + f"admission {target_id} signed acceptance policy digest differs" + ) + if summary["lifecycle_policy_sha256"] != policy_sha256: + raise LifecycleError( + f"admission {target_id} signed lifecycle policy digest differs" + ) + summary_identity = _validate_release_identity( + summary["release_identity"], + authority, + f"admission {target_id} signed release identity", + ) + if summary_identity != admission["release_identity"]: + raise LifecycleError(f"admission {target_id} signed release identity differs") + expected_release_digest = _target_release_digest( + target_id, admission["claim_scope"], release + ) + if summary["release_sha256"] != expected_release_digest: + raise LifecycleError(f"admission {target_id} summary release digest differs") + artifacts = release.get("artifacts", []) + expected_artifact_digest = _artifact_inventory_digest( + target_id, admission["claim_scope"], artifacts + ) + if summary["artifact_inventory_sha256"] != expected_artifact_digest: + raise LifecycleError( + f"admission {target_id} summary artifact inventory differs" + ) + _digest( + summary["evidence_identity_sha256"], + f"admission {target_id} evidence identity", + ) + certificate = _closed( + summary["private_certificate_binding"], + { + "schema_version", + "sha256", + "signer_provenance_sha256", + "evidence_identity_sha256", + "target", + "target_release_sha256", + "target_artifact_inventory_sha256", + }, + f"admission {target_id} private certificate binding", + ) + if certificate["schema_version"] != authority["private_certificate_schema_version"]: + raise LifecycleError( + f"admission {target_id} private certificate schema differs" + ) + _digest(certificate["sha256"], f"admission {target_id} private certificate digest") + _digest( + certificate["signer_provenance_sha256"], + f"admission {target_id} private certificate signer provenance", + ) + _digest( + certificate["evidence_identity_sha256"], + f"admission {target_id} private evidence identity", + ) + if certificate["evidence_identity_sha256"] != summary["evidence_identity_sha256"]: + raise LifecycleError(f"admission {target_id} evidence identity differs") + if certificate["target"] != target_id: + raise LifecycleError(f"admission {target_id} private evidence target differs") + if certificate["target_release_sha256"] != summary["release_sha256"]: + raise LifecycleError(f"admission {target_id} private release binding differs") + if ( + certificate["target_artifact_inventory_sha256"] + != summary["artifact_inventory_sha256"] + ): + raise LifecycleError(f"admission {target_id} private artifact binding differs") + manifest = _closed( + summary["evidence_manifest"], + {"schema_version", "url", "sha256"}, + f"admission {target_id} evidence manifest", + ) + manifest_schema = manifest["schema_version"] + if manifest_schema != authority["evidence_manifest_schema_version"]: + raise LifecycleError( + f"admission {target_id} evidence manifest schema is not supported" + ) + manifest_digest = _digest( + manifest["sha256"], f"admission {target_id} evidence manifest digest" + ) + manifest_url = _validate_url( + manifest["url"], manifest_digest, f"admission {target_id} evidence manifest URL" + ) + try: + manifest_bytes = fetch(manifest_url) + except (OSError, urllib.error.URLError, TimeoutError, ValueError) as exc: + raise LifecycleError( + f"admission {target_id} evidence manifest could not be fetched: {exc}" + ) from exc + if _bytes_digest(manifest_bytes) != manifest_digest: + raise LifecycleError(f"admission {target_id} evidence manifest digest changed") + try: + manifest_value = json.loads(manifest_bytes) + except json.JSONDecodeError as exc: + raise LifecycleError( + f"admission {target_id} evidence manifest is not JSON" + ) from exc + _validate_manifest( + manifest_value, + admission=admission, + summary=summary, + authority=authority, + now=now, + ) + for key in ("issued_at", "expires_at", "revoked_at"): + if summary[key] != admission[key]: + raise LifecycleError( + f"admission {target_id} {key} differs from signed summary" + ) + + +def validate( + policy_value: object, + admissions_value: object, + repository_lifecycle: Mapping[str, Sequence[str]], + surface_lifecycle: Mapping[str, Sequence[str]], + *, + policy_sha256: str, + now: datetime | None = None, + fetch: Callable[[str], bytes] = _fetch_url, + verify_attestation: Callable[ + [bytes, bytes, Mapping[str, Any], str], None + ] = _verify_attestation, +) -> dict[str, str]: + """Validate the complete lifecycle state and return target to admission IDs.""" + + now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + policy, targets = _validate_policy(policy_value) + admissions_doc = _closed( + admissions_value, + {"$schema", "schema_version", "policy_sha256", "admissions"}, + "production lifecycle admissions", + ) + if admissions_doc["schema_version"] != ADMISSIONS_SCHEMA: + raise LifecycleError("production lifecycle admissions schema is not supported") + if admissions_doc["policy_sha256"] != policy_sha256: + raise LifecycleError("production lifecycle admissions policy digest differs") + admissions_value_list = admissions_doc["admissions"] + if not isinstance(admissions_value_list, list): + raise LifecycleError("production lifecycle admissions must be a list") + active: dict[str, str] = {} + admission_ids: set[str] = set() + records_by_target: dict[ + str, + list[ + tuple[dict[str, Any], dict[str, Any], datetime, datetime, datetime | None] + ], + ] = {} + for index, item in enumerate(admissions_value_list): + admission = _closed( + item, + { + "admission_id", + "target", + "claim_scope", + "release_identity", + "policy_revision", + "release", + "acceptance_evidence", + "issued_at", + "expires_at", + "revoked_at", + }, + f"admission {index}", + ) + admission_id = _nonempty(admission["admission_id"], f"admission {index} id") + if admission_id in admission_ids: + raise LifecycleError( + f"production admission id is duplicate: {admission_id!r}" + ) + admission_ids.add(admission_id) + target_id = _nonempty(admission["target"], f"admission {index} target") + if target_id not in targets: + raise LifecycleError( + f"production admission target is not eligible: {target_id!r}" + ) + if admission["policy_revision"] != policy["revision"]: + raise LifecycleError(f"admission {target_id} policy revision differs") + if admission["claim_scope"] != targets[target_id]["required_claim_scope"]: + raise LifecycleError( + f"admission {target_id} claim scope differs from policy" + ) + _validate_release_identity( + admission["release_identity"], + policy["summary_authority"], + f"admission {target_id} release identity", + ) + issued_at = _timestamp( + admission["issued_at"], f"admission {target_id} issued_at" + ) + expires_at = _timestamp( + admission["expires_at"], f"admission {target_id} expires_at" + ) + if issued_at > now: + raise LifecycleError(f"admission {target_id} is not valid yet") + if expires_at <= issued_at or expires_at > issued_at + timedelta( + days=policy["maximum_admission_days"] + ): + raise LifecycleError( + f"admission {target_id} validity window is outside policy" + ) + revoked_at = admission["revoked_at"] + revoked: datetime | None = None + if revoked_at is not None: + revoked = _timestamp(revoked_at, f"admission {target_id} revoked_at") + if revoked < issued_at or revoked > now: + raise LifecycleError( + f"admission {target_id} revocation timestamp is invalid" + ) + release = _validate_release(admission["release"], targets[target_id]) + records_by_target.setdefault(target_id, []).append( + (admission, release, issued_at, expires_at, revoked) + ) + + for target_id, records in records_by_target.items(): + records.sort(key=lambda item: item[0]["release_identity"]["sequence"]) + previous_digest: str | None = None + previous_issued_at: datetime | None = None + release_digests: set[str] = set() + for expected_sequence, (admission, release, issued_at, *_times) in enumerate( + records, start=1 + ): + identity = admission["release_identity"] + if identity["sequence"] != expected_sequence: + raise LifecycleError( + f"admission {target_id} release sequence is not continuous" + ) + if identity["previous_admission_sha256"] != previous_digest: + raise LifecycleError(f"admission {target_id} predecessor hash differs") + if previous_issued_at is not None and issued_at <= previous_issued_at: + raise LifecycleError( + f"admission {target_id} release time is not monotonic" + ) + release_digest = _canonical_digest(release) + if release_digest in release_digests: + raise LifecycleError( + f"admission {target_id} repeats an earlier release" + ) + release_digests.add(release_digest) + previous_digest = _canonical_digest(admission) + previous_issued_at = issued_at + + latest, latest_release, _issued_at, expires_at, revoked = records[-1] + if revoked is not None or expires_at <= now: + continue + _verify_artifact_authorities(latest_release, targets[target_id], fetch) + _validate_remote_summary( + latest, + latest_release, + policy["summary_authority"], + policy_sha256, + now, + fetch=fetch, + verify_attestation=verify_attestation, + ) + active[target_id] = latest["admission_id"] + + if repository_lifecycle.get("production", []): + raise LifecycleError( + "static Production repository membership is not permitted; " + "derive it from active admissions" + ) + if surface_lifecycle.get("production", []): + raise LifecycleError( + "static Production public-surface membership is not permitted; " + "derive it from active admissions" + ) + for target_id, target in targets.items(): + lifecycle = ( + repository_lifecycle + if target["lifecycle_scope"] == "repository" + else surface_lifecycle + ) + memberships = [ + group + for group, subjects in lifecycle.items() + if group != "production" and target["lifecycle_subject"] in subjects + ] + if len(memberships) != 1: + raise LifecycleError( + f"target {target_id} must have exactly one baseline lifecycle " + f"membership; got {memberships}" + ) + return active + + +def validate_files(root: Path = ROOT, *, now: datetime | None = None) -> dict[str, str]: + policy_path = root / POLICY_PATH.name + admissions_path = root / ADMISSIONS_PATH.name + policy = _load_json(policy_path, policy_path.name) + admissions = _load_json(admissions_path, admissions_path.name) + repositories, surfaces = load_lifecycle(root / LIFECYCLE_PATH.name) + return validate( + policy, + admissions, + repositories, + surfaces, + policy_sha256=_file_digest(policy_path), + now=now, + ) + + +def validate_append_only_history(previous_value: object, current_value: object) -> None: + """Reject release-ledger rollback while allowing one-way current revocation.""" + + if not isinstance(previous_value, dict) or not isinstance(current_value, dict): + raise LifecycleError("Production admission history must be JSON objects") + previous = previous_value.get("admissions") + current = current_value.get("admissions") + if not isinstance(previous, list) or not isinstance(current, list): + raise LifecycleError( + "Production admission history must contain admission lists" + ) + if len(current) < len(previous): + raise LifecycleError("Production admission history cannot remove records") + latest_previous_index_by_target: dict[str, int] = {} + for index, record in enumerate(previous): + if isinstance(record, dict) and isinstance(record.get("target"), str): + latest_previous_index_by_target[record["target"]] = index + for index, old_record in enumerate(previous): + new_record = current[index] + if new_record == old_record: + continue + if not isinstance(old_record, dict) or not isinstance(new_record, dict): + raise LifecycleError("Production admission history changed a record") + old_without_revocation = dict(old_record) + new_without_revocation = dict(new_record) + old_revocation = old_without_revocation.pop("revoked_at", None) + new_revocation = new_without_revocation.pop("revoked_at", None) + one_way_current_revocation = ( + index == latest_previous_index_by_target.get(old_record.get("target")) + and old_without_revocation == new_without_revocation + and old_revocation is None + and isinstance(new_revocation, str) + ) + if not one_way_current_revocation: + raise LifecycleError( + "Production admission history can only append records or revoke " + "the current record once" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--previous-admissions", type=Path) + args = parser.parse_args() + try: + active = validate_files(args.root) + if args.previous_admissions is not None: + previous = _load_json( + args.previous_admissions, "previous Production lifecycle admissions" + ) + current = _load_json( + args.root / ADMISSIONS_PATH.name, + "current Production lifecycle admissions", + ) + validate_append_only_history(previous, current) + except LifecycleError as exc: + print(f"REFUSED: {exc}") + return 1 + print( + "Validated evidence-gated Production lifecycle: " + f"{len(active)} active admission(s)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py new file mode 100644 index 0000000..9967a17 --- /dev/null +++ b/tests/test_production_lifecycle.py @@ -0,0 +1,884 @@ +"""Fail-closed tests for evidence-gated Production lifecycle state.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import sys +import unittest +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import validate_production_lifecycle as lifecycle # noqa: E402 + +NOW = datetime(2026, 8, 18, 12, 0, 0, tzinfo=timezone.utc) +SOURCE_COMMIT = "1" * 40 +POLICY_DIGEST = "sha256:" + "a" * 64 + + +def digest_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def load_policy() -> dict: + return json.loads( + (ROOT / "production-lifecycle-policy.json").read_text(encoding="utf-8") + ) + + +def flow_release() -> dict: + artifacts = [ + { + "name": "openadapt_flow-2.0.0.tar.gz", + "kind": "sdist", + "authority": "pypi", + "url": "https://files.pythonhosted.org/packages/" + + "2" * 64 + + "/openadapt_flow-2.0.0.tar.gz", + "sha256": "sha256:" + "2" * 64, + "size_bytes": 200, + }, + { + "name": "openadapt_flow-2.0.0-py3-none-any.whl", + "kind": "wheel", + "authority": "pypi", + "url": "https://files.pythonhosted.org/packages/" + + "3" * 64 + + "/openadapt_flow-2.0.0-py3-none-any.whl", + "sha256": "sha256:" + "3" * 64, + "size_bytes": 300, + }, + ] + return { + "kind": "public_package", + "version": "2.0.0", + "tag": "v2.0.0", + "source_commit": SOURCE_COMMIT, + "immutable_release_url": ( + "https://github.com/OpenAdaptAI/openadapt-flow/commit/" + SOURCE_COMMIT + ), + "artifacts": artifacts, + } + + +def next_flow_release() -> dict: + release = flow_release() + release["version"] = "2.0.1" + release["tag"] = "v2.0.1" + release["source_commit"] = "f" * 40 + release["immutable_release_url"] = ( + "https://github.com/OpenAdaptAI/openadapt-flow/commit/" + "f" * 40 + ) + for artifact in release["artifacts"]: + artifact["name"] = artifact["name"].replace("2.0.0", "2.0.1") + return release + + +def cloud_release() -> dict: + return { + "kind": "private_deployment", + "deployment_release_id": "cloud-production-2026-08-18.1", + "deployment_release_sha256": "sha256:" + "4" * 64, + "manifest_sha256": "sha256:" + "5" * 64, + } + + +def docs_release() -> dict: + artifacts = [ + { + "name": "deployment-manifest.json", + "kind": "deployment-manifest", + "authority": "managed_evidence", + "url": "https://evidence.openadapt.ai/objects/" + + "8" * 64 + + "/manifest.json", + "sha256": "sha256:" + "8" * 64, + "size_bytes": 80, + }, + { + "name": "docs-site.tar.zst", + "kind": "site-archive", + "authority": "managed_evidence", + "url": "https://evidence.openadapt.ai/objects/" + + "9" * 64 + + "/docs-site.tar.zst", + "sha256": "sha256:" + "9" * 64, + "size_bytes": 900, + }, + ] + return { + "kind": "public_deployment", + "deployment_id": "docs-production-2026-08-18.1", + "deployment_sha256": "sha256:" + "d" * 64, + "source_commit": SOURCE_COMMIT, + "immutable_release_url": ( + "https://github.com/OpenAdaptAI/openadapt-ops/commit/" + SOURCE_COMMIT + ), + "artifacts": artifacts, + } + + +def build_case( + target: str = "flow", + release: dict | None = None, + *, + release_sequence: int = 1, + previous_admission_sha256: str | None = None, + issued_at: str = "2026-08-18T11:00:00Z", + expires_at: str = "2026-08-20T11:00:00Z", +) -> tuple[dict, dict, dict[str, bytes]]: + release = copy.deepcopy(release or flow_release()) + policy = load_policy() + claim_scope = next( + item["required_claim_scope"] + for item in policy["targets"] + if item["id"] == target + ) + acceptance_policy_digest = policy["summary_authority"]["acceptance_policy_sha256"] + release_digest = lifecycle._target_release_digest(target, claim_scope, release) + artifact_digest = lifecycle._artifact_inventory_digest( + target, claim_scope, release.get("artifacts", []) + ) + release_identity = { + "schema_version": lifecycle.RELEASE_IDENTITY_SCHEMA, + "channel": "production", + "sequence": release_sequence, + "previous_admission_sha256": previous_admission_sha256, + } + evidence_identity = "sha256:" + "c" * 64 + evidence = json.dumps( + { + "schema_version": "openadapt.production-acceptance/v1", + "target": target, + "claim_scope": claim_scope, + "verdict": "accepted", + "acceptance_policy_sha256": acceptance_policy_digest, + "lifecycle_policy_sha256": POLICY_DIGEST, + "target_release_sha256": release_digest, + "target_artifact_inventory_sha256": artifact_digest, + "evidence_identity_sha256": evidence_identity, + "source_evidence": { + "source_result_sha256": "sha256:" + "0" * 64, + "certificate_sha256": "sha256:" + "6" * 64, + "campaign_sha256": "sha256:" + "b" * 64, + "qualification_admission_sha256": "sha256:" + "a" * 64, + "attestation_sha256": "sha256:" + "c" * 64, + "attestation_bundle_sha256": "sha256:" + "d" * 64, + }, + "qualification": { + "campaign_contract_sha256": "sha256:" + "b" * 64, + "campaign_outcomes_sha256": "sha256:" + "c" * 64, + "oracle_contract_sha256": "sha256:" + "d" * 64, + "task_count": 1, + "condition_count": 1, + "required_trial_count": 3, + "observed_trial_count": 3, + "minimum_trials_per_condition": 3, + "excluded_trial_count": 0, + "task_condition_inventory_sha256": "sha256:" + "e" * 64, + }, + "failure_taxonomy_counts": { + "collateral_effect": 0, + "duplicate_effect": 0, + "healthy_path_model_call": 0, + "operator_intervention": 0, + "over_halt": 0, + "platform_failure": 0, + "safe_halt": 0, + "silent_incorrect_success": 0, + "uncertain_delivery": 0, + "verified": 3, + "wrong_record": 0, + }, + "reliability": { + "silent_incorrect_success_count": 0, + "over_halt_count": 0, + "wrong_record_count": 0, + "duplicate_effect_count": 0, + "collateral_effect_count": 0, + "operator_intervention_count": 0, + "uncertain_delivery_count": 0, + "model_call_count": 0, + }, + "retention": { + "receipt_id": "retention:" + "f" * 32, + "ciphertext_sha256": "sha256:" + "1" * 64, + "candidate_sha256": "sha256:" + "2" * 64, + "private_envelope_sha256": "sha256:" + "3" * 64, + "store_attestation_sha256": "sha256:" + "4" * 64, + "storage_identity_sha256": "sha256:" + "5" * 64, + "object_version_sha256": "sha256:" + "6" * 64, + "private_locator_version_sha256": "sha256:" + "7" * 64, + "kms_key_identity_sha256": "sha256:" + "8" * 64, + "uploader_identity_sha256": "sha256:" + "9" * 64, + "retention_mode": "COMPLIANCE", + "retention_until": "2027-08-18T10:00:00.000Z", + "retained_at": "2026-08-18T10:00:00.000Z", + "upload_verified": True, + "head_verified": True, + "object_lock_verified": True, + "private_locator_recorded": True, + "acceptance_verified_at": "2026-08-18T09:59:59.500Z", + "provenance_attestation": "github-artifact-attestation-v4", + }, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + evidence_digest = digest_bytes(evidence) + evidence_url = ( + "https://evidence.openadapt.ai/objects/" + + evidence_digest.removeprefix("sha256:") + + "/manifest.json" + ) + summary = { + "schema_version": lifecycle.SUMMARY_SCHEMA, + "target": target, + "verdict": "accepted", + "claim_scope": claim_scope, + "acceptance_policy_sha256": acceptance_policy_digest, + "lifecycle_policy_sha256": POLICY_DIGEST, + "release_identity": release_identity, + "release_sha256": release_digest, + "artifact_inventory_sha256": artifact_digest, + "evidence_identity_sha256": evidence_identity, + "private_certificate_binding": { + "schema_version": "openadapt.execute-live-acceptance-record/v2", + "sha256": "sha256:" + "6" * 64, + "signer_provenance_sha256": "sha256:" + "7" * 64, + "evidence_identity_sha256": evidence_identity, + "target": target, + "target_release_sha256": release_digest, + "target_artifact_inventory_sha256": artifact_digest, + }, + "evidence_manifest": { + "schema_version": "openadapt.production-acceptance/v1", + "url": evidence_url, + "sha256": evidence_digest, + }, + "issued_at": issued_at, + "expires_at": expires_at, + "revoked_at": None, + } + summary_bytes = json.dumps(summary, sort_keys=True, separators=(",", ":")).encode() + summary_digest = digest_bytes(summary_bytes) + bundle_bytes = b"synthetic-attestation-bundle" + bundle_digest = digest_bytes(bundle_bytes) + summary_url = ( + "https://github.com/OpenAdaptAI/openadapt-evals/raw/" + + SOURCE_COMMIT + + "/evidence/" + + summary_digest.removeprefix("sha256:") + + ".json" + ) + bundle_url = ( + "https://github.com/OpenAdaptAI/openadapt-evals/raw/" + + SOURCE_COMMIT + + "/evidence/" + + bundle_digest.removeprefix("sha256:") + + ".sigstore.json" + ) + admission = { + "admission_id": f"production:{target}:{release_sequence}", + "target": target, + "claim_scope": claim_scope, + "release_identity": release_identity, + "policy_revision": 1, + "release": release, + "acceptance_evidence": { + "summary_url": summary_url, + "summary_sha256": summary_digest, + "attestation_bundle_url": bundle_url, + "attestation_bundle_sha256": bundle_digest, + "authority_source_commit": SOURCE_COMMIT, + }, + "issued_at": summary["issued_at"], + "expires_at": summary["expires_at"], + "revoked_at": None, + } + admissions = { + "$schema": "schemas/production-lifecycle-admissions.schema.json", + "schema_version": lifecycle.ADMISSIONS_SCHEMA, + "policy_sha256": POLICY_DIGEST, + "admissions": [admission], + } + remote = { + summary_url: summary_bytes, + bundle_url: bundle_bytes, + evidence_url: evidence, + } + pypi_artifacts = [ + item for item in release.get("artifacts", []) if item["authority"] == "pypi" + ] + if pypi_artifacts: + project = next( + item["package_index_project"] + for item in policy["targets"] + if item["id"] == target + ) + metadata_url = f"https://pypi.org/pypi/{project}/{release['version']}/json" + remote[metadata_url] = json.dumps( + { + "info": {"version": release["version"]}, + "urls": [ + { + "filename": item["name"], + "url": item["url"], + "size": item["size_bytes"], + "digests": {"sha256": item["sha256"].removeprefix("sha256:")}, + "yanked": False, + } + for item in pypi_artifacts + ], + } + ).encode() + for artifact in release.get("artifacts", []): + if artifact["authority"] != "managed_evidence": + continue + metadata_url = ( + "https://evidence.openadapt.ai/api/v1/objects/sha256/" + + artifact["sha256"].removeprefix("sha256:") + ) + remote[metadata_url] = json.dumps( + { + "schema_version": "openadapt.managed-artifact-head/v1", + "exists": True, + "artifact_url": artifact["url"], + "sha256": artifact["sha256"], + "size_bytes": artifact["size_bytes"], + "object_version_sha256": "sha256:" + "a" * 64, + "head_verified": True, + } + ).encode() + return admissions, summary, remote + + +def empty_admissions() -> dict: + return { + "$schema": "schemas/production-lifecycle-admissions.schema.json", + "schema_version": lifecycle.ADMISSIONS_SCHEMA, + "policy_sha256": POLICY_DIGEST, + "admissions": [], + } + + +def replace_summary(admissions: dict, summary: dict, remote: dict[str, bytes]) -> None: + summary_bytes = json.dumps(summary, sort_keys=True, separators=(",", ":")).encode() + reference = admissions["admissions"][-1]["acceptance_evidence"] + old_url = reference["summary_url"] + new_digest = digest_bytes(summary_bytes) + new_url = ( + "https://evidence.openadapt.ai/objects/" + + new_digest.removeprefix("sha256:") + + "/summary.json" + ) + reference["summary_url"] = new_url + reference["summary_sha256"] = new_digest + remote[new_url] = summary_bytes + del remote[old_url] + + +def replace_manifest( + admissions: dict, summary: dict, remote: dict[str, bytes], manifest: dict +) -> None: + old_url = summary["evidence_manifest"]["url"] + manifest_bytes = json.dumps( + manifest, sort_keys=True, separators=(",", ":") + ).encode() + manifest_digest = digest_bytes(manifest_bytes) + manifest_url = ( + "https://evidence.openadapt.ai/objects/" + + manifest_digest.removeprefix("sha256:") + + "/manifest.json" + ) + summary["evidence_manifest"]["url"] = manifest_url + summary["evidence_manifest"]["sha256"] = manifest_digest + remote[manifest_url] = manifest_bytes + del remote[old_url] + replace_summary(admissions, summary, remote) + + +def validate_case( + admissions: dict, + remote: dict[str, bytes] | None = None, + repositories: list[str] | None = None, + surfaces: list[str] | None = None, + attestation_valid: bool = True, +) -> dict[str, str]: + attestation_calls: list[tuple[bytes, bytes]] = [] + + def fetch(url: str) -> bytes: + assert remote is not None + try: + return remote[url] + except KeyError as exc: + raise OSError(f"synthetic URL is absent: {url}") from exc + + def verify( + summary: bytes, bundle: bytes, _authority: dict, _source_commit: str + ) -> None: + if not attestation_valid: + raise lifecycle.LifecycleError("synthetic attestation failure") + attestation_calls.append((summary, bundle)) + + repository_lifecycle, surface_lifecycle = lifecycle.load_lifecycle() + repository_lifecycle = copy.deepcopy(repository_lifecycle) + surface_lifecycle = copy.deepcopy(surface_lifecycle) + repository_lifecycle["production"] = list(repositories or []) + surface_lifecycle["production"] = list(surfaces or []) + for subject in repositories or []: + for group, subjects in repository_lifecycle.items(): + if group != "production" and subject in subjects: + subjects.remove(subject) + for subject in surfaces or []: + for group, subjects in surface_lifecycle.items(): + if group != "production" and subject in subjects: + subjects.remove(subject) + + result = lifecycle.validate( + load_policy(), + admissions, + repository_lifecycle, + surface_lifecycle, + policy_sha256=POLICY_DIGEST, + now=NOW, + fetch=fetch, + verify_attestation=verify, + ) + return result + + +class ProductionLifecycleTests(unittest.TestCase): + def test_current_registry_has_no_production_admissions(self) -> None: + self.assertEqual(validate_case(empty_admissions()), {}) + + def test_valid_exact_public_package_admission(self) -> None: + admissions, _summary, remote = build_case() + self.assertEqual( + validate_case(admissions, remote), + {"flow": "production:flow:1"}, + ) + + def test_private_cloud_uses_opaque_release_without_public_url(self) -> None: + admissions, _summary, remote = build_case("cloud", cloud_release()) + self.assertEqual( + validate_case(admissions, remote), + {"cloud": "production:cloud:1"}, + ) + serialized = json.dumps(admissions["admissions"][0]["release"]) + self.assertNotIn("url", serialized.lower()) + + def test_valid_exact_docs_deployment_admission(self) -> None: + admissions, _summary, remote = build_case("docs", docs_release()) + self.assertEqual( + validate_case(admissions, remote), + {"docs": "production:docs:1"}, + ) + + def test_registry_membership_without_admission_is_refused(self) -> None: + with self.assertRaisesRegex(lifecycle.LifecycleError, "static Production"): + validate_case(empty_admissions(), repositories=["openadapt-flow"]) + + def test_admission_derives_production_without_static_membership(self) -> None: + admissions, _summary, remote = build_case() + self.assertEqual( + validate_case(admissions, remote), {"flow": "production:flow:1"} + ) + + def test_policy_digest_mismatch_is_refused(self) -> None: + admissions = empty_admissions() + admissions["policy_sha256"] = "sha256:" + "b" * 64 + with self.assertRaisesRegex(lifecycle.LifecycleError, "policy digest differs"): + validate_case(admissions) + + def test_expired_latest_admission_removes_derived_production(self) -> None: + admissions, _summary, remote = build_case( + issued_at="2026-08-17T11:00:00Z", + expires_at="2026-08-18T11:59:59Z", + ) + self.assertEqual(validate_case(admissions, remote), {}) + + def test_revoked_latest_admission_removes_derived_production(self) -> None: + admissions, _summary, remote = build_case() + admissions["admissions"][0]["revoked_at"] = "2026-08-18T11:30:00Z" + self.assertEqual(validate_case(admissions, remote), {}) + + def test_summary_digest_mismatch_is_refused(self) -> None: + admissions, _summary, remote = build_case() + summary_url = admissions["admissions"][0]["acceptance_evidence"]["summary_url"] + remote[summary_url] += b"tampered" + with self.assertRaisesRegex(lifecycle.LifecycleError, "summary digest changed"): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_invalid_summary_attestation_is_refused(self) -> None: + admissions, _summary, remote = build_case() + with self.assertRaisesRegex(lifecycle.LifecycleError, "attestation failure"): + validate_case( + admissions, + remote, + repositories=["openadapt-flow"], + attestation_valid=False, + ) + + def test_evidence_manifest_digest_mismatch_is_refused(self) -> None: + admissions, summary, remote = build_case() + remote[summary["evidence_manifest"]["url"]] += b"tampered" + with self.assertRaisesRegex( + lifecycle.LifecycleError, "evidence manifest digest changed" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_rejected_evidence_manifest_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["verdict"] = "rejected" + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex(lifecycle.LifecycleError, "verdict differs"): + validate_case(admissions, remote) + + def test_incomplete_failure_taxonomy_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["failure_taxonomy_counts"]["verified"] = 0 + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex(lifecycle.LifecycleError, "every trial"): + validate_case(admissions, remote) + + def test_governed_repair_model_calls_remain_informational(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["reliability"]["model_call_count"] = 2 + replace_manifest(admissions, summary, remote, manifest) + self.assertEqual( + validate_case(admissions, remote), {"flow": "production:flow:1"} + ) + + def test_healthy_path_model_call_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["failure_taxonomy_counts"]["healthy_path_model_call"] = 1 + manifest["failure_taxonomy_counts"]["verified"] = 2 + manifest["reliability"]["model_call_count"] = 1 + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex(lifecycle.LifecycleError, "Production failure"): + validate_case(admissions, remote) + + def test_future_retention_verification_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["retention"]["acceptance_verified_at"] = "2026-08-19T09:59:59.000Z" + manifest["retention"]["retained_at"] = "2026-08-19T10:00:00.000Z" + manifest["retention"]["retention_until"] = "2027-08-19T10:00:00.000Z" + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex(lifecycle.LifecycleError, "in the future"): + validate_case(admissions, remote) + + def test_whole_second_retention_timestamp_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["retention"]["retained_at"] = "2026-08-18T10:00:00Z" + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "retained time must be a millisecond UTC" + ): + validate_case(admissions, remote) + + def test_offset_retention_timestamp_is_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["retention"]["retention_until"] = "2027-08-18T10:00:00.000+00:00" + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "retention end must be a millisecond UTC" + ): + validate_case(admissions, remote) + + def test_observed_trials_below_condition_minimum_are_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["qualification"]["minimum_trials_per_condition"] = 4 + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "observed trial total is too small" + ): + validate_case(admissions, remote) + + def test_required_trials_below_policy_floor_are_refused(self) -> None: + admissions, summary, remote = build_case() + manifest = json.loads(remote[summary["evidence_manifest"]["url"]]) + manifest["qualification"]["condition_count"] = 2 + replace_manifest(admissions, summary, remote, manifest) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "required trial total is too small" + ): + validate_case(admissions, remote) + + def test_evidence_manifest_schema_mismatch_is_refused(self) -> None: + admissions, summary, remote = build_case() + summary["evidence_manifest"]["schema_version"] = "unbound/v1" + summary_bytes = json.dumps( + summary, sort_keys=True, separators=(",", ":") + ).encode() + reference = admissions["admissions"][0]["acceptance_evidence"] + old_url = reference["summary_url"] + new_digest = digest_bytes(summary_bytes) + new_url = ( + "https://evidence.openadapt.ai/objects/" + + new_digest.removeprefix("sha256:") + + "/summary.json" + ) + reference["summary_url"] = new_url + reference["summary_sha256"] = new_digest + remote[new_url] = summary_bytes + del remote[old_url] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "evidence manifest schema is not supported" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_release_digest_mismatch_is_refused(self) -> None: + admissions, summary, remote = build_case() + summary["release_sha256"] = "sha256:" + "f" * 64 + summary_bytes = json.dumps( + summary, sort_keys=True, separators=(",", ":") + ).encode() + reference = admissions["admissions"][0]["acceptance_evidence"] + old_url = reference["summary_url"] + new_digest = digest_bytes(summary_bytes) + new_url = ( + "https://evidence.openadapt.ai/objects/" + + new_digest.removeprefix("sha256:") + + "/summary.json" + ) + reference["summary_url"] = new_url + reference["summary_sha256"] = new_digest + remote[new_url] = summary_bytes + del remote[old_url] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "summary release digest differs" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_private_identity_target_binding_mismatch_is_refused(self) -> None: + admissions, summary, remote = build_case() + summary["private_certificate_binding"]["target"] = "cloud" + summary_bytes = json.dumps( + summary, sort_keys=True, separators=(",", ":") + ).encode() + reference = admissions["admissions"][0]["acceptance_evidence"] + old_url = reference["summary_url"] + new_digest = digest_bytes(summary_bytes) + new_url = ( + "https://evidence.openadapt.ai/objects/" + + new_digest.removeprefix("sha256:") + + "/summary.json" + ) + reference["summary_url"] = new_url + reference["summary_sha256"] = new_digest + remote[new_url] = summary_bytes + del remote[old_url] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "private evidence target" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_private_identity_release_binding_mismatch_is_refused(self) -> None: + admissions, summary, remote = build_case() + summary["private_certificate_binding"]["target_release_sha256"] = ( + "sha256:" + "f" * 64 + ) + summary_bytes = json.dumps( + summary, sort_keys=True, separators=(",", ":") + ).encode() + reference = admissions["admissions"][0]["acceptance_evidence"] + old_url = reference["summary_url"] + new_digest = digest_bytes(summary_bytes) + new_url = ( + "https://evidence.openadapt.ai/objects/" + + new_digest.removeprefix("sha256:") + + "/summary.json" + ) + reference["summary_url"] = new_url + reference["summary_sha256"] = new_digest + remote[new_url] = summary_bytes + del remote[old_url] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "private release binding" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_target_claim_scope_mismatch_is_refused(self) -> None: + admissions, _summary, remote = build_case() + admissions["admissions"][0]["claim_scope"] = "qualified_native_recorder_release" + with self.assertRaisesRegex( + lifecycle.LifecycleError, "claim scope differs from policy" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_signer_provenance_domain_mismatch_is_refused(self) -> None: + policy = load_policy() + policy["summary_authority"]["signer_provenance_digest_domain"] = ( + "OpenAdapt production certificate signer provenance v2\0" + ) + with self.assertRaisesRegex( + lifecycle.LifecycleError, "signer provenance digest domain" + ): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + def test_missing_required_artifact_is_refused(self) -> None: + admissions, _summary, remote = build_case() + admissions["admissions"][0]["release"]["artifacts"] = [ + admissions["admissions"][0]["release"]["artifacts"][1] + ] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "missing required artifact" + ): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_unbound_evidence_url_is_refused(self) -> None: + admissions, _summary, remote = build_case() + admissions["admissions"][0]["acceptance_evidence"]["summary_url"] = ( + "https://evidence.openadapt.ai/latest/summary.json" + ) + with self.assertRaisesRegex(lifecycle.LifecycleError, "not bound"): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_artifact_url_outside_pinned_authority_is_refused(self) -> None: + admissions, _summary, remote = build_case() + admissions["admissions"][0]["release"]["artifacts"][0]["url"] = ( + "https://downloads.example.test/" + SOURCE_COMMIT + "/artifact.tar.gz" + ) + with self.assertRaisesRegex(lifecycle.LifecycleError, "not a PyPI artifact"): + validate_case(admissions, remote, repositories=["openadapt-flow"]) + + def test_pypi_metadata_digest_mismatch_is_refused(self) -> None: + admissions, _summary, remote = build_case() + metadata_url = "https://pypi.org/pypi/openadapt-flow/2.0.0/json" + metadata = json.loads(remote[metadata_url]) + metadata["urls"][0]["digests"]["sha256"] = "0" * 64 + remote[metadata_url] = json.dumps(metadata).encode() + with self.assertRaisesRegex(lifecycle.LifecycleError, "PyPI does not verify"): + validate_case(admissions, remote) + + def test_missing_managed_evidence_metadata_is_refused(self) -> None: + admissions, _summary, remote = build_case("docs", docs_release()) + metadata_url = "https://evidence.openadapt.ai/api/v1/objects/sha256/" + "8" * 64 + del remote[metadata_url] + with self.assertRaisesRegex( + lifecycle.LifecycleError, "managed evidence object metadata" + ): + validate_case(admissions, remote) + + def test_monotonic_latest_release_is_derived(self) -> None: + first, _first_summary, first_remote = build_case( + issued_at="2026-08-17T10:00:00Z" + ) + predecessor = lifecycle._canonical_digest(first["admissions"][0]) + second, _second_summary, second_remote = build_case( + release=next_flow_release(), + release_sequence=2, + previous_admission_sha256=predecessor, + issued_at="2026-08-18T11:00:00Z", + ) + first["admissions"].extend(second["admissions"]) + first_remote.update(second_remote) + self.assertEqual( + validate_case(first, first_remote), {"flow": "production:flow:2"} + ) + + def test_expired_latest_release_does_not_fall_back(self) -> None: + first, _first_summary, first_remote = build_case( + issued_at="2026-08-17T10:00:00Z" + ) + predecessor = lifecycle._canonical_digest(first["admissions"][0]) + second, _second_summary, second_remote = build_case( + release=next_flow_release(), + release_sequence=2, + previous_admission_sha256=predecessor, + issued_at="2026-08-17T11:00:00Z", + expires_at="2026-08-18T11:59:59Z", + ) + first["admissions"].extend(second["admissions"]) + first_remote.update(second_remote) + self.assertEqual(validate_case(first, first_remote), {}) + + def test_release_chain_truncation_is_refused_by_history_gate(self) -> None: + first, _summary, _remote = build_case() + predecessor = lifecycle._canonical_digest(first["admissions"][0]) + second, _summary, _remote = build_case( + release=next_flow_release(), + release_sequence=2, + previous_admission_sha256=predecessor, + issued_at="2026-08-18T11:30:00Z", + ) + previous = copy.deepcopy(first) + previous["admissions"].extend(second["admissions"]) + with self.assertRaisesRegex(lifecycle.LifecycleError, "cannot remove"): + lifecycle.validate_append_only_history(previous, first) + + def test_history_gate_allows_one_way_revocation_per_target(self) -> None: + flow, _summary, _remote = build_case() + docs, _summary, _remote = build_case("docs", docs_release()) + previous = copy.deepcopy(flow) + previous["admissions"].extend(docs["admissions"]) + current = copy.deepcopy(previous) + current["admissions"][0]["revoked_at"] = "2026-08-18T11:30:00Z" + lifecycle.validate_append_only_history(previous, current) + + def test_history_gate_refuses_revocation_rollback(self) -> None: + previous, _summary, _remote = build_case() + previous["admissions"][0]["revoked_at"] = "2026-08-18T11:30:00Z" + current = copy.deepcopy(previous) + current["admissions"][0]["revoked_at"] = None + with self.assertRaisesRegex(lifecycle.LifecycleError, "only append"): + lifecycle.validate_append_only_history(previous, current) + + def test_pinned_target_map_refuses_subject_substitution(self) -> None: + policy = load_policy() + next(item for item in policy["targets"] if item["id"] == "flow")[ + "lifecycle_subject" + ] = "openadapt-evals" + with self.assertRaisesRegex(lifecycle.LifecycleError, "pinned target map"): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + def test_pinned_attestation_authority_refuses_substitution(self) -> None: + policy = load_policy() + authority = policy["summary_authority"] + authority["repository"] = "OpenAdaptAI/attacker" + authority["certificate_identity"] = ( + "https://github.com/OpenAdaptAI/attacker/" + + authority["workflow"] + + "@refs/heads/main" + ) + with self.assertRaisesRegex(lifecycle.LifecycleError, "pinned trust root"): + lifecycle.validate( + policy, + empty_admissions(), + *lifecycle.load_lifecycle(), + policy_sha256=POLICY_DIGEST, + now=NOW, + ) + + def test_schema_files_are_valid_json(self) -> None: + for path in sorted((ROOT / "schemas").glob("production-lifecycle-*.json")): + self.assertIsInstance(json.loads(path.read_text(encoding="utf-8")), dict) + + +if __name__ == "__main__": + unittest.main()