diff --git a/README.md b/README.md index 014ab2b..6a07469 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ Documentation for the whole stack lives at | `FailureRecord` | Classified failure for dataset pipelines | | `ControlOverlayFrameV1` / `ControlOverlayTimelineV1` | PHI-safe execution overlay state bound to exact evidence media | | `ControlOverlayFrameV2` / `ControlOverlayTimelineV2` | Exact, privacy-safe target geometry for sibling overlays and media composition | +| `ExecuteRequestV1` / `ExecuteStatusV1` | Async qualified-execution request and lifecycle contracts | +| `ExecuteEvidenceReceiptV1` | Outcome receipt with contract proof and evidence identifiers | +| `EffectStrengthV1` | Named effect-proof strength for consequential execution | ## Quick start @@ -183,6 +186,12 @@ If multiple runtime states land in one decoded media frame, the producer must coalesce them deterministically; it must not invent extra media frames or approximate their timing. +`openadapt_types/schemas/execute-v1-openapi.json` is the public OpenAdapt +Execute v1 contract. It defines asynchronous execution submission, lifecycle +status, terminal evidence receipts, and signed decision and terminal webhooks. +It keeps `waiting_for_reconciliation` as a lifecycle state and +`reconciliation_required` as a terminal outcome. + ## Design principles - **Pydantic v2**: runtime validation, JSON Schema export, fast serialization diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index b167d08..7d044db 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -81,6 +81,30 @@ control_overlay_state_id_v2, ) from openadapt_types.episode import Episode, Step +from openadapt_types.execute import ( + EXECUTE_ACCEPTED_SCHEMA, + EXECUTE_EVIDENCE_RECEIPT_SCHEMA, + EXECUTE_OPENAPI_SCHEMA, + EXECUTE_REQUEST_SCHEMA, + EXECUTE_STATUS_SCHEMA, + EXECUTE_WEBHOOK_SCHEMA, + EffectStrengthV1, + ExecuteAcceptedV1, + ExecuteAuthorizationContextV1, + ExecuteDecisionRequiredWebhookV1, + ExecuteEvidenceContractV1, + ExecuteEvidenceReceiptV1, + ExecuteLifecycleStateV1, + ExecuteRequestV1, + ExecuteStateChangedWebhookV1, + ExecuteStatusV1, + ExecuteTerminalOutcomeV1, + ExecuteTerminalWebhookV1, + ExecuteWebhookEventTypeV1, + ExecuteWebhookV1, + sign_execute_webhook_hmac, +) +from openadapt_types.execute_openapi import execute_openapi_document from openadapt_types.execution_requirements import ( CAPABILITY_MATCH_SCHEMA, EXECUTION_REQUIREMENTS_SCHEMA, @@ -198,6 +222,29 @@ # episode "Episode", "Step", + # Execute v1 + "EXECUTE_ACCEPTED_SCHEMA", + "EXECUTE_EVIDENCE_RECEIPT_SCHEMA", + "EXECUTE_OPENAPI_SCHEMA", + "EXECUTE_REQUEST_SCHEMA", + "EXECUTE_STATUS_SCHEMA", + "EXECUTE_WEBHOOK_SCHEMA", + "EffectStrengthV1", + "ExecuteAcceptedV1", + "ExecuteAuthorizationContextV1", + "ExecuteDecisionRequiredWebhookV1", + "ExecuteEvidenceContractV1", + "ExecuteEvidenceReceiptV1", + "ExecuteLifecycleStateV1", + "ExecuteRequestV1", + "ExecuteStateChangedWebhookV1", + "ExecuteStatusV1", + "ExecuteTerminalOutcomeV1", + "ExecuteTerminalWebhookV1", + "ExecuteWebhookEventTypeV1", + "ExecuteWebhookV1", + "sign_execute_webhook_hmac", + "execute_openapi_document", # runner capability and execution requirements "CAPABILITY_MATCH_SCHEMA", "EXECUTION_REQUIREMENTS_SCHEMA", diff --git a/openadapt_types/execute.py b/openadapt_types/execute.py new file mode 100644 index 0000000..2e5b0f0 --- /dev/null +++ b/openadapt_types/execute.py @@ -0,0 +1,285 @@ +"""Versioned public contracts for asynchronous OpenAdapt Execute requests. + +This module defines the portable trust boundary. It does not implement +tenanting, permit issuance, dispatch, webhook delivery, or any application +connector. Those production systems stay outside this MIT package. + +The contract intentionally separates a lifecycle state from a terminal +outcome. A run can wait for reconciliation without pretending that +reconciliation is already an outcome. A completed compensation is reported +as ``rolled_back_verified`` only after an independent effect check proves it. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from enum import Enum +from typing import Any, Literal, Mapping, TypeAlias + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + JsonValue, + StrictBool, + StrictInt, + StrictStr, + model_validator, +) + +from openadapt_types.human_decision import HumanDecisionTaskV1 + +EXECUTE_REQUEST_SCHEMA = "openadapt.execute-request/v1" +EXECUTE_ACCEPTED_SCHEMA = "openadapt.execute-accepted/v1" +EXECUTE_STATUS_SCHEMA = "openadapt.execute-status/v1" +EXECUTE_EVIDENCE_RECEIPT_SCHEMA = "openadapt.execute-evidence-receipt/v1" +EXECUTE_WEBHOOK_SCHEMA = "openadapt.execute-webhook/v1" +EXECUTE_OPENAPI_SCHEMA = "openadapt.execute-openapi/v1" + +_OPAQUE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$" +_SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$" +_SIGNATURE_PATTERN = r"^hmac-sha256:[0-9a-f]{64}$" +_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" +_WEBHOOK_SIGNING_DOMAIN = b"openadapt.execute-webhook/v1\x00" + + +class _StrictContract(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class EffectStrengthV1(str, Enum): + """The required strength of proof for a consequential effect. + + The members are named so a future protocol version can add a strength + without changing the meaning of a numeric value. + """ + + INDEPENDENT_SYSTEM_OF_RECORD = "independent_system_of_record" + INDEPENDENT_SESSION = "independent_session" + PERSISTED_STATE_REACQUISITION = "persisted_state_reacquisition" + IMMEDIATE_SCREEN_CONFIRMATION = "immediate_screen_confirmation" + + +_EFFECT_STRENGTH_RANK = { + EffectStrengthV1.IMMEDIATE_SCREEN_CONFIRMATION: 1, + EffectStrengthV1.PERSISTED_STATE_REACQUISITION: 2, + EffectStrengthV1.INDEPENDENT_SESSION: 3, + EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD: 4, +} + + +class ExecuteLifecycleStateV1(str, Enum): + QUEUED = "queued" + RUNNING = "running" + DECISION_REQUIRED = "decision_required" + WAITING_FOR_RECONCILIATION = "waiting_for_reconciliation" + TERMINAL = "terminal" + + +class ExecuteTerminalOutcomeV1(str, Enum): + VERIFIED = "verified" + HALTED_BEFORE_EFFECT = "halted_before_effect" + RECONCILIATION_REQUIRED = "reconciliation_required" + REJECTED_POLICY = "rejected_policy" + FAILED_PLATFORM = "failed_platform" + ROLLED_BACK_VERIFIED = "rolled_back_verified" + + +class ExecuteWebhookEventTypeV1(str, Enum): + EXECUTION_STATE_CHANGED = "execution.state_changed" + EXECUTION_TERMINAL = "execution.terminal" + DECISION_REQUIRED = "execution.decision_required" + + +class ExecuteAuthorizationContextV1(_StrictContract): + """Opaque authorization references from the caller's own system.""" + + actor_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + authorization_reference: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + + +class ExecuteRequestV1(_StrictContract): + schema_version: Literal[EXECUTE_REQUEST_SCHEMA] = EXECUTE_REQUEST_SCHEMA + qualification_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + workflow_version: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + workflow_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + environment_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + parameters: dict[str, JsonValue] = Field(default_factory=dict) + idempotency_key: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + authorization_context: ExecuteAuthorizationContextV1 + effect_strength_schema_version: Literal["1"] = "1" + minimum_effect_strength: EffectStrengthV1 + + +class ExecuteAcceptedV1(_StrictContract): + schema_version: Literal[EXECUTE_ACCEPTED_SCHEMA] = EXECUTE_ACCEPTED_SCHEMA + execution_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + state: Literal[ExecuteLifecycleStateV1.QUEUED] = ExecuteLifecycleStateV1.QUEUED + + +class ExecuteStatusV1(_StrictContract): + """The current public lifecycle view of an execution resource.""" + + schema_version: Literal[EXECUTE_STATUS_SCHEMA] = EXECUTE_STATUS_SCHEMA + execution_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + state: ExecuteLifecycleStateV1 + terminal_outcome: ExecuteTerminalOutcomeV1 | None = None + evidence_receipt_id: StrictStr | None = Field(default=None, pattern=_OPAQUE_ID_PATTERN) + updated_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + + @model_validator(mode="after") + def _validate_lifecycle(self) -> "ExecuteStatusV1": + if self.state is ExecuteLifecycleStateV1.TERMINAL: + if self.terminal_outcome is None or self.evidence_receipt_id is None: + raise ValueError("a terminal state requires outcome and evidence_receipt_id") + elif self.terminal_outcome is not None or self.evidence_receipt_id is not None: + raise ValueError("only a terminal state can carry outcome or evidence_receipt_id") + return self + + +class ExecuteEvidenceContractV1(_StrictContract): + """Boolean contract results. Evidence bytes remain in the declared boundary.""" + + authorization_passed: StrictBool + identity_passed: StrictBool + postcondition_passed: StrictBool + effect_passed: StrictBool + minimum_effect_strength: EffectStrengthV1 + observed_effect_strength: EffectStrengthV1 | None = None + model_used: StrictBool + external_network_used: StrictBool + + +class ExecuteEvidenceReceiptV1(_StrictContract): + """A portable receipt that identifies evidence without carrying it.""" + + schema_version: Literal[EXECUTE_EVIDENCE_RECEIPT_SCHEMA] = EXECUTE_EVIDENCE_RECEIPT_SCHEMA + receipt_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + execution_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + workflow_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + outcome: ExecuteTerminalOutcomeV1 + contracts: ExecuteEvidenceContractV1 + delivery_uncertain: StrictBool + compensation_effect_verified: StrictBool = False + evidence_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + issued_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + + @model_validator(mode="after") + def _validate_proof(self) -> "ExecuteEvidenceReceiptV1": + verified = self.outcome in { + ExecuteTerminalOutcomeV1.VERIFIED, + ExecuteTerminalOutcomeV1.ROLLED_BACK_VERIFIED, + } + if verified: + if not all( + ( + self.contracts.authorization_passed, + self.contracts.identity_passed, + self.contracts.postcondition_passed, + self.contracts.effect_passed, + ) + ): + raise ValueError("a verified outcome requires every required contract") + if self.contracts.observed_effect_strength is None: + raise ValueError("a verified outcome requires observed_effect_strength") + if ( + _EFFECT_STRENGTH_RANK[self.contracts.observed_effect_strength] + < _EFFECT_STRENGTH_RANK[self.contracts.minimum_effect_strength] + ): + raise ValueError("observed effect strength is below the required strength") + if self.outcome is ExecuteTerminalOutcomeV1.ROLLED_BACK_VERIFIED: + if not self.compensation_effect_verified: + raise ValueError("rolled_back_verified requires independently verified compensation") + elif self.compensation_effect_verified: + raise ValueError("only rolled_back_verified can claim compensation_effect_verified") + if self.outcome is ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED: + if not self.delivery_uncertain: + raise ValueError("reconciliation_required requires an uncertain effect or delivery") + return self + + +class _SignedExecuteWebhookV1(_StrictContract): + schema_version: Literal[EXECUTE_WEBHOOK_SCHEMA] = EXECUTE_WEBHOOK_SCHEMA + event_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + delivery_attempt: StrictInt = Field(ge=1, le=100) + issued_at: StrictStr = Field(pattern=_TIMESTAMP_PATTERN) + issuer_key_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + signature: StrictStr = Field(pattern=_SIGNATURE_PATTERN) + + def unsigned_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_bytes(self) -> bytes: + return json.dumps( + self.unsigned_payload(), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + + @property + def digest(self) -> str: + return "sha256:" + hashlib.sha256(self.canonical_bytes()).hexdigest() + + def verify_hmac(self, key: bytes) -> bool: + expected = "hmac-sha256:" + hmac.new( + key, + _WEBHOOK_SIGNING_DOMAIN + self.canonical_bytes(), + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(self.signature, expected) + + +class ExecuteStateChangedWebhookV1(_SignedExecuteWebhookV1): + event_type: Literal[ExecuteWebhookEventTypeV1.EXECUTION_STATE_CHANGED] = ( + ExecuteWebhookEventTypeV1.EXECUTION_STATE_CHANGED + ) + execution: ExecuteStatusV1 + + +class ExecuteTerminalWebhookV1(_SignedExecuteWebhookV1): + event_type: Literal[ExecuteWebhookEventTypeV1.EXECUTION_TERMINAL] = ( + ExecuteWebhookEventTypeV1.EXECUTION_TERMINAL + ) + receipt: ExecuteEvidenceReceiptV1 + + +class ExecuteDecisionRequiredWebhookV1(_SignedExecuteWebhookV1): + event_type: Literal[ExecuteWebhookEventTypeV1.DECISION_REQUIRED] = ( + ExecuteWebhookEventTypeV1.DECISION_REQUIRED + ) + execution_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + decision: HumanDecisionTaskV1 + + +ExecuteWebhookV1: TypeAlias = ( + ExecuteStateChangedWebhookV1 + | ExecuteTerminalWebhookV1 + | ExecuteDecisionRequiredWebhookV1 +) + + +def sign_execute_webhook_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> ExecuteWebhookV1: + """Build one signed webhook envelope from a closed event type.""" + + event_type = fields.get("event_type") + model_by_event = { + ExecuteWebhookEventTypeV1.EXECUTION_STATE_CHANGED: ExecuteStateChangedWebhookV1, + ExecuteWebhookEventTypeV1.EXECUTION_TERMINAL: ExecuteTerminalWebhookV1, + ExecuteWebhookEventTypeV1.DECISION_REQUIRED: ExecuteDecisionRequiredWebhookV1, + } + try: + model = model_by_event[ExecuteWebhookEventTypeV1(event_type)] + except (KeyError, ValueError) as exc: + raise ValueError("event_type must select a supported Execute webhook") from exc + unsigned = model.model_validate({**fields, "signature": "hmac-sha256:" + "0" * 64}) + signature = "hmac-sha256:" + hmac.new( + key, + _WEBHOOK_SIGNING_DOMAIN + unsigned.canonical_bytes(), + hashlib.sha256, + ).hexdigest() + return model.model_validate({**fields, "signature": signature}) diff --git a/openadapt_types/execute_openapi.py b/openadapt_types/execute_openapi.py new file mode 100644 index 0000000..ac6efd8 --- /dev/null +++ b/openadapt_types/execute_openapi.py @@ -0,0 +1,160 @@ +"""Generate the versioned public OpenAdapt Execute OpenAPI document.""" + +from __future__ import annotations + +from typing import Any + +from pydantic.json_schema import models_json_schema + +from openadapt_types.execute import ( + EXECUTE_OPENAPI_SCHEMA, + ExecuteAcceptedV1, + ExecuteDecisionRequiredWebhookV1, + ExecuteEvidenceReceiptV1, + ExecuteRequestV1, + ExecuteStateChangedWebhookV1, + ExecuteStatusV1, + ExecuteTerminalWebhookV1, +) + + +def execute_openapi_document() -> dict[str, Any]: + """Return the portable API document; Cloud owns its implementation details.""" + + _, definitions = models_json_schema( + [ + (ExecuteRequestV1, "validation"), + (ExecuteAcceptedV1, "validation"), + (ExecuteStatusV1, "validation"), + (ExecuteEvidenceReceiptV1, "validation"), + (ExecuteStateChangedWebhookV1, "validation"), + (ExecuteTerminalWebhookV1, "validation"), + (ExecuteDecisionRequiredWebhookV1, "validation"), + ], + ref_template="#/components/schemas/{model}", + ) + schemas = definitions["$defs"] + execution_id = { + "name": "execution_id", + "in": "path", + "required": True, + "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$"}, + } + return { + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "OpenAdapt Execute API", + "version": "1.0.0", + "description": ( + "Versioned async execution contract. Customer-specific connectors, " + "evidence bytes, and Cloud implementation details are out of scope." + ), + }, + "x-openadapt-schema": EXECUTE_OPENAPI_SCHEMA, + "paths": { + "/v1/executions": { + "post": { + "operationId": "createExecution", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ExecuteRequestV1"} + } + }, + }, + "responses": { + "202": { + "description": "Execution accepted for durable processing.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ExecuteAcceptedV1"} + } + }, + } + }, + } + }, + "/v1/executions/{execution_id}": { + "get": { + "operationId": "getExecution", + "parameters": [execution_id], + "responses": { + "200": { + "description": "Current lifecycle state.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ExecuteStatusV1"} + } + }, + } + }, + } + }, + "/v1/executions/{execution_id}/receipt": { + "get": { + "operationId": "getExecutionReceipt", + "parameters": [execution_id], + "responses": { + "200": { + "description": "Terminal receipt with evidence identifiers.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ExecuteEvidenceReceiptV1"} + } + }, + } + }, + } + }, + }, + "webhooks": { + "executionStateChanged": { + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteStateChangedWebhookV1" + } + } + }, + }, + "responses": {"204": {"description": "Accepted by endpoint."}}, + } + }, + "executionTerminal": { + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteTerminalWebhookV1" + } + } + }, + }, + "responses": {"204": {"description": "Accepted by endpoint."}}, + } + }, + "executionDecisionRequired": { + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteDecisionRequiredWebhookV1" + } + } + }, + }, + "responses": {"204": {"description": "Accepted by endpoint."}}, + } + }, + }, + "components": {"schemas": schemas}, + } diff --git a/openadapt_types/schemas/execute-v1-openapi.json b/openadapt_types/schemas/execute-v1-openapi.json new file mode 100644 index 0000000..e1f898e --- /dev/null +++ b/openadapt_types/schemas/execute-v1-openapi.json @@ -0,0 +1,1061 @@ +{ + "components": { + "schemas": { + "EffectStrengthV1": { + "description": "The required strength of proof for a consequential effect.\n\nThe members are named so a future protocol version can add a strength\nwithout changing the meaning of a numeric value.", + "enum": [ + "independent_system_of_record", + "independent_session", + "persisted_state_reacquisition", + "immediate_screen_confirmation" + ], + "title": "EffectStrengthV1", + "type": "string" + }, + "ExecuteAcceptedV1": { + "additionalProperties": false, + "properties": { + "execution_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Execution Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-accepted/v1", + "default": "openadapt.execute-accepted/v1", + "title": "Schema Version", + "type": "string" + }, + "state": { + "const": "queued", + "default": "queued", + "title": "State", + "type": "string" + } + }, + "required": [ + "execution_id" + ], + "title": "ExecuteAcceptedV1", + "type": "object" + }, + "ExecuteAuthorizationContextV1": { + "additionalProperties": false, + "description": "Opaque authorization references from the caller's own system.", + "properties": { + "actor_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Actor Id", + "type": "string" + }, + "authorization_reference": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Authorization Reference", + "type": "string" + } + }, + "required": [ + "actor_id", + "authorization_reference" + ], + "title": "ExecuteAuthorizationContextV1", + "type": "object" + }, + "ExecuteDecisionRequiredWebhookV1": { + "additionalProperties": false, + "properties": { + "decision": { + "$ref": "#/components/schemas/HumanDecisionTaskV1" + }, + "delivery_attempt": { + "maximum": 100, + "minimum": 1, + "title": "Delivery Attempt", + "type": "integer" + }, + "event_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Event Id", + "type": "string" + }, + "event_type": { + "const": "execution.decision_required", + "default": "execution.decision_required", + "title": "Event Type", + "type": "string" + }, + "execution_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Execution Id", + "type": "string" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Issued At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-webhook/v1", + "default": "openadapt.execute-webhook/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + } + }, + "required": [ + "event_id", + "delivery_attempt", + "issued_at", + "issuer_key_id", + "signature", + "execution_id", + "decision" + ], + "title": "ExecuteDecisionRequiredWebhookV1", + "type": "object" + }, + "ExecuteEvidenceContractV1": { + "additionalProperties": false, + "description": "Boolean contract results. Evidence bytes remain in the declared boundary.", + "properties": { + "authorization_passed": { + "title": "Authorization Passed", + "type": "boolean" + }, + "effect_passed": { + "title": "Effect Passed", + "type": "boolean" + }, + "external_network_used": { + "title": "External Network Used", + "type": "boolean" + }, + "identity_passed": { + "title": "Identity Passed", + "type": "boolean" + }, + "minimum_effect_strength": { + "$ref": "#/components/schemas/EffectStrengthV1" + }, + "model_used": { + "title": "Model Used", + "type": "boolean" + }, + "observed_effect_strength": { + "anyOf": [ + { + "$ref": "#/components/schemas/EffectStrengthV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "postcondition_passed": { + "title": "Postcondition Passed", + "type": "boolean" + } + }, + "required": [ + "authorization_passed", + "identity_passed", + "postcondition_passed", + "effect_passed", + "minimum_effect_strength", + "model_used", + "external_network_used" + ], + "title": "ExecuteEvidenceContractV1", + "type": "object" + }, + "ExecuteEvidenceReceiptV1": { + "additionalProperties": false, + "description": "A portable receipt that identifies evidence without carrying it.", + "properties": { + "compensation_effect_verified": { + "default": false, + "title": "Compensation Effect Verified", + "type": "boolean" + }, + "contracts": { + "$ref": "#/components/schemas/ExecuteEvidenceContractV1" + }, + "delivery_uncertain": { + "title": "Delivery Uncertain", + "type": "boolean" + }, + "evidence_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Evidence Digest", + "type": "string" + }, + "execution_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Execution Id", + "type": "string" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Issued At", + "type": "string" + }, + "outcome": { + "$ref": "#/components/schemas/ExecuteTerminalOutcomeV1" + }, + "receipt_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Receipt Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-evidence-receipt/v1", + "default": "openadapt.execute-evidence-receipt/v1", + "title": "Schema Version", + "type": "string" + }, + "workflow_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Workflow Digest", + "type": "string" + } + }, + "required": [ + "receipt_id", + "execution_id", + "workflow_digest", + "outcome", + "contracts", + "delivery_uncertain", + "evidence_digest", + "issued_at" + ], + "title": "ExecuteEvidenceReceiptV1", + "type": "object" + }, + "ExecuteLifecycleStateV1": { + "enum": [ + "queued", + "running", + "decision_required", + "waiting_for_reconciliation", + "terminal" + ], + "title": "ExecuteLifecycleStateV1", + "type": "string" + }, + "ExecuteRequestV1": { + "additionalProperties": false, + "properties": { + "authorization_context": { + "$ref": "#/components/schemas/ExecuteAuthorizationContextV1" + }, + "effect_strength_schema_version": { + "const": "1", + "default": "1", + "title": "Effect Strength Schema Version", + "type": "string" + }, + "environment_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Environment Id", + "type": "string" + }, + "idempotency_key": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Idempotency Key", + "type": "string" + }, + "minimum_effect_strength": { + "$ref": "#/components/schemas/EffectStrengthV1" + }, + "parameters": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Parameters", + "type": "object" + }, + "qualification_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Qualification Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-request/v1", + "default": "openadapt.execute-request/v1", + "title": "Schema Version", + "type": "string" + }, + "workflow_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Workflow Digest", + "type": "string" + }, + "workflow_version": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Workflow Version", + "type": "string" + } + }, + "required": [ + "qualification_id", + "workflow_version", + "workflow_digest", + "environment_id", + "idempotency_key", + "authorization_context", + "minimum_effect_strength" + ], + "title": "ExecuteRequestV1", + "type": "object" + }, + "ExecuteStateChangedWebhookV1": { + "additionalProperties": false, + "properties": { + "delivery_attempt": { + "maximum": 100, + "minimum": 1, + "title": "Delivery Attempt", + "type": "integer" + }, + "event_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Event Id", + "type": "string" + }, + "event_type": { + "const": "execution.state_changed", + "default": "execution.state_changed", + "title": "Event Type", + "type": "string" + }, + "execution": { + "$ref": "#/components/schemas/ExecuteStatusV1" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Issued At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-webhook/v1", + "default": "openadapt.execute-webhook/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + } + }, + "required": [ + "event_id", + "delivery_attempt", + "issued_at", + "issuer_key_id", + "signature", + "execution" + ], + "title": "ExecuteStateChangedWebhookV1", + "type": "object" + }, + "ExecuteStatusV1": { + "additionalProperties": false, + "description": "The current public lifecycle view of an execution resource.", + "properties": { + "evidence_receipt_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Evidence Receipt Id" + }, + "execution_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Execution Id", + "type": "string" + }, + "schema_version": { + "const": "openadapt.execute-status/v1", + "default": "openadapt.execute-status/v1", + "title": "Schema Version", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/ExecuteLifecycleStateV1" + }, + "terminal_outcome": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecuteTerminalOutcomeV1" + }, + { + "type": "null" + } + ], + "default": null + }, + "updated_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "execution_id", + "state", + "updated_at" + ], + "title": "ExecuteStatusV1", + "type": "object" + }, + "ExecuteTerminalOutcomeV1": { + "enum": [ + "verified", + "halted_before_effect", + "reconciliation_required", + "rejected_policy", + "failed_platform", + "rolled_back_verified" + ], + "title": "ExecuteTerminalOutcomeV1", + "type": "string" + }, + "ExecuteTerminalWebhookV1": { + "additionalProperties": false, + "properties": { + "delivery_attempt": { + "maximum": 100, + "minimum": 1, + "title": "Delivery Attempt", + "type": "integer" + }, + "event_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Event Id", + "type": "string" + }, + "event_type": { + "const": "execution.terminal", + "default": "execution.terminal", + "title": "Event Type", + "type": "string" + }, + "issued_at": { + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Issued At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "receipt": { + "$ref": "#/components/schemas/ExecuteEvidenceReceiptV1" + }, + "schema_version": { + "const": "openadapt.execute-webhook/v1", + "default": "openadapt.execute-webhook/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + } + }, + "required": [ + "event_id", + "delivery_attempt", + "issued_at", + "issuer_key_id", + "signature", + "receipt" + ], + "title": "ExecuteTerminalWebhookV1", + "type": "object" + }, + "HumanDecisionAction": { + "description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.", + "enum": [ + "verify_and_resume", + "skip", + "reject", + "teach", + "escalate" + ], + "title": "HumanDecisionAction", + "type": "string" + }, + "HumanDecisionDeliveryState": { + "enum": [ + "not_delivered", + "delivered", + "unknown" + ], + "title": "HumanDecisionDeliveryState", + "type": "string" + }, + "HumanDecisionEvidenceSummaryV1": { + "additionalProperties": false, + "properties": { + "effect_confirmed_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effect Confirmed Count" + }, + "effect_required_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Effect Required Count" + }, + "frame_available_locally": { + "default": false, + "title": "Frame Available Locally", + "type": "boolean" + }, + "identity_confirmed_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Confirmed Count" + }, + "identity_required_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Identity Required Count" + }, + "minimum_effect_tier": { + "anyOf": [ + { + "maximum": 4, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Minimum Effect Tier" + }, + "observed_effect_tier": { + "anyOf": [ + { + "maximum": 4, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Observed Effect Tier" + }, + "sensitive_evidence_local_only": { + "const": true, + "default": true, + "title": "Sensitive Evidence Local Only", + "type": "boolean" + } + }, + "title": "HumanDecisionEvidenceSummaryV1", + "type": "object" + }, + "HumanDecisionQuestionTemplate": { + "enum": [ + "confirm_identity", + "confirm_persisted_effect", + "resolve_ambiguity", + "complete_human_step", + "review_uncertain_delivery", + "review_halt" + ], + "title": "HumanDecisionQuestionTemplate", + "type": "string" + }, + "HumanDecisionQuestionV1": { + "additionalProperties": false, + "properties": { + "safe_slots": { + "$ref": "#/components/schemas/HumanDecisionSafeSlotsV1" + }, + "template": { + "$ref": "#/components/schemas/HumanDecisionQuestionTemplate" + } + }, + "required": [ + "template" + ], + "title": "HumanDecisionQuestionV1", + "type": "object" + }, + "HumanDecisionRequiredAuthn": { + "enum": [ + "local_session", + "aal2", + "webauthn" + ], + "title": "HumanDecisionRequiredAuthn", + "type": "string" + }, + "HumanDecisionRiskClass": { + "enum": [ + "read_only", + "state_changing", + "consequential", + "irreversible", + "unknown" + ], + "title": "HumanDecisionRiskClass", + "type": "string" + }, + "HumanDecisionSafeSlotsV1": { + "additionalProperties": false, + "description": "Bounded numeric context safe to relay outside the evidence boundary.", + "properties": { + "candidate_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Candidate Count" + }, + "confirmed_signal_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Confirmed Signal Count" + }, + "required_signal_count": { + "anyOf": [ + { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Required Signal Count" + } + }, + "title": "HumanDecisionSafeSlotsV1", + "type": "object" + }, + "HumanDecisionSubstrate": { + "enum": [ + "browser", + "windows", + "macos", + "linux", + "rdp", + "citrix", + "mixed", + "unknown" + ], + "title": "HumanDecisionSubstrate", + "type": "string" + }, + "HumanDecisionTaskKind": { + "enum": [ + "identity", + "effect", + "ambiguity", + "human_step", + "delivery_uncertain", + "halt", + "operator_review" + ], + "title": "HumanDecisionTaskKind", + "type": "string" + }, + "HumanDecisionTaskV1": { + "additionalProperties": false, + "description": "A signed, PHI-free projection of one exact attended runtime pause.", + "properties": { + "allowed_actions": { + "items": { + "$ref": "#/components/schemas/HumanDecisionAction" + }, + "maxItems": 5, + "minItems": 1, + "title": "Allowed Actions", + "type": "array" + }, + "bundle_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Bundle Digest", + "type": "string" + }, + "capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Capability Digest", + "type": "string" + }, + "created_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Created At", + "type": "string" + }, + "delivery_state": { + "$ref": "#/components/schemas/HumanDecisionDeliveryState" + }, + "evidence": { + "$ref": "#/components/schemas/HumanDecisionEvidenceSummaryV1" + }, + "expires_at": { + "maxLength": 40, + "minLength": 20, + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$", + "title": "Expires At", + "type": "string" + }, + "issuer_key_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Issuer Key Id", + "type": "string" + }, + "nonce": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Nonce", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "question": { + "$ref": "#/components/schemas/HumanDecisionQuestionV1" + }, + "required_authn": { + "$ref": "#/components/schemas/HumanDecisionRequiredAuthn" + }, + "risk_class": { + "$ref": "#/components/schemas/HumanDecisionRiskClass", + "default": "unknown" + }, + "run_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Run Id", + "type": "string" + }, + "runner_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Runner Id" + }, + "schema_version": { + "const": "openadapt.human-decision-task/v1", + "default": "openadapt.human-decision-task/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "title": "Signature", + "type": "string" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "substrate": { + "$ref": "#/components/schemas/HumanDecisionSubstrate", + "default": "unknown" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_kind": { + "$ref": "#/components/schemas/HumanDecisionTaskKind" + }, + "task_revision": { + "default": 1, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "tenant_id": { + "anyOf": [ + { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tenant Id" + } + }, + "required": [ + "task_id", + "run_id", + "pause_id", + "capability_digest", + "bundle_digest", + "task_kind", + "delivery_state", + "question", + "evidence", + "allowed_actions", + "required_authn", + "created_at", + "expires_at", + "nonce", + "issuer_key_id", + "signature" + ], + "title": "HumanDecisionTaskV1", + "type": "object" + }, + "JsonValue": {} + } + }, + "info": { + "description": "Versioned async execution contract. Customer-specific connectors, evidence bytes, and Cloud implementation details are out of scope.", + "title": "OpenAdapt Execute API", + "version": "1.0.0" + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "openapi": "3.1.0", + "paths": { + "/v1/executions": { + "post": { + "operationId": "createExecution", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequestV1" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteAcceptedV1" + } + } + }, + "description": "Execution accepted for durable processing." + } + } + } + }, + "/v1/executions/{execution_id}": { + "get": { + "operationId": "getExecution", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteStatusV1" + } + } + }, + "description": "Current lifecycle state." + } + } + } + }, + "/v1/executions/{execution_id}/receipt": { + "get": { + "operationId": "getExecutionReceipt", + "parameters": [ + { + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteEvidenceReceiptV1" + } + } + }, + "description": "Terminal receipt with evidence identifiers." + } + } + } + } + }, + "webhooks": { + "executionDecisionRequired": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteDecisionRequiredWebhookV1" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Accepted by endpoint." + } + } + } + }, + "executionStateChanged": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteStateChangedWebhookV1" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Accepted by endpoint." + } + } + } + }, + "executionTerminal": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteTerminalWebhookV1" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Accepted by endpoint." + } + } + } + } + }, + "x-openadapt-schema": "openadapt.execute-openapi/v1" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index 749274b..a84363a 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -18,6 +18,7 @@ RunnerCapabilityManifestV1, match_runner_capabilities, ) +from openadapt_types.execute_openapi import execute_openapi_document ROOT = Path(__file__).resolve().parents[1] SCHEMA_DIR = ROOT / "openadapt_types" / "schemas" @@ -203,6 +204,10 @@ def main() -> int: json.dumps(negative_match_vectors(), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) + (SCHEMA_DIR / "execute-v1-openapi.json").write_text( + json.dumps(execute_openapi_document(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) return 0 diff --git a/tests/test_execute_contract.py b/tests/test_execute_contract.py new file mode 100644 index 0000000..f77d092 --- /dev/null +++ b/tests/test_execute_contract.py @@ -0,0 +1,161 @@ +"""Focused invariants for the public Execute v1 contract.""" + +import json +from importlib.resources import files + +import pytest +from pydantic import ValidationError + +from openadapt_types import ( + EffectStrengthV1, + ExecuteEvidenceContractV1, + ExecuteEvidenceReceiptV1, + ExecuteLifecycleStateV1, + ExecuteRequestV1, + ExecuteStatusV1, + ExecuteTerminalOutcomeV1, + ExecuteWebhookEventTypeV1, + execute_openapi_document, + sign_execute_webhook_hmac, +) + + +def _contract(**updates: object) -> ExecuteEvidenceContractV1: + fields: dict[str, object] = { + "authorization_passed": True, + "identity_passed": True, + "postcondition_passed": True, + "effect_passed": True, + "minimum_effect_strength": EffectStrengthV1.INDEPENDENT_SESSION, + "observed_effect_strength": EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, + "model_used": False, + "external_network_used": False, + } + fields.update(updates) + return ExecuteEvidenceContractV1.model_validate(fields) + + +def _receipt(**updates: object) -> ExecuteEvidenceReceiptV1: + fields: dict[str, object] = { + "receipt_id": "receipt_12345678", + "execution_id": "execution_12345678", + "workflow_digest": "sha256:" + "a" * 64, + "outcome": ExecuteTerminalOutcomeV1.VERIFIED, + "contracts": _contract(), + "delivery_uncertain": False, + "evidence_digest": "sha256:" + "b" * 64, + "issued_at": "2026-07-29T12:00:00Z", + } + fields.update(updates) + return ExecuteEvidenceReceiptV1.model_validate(fields) + + +def test_request_binds_qualified_workflow_environment_and_idempotency() -> None: + request = ExecuteRequestV1( + qualification_id="qualification_12345678", + workflow_version="workflow_20260729", + workflow_digest="sha256:" + "c" * 64, + environment_id="environment_12345678", + parameters={"patient": {"id": "12345"}, "date": "2026-08-15"}, + idempotency_key="caller_key_12345678", + authorization_context={ + "actor_id": "caller_agent_12345678", + "authorization_reference": "authorization_12345678", + }, + minimum_effect_strength=EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, + ) + + assert request.effect_strength_schema_version == "1" + assert request.parameters["patient"] == {"id": "12345"} + + +def test_lifecycle_state_and_terminal_outcome_cannot_be_conflated() -> None: + waiting = ExecuteStatusV1( + execution_id="execution_12345678", + state=ExecuteLifecycleStateV1.WAITING_FOR_RECONCILIATION, + updated_at="2026-07-29T12:00:00Z", + ) + assert waiting.terminal_outcome is None + + with pytest.raises(ValidationError, match="only a terminal state"): + ExecuteStatusV1( + execution_id="execution_12345678", + state=ExecuteLifecycleStateV1.WAITING_FOR_RECONCILIATION, + terminal_outcome=ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED, + updated_at="2026-07-29T12:00:00Z", + ) + + terminal = ExecuteStatusV1( + execution_id="execution_12345678", + state=ExecuteLifecycleStateV1.TERMINAL, + terminal_outcome=ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED, + evidence_receipt_id="receipt_12345678", + updated_at="2026-07-29T12:00:00Z", + ) + assert terminal.terminal_outcome is ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED + + +def test_verified_and_rollback_outcomes_require_their_independent_proof() -> None: + _receipt() + + with pytest.raises(ValidationError, match="below the required strength"): + _receipt( + contracts=_contract( + minimum_effect_strength=EffectStrengthV1.INDEPENDENT_SYSTEM_OF_RECORD, + observed_effect_strength=EffectStrengthV1.INDEPENDENT_SESSION, + ) + ) + + with pytest.raises(ValidationError, match="independently verified compensation"): + _receipt(outcome=ExecuteTerminalOutcomeV1.ROLLED_BACK_VERIFIED) + + rolled_back = _receipt( + outcome=ExecuteTerminalOutcomeV1.ROLLED_BACK_VERIFIED, + compensation_effect_verified=True, + ) + assert rolled_back.compensation_effect_verified is True + + +def test_reconciliation_requires_uncertainty_not_a_success_shaped_receipt() -> None: + with pytest.raises(ValidationError, match="requires an uncertain"): + _receipt(outcome=ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED) + + receipt = _receipt( + outcome=ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED, + delivery_uncertain=True, + contracts=_contract(effect_passed=False, observed_effect_strength=None), + ) + assert receipt.outcome is ExecuteTerminalOutcomeV1.RECONCILIATION_REQUIRED + + +def test_webhook_signature_binds_the_closed_state_payload() -> None: + key = b"k" * 32 + webhook = sign_execute_webhook_hmac( + key=key, + fields={ + "event_type": ExecuteWebhookEventTypeV1.EXECUTION_STATE_CHANGED, + "event_id": "event_12345678", + "delivery_attempt": 1, + "issued_at": "2026-07-29T12:00:00Z", + "issuer_key_id": "webhook_key_12345678", + "execution": { + "execution_id": "execution_12345678", + "state": "running", + "updated_at": "2026-07-29T12:00:00Z", + }, + }, + ) + assert webhook.verify_hmac(key) + assert not webhook.model_copy(update={"delivery_attempt": 2}).verify_hmac(key) + + +def test_packaged_openapi_is_the_generated_public_contract() -> None: + generated = execute_openapi_document() + packaged = files("openadapt_types.schemas").joinpath("execute-v1-openapi.json") + assert json.loads(packaged.read_text()) == generated + assert generated["x-openadapt-schema"] == "openadapt.execute-openapi/v1" + assert set(generated["paths"]) == { + "/v1/executions", + "/v1/executions/{execution_id}", + "/v1/executions/{execution_id}/receipt", + }