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/human_decision.py b/openadapt_types/human_decision.py index 6992711..b364ff0 100644 --- a/openadapt_types/human_decision.py +++ b/openadapt_types/human_decision.py @@ -154,6 +154,12 @@ class HumanDecisionAction(str, Enum): is wrong; that assertion changes future runs and belongs to ``teach``, which carries the demonstration and requalification gate such authority requires. + + ``reconcile`` asks the runner to re-establish the business effect after an + uncertain delivery. It is intentionally distinct from + ``verify_and_resume``: reconciliation may prove that the original action + already succeeded, and therefore must never imply permission to dispatch + that action again. """ VERIFY_AND_RESUME = "verify_and_resume" @@ -161,6 +167,7 @@ class HumanDecisionAction(str, Enum): REJECT = "reject" TEACH = "teach" ESCALATE = "escalate" + RECONCILE = "reconcile" class HumanDecisionDeliveryState(str, Enum): @@ -298,7 +305,7 @@ class HumanDecisionTaskV1(_StrictContract): #: can legitimately offer every member at once. Widening it is what a new #: member costs, and it is why a consumer must re-validate against the #: current schema rather than a cached copy. - allowed_actions: tuple[HumanDecisionAction, ...] = Field(min_length=1, max_length=5) + allowed_actions: tuple[HumanDecisionAction, ...] = Field(min_length=1, max_length=6) required_authn: HumanDecisionRequiredAuthn created_at: StrictStr = Field( min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN @@ -315,6 +322,13 @@ class HumanDecisionTaskV1(_StrictContract): def _validate_envelope(self) -> "HumanDecisionTaskV1": if len(set(self.allowed_actions)) != len(self.allowed_actions): raise ValueError("allowed_actions must be unique") + if ( + HumanDecisionAction.RECONCILE in self.allowed_actions + and self.delivery_state is HumanDecisionDeliveryState.NOT_DELIVERED + ): + raise ValueError( + "reconcile requires a delivered or delivery-uncertain action" + ) created_at = _parse_timestamp(self.created_at, "created_at") expires_at = _parse_timestamp(self.expires_at, "expires_at") if expires_at <= created_at: @@ -424,6 +438,7 @@ class HumanDecisionReceiptReason(str, Enum): PENDING_RUNNER = "pending_runner" VERIFIED_AND_RESUMED = "verified_and_resumed" SKIPPED_AND_RESUMED = "skipped_and_resumed" + RECONCILED_AND_RESUMED = "reconciled_and_resumed" CONTINUATION_HALTED = "continuation_halted" REVALIDATION_REFUSED = "revalidation_refused" EXPIRED = "expired" @@ -459,6 +474,7 @@ class HumanDecisionReceiptReason(str, Enum): { HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, HumanDecisionReceiptReason.SKIPPED_AND_RESUMED, + HumanDecisionReceiptReason.RECONCILED_AND_RESUMED, } ), HumanDecisionReceiptState.REFUSED: frozenset( @@ -509,6 +525,9 @@ class HumanDecisionReceiptV1(_StrictContract): The digests are one-way commitments, not content: they let a consumer bind this receipt to the exact capability, request, decision record, and transition receipt it came from without ever receiving those payloads. + A completed receipt requires ``report_success=true`` and a transition + receipt digest. A producer cannot use a success-shaped state without the + corresponding transition commitment. ``signature`` is optional because a runtime that returns a receipt over an authenticated loopback connection is already inside its own trust boundary @@ -546,13 +565,39 @@ def _validate_outcome(self) -> "HumanDecisionReceiptV1": f"reason_code {self.reason_code.value!r} is not a cause of state " f"{self.state.value!r}" ) - if ( - self.report_success - and self.state not in HUMAN_DECISION_RECEIPT_SUCCESS_STATES - ): + is_success = self.state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + if self.report_success and not is_success: raise ValueError( f"report_success cannot be true for state {self.state.value!r}" ) + if is_success and self.report_success is not True: + raise ValueError("a completed receipt requires report_success=true") + if is_success and self.transition_receipt_digest is None: + raise ValueError("a completed receipt requires a transition receipt digest") + completed_action = { + HumanDecisionReceiptReason.VERIFIED_AND_RESUMED: ( + HumanDecisionAction.VERIFY_AND_RESUME + ), + HumanDecisionReceiptReason.SKIPPED_AND_RESUMED: HumanDecisionAction.SKIP, + HumanDecisionReceiptReason.RECONCILED_AND_RESUMED: ( + HumanDecisionAction.RECONCILE + ), + } + expected_action = completed_action.get(self.reason_code) + if expected_action is not None and self.action is not expected_action: + raise ValueError( + f"reason_code {self.reason_code.value!r} requires action " + f"{expected_action.value!r}" + ) + terminal_action = { + HumanDecisionReceiptState.DEMONSTRATION_REQUESTED: HumanDecisionAction.TEACH, + HumanDecisionReceiptState.ESCALATED: HumanDecisionAction.ESCALATE, + HumanDecisionReceiptState.REJECTED: HumanDecisionAction.REJECT, + }.get(self.state) + if terminal_action is not None and self.action is not terminal_action: + raise ValueError( + f"state {self.state.value!r} requires action {terminal_action.value!r}" + ) _parse_timestamp(self.decided_at, "decided_at") return self @@ -560,7 +605,10 @@ def _validate_outcome(self) -> "HumanDecisionReceiptV1": def succeeded(self) -> bool: """Whether this receipt reports a successful workflow continuation.""" - return self.state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + return ( + self.state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + and self.report_success is True + ) def unsigned_payload(self) -> dict[str, Any]: """Return the deterministic language-agnostic signed payload.""" diff --git a/openadapt_types/schemas/execute-v1-openapi.json b/openadapt_types/schemas/execute-v1-openapi.json new file mode 100644 index 0000000..b958503 --- /dev/null +++ b/openadapt_types/schemas/execute-v1-openapi.json @@ -0,0 +1,1062 @@ +{ + "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.\n\n``reconcile`` asks the runner to re-establish the business effect after an\nuncertain delivery. It is intentionally distinct from\n``verify_and_resume``: reconciliation may prove that the original action\nalready succeeded, and therefore must never imply permission to dispatch\nthat action again.", + "enum": [ + "verify_and_resume", + "skip", + "reject", + "teach", + "escalate", + "reconcile" + ], + "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": 6, + "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/openadapt_types/schemas/human-decision-receipt-v1.json b/openadapt_types/schemas/human-decision-receipt-v1.json index 21ab995..378f87f 100644 --- a/openadapt_types/schemas/human-decision-receipt-v1.json +++ b/openadapt_types/schemas/human-decision-receipt-v1.json @@ -1,13 +1,14 @@ { "$defs": { "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.", + "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.\n\n``reconcile`` asks the runner to re-establish the business effect after an\nuncertain delivery. It is intentionally distinct from\n``verify_and_resume``: reconciliation may prove that the original action\nalready succeeded, and therefore must never imply permission to dispatch\nthat action again.", "enum": [ "verify_and_resume", "skip", "reject", "teach", - "escalate" + "escalate", + "reconcile" ], "title": "HumanDecisionAction", "type": "string" @@ -18,6 +19,7 @@ "pending_runner", "verified_and_resumed", "skipped_and_resumed", + "reconciled_and_resumed", "continuation_halted", "revalidation_refused", "expired", @@ -47,7 +49,7 @@ } }, "additionalProperties": false, - "description": "The closed, PHI-free terminal outcome of one attended decision.\n\nThis is the only decision result that may cross to a phone, a tray, or an\nauthenticated remote relay. It is a rebuilt value, not a filtered copy of\na runtime's audit record: there is no free-text field, no operator\nidentity, no workflow or step label, no parameter, no path, no observed\nvalue, and no evidence. Protected content is structurally unrepresentable\nrather than stripped on send, so a later field addition on the producer\nside cannot reopen a hole here.\n\n``action`` reuses :class:`HumanDecisionAction`, the same portable\nvocabulary the task advertises in ``allowed_actions``, so a consumer\ncompares the receipt against the task it answered without translating an\nengine-internal name.\n\nThe digests are one-way commitments, not content: they let a consumer bind\nthis receipt to the exact capability, request, decision record, and\ntransition receipt it came from without ever receiving those payloads.\n\n``signature`` is optional because a runtime that returns a receipt over an\nauthenticated loopback connection is already inside its own trust boundary\nand does not sign. A receipt that arrives over a network must be signed,\nand its consumer must verify it: see :meth:`verify_hmac`.", + "description": "The closed, PHI-free terminal outcome of one attended decision.\n\nThis is the only decision result that may cross to a phone, a tray, or an\nauthenticated remote relay. It is a rebuilt value, not a filtered copy of\na runtime's audit record: there is no free-text field, no operator\nidentity, no workflow or step label, no parameter, no path, no observed\nvalue, and no evidence. Protected content is structurally unrepresentable\nrather than stripped on send, so a later field addition on the producer\nside cannot reopen a hole here.\n\n``action`` reuses :class:`HumanDecisionAction`, the same portable\nvocabulary the task advertises in ``allowed_actions``, so a consumer\ncompares the receipt against the task it answered without translating an\nengine-internal name.\n\nThe digests are one-way commitments, not content: they let a consumer bind\nthis receipt to the exact capability, request, decision record, and\ntransition receipt it came from without ever receiving those payloads.\nA completed receipt requires ``report_success=true`` and a transition\nreceipt digest. A producer cannot use a success-shaped state without the\ncorresponding transition commitment.\n\n``signature`` is optional because a runtime that returns a receipt over an\nauthenticated loopback connection is already inside its own trust boundary\nand does not sign. A receipt that arrives over a network must be signed,\nand its consumer must verify it: see :meth:`verify_hmac`.", "properties": { "action": { "$ref": "#/$defs/HumanDecisionAction" diff --git a/openadapt_types/schemas/human-decision-task-v1.json b/openadapt_types/schemas/human-decision-task-v1.json index 855105d..76f44b3 100644 --- a/openadapt_types/schemas/human-decision-task-v1.json +++ b/openadapt_types/schemas/human-decision-task-v1.json @@ -1,13 +1,14 @@ { "$defs": { "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.", + "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.\n\n``reconcile`` asks the runner to re-establish the business effect after an\nuncertain delivery. It is intentionally distinct from\n``verify_and_resume``: reconciliation may prove that the original action\nalready succeeded, and therefore must never imply permission to dispatch\nthat action again.", "enum": [ "verify_and_resume", "skip", "reject", "teach", - "escalate" + "escalate", + "reconcile" ], "title": "HumanDecisionAction", "type": "string" @@ -256,7 +257,7 @@ "items": { "$ref": "#/$defs/HumanDecisionAction" }, - "maxItems": 5, + "maxItems": 6, "minItems": 1, "title": "Allowed Actions", "type": "array" 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", + } diff --git a/tests/test_human_decision.py b/tests/test_human_decision.py index 40df72a..980827d 100644 --- a/tests/test_human_decision.py +++ b/tests/test_human_decision.py @@ -309,6 +309,17 @@ def _receipt_fields() -> dict[str, object]: } +def _action_for_receipt_reason(reason: str | HumanDecisionReceiptReason) -> str: + value = reason.value if isinstance(reason, HumanDecisionReceiptReason) else reason + return { + "skipped_and_resumed": "skip", + "reconciled_and_resumed": "reconcile", + "demonstration_requested": "teach", + "escalation_recorded": "escalate", + "rejected_by_operator": "reject", + }.get(value, "verify_and_resume") + + #: Every terminal outcome the runtime engine really produces, in the exact #: (state, reason_code) pairing the receipt must accept. ``expired`` is #: reachable through admission rather than through an engine decision record, @@ -349,13 +360,7 @@ def test_receipt_represents_every_real_engine_terminal_outcome( "state": state, "reason_code": reason_code, "report_success": state == "completed", - "action": ( - "skip" - if reason_code == "skipped_and_resumed" - else "reject" - if reason_code == "rejected_by_operator" - else "verify_and_resume" - ), + "action": _action_for_receipt_reason(reason_code), } ) assert receipt.state.value == state @@ -369,6 +374,7 @@ def test_receipt_reason_code_is_closed_and_never_free_text() -> None: "pending_runner", "verified_and_resumed", "skipped_and_resumed", + "reconciled_and_resumed", "continuation_halted", "revalidation_refused", "expired", @@ -511,7 +517,10 @@ def test_receipt_state_and_reason_code_cannot_be_paired_arbitrarily() -> None: **_receipt_fields(), "state": state, "reason_code": reason, - "report_success": None, + "report_success": ( + True if state is HumanDecisionReceiptState.COMPLETED else None + ), + "action": _action_for_receipt_reason(reason), } if reason in reasons: assert HumanDecisionReceiptV1.model_validate(fields).state is state @@ -537,6 +546,7 @@ def test_receipt_cannot_report_success_outside_a_success_state() -> None: "state": state, "reason_code": reason, "report_success": True, + "action": _action_for_receipt_reason(reason), } if state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES: assert HumanDecisionReceiptV1.model_validate(fields).report_success is True @@ -559,6 +569,7 @@ def test_receipt_action_reuses_the_portable_task_vocabulary() -> None: "reject", "teach", "escalate", + "reconcile", } with pytest.raises(ValidationError): HumanDecisionReceiptV1.model_validate( @@ -624,6 +635,8 @@ def test_receipt_canonicalization_matches_the_documented_rules() -> None: key=b"k" * 32, fields={ **_receipt_fields(), + "state": HumanDecisionReceiptState.DELIVERY_UNCERTAIN, + "reason_code": HumanDecisionReceiptReason.DELIVERY_UNCERTAIN, "transition_receipt_digest": None, "report_success": None, }, @@ -733,12 +746,110 @@ def test_a_task_can_advertise_the_complete_action_vocabulary() -> None: the most choice. """ fields = _task_fields() + fields["delivery_state"] = HumanDecisionDeliveryState.UNKNOWN fields["allowed_actions"] = tuple(HumanDecisionAction) task = sign_human_decision_task_hmac(key=b"k" * 32, fields=fields) - assert len(task.allowed_actions) == len(HumanDecisionAction) == 5 + assert len(task.allowed_actions) == len(HumanDecisionAction) == 6 assert task.verify_hmac(b"k" * 32) +def test_reconcile_cannot_be_offered_when_no_action_was_delivered() -> None: + fields = _task_fields() + fields["allowed_actions"] = (HumanDecisionAction.RECONCILE,) + with pytest.raises( + ValidationError, + match="reconcile requires a delivered or delivery-uncertain action", + ): + sign_human_decision_task_hmac(key=b"k" * 32, fields=fields) + + for delivery_state in ( + HumanDecisionDeliveryState.DELIVERED, + HumanDecisionDeliveryState.UNKNOWN, + ): + task = sign_human_decision_task_hmac( + key=b"k" * 32, + fields={**fields, "delivery_state": delivery_state}, + ) + assert task.allowed_actions == (HumanDecisionAction.RECONCILE,) + + +def test_reconcile_is_a_distinct_effect_safe_success() -> None: + """Reconciliation proves the prior effect; it does not re-dispatch it.""" + receipt = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": HumanDecisionAction.RECONCILE, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": HumanDecisionReceiptReason.RECONCILED_AND_RESUMED, + "report_success": True, + } + ) + assert receipt.succeeded + + for missing_success_proof in ( + {"report_success": None}, + {"report_success": False}, + {"transition_receipt_digest": None}, + ): + with pytest.raises(ValidationError, match="completed receipt requires"): + HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": HumanDecisionAction.RECONCILE, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": ( + HumanDecisionReceiptReason.RECONCILED_AND_RESUMED + ), + "report_success": True, + **missing_success_proof, + } + ) + + for action in ( + HumanDecisionAction.VERIFY_AND_RESUME, + HumanDecisionAction.SKIP, + ): + with pytest.raises(ValidationError, match="requires action 'reconcile'"): + HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": action, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": HumanDecisionReceiptReason.RECONCILED_AND_RESUMED, + "report_success": True, + } + ) + + +@pytest.mark.parametrize( + ("action", "reason"), + [ + ( + HumanDecisionAction.SKIP, + HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, + ), + ( + HumanDecisionAction.VERIFY_AND_RESUME, + HumanDecisionReceiptReason.SKIPPED_AND_RESUMED, + ), + ], +) +def test_completed_receipt_reason_is_bound_to_the_action( + action: HumanDecisionAction, + reason: HumanDecisionReceiptReason, +) -> None: + with pytest.raises(ValidationError, match="requires action"): + HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": action, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": reason, + "report_success": True, + } + ) + + def test_rejected_is_terminal_and_distinct_from_escalated() -> None: """Parking a run and ending it must not project to the same receipt.