diff --git a/README.md b/README.md index 6e24092..8a546e7 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ out the pixels. Coordinates are the thing that breaks when a window moves. | `ArtifactRefV1` | A path-free reference to an immutable process artifact | | `CodeCapabilityManifestV1` | Exact Python, locked dependencies, typed I/O, permissions, and verifier bindings | | `ProcessEvidenceReceiptV1` | One signed root over child receipts, human receipts, and the artifact graph | +| `ProductionAdmissionRegistryStateV1` | One signed current state for active and revoked Production admissions | +| `ProductionLifecycleAdmissionBindingV2` | The target, release, artifact, digest, authority, and validity fields from one verified admission | | `RewardEvidenceReceiptV1` | One verified terminal effect for a training episode. Not an Execute Seal | | `AuthenticationTaskContractV1` | A value-free login requirement bound to an existing attended task | | `AuthoringObserveV1` | PHI-safe authoring observe tree for the hosted MCP wire | @@ -104,7 +106,7 @@ print(json.dumps(ComputerState.model_json_schema(), indent=2)) ``` The same schemas ship as JSON under `openadapt_types/schemas/` for TypeScript, -Rust, and anything else that isn't Python. Thirty-four files, including +Rust, and anything else that isn't Python. Thirty-eight files, including `execute-v1-openapi.json`, the public OpenAdapt Execute contract. ## Converting from the older formats diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index ceea349..9575a04 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -40,6 +40,23 @@ free-form notes, and live record values stay on the customer runner. An accepted answer only selects a compiled branch. The next action must still pass its live-state, identity, policy, and effect contracts. +## Production admission registry + +`ProductionAdmissionRegistryStateV1` is the one current signed registry state. +Each admission reference is either `active` or `revoked`. The registry does not +add a second permit, lease, authority file, or revocation history. + +A consumer first verifies the registry signature and checks its saved minimum +registry revision. It then hashes and parses the exact referenced admission +bytes. The consumer checks the target, claim, repository, release kind, +artifact set, artifact digests, and artifact authorities against the exact +policy target. It saves the newest verified registry revision. This check +rejects an older active registry after a later signed revocation. + +`expires_at: null` means that the admission stays active until the signed +registry revokes it. When an expiry is present, it must follow `not_before`, +and the consumer enforces it at read time. The policy does not cap this expiry. + ## OpenAdapt Execute v1 OpenAdapt Execute is the public asynchronous contract for a qualified @@ -162,6 +179,12 @@ Compile returns `needs_human_admit`, never `VERIFIED`. Bind tokens are `oab_` plus 43 unreserved characters. Lease secrets are `oals_` plus 64 hex characters. Cloud `oar_` and pairing `oap_` are refused. +Command ids are `cmd_` plus one canonical Crockford ULID. Command times use +RFC 3339 with seconds and an offset. A command can live for at most 900 +seconds. `parse_authoring_command` checks the full closed envelope and refuses +it at or after `expires_at`. `client_display` stays in the closed bind result +and bind status. It is not a command-envelope field. + ## Clinic job inbox and MCP tools `ClinicInboxV1`, `ClinicOutboxV1`, and `ClinicToolResultV1` are the public diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 4c2fc45..b9459f0 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -82,8 +82,10 @@ BIND_TOKEN_PATTERN, LEASE_SECRET_PATTERN, parse_authoring_bind_token, + parse_authoring_command, parse_authoring_lease_secret, parse_authoring_runner_uri, + require_authoring_command_active, ) from openadapt_types.clinic_job import ( ACTION_TO_TOOL, @@ -324,6 +326,27 @@ CodeRuntimeKind, ProcessEvidenceReceiptV1, ) +from openadapt_types.production_lifecycle import ( + PRODUCTION_ADMISSION_REGISTRY_STATE_SCHEMA, + PRODUCTION_EVIDENCE_OBJECT_REFERENCE_SCHEMA, + PRODUCTION_LIFECYCLE_ADMISSION_BINDING_SCHEMA, + PRODUCTION_LIFECYCLE_TARGET_SCHEMA, + ProductionAdmissionRegistryEntryV1, + ProductionAdmissionRegistryStateV1, + ProductionAdmissionStateV1, + ProductionArtifactAuthoritiesV2, + ProductionArtifactAuthorityV2, + ProductionArtifactKindV2, + ProductionLifecycleAdmissionBindingV2, + ProductionLifecycleTargetV2, + ProductionReleaseArtifactBindingV2, + ProductionReleaseKindV2, + ProductionTargetIdV2, + QualificationReleaseReferenceV2, + project_production_lifecycle_target_v3, + production_registry_signing_payload, + validate_production_admission, +) from openadapt_types.reward import ( DEFAULT_REWARD_SCORING, REWARD_CERTIFICATE_SCHEMA, @@ -433,8 +456,10 @@ "BIND_TOKEN_PATTERN", "LEASE_SECRET_PATTERN", "parse_authoring_bind_token", + "parse_authoring_command", "parse_authoring_lease_secret", "parse_authoring_runner_uri", + "require_authoring_command_active", # clinic job inbox / outbox / MCP "ACTION_TO_TOOL", "CLINIC_INBOX_SCHEMA", @@ -639,6 +664,26 @@ "CodePermissionContractV1", "CodeRuntimeKind", "ProcessEvidenceReceiptV1", + # Production admission registry + "PRODUCTION_ADMISSION_REGISTRY_STATE_SCHEMA", + "PRODUCTION_EVIDENCE_OBJECT_REFERENCE_SCHEMA", + "PRODUCTION_LIFECYCLE_ADMISSION_BINDING_SCHEMA", + "PRODUCTION_LIFECYCLE_TARGET_SCHEMA", + "ProductionAdmissionRegistryEntryV1", + "ProductionAdmissionRegistryStateV1", + "ProductionAdmissionStateV1", + "ProductionArtifactAuthoritiesV2", + "ProductionArtifactAuthorityV2", + "ProductionArtifactKindV2", + "ProductionLifecycleAdmissionBindingV2", + "ProductionLifecycleTargetV2", + "ProductionReleaseArtifactBindingV2", + "ProductionReleaseKindV2", + "ProductionTargetIdV2", + "QualificationReleaseReferenceV2", + "project_production_lifecycle_target_v3", + "production_registry_signing_payload", + "validate_production_admission", # reward contracts (not an Execute Seal) "DEFAULT_REWARD_SCORING", "REWARD_CERTIFICATE_SCHEMA", diff --git a/openadapt_types/authoring.py b/openadapt_types/authoring.py index 5abacf9..8ef5a67 100644 --- a/openadapt_types/authoring.py +++ b/openadapt_types/authoring.py @@ -14,6 +14,7 @@ import json import re +from datetime import datetime, timedelta, timezone from enum import Enum from math import isfinite from typing import Any, Literal @@ -72,7 +73,7 @@ _LEASE_BASE64URL_BODY_RE = re.compile(_LEASE_BASE64URL_BODY_PATTERN) _NODE_ID_PATTERN = r"^n_[0-9a-f]{8}$" -_COMMAND_ID_PATTERN = r"^cmd_[0-9A-HJKMNP-TV-Z]{26}$" +_COMMAND_ID_PATTERN = r"^cmd_[0-7][0-9A-HJKMNP-TV-Z]{25}$" _WORKFLOW_ID_PATTERN = r"^wf_[A-Za-z0-9_-]{8,64}$" _PACK_ID_PATTERN = r"^(p\.[A-Za-z0-9_-]{12}|v1\.[A-Za-z0-9_-]{38,512})$" _PROCESS_NAME_PATTERN = r"^[A-Za-z0-9 ._-]{1,64}$" @@ -304,6 +305,30 @@ def _finite_unit(value: object) -> float: return number +def _parse_rfc3339(value: str, field_name: str) -> datetime: + """Parse the closed authoring timestamp profile as an absolute instant.""" + + if re.fullmatch(_TIMESTAMP_PATTERN, value) is None: + raise ValueError(f"{field_name} must be an RFC 3339 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field_name} must be an RFC 3339 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError(f"{field_name} must include an offset") + return parsed + + +def _coerce_check_time(value: str | datetime | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, str): + return _parse_rfc3339(value, "at") + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: + raise ValueError("at must be an offset-aware datetime or RFC 3339 timestamp") + return value + + class AuthoringNormalizedBoundsV1(_StrictContract): """Viewport-normalized overlay coordinates. Not backend pixels.""" @@ -633,8 +658,12 @@ def _status_and_result(self) -> "AuthoringCommandV1": and self.args.pack_id != self.pack_id ): raise ValueError("bind_pack args pack_id must match the envelope") - if self.expires_at <= self.enqueued_at: + enqueued_at = _parse_rfc3339(self.enqueued_at, "enqueued_at") + expires_at = _parse_rfc3339(self.expires_at, "expires_at") + if expires_at <= enqueued_at: raise ValueError("expires_at must be after enqueued_at") + if expires_at - enqueued_at > timedelta(seconds=AUTHORING_LEASE_S): + raise ValueError("expires_at must be no more than 900 seconds after enqueued_at") if self.status is AuthoringCommandStatusV1.ERROR: if not isinstance(self.result, AuthoringErrorResultV1): raise ValueError("error status requires an error result") @@ -657,6 +686,31 @@ def _status_and_result(self) -> "AuthoringCommandV1": return self +def require_authoring_command_active( + command: AuthoringCommandV1, + *, + at: str | datetime | None = None, +) -> AuthoringCommandV1: + """Refuse a mailbox command at or after its expiry instant.""" + + check_time = _coerce_check_time(at) + expires_at = _parse_rfc3339(command.expires_at, "expires_at") + if check_time >= expires_at: + raise ValueError("authoring command has expired") + return command + + +def parse_authoring_command( + value: object, + *, + at: str | datetime | None = None, +) -> AuthoringCommandV1: + """Strictly parse one command and enforce its current-time expiry.""" + + command = AuthoringCommandV1.model_validate(value) + return require_authoring_command_active(command, at=at) + + class AuthoringCommandLookupV1(_StrictContract): """Non-blocking ``get_command_result`` body. No tree unless observe is done.""" diff --git a/openadapt_types/production_lifecycle.py b/openadapt_types/production_lifecycle.py new file mode 100644 index 0000000..9b022d7 --- /dev/null +++ b/openadapt_types/production_lifecycle.py @@ -0,0 +1,723 @@ +"""Simple, signed Production admission registry contracts. + +The registry is the one current authority for admission state. Its entries are +active or revoked. A consumer verifies the registry signature, dereferences the +immutable admission object, and checks every bound product field before use. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from enum import Enum +from pathlib import PurePosixPath +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, model_validator + +_DIGEST_PATTERN = r"^sha256:[0-9a-f]{64}$" +_COMMIT_PATTERN = r"^[0-9a-f]{40}$" +_REPOSITORY_PATTERN = r"^OpenAdaptAI/[A-Za-z0-9._-]+$" +_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$" +_ARTIFACT_NAME_PATTERN = r"^[^/\\]{1,255}$" +_SIGNATURE_PATTERN = r"^[A-Za-z0-9_-]{86}$" +_DECIMAL_ID_PATTERN = r"^[1-9][0-9]*$" + +_ARTIFACT_INVENTORY_DOMAIN = b"OpenAdapt production release artifact inventory v1\0" +_RELEASE_DOMAIN = b"OpenAdapt production release candidate v1\0" +_RELEASE_ADMISSION_DOMAIN = b"OpenAdapt qualification release admission v2\0" + +PRODUCTION_EVIDENCE_OBJECT_REFERENCE_SCHEMA: Literal[ + "openadapt.production-evidence-object-reference/v2" +] = "openadapt.production-evidence-object-reference/v2" +PRODUCTION_LIFECYCLE_TARGET_SCHEMA: Literal[ + "openadapt.production-lifecycle-target/v2" +] = "openadapt.production-lifecycle-target/v2" +PRODUCTION_LIFECYCLE_ADMISSION_BINDING_SCHEMA: Literal[ + "openadapt.production-lifecycle-admission-binding/v2" +] = "openadapt.production-lifecycle-admission-binding/v2" +PRODUCTION_ADMISSION_REGISTRY_STATE_SCHEMA: Literal[ + "openadapt.production-admission-registry-state/v1" +] = "openadapt.production-admission-registry-state/v1" + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ProductionTargetIdV2(str, Enum): + AGENT = "agent" + CAPTURE = "capture" + CLOUD = "cloud" + DESKTOP = "desktop" + DOCS = "docs" + FLOW = "flow" + OPENADAPT = "openadapt" + + +class ProductionReleaseKindV2(str, Enum): + PACKAGE = "package" + DEPLOYMENT = "deployment" + HYBRID = "hybrid" + + +class ProductionArtifactKindV2(str, Enum): + PYTHON_SDIST = "python-sdist" + PYTHON_WHEEL = "python-wheel" + DEPLOYMENT_MANIFEST = "deployment-manifest" + + +class ProductionArtifactAuthorityV2(str, Enum): + PYPI = "pypi" + GITHUB_RELEASE = "github_release" + MANAGED_EVIDENCE = "managed_evidence" + + +class ProductionAdmissionStateV1(str, Enum): + ACTIVE = "active" + REVOKED = "revoked" + + +_PACKAGE_ARTIFACTS = ( + ProductionArtifactKindV2.PYTHON_SDIST, + ProductionArtifactKindV2.PYTHON_WHEEL, +) +_DEPLOYMENT_ARTIFACTS = (ProductionArtifactKindV2.DEPLOYMENT_MANIFEST,) +_TARGET_CONTRACTS = { + ProductionTargetIdV2.AGENT: ( + "OpenAdaptAI/openadapt-agent", + "1136136670", + ProductionReleaseKindV2.PACKAGE, + "production_agent", + _PACKAGE_ARTIFACTS, + "openadapt-agent", + ), + ProductionTargetIdV2.CAPTURE: ( + "OpenAdaptAI/openadapt-capture", + "1115283835", + ProductionReleaseKindV2.PACKAGE, + "production_capture", + _PACKAGE_ARTIFACTS, + "openadapt-capture", + ), + ProductionTargetIdV2.CLOUD: ( + "OpenAdaptAI/openadapt-cloud", + "1300570990", + ProductionReleaseKindV2.DEPLOYMENT, + "production_cloud", + _DEPLOYMENT_ARTIFACTS, + None, + ), + ProductionTargetIdV2.DESKTOP: ( + "OpenAdaptAI/openadapt-desktop", + "1171291730", + ProductionReleaseKindV2.PACKAGE, + "production_desktop", + _PACKAGE_ARTIFACTS, + "openadapt-desktop", + ), + ProductionTargetIdV2.DOCS: ( + "OpenAdaptAI/openadapt-ops", + "1172011294", + ProductionReleaseKindV2.DEPLOYMENT, + "production_docs", + _DEPLOYMENT_ARTIFACTS, + None, + ), + ProductionTargetIdV2.FLOW: ( + "OpenAdaptAI/openadapt-flow", + "1291376938", + ProductionReleaseKindV2.PACKAGE, + "production_flow", + _PACKAGE_ARTIFACTS, + "openadapt-flow", + ), + ProductionTargetIdV2.OPENADAPT: ( + "OpenAdaptAI/OpenAdapt", + "627024850", + ProductionReleaseKindV2.PACKAGE, + "production_openadapt", + _PACKAGE_ARTIFACTS, + "openadapt", + ), +} +_EXPECTED_ARTIFACT_AUTHORITIES = { + ProductionArtifactKindV2.PYTHON_SDIST: ProductionArtifactAuthorityV2.PYPI, + ProductionArtifactKindV2.PYTHON_WHEEL: ProductionArtifactAuthorityV2.PYPI, + ProductionArtifactKindV2.DEPLOYMENT_MANIFEST: ( + ProductionArtifactAuthorityV2.MANAGED_EVIDENCE + ), +} + + +def _parse_utc(value: str, field_name: str) -> datetime: + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError as exc: + raise ValueError( + f"{field_name} must be a canonical UTC RFC 3339 timestamp" + ) from exc + + +class ProductionArtifactAuthoritiesV2(_StrictContract): + """The closed authority map for the supported artifact kinds.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + python_sdist: ProductionArtifactAuthorityV2 | None = Field( + default=None, alias="python-sdist" + ) + python_wheel: ProductionArtifactAuthorityV2 | None = Field( + default=None, alias="python-wheel" + ) + deployment_manifest: ProductionArtifactAuthorityV2 | None = Field( + default=None, alias="deployment-manifest" + ) + + @property + def declared_kinds(self) -> frozenset[ProductionArtifactKindV2]: + return frozenset( + ProductionArtifactKindV2(kind) + for kind in self.model_dump(by_alias=True, exclude_none=True) + ) + + @model_validator(mode="after") + def _authorities_match_artifacts(self) -> "ProductionArtifactAuthoritiesV2": + for name, authority in self.model_dump( + by_alias=True, exclude_none=True + ).items(): + if ( + authority + is not _EXPECTED_ARTIFACT_AUTHORITIES[ProductionArtifactKindV2(name)] + ): + raise ValueError(f"{name} has the wrong artifact authority") + return self + + +class QualificationReleaseReferenceV2(_StrictContract): + """An immutable registry index entry with no target assertion.""" + + schema_version: Literal["openadapt.production-evidence-object-reference/v2"] = ( + PRODUCTION_EVIDENCE_OBJECT_REFERENCE_SCHEMA + ) + repository: Literal["OpenAdaptAI/.github"] = "OpenAdaptAI/.github" + repository_id: Literal["858454062"] = "858454062" + repository_owner_id: Literal["132681217"] = "132681217" + registry_source_commit: StrictStr = Field(pattern=_COMMIT_PATTERN) + registry_revision: StrictInt = Field(ge=1) + registry_head_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + registry_entry_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + kind: Literal["qualification-release"] = "qualification-release" + object_schema_version: Literal["openadapt.qualification-release/v2"] = ( + "openadapt.qualification-release/v2" + ) + object_path: StrictStr = Field( + pattern=( + r"^production-evidence/objects/sha256/[0-9a-f]{2}/" + r"[0-9a-f]{64}\.qualification-release\.json$" + ) + ) + object_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + size_bytes: StrictInt = Field(ge=1) + object_media_type: Literal[ + "application/vnd.openadapt.qualification-release+json;version=2" + ] = "application/vnd.openadapt.qualification-release+json;version=2" + semantic_identity_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + subject_sha256: None = None + + @model_validator(mode="after") + def _path_binds_digest(self) -> "QualificationReleaseReferenceV2": + digest = self.object_sha256.removeprefix("sha256:") + path = PurePosixPath(self.object_path) + if ( + path.parent.name != digest[:2] + or path.name != f"{digest}.qualification-release.json" + ): + raise ValueError("object_path must bind object_sha256") + return self + + +class ProductionLifecycleTargetV2(_StrictContract): + """One exact policy-native Production target.""" + + schema_version: Literal["openadapt.production-lifecycle-target/v2"] = ( + PRODUCTION_LIFECYCLE_TARGET_SCHEMA + ) + id: ProductionTargetIdV2 + source_repository: StrictStr = Field(pattern=_REPOSITORY_PATTERN) + source_repository_id: StrictStr = Field(pattern=_DECIMAL_ID_PATTERN) + release_kind: ProductionReleaseKindV2 + claim_scope: StrictStr = Field(pattern=r"^production_[a-z]+$") + required_artifact_kinds: tuple[ProductionArtifactKindV2, ...] = Field(min_length=1) + package_index_project: StrictStr | None + artifact_authority_by_kind: ProductionArtifactAuthoritiesV2 + + @model_validator(mode="after") + def _matches_canonical_target(self) -> "ProductionLifecycleTargetV2": + ( + repository, + repository_id, + release_kind, + claim_scope, + artifact_kinds, + package_index_project, + ) = _TARGET_CONTRACTS[self.id] + if self.source_repository != repository: + raise ValueError("source_repository does not match the target") + if self.source_repository_id != repository_id: + raise ValueError("source_repository_id does not match the target") + if self.release_kind is not release_kind: + raise ValueError("release_kind does not match the target") + if self.claim_scope != claim_scope: + raise ValueError("claim_scope does not match the target") + if self.required_artifact_kinds != artifact_kinds: + raise ValueError("required_artifact_kinds do not match the target") + if self.package_index_project != package_index_project: + raise ValueError("package_index_project does not match the target") + if self.artifact_authority_by_kind.declared_kinds != frozenset(artifact_kinds): + raise ValueError("every required artifact kind must have one authority") + return self + + +def project_production_lifecycle_target_v3( + policy: Mapping[str, Any], target_id: ProductionTargetIdV2 | str +) -> ProductionLifecycleTargetV2: + """Project one exact target from the canonical lifecycle policy v3.""" + + if policy.get("schema_version") != "openadapt.production-lifecycle-policy/v3": + raise ValueError("Production lifecycle policy v3 is required") + if policy.get("admission_validity") != "until_revoked": + raise ValueError("the lifecycle policy must use until-revoked admissions") + if ( + "maximum_release_admission_days" not in policy + or policy.get("maximum_release_admission_days") is not None + ): + raise ValueError("the lifecycle policy must not cap release admission expiry") + try: + expected_id = ProductionTargetIdV2(target_id) + except ValueError as exc: + raise ValueError("the Production target is not supported") from exc + targets = policy.get("targets") + if not isinstance(targets, list): + raise ValueError("the lifecycle policy targets must be an array") + matching = [ + item + for item in targets + if isinstance(item, Mapping) and item.get("id") == expected_id.value + ] + if len(matching) != 1: + raise ValueError("the lifecycle policy must contain one exact target") + target = matching[0] + raw_kinds = target.get("required_artifact_kinds") + if not isinstance(raw_kinds, list): + raise ValueError("required_artifact_kinds must be an array") + try: + kinds = tuple(ProductionArtifactKindV2(kind) for kind in raw_kinds) + except (TypeError, ValueError) as exc: + raise ValueError( + "the lifecycle policy has an unsupported artifact kind" + ) from exc + authorities = { + kind.value: _EXPECTED_ARTIFACT_AUTHORITIES[kind].value for kind in kinds + } + return ProductionLifecycleTargetV2.model_validate( + { + "id": target.get("id"), + "source_repository": target.get("source_repository"), + "source_repository_id": target.get("source_repository_id"), + "release_kind": target.get("release_kind"), + "claim_scope": target.get("claim_scope"), + "required_artifact_kinds": raw_kinds, + "package_index_project": target.get("package_index_project"), + "artifact_authority_by_kind": authorities, + } + ) + + +class ProductionReleaseArtifactBindingV2(_StrictContract): + """One exact artifact and its verification authority.""" + + name: StrictStr = Field(pattern=_ARTIFACT_NAME_PATTERN) + kind: ProductionArtifactKindV2 + sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + size_bytes: StrictInt = Field(ge=1) + authority: ProductionArtifactAuthorityV2 + + @model_validator(mode="after") + def _authority_matches_kind(self) -> "ProductionReleaseArtifactBindingV2": + if self.authority is not _EXPECTED_ARTIFACT_AUTHORITIES[self.kind]: + raise ValueError("artifact authority does not match its kind") + return self + + +class _QualificationReleaseArtifactV2(_StrictContract): + """The canonical artifact fields needed for release digest verification.""" + + name: StrictStr = Field(pattern=_ARTIFACT_NAME_PATTERN) + kind: ProductionArtifactKindV2 + sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + size_bytes: StrictInt = Field(ge=1) + media_type: StrictStr = Field(pattern=r"^[^/]+/[^/]+$", max_length=200) + publish_destinations: tuple[ + Literal["deployment", "github-release", "pypi"], ... + ] = Field(min_length=1, max_length=3) + + @model_validator(mode="after") + def _destinations_are_unique(self) -> "_QualificationReleaseArtifactV2": + if len(frozenset(self.publish_destinations)) != len(self.publish_destinations): + raise ValueError("artifact publish destinations must be unique") + return self + + +class _QualificationReleaseCandidateV1(_StrictContract): + """The canonical release candidate embedded in qualification release v2.""" + + schema_version: Literal["openadapt.production-release-candidate/v1"] + kind: ProductionReleaseKindV2 + source_repository: StrictStr = Field(pattern=_REPOSITORY_PATTERN) + source_repository_id: StrictStr = Field(pattern=_DECIMAL_ID_PATTERN) + source_commit: StrictStr = Field(pattern=_COMMIT_PATTERN) + version: StrictStr | None + tag: StrictStr | None + deployment_id: StrictStr | None = Field(pattern=_DECIMAL_ID_PATTERN) + deployment_sha256: StrictStr | None = Field(pattern=_DIGEST_PATTERN) + artifacts: tuple[_QualificationReleaseArtifactV2, ...] = Field(min_length=1) + + @model_validator(mode="after") + def _release_identity_matches_kind(self) -> "_QualificationReleaseCandidateV1": + if self.kind is ProductionReleaseKindV2.PACKAGE: + if self.version is None or self.tag is None: + raise ValueError("a package release requires a version and tag") + if self.deployment_id is not None or self.deployment_sha256 is not None: + raise ValueError("a package release cannot contain deployment identity") + elif self.kind is ProductionReleaseKindV2.DEPLOYMENT: + if self.version is not None or self.tag is not None: + raise ValueError("a deployment release cannot contain package identity") + if self.deployment_id is None or self.deployment_sha256 is None: + raise ValueError("a deployment release requires deployment identity") + elif any( + value is None + for value in ( + self.version, + self.tag, + self.deployment_id, + self.deployment_sha256, + ) + ): + raise ValueError( + "a hybrid release requires package and deployment identity" + ) + return self + + +class ProductionLifecycleAdmissionBindingV2(_StrictContract): + """Fields read only after an admission object passes its digest check.""" + + schema_version: Literal["openadapt.production-lifecycle-admission-binding/v2"] = ( + PRODUCTION_LIFECYCLE_ADMISSION_BINDING_SCHEMA + ) + target: ProductionTargetIdV2 + source_repository: StrictStr = Field(pattern=_REPOSITORY_PATTERN) + release_kind: ProductionReleaseKindV2 + claim_scope: StrictStr = Field(pattern=r"^production_[a-z]+$") + release_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + artifact_inventory_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + artifacts: tuple[ProductionReleaseArtifactBindingV2, ...] = Field(min_length=1) + issued_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + not_before: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + expires_at: StrictStr | None = Field(default=None, pattern=_TIMESTAMP_PATTERN) + verdict: Literal["accepted"] = "accepted" + + @model_validator(mode="after") + def _valid_fields(self) -> "ProductionLifecycleAdmissionBindingV2": + issued_at = _parse_utc(self.issued_at, "issued_at") + not_before = _parse_utc(self.not_before, "not_before") + if not_before < issued_at: + raise ValueError("not_before must not precede issued_at") + if ( + self.expires_at is not None + and _parse_utc(self.expires_at, "expires_at") <= not_before + ): + raise ValueError("expires_at must be after not_before") + kinds = tuple(artifact.kind for artifact in self.artifacts) + names = tuple(artifact.name for artifact in self.artifacts) + if len(frozenset(kinds)) != len(kinds): + raise ValueError("artifact kinds must be unique") + if len(frozenset(names)) != len(names): + raise ValueError("artifact names must be unique") + return self + + +class ProductionAdmissionRegistryEntryV1(_StrictContract): + """The current state of one immutable admission reference.""" + + reference: QualificationReleaseReferenceV2 + state: ProductionAdmissionStateV1 + state_changed_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + + +class _ProductionAdmissionRegistryPayloadV1(_StrictContract): + """The normalized registry fields covered by the one signature.""" + + schema_version: Literal["openadapt.production-admission-registry-state/v1"] = ( + PRODUCTION_ADMISSION_REGISTRY_STATE_SCHEMA + ) + repository: Literal["OpenAdaptAI/.github"] = "OpenAdaptAI/.github" + source_commit: StrictStr = Field(pattern=_COMMIT_PATTERN) + revision: StrictInt = Field(ge=1) + issued_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + entries: tuple[ProductionAdmissionRegistryEntryV1, ...] = Field(min_length=1) + signer_key_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + signature_algorithm: Literal["ed25519"] = "ed25519" + + @model_validator(mode="after") + def _closed_current_state(self) -> "_ProductionAdmissionRegistryPayloadV1": + issued_at = _parse_utc(self.issued_at, "issued_at") + object_digests: set[str] = set() + for entry in self.entries: + object_digest = entry.reference.object_sha256 + if object_digest in object_digests: + raise ValueError( + "an admission can have only one current registry state" + ) + object_digests.add(object_digest) + if _parse_utc(entry.state_changed_at, "state_changed_at") > issued_at: + raise ValueError("state_changed_at must not follow issued_at") + return self + + +def production_registry_signing_payload( + value: Mapping[str, Any] | "ProductionAdmissionRegistryStateV1", +) -> bytes: + """Return normalized bytes, including defaults, covered by the signature.""" + + if isinstance(value, BaseModel): + payload = value.model_dump(mode="json", by_alias=True) + else: + payload = dict(value) + payload.pop("signed_payload_sha256", None) + payload.pop("signature_b64url", None) + normalized = _ProductionAdmissionRegistryPayloadV1.model_validate(payload) + return json.dumps( + normalized.model_dump(mode="json", by_alias=True), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + + +class ProductionAdmissionRegistryStateV1(_ProductionAdmissionRegistryPayloadV1): + """The single signed active/revoked registry state.""" + + signed_payload_sha256: StrictStr = Field(pattern=_DIGEST_PATTERN) + signature_b64url: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _signed_payload_matches(self) -> "ProductionAdmissionRegistryStateV1": + expected = ( + "sha256:" + + hashlib.sha256(production_registry_signing_payload(self)).hexdigest() + ) + if self.signed_payload_sha256 != expected: + raise ValueError("signed_payload_sha256 does not bind the registry state") + return self + + +RegistrySignatureVerifier = Callable[[ProductionAdmissionRegistryStateV1], bool] + + +def _canonical_json(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _domain_digest(domain: bytes, value: Any) -> str: + return "sha256:" + hashlib.sha256(domain + _canonical_json(value)).hexdigest() + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError(f"qualification release contains duplicate key {key!r}") + value[key] = item + return value + + +def _validate_admission_target( + admission: ProductionLifecycleAdmissionBindingV2, + target: ProductionLifecycleTargetV2, +) -> None: + if admission.target is not target.id: + raise ValueError("admission target does not match the policy target") + if admission.source_repository != target.source_repository: + raise ValueError("admission repository does not match the policy target") + if admission.release_kind is not target.release_kind: + raise ValueError("admission release kind does not match the policy target") + if admission.claim_scope != target.claim_scope: + raise ValueError("admission claim scope does not match the policy target") + admitted_kinds = tuple(artifact.kind for artifact in admission.artifacts) + if admitted_kinds != target.required_artifact_kinds: + raise ValueError("admission artifacts do not match the policy target") + authorities = target.artifact_authority_by_kind.model_dump( + by_alias=True, exclude_none=True + ) + for artifact in admission.artifacts: + if authorities[artifact.kind.value] != artifact.authority: + raise ValueError("admission artifact authority does not match the policy") + + +def _parse_qualification_release_v2( + *, + reference: QualificationReleaseReferenceV2, + object_bytes: bytes, + target: ProductionLifecycleTargetV2, +) -> ProductionLifecycleAdmissionBindingV2: + """Verify and project one canonical qualification-release/v2 object.""" + + if not isinstance(object_bytes, bytes): + raise ValueError("qualification release object_bytes must be bytes") + object_digest = "sha256:" + hashlib.sha256(object_bytes).hexdigest() + if object_digest != reference.object_sha256: + raise ValueError("qualification release object digest does not match reference") + if len(object_bytes) != reference.size_bytes: + raise ValueError("qualification release object size does not match reference") + try: + decoded = object_bytes.decode("utf-8") + value = json.loads(decoded, object_pairs_hook=_reject_duplicate_json_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("qualification release object is not canonical JSON") from exc + if not isinstance(value, dict): + raise ValueError("qualification release object must be a JSON object") + if value.get("schema_version") != "openadapt.qualification-release/v2": + raise ValueError("qualification release object schema is not supported") + + admission_id = value.get("admission_id_sha256") + unsigned_admission = dict(value) + unsigned_admission.pop("admission_id_sha256", None) + if admission_id != _domain_digest(_RELEASE_ADMISSION_DOMAIN, unsigned_admission): + raise ValueError("qualification release admission digest is invalid") + + release = _QualificationReleaseCandidateV1.model_validate(value.get("release")) + release_value = release.model_dump(mode="json") + target_value = value.get("target") + claim_scope = value.get("claim_scope") + if value.get("release_sha256") != _domain_digest( + _RELEASE_DOMAIN, + { + "target": target_value, + "claim_scope": claim_scope, + "release": release_value, + }, + ): + raise ValueError("qualification release candidate digest is invalid") + artifact_values = [ + artifact.model_dump(mode="json") for artifact in release.artifacts + ] + if value.get("artifact_inventory_sha256") != _domain_digest( + _ARTIFACT_INVENTORY_DOMAIN, + { + "target": target_value, + "claim_scope": claim_scope, + "artifacts": artifact_values, + }, + ): + raise ValueError("qualification release artifact inventory digest is invalid") + + authorities = target.artifact_authority_by_kind.model_dump( + by_alias=True, exclude_none=True + ) + artifact_kinds = tuple(artifact.kind for artifact in release.artifacts) + if artifact_kinds != target.required_artifact_kinds: + raise ValueError( + "qualification release artifacts do not match the policy target" + ) + admission = ProductionLifecycleAdmissionBindingV2.model_validate( + { + "target": target_value, + "source_repository": release.source_repository, + "release_kind": release.kind, + "claim_scope": claim_scope, + "release_sha256": value.get("release_sha256"), + "artifact_inventory_sha256": value.get("artifact_inventory_sha256"), + "artifacts": [ + { + "name": artifact.name, + "kind": artifact.kind, + "sha256": artifact.sha256, + "size_bytes": artifact.size_bytes, + "authority": authorities[artifact.kind.value], + } + for artifact in release.artifacts + ], + "issued_at": value.get("issued_at"), + "not_before": value.get("not_before"), + "expires_at": value.get("expires_at"), + "verdict": value.get("verdict"), + } + ) + if release.source_repository_id != target.source_repository_id: + raise ValueError("admission repository id does not match the policy target") + _validate_admission_target(admission, target) + return admission + + +def validate_production_admission( + *, + registry: ProductionAdmissionRegistryStateV1, + verify_registry_signature: RegistrySignatureVerifier, + minimum_registry_revision: int, + reference: QualificationReleaseReferenceV2, + qualification_release_bytes: bytes, + target: ProductionLifecycleTargetV2, + at: str | datetime | None = None, +) -> ProductionLifecycleAdmissionBindingV2: + """Return one active admission after all registry and object checks pass.""" + + if not verify_registry_signature(registry): + raise ValueError("registry signature verification failed") + if ( + isinstance(minimum_registry_revision, bool) + or not isinstance(minimum_registry_revision, int) + or minimum_registry_revision < 1 + ): + raise ValueError("minimum_registry_revision must be a positive integer") + if registry.revision < minimum_registry_revision: + raise ValueError("registry revision is older than the trusted minimum") + matching = [ + entry + for entry in registry.entries + if entry.reference.object_sha256 == reference.object_sha256 + ] + if len(matching) != 1 or matching[0].reference != reference: + raise ValueError("admission reference is not in the signed registry") + if matching[0].state is ProductionAdmissionStateV1.REVOKED: + raise ValueError("admission is revoked") + admission = _parse_qualification_release_v2( + reference=reference, + object_bytes=qualification_release_bytes, + target=target, + ) + + check_time = ( + datetime.now(timezone.utc) + if at is None + else (_parse_utc(at, "at") if isinstance(at, str) else at) + ) + if not isinstance(check_time, datetime) or check_time.tzinfo is None: + raise ValueError( + "at must be an offset-aware datetime or canonical UTC timestamp" + ) + not_before = _parse_utc(admission.not_before, "not_before") + if check_time < not_before: + raise ValueError("admission is not active yet") + if admission.expires_at is not None: + expires_at = _parse_utc(admission.expires_at, "expires_at") + if check_time >= expires_at: + raise ValueError("admission has expired") + return admission diff --git a/openadapt_types/schemas/authoring-command-v1.json b/openadapt_types/schemas/authoring-command-v1.json index 8432854..76fd88a 100644 --- a/openadapt_types/schemas/authoring-command-v1.json +++ b/openadapt_types/schemas/authoring-command-v1.json @@ -179,7 +179,7 @@ "command_id": { "anyOf": [ { - "pattern": "^cmd_[0-9A-HJKMNP-TV-Z]{26}$", + "pattern": "^cmd_[0-7][0-9A-HJKMNP-TV-Z]{25}$", "type": "string" }, { @@ -640,7 +640,7 @@ "type": "string" }, "command_id": { - "pattern": "^cmd_[0-9A-HJKMNP-TV-Z]{26}$", + "pattern": "^cmd_[0-7][0-9A-HJKMNP-TV-Z]{25}$", "title": "Command Id", "type": "string" }, diff --git a/openadapt_types/schemas/production-admission-registry-state-v1.json b/openadapt_types/schemas/production-admission-registry-state-v1.json new file mode 100644 index 0000000..97b39e7 --- /dev/null +++ b/openadapt_types/schemas/production-admission-registry-state-v1.json @@ -0,0 +1,212 @@ +{ + "$defs": { + "ProductionAdmissionRegistryEntryV1": { + "additionalProperties": false, + "description": "The current state of one immutable admission reference.", + "properties": { + "reference": { + "$ref": "#/$defs/QualificationReleaseReferenceV2" + }, + "state": { + "$ref": "#/$defs/ProductionAdmissionStateV1" + }, + "state_changed_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "title": "State Changed At", + "type": "string" + } + }, + "required": [ + "reference", + "state", + "state_changed_at" + ], + "title": "ProductionAdmissionRegistryEntryV1", + "type": "object" + }, + "ProductionAdmissionStateV1": { + "enum": [ + "active", + "revoked" + ], + "title": "ProductionAdmissionStateV1", + "type": "string" + }, + "QualificationReleaseReferenceV2": { + "additionalProperties": false, + "description": "An immutable registry index entry with no target assertion.", + "properties": { + "kind": { + "const": "qualification-release", + "default": "qualification-release", + "title": "Kind", + "type": "string" + }, + "object_media_type": { + "const": "application/vnd.openadapt.qualification-release+json;version=2", + "default": "application/vnd.openadapt.qualification-release+json;version=2", + "title": "Object Media Type", + "type": "string" + }, + "object_path": { + "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-release\\.json$", + "title": "Object Path", + "type": "string" + }, + "object_schema_version": { + "const": "openadapt.qualification-release/v2", + "default": "openadapt.qualification-release/v2", + "title": "Object Schema Version", + "type": "string" + }, + "object_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Object Sha256", + "type": "string" + }, + "registry_entry_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Registry Entry Sha256", + "type": "string" + }, + "registry_head_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Registry Head Sha256", + "type": "string" + }, + "registry_revision": { + "minimum": 1, + "title": "Registry Revision", + "type": "integer" + }, + "registry_source_commit": { + "pattern": "^[0-9a-f]{40}$", + "title": "Registry Source Commit", + "type": "string" + }, + "repository": { + "const": "OpenAdaptAI/.github", + "default": "OpenAdaptAI/.github", + "title": "Repository", + "type": "string" + }, + "repository_id": { + "const": "858454062", + "default": "858454062", + "title": "Repository Id", + "type": "string" + }, + "repository_owner_id": { + "const": "132681217", + "default": "132681217", + "title": "Repository Owner Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.production-evidence-object-reference/v2", + "default": "openadapt.production-evidence-object-reference/v2", + "title": "Schema Version", + "type": "string" + }, + "semantic_identity_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Semantic Identity Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 1, + "title": "Size Bytes", + "type": "integer" + }, + "subject_sha256": { + "default": null, + "title": "Subject Sha256", + "type": "null" + } + }, + "required": [ + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + "object_path", + "object_sha256", + "size_bytes", + "semantic_identity_sha256" + ], + "title": "QualificationReleaseReferenceV2", + "type": "object" + } + }, + "additionalProperties": false, + "description": "The single signed active/revoked registry state.", + "properties": { + "entries": { + "items": { + "$ref": "#/$defs/ProductionAdmissionRegistryEntryV1" + }, + "minItems": 1, + "title": "Entries", + "type": "array" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "title": "Issued At", + "type": "string" + }, + "repository": { + "const": "OpenAdaptAI/.github", + "default": "OpenAdaptAI/.github", + "title": "Repository", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": "openadapt.production-admission-registry-state/v1", + "default": "openadapt.production-admission-registry-state/v1", + "title": "Schema Version", + "type": "string" + }, + "signature_algorithm": { + "const": "ed25519", + "default": "ed25519", + "title": "Signature Algorithm", + "type": "string" + }, + "signature_b64url": { + "pattern": "^[A-Za-z0-9_-]{86}$", + "title": "Signature B64Url", + "type": "string" + }, + "signed_payload_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Signed Payload Sha256", + "type": "string" + }, + "signer_key_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Signer Key Sha256", + "type": "string" + }, + "source_commit": { + "pattern": "^[0-9a-f]{40}$", + "title": "Source Commit", + "type": "string" + } + }, + "required": [ + "source_commit", + "revision", + "issued_at", + "entries", + "signer_key_sha256", + "signed_payload_sha256", + "signature_b64url" + ], + "title": "ProductionAdmissionRegistryStateV1", + "type": "object" +} diff --git a/openadapt_types/schemas/production-lifecycle-admission-binding-v2.json b/openadapt_types/schemas/production-lifecycle-admission-binding-v2.json new file mode 100644 index 0000000..7a05ad3 --- /dev/null +++ b/openadapt_types/schemas/production-lifecycle-admission-binding-v2.json @@ -0,0 +1,166 @@ +{ + "$defs": { + "ProductionArtifactAuthorityV2": { + "enum": [ + "pypi", + "github_release", + "managed_evidence" + ], + "title": "ProductionArtifactAuthorityV2", + "type": "string" + }, + "ProductionArtifactKindV2": { + "enum": [ + "python-sdist", + "python-wheel", + "deployment-manifest" + ], + "title": "ProductionArtifactKindV2", + "type": "string" + }, + "ProductionReleaseArtifactBindingV2": { + "additionalProperties": false, + "description": "One exact artifact and its verification authority.", + "properties": { + "authority": { + "$ref": "#/$defs/ProductionArtifactAuthorityV2" + }, + "kind": { + "$ref": "#/$defs/ProductionArtifactKindV2" + }, + "name": { + "pattern": "^[^/\\\\]{1,255}$", + "title": "Name", + "type": "string" + }, + "sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 1, + "title": "Size Bytes", + "type": "integer" + } + }, + "required": [ + "name", + "kind", + "sha256", + "size_bytes", + "authority" + ], + "title": "ProductionReleaseArtifactBindingV2", + "type": "object" + }, + "ProductionReleaseKindV2": { + "enum": [ + "package", + "deployment", + "hybrid" + ], + "title": "ProductionReleaseKindV2", + "type": "string" + }, + "ProductionTargetIdV2": { + "enum": [ + "agent", + "capture", + "cloud", + "desktop", + "docs", + "flow", + "openadapt" + ], + "title": "ProductionTargetIdV2", + "type": "string" + } + }, + "additionalProperties": false, + "description": "Fields read only after an admission object passes its digest check.", + "properties": { + "artifact_inventory_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Artifact Inventory Sha256", + "type": "string" + }, + "artifacts": { + "items": { + "$ref": "#/$defs/ProductionReleaseArtifactBindingV2" + }, + "minItems": 1, + "title": "Artifacts", + "type": "array" + }, + "claim_scope": { + "pattern": "^production_[a-z]+$", + "title": "Claim Scope", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "title": "Issued At", + "type": "string" + }, + "not_before": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", + "title": "Not Before", + "type": "string" + }, + "release_kind": { + "$ref": "#/$defs/ProductionReleaseKindV2" + }, + "release_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Release Sha256", + "type": "string" + }, + "schema_version": { + "const": "openadapt.production-lifecycle-admission-binding/v2", + "default": "openadapt.production-lifecycle-admission-binding/v2", + "title": "Schema Version", + "type": "string" + }, + "source_repository": { + "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$", + "title": "Source Repository", + "type": "string" + }, + "target": { + "$ref": "#/$defs/ProductionTargetIdV2" + }, + "verdict": { + "const": "accepted", + "default": "accepted", + "title": "Verdict", + "type": "string" + } + }, + "required": [ + "target", + "source_repository", + "release_kind", + "claim_scope", + "release_sha256", + "artifact_inventory_sha256", + "artifacts", + "issued_at", + "not_before" + ], + "title": "ProductionLifecycleAdmissionBindingV2", + "type": "object" +} diff --git a/openadapt_types/schemas/production-lifecycle-target-v2.json b/openadapt_types/schemas/production-lifecycle-target-v2.json new file mode 100644 index 0000000..1c95abe --- /dev/null +++ b/openadapt_types/schemas/production-lifecycle-target-v2.json @@ -0,0 +1,150 @@ +{ + "$defs": { + "ProductionArtifactAuthoritiesV2": { + "additionalProperties": false, + "description": "The closed authority map for the supported artifact kinds.", + "properties": { + "deployment-manifest": { + "anyOf": [ + { + "$ref": "#/$defs/ProductionArtifactAuthorityV2" + }, + { + "type": "null" + } + ], + "default": null + }, + "python-sdist": { + "anyOf": [ + { + "$ref": "#/$defs/ProductionArtifactAuthorityV2" + }, + { + "type": "null" + } + ], + "default": null + }, + "python-wheel": { + "anyOf": [ + { + "$ref": "#/$defs/ProductionArtifactAuthorityV2" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "title": "ProductionArtifactAuthoritiesV2", + "type": "object" + }, + "ProductionArtifactAuthorityV2": { + "enum": [ + "pypi", + "github_release", + "managed_evidence" + ], + "title": "ProductionArtifactAuthorityV2", + "type": "string" + }, + "ProductionArtifactKindV2": { + "enum": [ + "python-sdist", + "python-wheel", + "deployment-manifest" + ], + "title": "ProductionArtifactKindV2", + "type": "string" + }, + "ProductionReleaseKindV2": { + "enum": [ + "package", + "deployment", + "hybrid" + ], + "title": "ProductionReleaseKindV2", + "type": "string" + }, + "ProductionTargetIdV2": { + "enum": [ + "agent", + "capture", + "cloud", + "desktop", + "docs", + "flow", + "openadapt" + ], + "title": "ProductionTargetIdV2", + "type": "string" + } + }, + "additionalProperties": false, + "description": "One exact policy-native Production target.", + "properties": { + "artifact_authority_by_kind": { + "$ref": "#/$defs/ProductionArtifactAuthoritiesV2" + }, + "claim_scope": { + "pattern": "^production_[a-z]+$", + "title": "Claim Scope", + "type": "string" + }, + "id": { + "$ref": "#/$defs/ProductionTargetIdV2" + }, + "package_index_project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Package Index Project" + }, + "release_kind": { + "$ref": "#/$defs/ProductionReleaseKindV2" + }, + "required_artifact_kinds": { + "items": { + "$ref": "#/$defs/ProductionArtifactKindV2" + }, + "minItems": 1, + "title": "Required Artifact Kinds", + "type": "array" + }, + "schema_version": { + "const": "openadapt.production-lifecycle-target/v2", + "default": "openadapt.production-lifecycle-target/v2", + "title": "Schema Version", + "type": "string" + }, + "source_repository": { + "pattern": "^OpenAdaptAI/[A-Za-z0-9._-]+$", + "title": "Source Repository", + "type": "string" + }, + "source_repository_id": { + "pattern": "^[1-9][0-9]*$", + "title": "Source Repository Id", + "type": "string" + } + }, + "required": [ + "id", + "source_repository", + "source_repository_id", + "release_kind", + "claim_scope", + "required_artifact_kinds", + "package_index_project", + "artifact_authority_by_kind" + ], + "title": "ProductionLifecycleTargetV2", + "type": "object" +} diff --git a/openadapt_types/schemas/qualification-release-reference-v2.json b/openadapt_types/schemas/qualification-release-reference-v2.json new file mode 100644 index 0000000..6e7f4d0 --- /dev/null +++ b/openadapt_types/schemas/qualification-release-reference-v2.json @@ -0,0 +1,105 @@ +{ + "additionalProperties": false, + "description": "An immutable registry index entry with no target assertion.", + "properties": { + "kind": { + "const": "qualification-release", + "default": "qualification-release", + "title": "Kind", + "type": "string" + }, + "object_media_type": { + "const": "application/vnd.openadapt.qualification-release+json;version=2", + "default": "application/vnd.openadapt.qualification-release+json;version=2", + "title": "Object Media Type", + "type": "string" + }, + "object_path": { + "pattern": "^production-evidence/objects/sha256/[0-9a-f]{2}/[0-9a-f]{64}\\.qualification-release\\.json$", + "title": "Object Path", + "type": "string" + }, + "object_schema_version": { + "const": "openadapt.qualification-release/v2", + "default": "openadapt.qualification-release/v2", + "title": "Object Schema Version", + "type": "string" + }, + "object_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Object Sha256", + "type": "string" + }, + "registry_entry_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Registry Entry Sha256", + "type": "string" + }, + "registry_head_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Registry Head Sha256", + "type": "string" + }, + "registry_revision": { + "minimum": 1, + "title": "Registry Revision", + "type": "integer" + }, + "registry_source_commit": { + "pattern": "^[0-9a-f]{40}$", + "title": "Registry Source Commit", + "type": "string" + }, + "repository": { + "const": "OpenAdaptAI/.github", + "default": "OpenAdaptAI/.github", + "title": "Repository", + "type": "string" + }, + "repository_id": { + "const": "858454062", + "default": "858454062", + "title": "Repository Id", + "type": "string" + }, + "repository_owner_id": { + "const": "132681217", + "default": "132681217", + "title": "Repository Owner Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.production-evidence-object-reference/v2", + "default": "openadapt.production-evidence-object-reference/v2", + "title": "Schema Version", + "type": "string" + }, + "semantic_identity_sha256": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Semantic Identity Sha256", + "type": "string" + }, + "size_bytes": { + "minimum": 1, + "title": "Size Bytes", + "type": "integer" + }, + "subject_sha256": { + "default": null, + "title": "Subject Sha256", + "type": "null" + } + }, + "required": [ + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "registry_entry_sha256", + "object_path", + "object_sha256", + "size_bytes", + "semantic_identity_sha256" + ], + "title": "QualificationReleaseReferenceV2", + "type": "object" +} diff --git a/scripts/export_production_lifecycle_schemas.py b/scripts/export_production_lifecycle_schemas.py new file mode 100644 index 0000000..1cad499 --- /dev/null +++ b/scripts/export_production_lifecycle_schemas.py @@ -0,0 +1,36 @@ +"""Export the simple Production admission registry JSON Schemas.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from openadapt_types.production_lifecycle import ( + ProductionAdmissionRegistryStateV1, + ProductionLifecycleAdmissionBindingV2, + ProductionLifecycleTargetV2, + QualificationReleaseReferenceV2, +) + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = ROOT / "openadapt_types" / "schemas" +MODELS = { + "production-admission-registry-state-v1.json": ProductionAdmissionRegistryStateV1, + "production-lifecycle-admission-binding-v2.json": ( + ProductionLifecycleAdmissionBindingV2 + ), + "production-lifecycle-target-v2.json": ProductionLifecycleTargetV2, + "qualification-release-reference-v2.json": QualificationReleaseReferenceV2, +} + + +def main() -> None: + for filename, model in MODELS.items(): + (SCHEMA_DIR / filename).write_text( + json.dumps(model.model_json_schema(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_authoring.py b/tests/test_authoring.py index 560e8b8..f9020a0 100644 --- a/tests/test_authoring.py +++ b/tests/test_authoring.py @@ -42,6 +42,7 @@ ElementRole, UINode, parse_authoring_bind_token, + parse_authoring_command, parse_authoring_lease_secret, parse_authoring_runner_uri, ) @@ -255,6 +256,76 @@ def test_command_click_is_node_id_only() -> None: ) +@pytest.mark.parametrize( + "command_id", + [ + "cmd_81JABCDEFGHJKMNPQRSTVWXYZ0", + "cmd_01IABCDEFGHJKMNPQRSTVWXYZ0", + "cmd_01LABCDEFGHJKMNPQRSTVWXYZ0", + "cmd_01Jabcdefghjkmnpqrstvwxyz0", + "cmd_" + "0" * 25, + "cmd_" + "0" * 27, + "01JABCDEFGHJKMNPQRSTVWXYZ0", + ], +) +def test_command_id_is_a_canonical_crockford_ulid(command_id: str) -> None: + with pytest.raises(ValidationError): + AuthoringCommandV1.model_validate(_command_payload(command_id=command_id)) + + +@pytest.mark.parametrize( + "field,value", + [ + ("enqueued_at", "2026-02-30T12:00:00Z"), + ("expires_at", "2026-08-31 12:15:00Z"), + ("expires_at", "2026-08-31T12:15:00"), + ("expires_at", 1788178500000), + ], +) +def test_command_times_are_real_rfc3339_instants(field: str, value: object) -> None: + with pytest.raises(ValidationError): + AuthoringCommandV1.model_validate(_command_payload(**{field: value})) + + +def test_command_expiry_uses_instants_and_is_bounded_to_lease() -> None: + command = AuthoringCommandV1.model_validate( + _command_payload( + enqueued_at="2026-08-31T12:00:00+01:00", + expires_at="2026-08-31T11:15:00Z", + ) + ) + assert command.expires_at == "2026-08-31T11:15:00Z" + with pytest.raises(ValidationError, match="900 seconds"): + AuthoringCommandV1.model_validate( + _command_payload(expires_at="2026-08-31T12:15:01Z") + ) + with pytest.raises(ValidationError, match="after enqueued_at"): + AuthoringCommandV1.model_validate( + _command_payload( + enqueued_at="2026-08-31T12:00:00+01:00", + expires_at="2026-08-31T11:00:00Z", + ) + ) + + +def test_parse_command_refuses_expired_and_extra_wire_fields() -> None: + command = parse_authoring_command( + _command_payload(), + at="2026-08-31T12:14:59Z", + ) + assert command.command_id == VALID_COMMAND_ID + with pytest.raises(ValueError, match="expired"): + parse_authoring_command( + _command_payload(), + at="2026-08-31T12:15:00Z", + ) + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + parse_authoring_command( + _command_payload(client_display="ChatGPT"), + at="2026-08-31T12:14:59Z", + ) + + @pytest.mark.parametrize("field,value", sorted(FORBIDDEN_FIELDS.items())) def test_command_refuses_value_title_screenshot_and_extra_keys( field: str, value: object diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py new file mode 100644 index 0000000..f2140bd --- /dev/null +++ b/tests/test_production_lifecycle.py @@ -0,0 +1,658 @@ +"""Tests for the simple signed Production admission registry.""" + +from __future__ import annotations + +import hashlib +import json +from importlib.resources import files +from typing import Any, cast + +import pytest +from pydantic import BaseModel, ValidationError + +from openadapt_types import ( + ProductionAdmissionRegistryStateV1, + ProductionLifecycleAdmissionBindingV2, + ProductionLifecycleTargetV2, + QualificationReleaseReferenceV2, + production_registry_signing_payload, + project_production_lifecycle_target_v3, + validate_production_admission, +) + +_A = "a" * 64 +_B = "b" * 64 +_OBJECT_COMMIT = "b" * 40 +_REGISTRY_COMMIT = "c" * 40 +_ARTIFACT_INVENTORY_DOMAIN = b"OpenAdapt production release artifact inventory v1\0" +_RELEASE_DOMAIN = b"OpenAdapt production release candidate v1\0" +_RELEASE_ADMISSION_DOMAIN = b"OpenAdapt qualification release admission v2\0" + + +def _canonical(value: object) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode() + + +def _digest(value: str) -> str: + return "sha256:" + hashlib.sha256(value.encode()).hexdigest() + + +def _domain_digest(domain: bytes, value: object) -> str: + return "sha256:" + hashlib.sha256(domain + _canonical(value)).hexdigest() + + +def _policy_targets() -> list[dict[str, object]]: + return [ + { + "id": "agent", + "display_name": "OpenAdapt Agent", + "source_repository": "OpenAdaptAI/openadapt-agent", + "source_repository_id": "1136136670", + "release_kind": "package", + "claim_scope": "production_agent", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt-agent", + }, + { + "id": "capture", + "display_name": "OpenAdapt Capture", + "source_repository": "OpenAdaptAI/openadapt-capture", + "source_repository_id": "1115283835", + "release_kind": "package", + "claim_scope": "production_capture", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt-capture", + }, + { + "id": "cloud", + "display_name": "OpenAdapt Cloud", + "source_repository": "OpenAdaptAI/openadapt-cloud", + "source_repository_id": "1300570990", + "release_kind": "deployment", + "claim_scope": "production_cloud", + "required_artifact_kinds": ["deployment-manifest"], + "package_index_project": None, + }, + { + "id": "desktop", + "display_name": "OpenAdapt Desktop", + "source_repository": "OpenAdaptAI/openadapt-desktop", + "source_repository_id": "1171291730", + "release_kind": "package", + "claim_scope": "production_desktop", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt-desktop", + }, + { + "id": "docs", + "display_name": "OpenAdapt Documentation", + "source_repository": "OpenAdaptAI/openadapt-ops", + "source_repository_id": "1172011294", + "release_kind": "deployment", + "claim_scope": "production_docs", + "required_artifact_kinds": ["deployment-manifest"], + "package_index_project": None, + }, + { + "id": "flow", + "display_name": "OpenAdapt Flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "source_repository_id": "1291376938", + "release_kind": "package", + "claim_scope": "production_flow", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt-flow", + }, + { + "id": "openadapt", + "display_name": "OpenAdapt", + "source_repository": "OpenAdaptAI/OpenAdapt", + "source_repository_id": "627024850", + "release_kind": "package", + "claim_scope": "production_openadapt", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt", + }, + ] + + +def _policy(**updates: object) -> dict[str, object]: + value: dict[str, object] = { + "$schema": "schemas/production-lifecycle-policy.schema.json", + "schema_version": "openadapt.production-lifecycle-policy/v3", + "revision": 7, + "admission_validity": "until_revoked", + "maximum_release_admission_days": None, + "maximum_workflow_admission_days": None, + "object_reference_schema_version": ( + "openadapt.production-evidence-object-reference/v2" + ), + "release_admission_schema_version": "openadapt.qualification-release/v2", + "workflow_admission_schema_version": "openadapt.qualification-admission/v4", + "lifecycle_checkpoint_schema_version": ( + "openadapt.production-lifecycle-checkpoint/v2" + ), + "lifecycle_feed_schema_version": "openadapt.production-lifecycle-feed/v2", + "lifecycle_feed_ref": "refs/heads/production-lifecycle-feed", + "targets": _policy_targets(), + } + value.update(updates) + return value + + +def _target(**updates: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": "openadapt.production-lifecycle-target/v2", + "id": "flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "source_repository_id": "1291376938", + "release_kind": "package", + "claim_scope": "production_flow", + "required_artifact_kinds": ["python-sdist", "python-wheel"], + "package_index_project": "openadapt-flow", + "artifact_authority_by_kind": { + "python-sdist": "pypi", + "python-wheel": "pypi", + }, + } + payload.update(updates) + return payload + + +def _release_artifacts() -> list[dict[str, object]]: + return [ + { + "name": "openadapt_flow-1.34.0.tar.gz", + "kind": "python-sdist", + "sha256": _digest("sdist"), + "size_bytes": 120, + "media_type": "application/gzip", + "publish_destinations": ["github-release", "pypi"], + }, + { + "name": "openadapt_flow-1.34.0-py3-none-any.whl", + "kind": "python-wheel", + "sha256": _digest("wheel"), + "size_bytes": 140, + "media_type": "application/zip", + "publish_destinations": ["github-release", "pypi"], + }, + ] + + +def _release(**updates: object) -> dict[str, object]: + value: dict[str, object] = { + "schema_version": "openadapt.production-release-candidate/v1", + "kind": "package", + "source_repository": "OpenAdaptAI/openadapt-flow", + "source_repository_id": "1291376938", + "source_commit": "d" * 40, + "version": "1.34.0", + "tag": "v1.34.0", + "deployment_id": None, + "deployment_sha256": None, + "artifacts": _release_artifacts(), + } + value.update(updates) + return value + + +def _qualification_release(**updates: object) -> dict[str, object]: + value: dict[str, object] = { + "schema_version": "openadapt.qualification-release/v2", + "evidence_class": "remote-safe-synthetic", + "target": "flow", + "verdict": "accepted", + "claim_scope": "production_flow", + "release_identity": { + "schema_version": "openadapt.monotonic-production-release/v1", + "channel": "production", + "sequence": 1, + "previous_admission_sha256": None, + }, + "release": _release(), + "publication_staging": {"schema_version": "test-staging/v1"}, + "publication_staging_sha256": _digest("staging"), + "production_acceptance_summary_reference": {"kind": "summary"}, + "production_acceptance_summary_bundle_reference": {"kind": "bundle"}, + "authority_state_sha256": _digest("authority"), + "revocation_state_sha256": _digest("revocation"), + "signer_registry_sha256": _digest("signer-registry"), + "publication_policy_sha256": _digest("publication-policy"), + "issued_at": "2026-09-03T12:00:00Z", + "not_before": "2026-09-03T12:00:00Z", + "expires_at": None, + "issuer": {"repository": "OpenAdaptAI/.github"}, + } + value.update(updates) + release = value["release"] + assert isinstance(release, dict) + artifacts = release["artifacts"] + value.setdefault( + "release_sha256", + _domain_digest( + _RELEASE_DOMAIN, + { + "target": value["target"], + "claim_scope": value["claim_scope"], + "release": release, + }, + ), + ) + value.setdefault( + "artifact_inventory_sha256", + _domain_digest( + _ARTIFACT_INVENTORY_DOMAIN, + { + "target": value["target"], + "claim_scope": value["claim_scope"], + "artifacts": artifacts, + }, + ), + ) + unsigned = dict(value) + unsigned.pop("admission_id_sha256", None) + value.setdefault( + "admission_id_sha256", + _domain_digest(_RELEASE_ADMISSION_DOMAIN, unsigned), + ) + return value + + +def _object_bytes(**updates: object) -> bytes: + return _canonical(_qualification_release(**updates)) + b"\n" + + +def _reference( + object_bytes: bytes | None = None, **updates: object +) -> dict[str, object]: + raw = object_bytes if object_bytes is not None else _object_bytes() + digest = hashlib.sha256(raw).hexdigest() + payload: dict[str, object] = { + "schema_version": "openadapt.production-evidence-object-reference/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "registry_source_commit": _OBJECT_COMMIT, + "registry_revision": 1, + "registry_head_sha256": f"sha256:{_A}", + "registry_entry_sha256": f"sha256:{_B}", + "kind": "qualification-release", + "object_schema_version": "openadapt.qualification-release/v2", + "object_path": ( + f"production-evidence/objects/sha256/{digest[:2]}/" + f"{digest}.qualification-release.json" + ), + "object_sha256": f"sha256:{digest}", + "size_bytes": len(raw), + "object_media_type": ( + "application/vnd.openadapt.qualification-release+json;version=2" + ), + "semantic_identity_sha256": f"sha256:{_B}", + "subject_sha256": None, + } + payload.update(updates) + return payload + + +def _registry( + state: str = "active", + *, + revision: int = 7, + reference: dict[str, object] | None = None, + **updates: object, +) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": "openadapt.production-admission-registry-state/v1", + "repository": "OpenAdaptAI/.github", + "source_commit": _REGISTRY_COMMIT, + "revision": revision, + "issued_at": "2026-09-03T12:05:00Z", + "entries": [ + { + "reference": reference or _reference(), + "state": state, + "state_changed_at": "2026-09-03T12:04:00Z", + } + ], + "signer_key_sha256": _digest("signer"), + "signature_algorithm": "ed25519", + "signature_b64url": "A" * 86, + } + payload.update(updates) + payload["signed_payload_sha256"] = ( + "sha256:" + + hashlib.sha256(production_registry_signing_payload(payload)).hexdigest() + ) + return payload + + +def _validate( + *, + object_bytes: bytes | None = None, + reference: QualificationReleaseReferenceV2 | None = None, + registry: ProductionAdmissionRegistryStateV1 | None = None, + signature_ok: bool = True, + minimum_registry_revision: int = 7, + at: str = "2036-09-03T12:00:00Z", +) -> ProductionLifecycleAdmissionBindingV2: + raw = object_bytes if object_bytes is not None else _object_bytes() + resolved_reference = reference or QualificationReleaseReferenceV2.model_validate( + _reference(raw) + ) + resolved_registry = registry or ProductionAdmissionRegistryStateV1.model_validate( + _registry(reference=resolved_reference.model_dump(mode="json")) + ) + return validate_production_admission( + registry=resolved_registry, + verify_registry_signature=lambda _state: signature_ok, + minimum_registry_revision=minimum_registry_revision, + reference=resolved_reference, + qualification_release_bytes=raw, + target=project_production_lifecycle_target_v3(_policy(), "flow"), + at=at, + ) + + +def test_null_expiry_stays_active_until_the_signed_registry_revokes_it() -> None: + raw = _object_bytes() + reference = QualificationReleaseReferenceV2.model_validate(_reference(raw)) + assert _validate(object_bytes=raw, reference=reference, at="2099-09-03T12:00:00Z") + + revoked = ProductionAdmissionRegistryStateV1.model_validate( + _registry( + "revoked", + revision=8, + reference=reference.model_dump(mode="json"), + ) + ) + with pytest.raises(ValueError, match="revoked"): + _validate( + object_bytes=raw, + reference=reference, + registry=revoked, + minimum_registry_revision=8, + ) + + +def test_registry_revision_high_water_mark_rejects_older_active_replay() -> None: + raw = _object_bytes() + reference = QualificationReleaseReferenceV2.model_validate(_reference(raw)) + old_active = ProductionAdmissionRegistryStateV1.model_validate( + _registry(reference=reference.model_dump(mode="json"), revision=7) + ) + with pytest.raises(ValueError, match="older than the trusted minimum"): + _validate( + object_bytes=raw, + reference=reference, + registry=old_active, + minimum_registry_revision=8, + ) + + +def test_supplied_expiry_has_no_policy_cap_and_is_enforced() -> None: + raw = _object_bytes(expires_at="2036-09-20T12:00:00Z") + assert _validate(object_bytes=raw, at="2036-09-20T11:59:59Z") + with pytest.raises(ValueError, match="expired"): + _validate(object_bytes=raw, at="2036-09-20T12:00:00Z") + + +def test_registry_signature_and_exact_object_bytes_are_required() -> None: + raw = _object_bytes() + with pytest.raises(ValueError, match="signature verification failed"): + _validate(object_bytes=raw, signature_ok=False) + + reference = QualificationReleaseReferenceV2.model_validate(_reference(raw)) + with pytest.raises(ValueError, match="object digest"): + _validate(object_bytes=raw + b" ", reference=reference) + + wrong_size = QualificationReleaseReferenceV2.model_validate( + _reference(raw, size_bytes=len(raw) + 1) + ) + registry = ProductionAdmissionRegistryStateV1.model_validate( + _registry(reference=wrong_size.model_dump(mode="json")) + ) + with pytest.raises(ValueError, match="object size"): + _validate( + object_bytes=raw, + reference=wrong_size, + registry=registry, + ) + + +def test_registry_has_one_current_state_for_each_admission() -> None: + duplicate = _registry() + duplicate_entries = cast(list[dict[str, Any]], duplicate["entries"]) + duplicate["entries"] = [duplicate_entries[0], duplicate_entries[0]] + with pytest.raises(ValidationError, match="one current registry state"): + production_registry_signing_payload(duplicate) + + future = _registry() + future_entries = cast(list[dict[str, Any]], future["entries"]) + future_entries[0]["state_changed_at"] = "2026-09-03T12:05:01Z" + with pytest.raises(ValidationError, match="must not follow"): + production_registry_signing_payload(future) + + +def test_registry_revision_can_advance_without_mutating_immutable_reference() -> None: + reference = _reference() + assert reference["registry_revision"] == 1 + state = ProductionAdmissionRegistryStateV1.model_validate( + _registry("revoked", revision=8, reference=reference) + ) + assert state.revision == 8 + assert state.entries[0].reference.registry_revision == 1 + + +def test_signed_payload_digest_covers_the_complete_registry_state() -> None: + registry = _registry() + entries = cast(list[dict[str, Any]], registry["entries"]) + entries[0]["state"] = "revoked" + with pytest.raises(ValidationError, match="does not bind"): + ProductionAdmissionRegistryStateV1.model_validate(registry) + + +def test_signing_payload_is_identical_for_mapping_and_model_defaults() -> None: + sparse_reference = _reference() + for field in ( + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "kind", + "object_schema_version", + "object_media_type", + "subject_sha256", + ): + sparse_reference.pop(field) + sparse = _registry(reference=sparse_reference) + for field in ("schema_version", "repository", "signature_algorithm"): + sparse.pop(field) + sparse["signed_payload_sha256"] = ( + "sha256:" + + hashlib.sha256(production_registry_signing_payload(sparse)).hexdigest() + ) + model = ProductionAdmissionRegistryStateV1.model_validate(sparse) + assert production_registry_signing_payload(sparse) == ( + production_registry_signing_payload(model) + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("source_repository", "OpenAdaptAI/OpenAdapt", "source_repository"), + ("source_repository_id", "627024850", "source_repository_id"), + ("release_kind", "deployment", "release_kind"), + ("claim_scope", "production_agent", "claim_scope"), + ( + "required_artifact_kinds", + ["python-wheel", "python-sdist"], + "required_artifact_kinds", + ), + ("package_index_project", "openadapt", "package_index_project"), + ], +) +def test_target_contract_is_exact(field: str, value: object, message: str) -> None: + with pytest.raises(ValidationError, match=message): + ProductionLifecycleTargetV2.model_validate(_target(**{field: value})) + + +def test_policy_v3_targets_project_to_exact_compatibility_contracts() -> None: + policy = _policy() + targets = [ + project_production_lifecycle_target_v3(policy, item["id"]) + for item in _policy_targets() + ] + assert [target.id.value for target in targets] == [ + "agent", + "capture", + "cloud", + "desktop", + "docs", + "flow", + "openadapt", + ] + assert targets[2].artifact_authority_by_kind.deployment_manifest.value == ( + "managed_evidence" + ) + assert targets[5].artifact_authority_by_kind.python_wheel.value == "pypi" + + +def test_policy_v3_projection_rejects_expiry_cap_and_target_drift() -> None: + with pytest.raises(ValueError, match="must not cap"): + project_production_lifecycle_target_v3( + _policy(maximum_release_admission_days=30), "flow" + ) + + policy = _policy() + targets = cast(list[dict[str, Any]], policy["targets"]) + targets[5]["source_repository_id"] = "627024850" + with pytest.raises(ValidationError, match="source_repository_id"): + project_production_lifecycle_target_v3(policy, "flow") + + +def test_verified_bytes_project_canonical_qualification_release_v2_fields() -> None: + raw = _object_bytes() + admission = _validate(object_bytes=raw) + assert admission.target.value == "flow" + assert [artifact.kind.value for artifact in admission.artifacts] == [ + "python-sdist", + "python-wheel", + ] + assert {artifact.authority.value for artifact in admission.artifacts} == {"pypi"} + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("target", "agent", "target"), + ("claim_scope", "production_agent", "claim scope"), + ], +) +def test_verified_object_projection_rejects_target_drift( + field: str, value: object, message: str +) -> None: + raw = _object_bytes(**{field: value}) + with pytest.raises(ValueError, match=message): + _validate(object_bytes=raw) + + +def test_verified_object_projection_rejects_repository_and_artifact_drift() -> None: + wrong_repository = _release(source_repository="OpenAdaptAI/OpenAdapt") + with pytest.raises(ValueError, match="repository"): + _validate(object_bytes=_object_bytes(release=wrong_repository)) + + wrong_artifacts = _release(artifacts=_release_artifacts()[1:]) + with pytest.raises(ValueError, match="artifacts"): + _validate(object_bytes=_object_bytes(release=wrong_artifacts)) + + +@pytest.mark.parametrize( + ("field", "message"), + [ + ("release_sha256", "candidate digest"), + ("artifact_inventory_sha256", "artifact inventory digest"), + ("admission_id_sha256", "admission digest"), + ], +) +def test_verified_object_projection_recomputes_internal_digests( + field: str, message: str +) -> None: + raw = _object_bytes(**{field: f"sha256:{_A}"}) + with pytest.raises(ValueError, match=message): + _validate(object_bytes=raw) + + +def test_reference_is_only_an_index_and_binds_its_object_path() -> None: + raw = _object_bytes() + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + QualificationReleaseReferenceV2.model_validate(_reference(raw, target="flow")) + with pytest.raises(ValidationError, match="object_path must bind"): + QualificationReleaseReferenceV2.model_validate( + _reference(raw, object_sha256=f"sha256:{_B}") + ) + + +@pytest.mark.parametrize( + ("model", "payload", "filename"), + [ + ( + QualificationReleaseReferenceV2, + _reference(), + "qualification-release-reference-v2.json", + ), + ( + ProductionLifecycleTargetV2, + _target(), + "production-lifecycle-target-v2.json", + ), + ( + ProductionLifecycleAdmissionBindingV2, + { + "target": "flow", + "source_repository": "OpenAdaptAI/openadapt-flow", + "release_kind": "package", + "claim_scope": "production_flow", + "release_sha256": _digest("release"), + "artifact_inventory_sha256": _digest("inventory"), + "artifacts": [ + { + "name": item["name"], + "kind": item["kind"], + "sha256": item["sha256"], + "size_bytes": item["size_bytes"], + "authority": "pypi", + } + for item in _release_artifacts() + ], + "issued_at": "2026-09-03T12:00:00Z", + "not_before": "2026-09-03T12:00:00Z", + "expires_at": None, + "verdict": "accepted", + }, + "production-lifecycle-admission-binding-v2.json", + ), + ( + ProductionAdmissionRegistryStateV1, + _registry(), + "production-admission-registry-state-v1.json", + ), + ], +) +def test_packaged_schemas_are_closed_and_current( + model: type[BaseModel], + payload: dict[str, object], + filename: str, +) -> None: + schema = model.model_json_schema() + assert schema["additionalProperties"] is False + for definition in schema.get("$defs", {}).values(): + if isinstance(definition, dict) and definition.get("type") == "object": + assert definition.get("additionalProperties") is False + parsed = model.model_validate(payload) + assert parsed is not None + packaged = files("openadapt_types.schemas").joinpath(filename) + assert json.loads(packaged.read_text(encoding="utf-8")) == schema diff --git a/tests/test_readme_claims.py b/tests/test_readme_claims.py index 571c68f..87f2fff 100644 --- a/tests/test_readme_claims.py +++ b/tests/test_readme_claims.py @@ -17,12 +17,45 @@ # Indexed by value, so WORDS[17] == "seventeen". Extend it when the directory # grows past the end rather than dropping the count from the README. WORDS = ( - "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", - "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", - "sixteen", "seventeen", "eighteen", "nineteen", "twenty", - "twenty-one", "twenty-two", "twenty-three", "twenty-four", "twenty-five", - "twenty-six", "twenty-seven", "twenty-eight", "twenty-nine", "thirty", - "thirty-one", "thirty-two", "thirty-three", "thirty-four", "thirty-five", + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + "twenty", + "twenty-one", + "twenty-two", + "twenty-three", + "twenty-four", + "twenty-five", + "twenty-six", + "twenty-seven", + "twenty-eight", + "twenty-nine", + "thirty", + "thirty-one", + "thirty-two", + "thirty-three", + "thirty-four", + "thirty-five", + "thirty-six", + "thirty-seven", + "thirty-eight", ) # Tied to the sentence itself, so an unrelated "N files" elsewhere in the