From ef5c33d26c61eb4108eacad4ca3dedda1368e069 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 2 Sep 2026 21:43:08 -0400 Subject: [PATCH] feat: list synthetic workflow admissions on a public ledger Add production-workflow-admissions.json as an append-only ledger of qualification-admission object references. The release ledger stays qualification-release-only. The validator still prints 7 active release admissions and now requires at least one active synthetic tutorial workflow admission. The GitHub issuer stays inactive. This is not a MockMed production_acceptance flip. --- .github/workflows/profile-consistency.yml | 8 +- REPOSITORY_LIFECYCLE.md | 9 + production-workflow-admissions.json | 133 +++++++ ...production-workflow-admissions.schema.json | 65 ++++ scripts/validate_production_lifecycle.py | 346 ++++++++++++++++-- tests/test_governance_workflows.py | 1 + tests/test_production_lifecycle.py | 91 +++++ tests/test_public_trust_schemas.py | 6 + 8 files changed, 628 insertions(+), 31 deletions(-) create mode 100644 production-workflow-admissions.json create mode 100644 schemas/production-workflow-admissions.schema.json diff --git a/.github/workflows/profile-consistency.yml b/.github/workflows/profile-consistency.yml index f616aa6..53d1660 100644 --- a/.github/workflows/profile-consistency.yml +++ b/.github/workflows/profile-consistency.yml @@ -68,9 +68,15 @@ jobs: else printf '{"admissions":[]}\n' > "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" fi + if git cat-file -e "$BASE_SHA:production-workflow-admissions.json"; then + git show "$BASE_SHA:production-workflow-admissions.json" > "$RUNNER_TEMP/previous-production-workflow-admissions.json" + else + printf '%s\n' '{"$schema":"schemas/production-workflow-admissions.schema.json","schema_version":"openadapt.production-workflow-admissions/v1","policy_sha256":"sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11","admissions":[]}' > "$RUNNER_TEMP/previous-production-workflow-admissions.json" + fi python3 scripts/validate_production_lifecycle.py \ --history-only \ - --previous-admissions "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" + --previous-admissions "$RUNNER_TEMP/previous-production-lifecycle-admissions.json" \ + --previous-workflow-admissions "$RUNNER_TEMP/previous-production-workflow-admissions.json" - name: Validate product truth and links run: python3 scripts/check_profile.py - name: Bind every published benchmark figure to its artifact diff --git a/REPOSITORY_LIFECYCLE.md b/REPOSITORY_LIFECYCLE.md index 915e916..324d3bb 100644 --- a/REPOSITORY_LIFECYCLE.md +++ b/REPOSITORY_LIFECYCLE.md @@ -65,6 +65,15 @@ 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. +A Production run needs an active workflow admission for one exact sealed +bundle. Product release admissions do not qualify a workflow. The dedicated +append-only ledger is +[`production-workflow-admissions.json`](production-workflow-admissions.json). +It lists signed `qualification-admission` objects. The current rows are +remote-safe-synthetic tutorial bundles (`0.0.0-synthetic`). They do not set +MockMed `production_acceptance`. The GitHub admission issuer stays inactive +until consumers pin the exact verifier. + 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 diff --git a/production-workflow-admissions.json b/production-workflow-admissions.json new file mode 100644 index 0000000..fb244e4 --- /dev/null +++ b/production-workflow-admissions.json @@ -0,0 +1,133 @@ +{ + "$schema": "schemas/production-workflow-admissions.schema.json", + "schema_version": "openadapt.production-workflow-admissions/v1", + "policy_sha256": "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11", + "admissions": [ + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "6e28818a94e057cae99dcc2c76d02b9137f87076", + "registry_revision": 2, + "registry_head_sha256": "sha256:12508b788997dcd841b7b116b512dd7c1e129192988a947952bcc2e2e8572a24", + "registry_entry_sha256": "sha256:813040c698d3b04d39825502635f5337bcf08d5885142363fe9b16a76b4667a6", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/dc/dcdb32a762aca87fbb1a7c9df5d346403b167ec5850d35fe47fd64d942684a04.qualification-admission.json", + "object_sha256": "sha256:dcdb32a762aca87fbb1a7c9df5d346403b167ec5850d35fe47fd64d942684a04", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:a480d62a9c2aa5ed6061d3b0140cf908024b23e90b7535bb3c09e9da6f748e90", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:ccaf41e33bb4c59fbb182af2c4cd184a3aa56dad23fd31b698ad979b94990739", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/34/34b8a1157cfd72d820cac0c49e3679959174083ccbb83cf0371925cc72226fdb.qualification-admission.json", + "object_sha256": "sha256:34b8a1157cfd72d820cac0c49e3679959174083ccbb83cf0371925cc72226fdb", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:4ae2e17a493b2b18b757423ab729dc1aa3729e21e3cf42e1ea22e9d98d5f8ac7", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:e9f906e705881b4f3b6f3a626198dd34d76e321be94d28f9ab0efdd137853b70", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/4b/4b505945969b876b8af9d9686416214d191b78cd3472e313ce3776a5e550efcb.qualification-admission.json", + "object_sha256": "sha256:4b505945969b876b8af9d9686416214d191b78cd3472e313ce3776a5e550efcb", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:55105879b0f9f324e3b08d8fd5b9b44f51d5c5930afcc8d43bf737ae8167f263", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:6d8c2fd866016a461ca6ce10c12f27b8e0396944460435905e3c40af00dec0f1", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/71/71b69bc2339fd3202eae960c2cd7fbef4963e6736fa5ad6e24d7879e53f8da7d.qualification-admission.json", + "object_sha256": "sha256:71b69bc2339fd3202eae960c2cd7fbef4963e6736fa5ad6e24d7879e53f8da7d", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:e2dca14ae8df88852b1b776bf8e6ab2bed2b01e1111046147dbbb544e73617ed", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:03d1aeb88a512ba4fb60c9ecb43ebaf247002b3982cebabe271b70fe9f235c1f", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/46/46e925b68d8313880756b38fcbf17d8bf02af1fb5b57692e63cc972479088a3e.qualification-admission.json", + "object_sha256": "sha256:46e925b68d8313880756b38fcbf17d8bf02af1fb5b57692e63cc972479088a3e", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:927f92c7f001997829efbbe7deade5e5c54ccf04dc5ebff4790de84a2d8ae76b", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:bb4facd0a1b5b8d939f82508f411cba4702c1c3bbeb5da100bd4811e8c53ffc7", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/71/71470304eb13b4d9aed64c0d26c9d31d1bb8a4eb463955be0b720e1aed523e61.qualification-admission.json", + "object_sha256": "sha256:71470304eb13b4d9aed64c0d26c9d31d1bb8a4eb463955be0b720e1aed523e61", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:a0288638b7f2061011d4a5fb1833f61663180dc40c3970bde54ce76864e52e31", + "subject_sha256": null + }, + { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": "78aebd3c98e66943eb3da0144945199300f16323", + "registry_revision": 3, + "registry_head_sha256": "sha256:d00a5f4f68db36de5da56de27045905c622b5f699ffd9c45eb29f452fe5e3ba1", + "registry_entry_sha256": "sha256:84fa3b4bdbd34ce18d9ca9fb49e4e8f13e01b4a3030115ad9dbcb8537c4bfa1f", + "kind": "qualification-admission", + "object_schema_version": "openadapt.qualification-admission/v4", + "object_path": "production-evidence/objects/sha256/6d/6d0c77a7831103e1959406513f7b53fca7fb0709c872056461dc8b6be06a784e.qualification-admission.json", + "object_sha256": "sha256:6d0c77a7831103e1959406513f7b53fca7fb0709c872056461dc8b6be06a784e", + "size_bytes": 8055, + "object_media_type": "application/vnd.openadapt.qualification-admission+json;version=4", + "semantic_identity_sha256": "sha256:920214d3bb3378e5cdab572c67ac04d13de3b2f19f99ade5e73f7e394282776e", + "subject_sha256": null + } + ] +} diff --git a/schemas/production-workflow-admissions.schema.json b/schemas/production-workflow-admissions.schema.json new file mode 100644 index 0000000..1e3b1ce --- /dev/null +++ b/schemas/production-workflow-admissions.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenAdaptAI/.github/blob/main/schemas/production-workflow-admissions.schema.json", + "title": "OpenAdapt Production workflow admissions", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "schema_version", "policy_sha256", "admissions"], + "properties": { + "$schema": {"const": "schemas/production-workflow-admissions.schema.json"}, + "schema_version": {"const": "openadapt.production-workflow-admissions/v1"}, + "policy_sha256": {"$ref": "#/$defs/digest"}, + "admissions": { + "type": "array", + "items": {"$ref": "#/$defs/qualificationAdmissionReference"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "qualificationAdmissionReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + "kind", + "object_schema_version", + "object_path", + "object_sha256", + "size_bytes", + "object_media_type", + "semantic_identity_sha256", + "subject_sha256" + ], + "properties": { + "schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, + "repository": {"const": "OpenAdaptAI/.github"}, + "repository_id": {"const": "858454062"}, + "repository_owner_id": {"const": "132681217"}, + "registry_source_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "registry_revision": {"type": "integer", "minimum": 1}, + "registry_head_sha256": {"$ref": "#/$defs/digest"}, + "registry_entry_sha256": {"$ref": "#/$defs/digest"}, + "kind": {"const": "qualification-admission"}, + "object_schema_version": {"const": "openadapt.qualification-admission/v4"}, + "object_path": { + "type": "string", + "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-admission\\.json$" + }, + "object_sha256": {"$ref": "#/$defs/digest"}, + "size_bytes": {"type": "integer", "minimum": 1}, + "object_media_type": { + "const": "application/vnd.openadapt.qualification-admission+json;version=4" + }, + "semantic_identity_sha256": {"$ref": "#/$defs/digest"}, + "subject_sha256": {"type": "null"} + } + } + } +} diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py index 01c070e..a353787 100755 --- a/scripts/validate_production_lifecycle.py +++ b/scripts/validate_production_lifecycle.py @@ -47,12 +47,14 @@ ROOT = Path(__file__).resolve().parents[1] POLICY_PATH = ROOT / "production-lifecycle-policy.json" ADMISSIONS_PATH = ROOT / "production-lifecycle-admissions.json" +WORKFLOW_ADMISSIONS_PATH = ROOT / "production-workflow-admissions.json" LIFECYCLE_PATH = ROOT / "repository-lifecycle.yml" POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v3" POLICY_DOCUMENT_SCHEMA = "schemas/production-lifecycle-policy.schema.json" POLICY_REVISION_MINIMUM = 4 ADMISSIONS_SCHEMA = "openadapt.production-lifecycle-admissions/v1" +WORKFLOW_ADMISSIONS_SCHEMA = "openadapt.production-workflow-admissions/v1" SUMMARY_SCHEMA = "openadapt.production-lifecycle-evidence-summary/v1" RELEASE_IDENTITY_SCHEMA = "openadapt.monotonic-production-release/v1" OBJECT_REFERENCE_SCHEMA = "openadapt.production-evidence-object-reference/v2" @@ -1336,6 +1338,14 @@ def _is_v2_release_reference(value: object) -> bool: ) +def _is_v2_workflow_reference(value: object) -> bool: + return ( + isinstance(value, dict) + and value.get("schema_version") == OBJECT_REFERENCE_SCHEMA + and value.get("kind") == "qualification-admission" + ) + + def _is_v2_release_object(value: object) -> bool: return ( isinstance(value, dict) @@ -1343,26 +1353,48 @@ def _is_v2_release_object(value: object) -> bool: ) +def _bind_reference_registry_snapshot( + reference: Mapping[str, Any], + registry_document: Mapping[str, Any], + *, + label: str, +) -> None: + """Historical rows keep the registry snapshot they were issued against.""" + + current_revision = registry_document["revision"] + bound_revision = reference["registry_revision"] + if bound_revision > current_revision: + raise LifecycleError(f"{label} binds a future registry revision") + if bound_revision == current_revision and ( + reference["registry_head_sha256"] != registry_document["registry_head_sha256"] + ): + raise LifecycleError(f"{label} does not bind the current registry") + previous_head = registry_document.get("previous_registry_head_sha256") + if ( + bound_revision == current_revision - 1 + and previous_head is not None + and reference["registry_head_sha256"] != previous_head + ): + raise LifecycleError(f"{label} does not bind the previous registry") + + def _adjacent_bundle_entry( entries: Sequence[Mapping[str, Any]], regular: Mapping[str, Any] ) -> dict[str, Any]: + kind = regular["kind"] for index, entry in enumerate(entries): if entry["registry_entry_sha256"] != regular["registry_entry_sha256"]: continue if index + 1 >= len(entries): - raise LifecycleError( - "qualification-release has no adjacent Sigstore bundle" - ) + raise LifecycleError(f"{kind} has no adjacent Sigstore bundle") bundle = entries[index + 1] if ( - bundle["kind"] != f"{regular['kind']}-sigstore-bundle" + bundle["kind"] != f"{kind}-sigstore-bundle" or bundle["subject_sha256"] != regular["object_sha256"] ): - raise LifecycleError( - "qualification-release Sigstore bundle is not adjacent" - ) + raise LifecycleError(f"{kind} Sigstore bundle is not adjacent") return dict(bundle) - raise LifecycleError("qualification-release is not registered") + raise LifecycleError(f"{kind} is not registered") def _load_registered_json( @@ -1481,28 +1513,11 @@ def _validate_v2_release_admission( # Historical rows keep the registry snapshot they were issued # against. Append-only history forbids rewriting them onto the # current revision. New rows bind the current registry. - current_revision = registry_document["revision"] - bound_revision = reference["registry_revision"] - if bound_revision > current_revision: - raise LifecycleError( - "qualification-release reference binds a future registry revision" - ) - if bound_revision == current_revision and ( - reference["registry_head_sha256"] - != registry_document["registry_head_sha256"] - ): - raise LifecycleError( - "qualification-release reference does not bind the current registry" - ) - previous_head = registry_document.get("previous_registry_head_sha256") - if ( - bound_revision == current_revision - 1 - and previous_head is not None - and reference["registry_head_sha256"] != previous_head - ): - raise LifecycleError( - "qualification-release reference does not bind the previous registry" - ) + _bind_reference_registry_snapshot( + reference, + registry_document, + label="qualification-release reference", + ) elif _is_v2_release_object(item): object_sha = "sha256:" + hashlib.sha256( evidence_registry.canonical(item) + b"\n" @@ -1623,6 +1638,202 @@ def _validate_v2_release_admission( return admission +def _authority_state_identity( + registry_entries: Sequence[Mapping[str, Any]], +) -> str: + matches = [ + entry + for entry in registry_entries + if entry["kind"] == "qualification-authority-state-receipt" + ] + if len(matches) != 1: + raise LifecycleError( + "workflow admissions require exactly one authority-state receipt" + ) + return matches[0]["semantic_identity_sha256"] + + +def _validate_v2_workflow_admission( + item: Mapping[str, Any], + *, + index: int, + root: Path, + registry_document: Mapping[str, Any], + registry_entries: Sequence[Mapping[str, Any]], + now: datetime, +) -> dict[str, Any]: + """Validate one registered qualification-admission/v4 ledger row. + + remote-safe-synthetic tutorial rows are retained and checked. One + active row is enough to list a workflow admission. It does not + qualify a product release, and it is not a MockMed + production_acceptance flip. + """ + + if not _is_v2_workflow_reference(item): + raise LifecycleError( + f"workflow admission {index} is not a qualification-admission/v4 row" + ) + try: + reference = evidence_registry.validate_reference(item) + regular = evidence_registry.require_registered( + list(registry_entries), + reference=reference, + label=f"workflow admission {index}", + ) + except evidence_registry.EvidenceRegistryError as exc: + raise LifecycleError(str(exc)) from exc + _bind_reference_registry_snapshot( + reference, + registry_document, + label="qualification-admission reference", + ) + bundle_entry = _adjacent_bundle_entry(registry_entries, regular) + object_raw, object_value = _load_registered_json( + root, regular, f"workflow admission {index}" + ) + bundle_raw, _bundle_value = _load_registered_json( + root, bundle_entry, f"workflow admission {index} bundle" + ) + try: + admission = production_trust.validate_qualification_admission( + object_value, now=now + ) + except production_trust.TrustError as exc: + raise LifecycleError(f"workflow admission {index}: {exc}") from exc + if admission["evidence_class"] != "remote-safe-synthetic": + raise LifecycleError( + f"workflow admission {index} evidence class is not remote-safe-synthetic" + ) + if admission["bundle_version"] != "0.0.0-synthetic": + raise LifecycleError( + f"workflow admission {index} is not the synthetic tutorial bundle" + ) + if admission["expires_at"] is not None: + raise LifecycleError(f"workflow admission {index} expiry must be until-revoked") + if admission.get("evals_production_acceptance") is not False and ( + "evals_production_acceptance" in admission + ): + raise LifecycleError( + f"workflow admission {index} must not claim evals production_acceptance" + ) + serialized = json.dumps(admission, ensure_ascii=False) + if "mockmed" in serialized.lower() or "production_acceptance" in admission: + raise LifecycleError( + f"workflow admission {index} must not invent MockMed production_acceptance" + ) + uncertain = admission["campaign_summary"]["uncertain_delivery"] + if uncertain["reconciliation_required_count"] < 3: + raise LifecycleError( + f"workflow admission {index} uncertain-delivery count is below 3" + ) + pointer = registry_document.get("signer_registry") + if not isinstance(pointer, dict): + raise LifecycleError("workflow admissions require an installed signer registry") + signer_path = root / pointer["object_path"] + try: + signer_raw = signer_path.read_bytes() + except OSError as exc: + raise LifecycleError(f"signer registry is missing: {exc}") from exc + try: + signer_registry = evidence_registry.validate_signer_registry( + json.loads(signer_raw) + ) + except (json.JSONDecodeError, evidence_registry.EvidenceRegistryError) as exc: + raise LifecycleError(f"signer registry is invalid: {exc}") from exc + if signer_registry["expires_at"] is not None: + raise LifecycleError("signer registry expiry must be until-revoked") + inner_ids = { + signer["key_id"] + for signer in signer_registry["signers"] + if signer.get("key_id", "").startswith("qa-ed25519-") + } + outer_ids = { + signer["key_id"] + for signer in signer_registry["signers"] + if signer.get("key_id", "").startswith("oa-public-trust-ed25519-") + } + if "qa-ed25519-9cf4bca214c01d79" not in inner_ids: + raise LifecycleError("until-revoked signer registry is missing the inner key") + if "oa-public-trust-ed25519-9cf4bca214c01d79" not in outer_ids: + raise LifecycleError("until-revoked signer registry is missing the outer key") + bundle_reference = _reference_from_entry( + bundle_entry, + registry_source_commit=reference["registry_source_commit"], + registry_revision=reference["registry_revision"], + registry_head_sha256=reference["registry_head_sha256"], + ) + try: + public_trust_resolver.verify_registered_public_trust_pair( + object_raw=object_raw, + object_reference=reference, + bundle_raw=bundle_raw, + bundle_reference=bundle_reference, + signer_registry_raw=signer_raw, + expected_signer_registry_sha256=admission["signer_registry_sha256"], + expected_authority_state_sha256=_authority_state_identity(registry_entries), + expected_revocation_state_sha256=admission["revocation_state_sha256"], + now=now, + ) + except public_trust_resolver.PublicTrustResolutionError as exc: + raise LifecycleError( + f"workflow admission {index} public-trust verification failed: {exc}" + ) from exc + return admission + + +def _validate_workflow_admissions( + value: object, + *, + root: Path, + registry_document: Mapping[str, Any], + registry_entries: Sequence[Mapping[str, Any]], + now: datetime, +) -> list[str]: + """Return active workflow admission ids. Require at least one.""" + + document = _closed( + value, + {"$schema", "schema_version", "policy_sha256", "admissions"}, + "production workflow admissions", + ) + if document["schema_version"] != WORKFLOW_ADMISSIONS_SCHEMA: + raise LifecycleError("production workflow admissions schema is not supported") + if document["$schema"] != "schemas/production-workflow-admissions.schema.json": + raise LifecycleError("production workflow admissions document schema differs") + if document["policy_sha256"] != RETAINED_POLICY_SHA256: + raise LifecycleError("production workflow admissions policy digest differs") + rows = document["admissions"] + if not isinstance(rows, list): + raise LifecycleError("production workflow admissions must be a list") + active: list[str] = [] + seen: set[str] = set() + for index, item in enumerate(rows): + if not isinstance(item, dict): + raise LifecycleError(f"workflow admission {index} must be an object") + admission = _validate_v2_workflow_admission( + item, + index=index, + root=root, + registry_document=registry_document, + registry_entries=registry_entries, + now=now, + ) + admission_id = admission["admission_id_sha256"] + if admission_id in seen: + raise LifecycleError( + f"workflow admission id is duplicate: {admission_id!r}" + ) + seen.add(admission_id) + active.append(admission_id) + if not active: + raise LifecycleError( + "at least one active workflow admission is required " + "(synthetic tutorial bundle)" + ) + return active + + def _validate_remote_summary( admission: Mapping[str, Any], release: Mapping[str, Any], @@ -2106,6 +2317,22 @@ def validate( f"target {target_id} has static lifecycle membership {memberships}; " "derive its state only from active admissions" ) + if root is not None: + if registry_document is None: + raise LifecycleError("workflow admissions require the evidence registry") + workflow_path = root / WORKFLOW_ADMISSIONS_PATH.name + if not workflow_path.exists(): + raise LifecycleError("production workflow admissions ledger is missing") + workflow_value = _load_json( + workflow_path, "production workflow admissions" + ) + _validate_workflow_admissions( + workflow_value, + root=root, + registry_document=registry_document, + registry_entries=registry_entries, + now=now, + ) return active @@ -2146,6 +2373,23 @@ def validate_history_document(value: object, label: str) -> None: raise LifecycleError(f"{label} admissions must be a list") +def validate_workflow_history_document(value: object, label: str) -> None: + """Validate one workflow-admission ledger document without its objects.""" + + document = _closed( + value, + {"$schema", "schema_version", "policy_sha256", "admissions"}, + label, + ) + if document["schema_version"] != WORKFLOW_ADMISSIONS_SCHEMA: + raise LifecycleError(f"{label} schema is not supported") + if document["$schema"] != "schemas/production-workflow-admissions.schema.json": + raise LifecycleError(f"{label} document schema differs") + _digest(document["policy_sha256"], f"{label} policy digest") + if not isinstance(document["admissions"], list): + raise LifecycleError(f"{label} admissions must be a list") + + def validate_append_only_history(previous_value: object, current_value: object) -> None: """Reject release-ledger rollback while allowing one-way current revocation.""" @@ -2190,6 +2434,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT) parser.add_argument("--previous-admissions", type=Path) + parser.add_argument("--previous-workflow-admissions", type=Path) parser.add_argument( "--history-only", action="store_true", @@ -2213,7 +2458,30 @@ def main() -> int: validate_history_document(previous, "previous Production admission history") validate_history_document(current, "current Production admission history") validate_append_only_history(previous, current) + workflow_path = args.root / WORKFLOW_ADMISSIONS_PATH.name + if workflow_path.exists(): + if args.previous_workflow_admissions is None: + parser.error( + "a workflow-admission ledger requires " + "--previous-workflow-admissions" + ) + previous_workflow = _load_json( + args.previous_workflow_admissions, + "previous Production workflow admissions", + ) + current_workflow = _load_json( + workflow_path, "current Production workflow admissions" + ) + validate_workflow_history_document( + previous_workflow, "previous Production workflow admission history" + ) + validate_workflow_history_document( + current_workflow, "current Production workflow admission history" + ) + validate_append_only_history(previous_workflow, current_workflow) print("Validated retained v1 Production admission history.") + if workflow_path.exists(): + print("Validated Production workflow admission history.") return 0 active = validate_files(args.root) if args.previous_admissions is not None: @@ -2225,13 +2493,31 @@ def main() -> int: "current Production lifecycle admissions", ) validate_append_only_history(previous, current) + if args.previous_workflow_admissions is not None: + previous_workflow = _load_json( + args.previous_workflow_admissions, + "previous Production workflow admissions", + ) + current_workflow = _load_json( + args.root / WORKFLOW_ADMISSIONS_PATH.name, + "current Production workflow admissions", + ) + validate_append_only_history(previous_workflow, current_workflow) except LifecycleError as exc: print(f"REFUSED: {exc}") return 1 + workflow_count = 0 + workflow_path = args.root / WORKFLOW_ADMISSIONS_PATH.name + if workflow_path.exists(): + workflow_doc = _load_json(workflow_path, "production workflow admissions") + rows = workflow_doc.get("admissions") + if isinstance(rows, list): + workflow_count = len(rows) print( "Validated evidence-gated Production lifecycle: " f"{len(active)} active admission(s)." ) + print(f"Validated {workflow_count} active workflow admission(s).") return 0 diff --git a/tests/test_governance_workflows.py b/tests/test_governance_workflows.py index ce18cb1..778a6b8 100644 --- a/tests/test_governance_workflows.py +++ b/tests/test_governance_workflows.py @@ -103,6 +103,7 @@ def test_v1_release_history_gate_does_not_parse_the_v2_policy_as_v1(self) -> Non )[1].split(" - name:", 1)[0] self.assertIn("--history-only", rollback_step) self.assertIn("--previous-admissions", rollback_step) + self.assertIn("--previous-workflow-admissions", rollback_step) self.assertNotIn("previous-production-lifecycle-policy", rollback_step) def test_profile_dispatch_refuses_the_lifecycle_app_in_every_job(self) -> None: diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index e0a22e9..e308581 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -1094,6 +1094,12 @@ def test_policy_target_cannot_require_an_undefined_artifact_kind(self) -> None: 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) + workflow_schema = ( + ROOT / "schemas" / "production-workflow-admissions.schema.json" + ) + self.assertIsInstance( + json.loads(workflow_schema.read_text(encoding="utf-8")), dict + ) class PublishedPolicyTests(unittest.TestCase): @@ -1134,6 +1140,91 @@ def test_seven_synthetic_target_admissions_are_product_production( } self.assertFalse(lifecycle.is_product_production(six)) + def test_published_workflow_ledger_lists_synthetic_tutorial_admissions( + self, + ) -> None: + published_now = datetime(2026, 9, 2, 19, 30, 0, tzinfo=timezone.utc) + active = lifecycle.validate_files(ROOT, now=published_now) + self.assertEqual(len(active), 7) + ledger = json.loads( + (ROOT / "production-workflow-admissions.json").read_text(encoding="utf-8") + ) + self.assertEqual( + ledger["schema_version"], "openadapt.production-workflow-admissions/v1" + ) + self.assertEqual(ledger["policy_sha256"], lifecycle.RETAINED_POLICY_SHA256) + self.assertEqual(len(ledger["admissions"]), 7) + kinds = {row["kind"] for row in ledger["admissions"]} + self.assertEqual(kinds, {"qualification-admission"}) + tutorial = json.loads( + ( + ROOT + / "production-evidence/objects/sha256/dc/" + "dcdb32a762aca87fbb1a7c9df5d346403b167ec5850d35fe47fd64d942684a04" + ".qualification-admission.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(tutorial["evidence_class"], "remote-safe-synthetic") + self.assertEqual(tutorial["bundle_version"], "0.0.0-synthetic") + self.assertEqual(tutorial["verdict"], "accepted") + self.assertIsNone(tutorial["expires_at"]) + self.assertNotIn("evals_production_acceptance", tutorial) + self.assertNotIn("production_acceptance", tutorial) + self.assertEqual( + tutorial["campaign_summary"]["uncertain_delivery"][ + "reconciliation_required_count" + ], + 3, + ) + self.assertEqual( + ledger["admissions"][0]["object_sha256"], + "sha256:dcdb32a762aca87fbb1a7c9df5d346403b167ec5850d35fe47fd64d942684a04", + ) + encoded = json.dumps(ledger) + json.dumps(tutorial) + self.assertNotIn("mockmed", encoded.lower()) + + def test_release_ledger_keeps_the_flow_object_reference(self) -> None: + ledger = json.loads( + (ROOT / "production-lifecycle-admissions.json").read_text(encoding="utf-8") + ) + self.assertEqual(len(ledger["admissions"]), 7) + flow = ledger["admissions"][0] + self.assertEqual(flow["kind"], "qualification-release") + self.assertEqual( + flow["registry_source_commit"], + "6e28818a94e057cae99dcc2c76d02b9137f87076", + ) + self.assertEqual(flow["registry_revision"], 2) + self.assertEqual( + flow["object_sha256"], + "sha256:790122a25c87e456c6e45d25ebf5cd029b21b8511265b062fd9129b74aa1dd82", + ) + + def test_workflow_history_gate_allows_empty_to_append(self) -> None: + current = json.loads( + (ROOT / "production-workflow-admissions.json").read_text(encoding="utf-8") + ) + previous = { + "$schema": "schemas/production-workflow-admissions.schema.json", + "schema_version": "openadapt.production-workflow-admissions/v1", + "policy_sha256": lifecycle.RETAINED_POLICY_SHA256, + "admissions": [], + } + lifecycle.validate_workflow_history_document( + previous, "previous Production workflow admission history" + ) + lifecycle.validate_workflow_history_document( + current, "current Production workflow admission history" + ) + lifecycle.validate_append_only_history(previous, current) + + def test_workflow_history_gate_refuses_truncation(self) -> None: + current = json.loads( + (ROOT / "production-workflow-admissions.json").read_text(encoding="utf-8") + ) + with self.assertRaisesRegex(lifecycle.LifecycleError, "cannot remove"): + lifecycle.validate_append_only_history(current, {"admissions": []}) + def test_check_profile_accepts_the_published_repository(self) -> None: completed = subprocess.run( [sys.executable, str(ROOT / "scripts" / "check_profile.py")], diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 3f6c77e..61951f0 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -448,6 +448,12 @@ "lifecycle_feed_ref", "targets", }, + "production-workflow-admissions.schema.json": { + "$schema", + "schema_version", + "policy_sha256", + "admissions", + }, "production-release-artifact-inventory.schema.json": { "schema_version", "target",