From efa8bcf4b8a6b1700d0a77ed3066bd1bcfb83c0d Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 27 Jul 2026 16:33:48 -0400 Subject: [PATCH] fix: close the attended terminal-outcome contract gap with HumanDecisionReceiptV1 The signed human-decision *task* contract shipped in 0.6.0/0.6.1, but the *receipt* that closes the round trip did not. `openadapt-flow` already returns a terminal receipt from its console decision route and had to define its own half locally, so no consumer outside Flow can validate the shape Flow actually produces. Cloud cannot check the one payload that reports whether an operator's decision resumed, halted, or may have been delivered. `HumanDecisionReceiptV1` closes that gap. It is a closed type in which protected content is structurally unrepresentable rather than stripped on send: no free-text field, `reason_code` a closed enum, every string closed by a pattern/const/enum, unknown fields rejected, RFC 3339 timestamps, and uncertainty a first-class terminal state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- openadapt_types/__init__.py | 14 + openadapt_types/human_decision.py | 288 ++++++++++- .../schemas/human-decision-receipt-v1.json | 160 ++++++ scripts/export_control_overlay_schemas.py | 2 + tests/test_human_decision.py | 465 +++++++++++++++++- 5 files changed, 896 insertions(+), 33 deletions(-) create mode 100644 openadapt_types/schemas/human-decision-receipt-v1.json diff --git a/openadapt_types/__init__.py b/openadapt_types/__init__.py index 1cfc3d0..5ae870c 100644 --- a/openadapt_types/__init__.py +++ b/openadapt_types/__init__.py @@ -81,18 +81,25 @@ from openadapt_types.episode import Episode, Step from openadapt_types.failure import FailureCategory, FailureRecord from openadapt_types.human_decision import ( + HUMAN_DECISION_RECEIPT_REASONS, + HUMAN_DECISION_RECEIPT_SCHEMA, + HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HUMAN_DECISION_TASK_SCHEMA, HumanDecisionAction, HumanDecisionDeliveryState, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionReceiptReason, + HumanDecisionReceiptState, + HumanDecisionReceiptV1, HumanDecisionRequiredAuthn, HumanDecisionRiskClass, HumanDecisionSafeSlotsV1, HumanDecisionSubstrate, HumanDecisionTaskKind, HumanDecisionTaskV1, + sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, ) from openadapt_types.parsing import ( @@ -166,18 +173,25 @@ "FailureCategory", "FailureRecord", # attended human decisions + "HUMAN_DECISION_RECEIPT_REASONS", + "HUMAN_DECISION_RECEIPT_SCHEMA", + "HUMAN_DECISION_RECEIPT_SUCCESS_STATES", "HUMAN_DECISION_TASK_SCHEMA", "HumanDecisionAction", "HumanDecisionDeliveryState", "HumanDecisionEvidenceSummaryV1", "HumanDecisionQuestionTemplate", "HumanDecisionQuestionV1", + "HumanDecisionReceiptReason", + "HumanDecisionReceiptState", + "HumanDecisionReceiptV1", "HumanDecisionRequiredAuthn", "HumanDecisionRiskClass", "HumanDecisionSafeSlotsV1", "HumanDecisionSubstrate", "HumanDecisionTaskKind", "HumanDecisionTaskV1", + "sign_human_decision_receipt_hmac", "sign_human_decision_task_hmac", # parsing "from_benchmark_action", diff --git a/openadapt_types/human_decision.py b/openadapt_types/human_decision.py index 3ba37d8..1ffaa6a 100644 --- a/openadapt_types/human_decision.py +++ b/openadapt_types/human_decision.py @@ -1,26 +1,44 @@ -"""Privacy-safe, signed tasks for attended human decisions. +"""Privacy-safe, signed contracts for attended human decisions. -The task is a portable projection of an existing runtime pause. It is not an -execution capability: a consumer must still present the runtime's separately -issued capability, satisfy its authentication policy, and pass fresh -revalidation before actuation. Free-form text, screenshots, identifiers, and -observed values deliberately do not belong in this contract. +Two models close one round trip: + +* :class:`HumanDecisionTaskV1` — the question. A portable projection of an + existing runtime pause. It is not an execution capability: a consumer must + still present the runtime's separately issued capability, satisfy its + authentication policy, and pass fresh revalidation before actuation. +* :class:`HumanDecisionReceiptV1` — the answer's terminal outcome. The only + decision result that may cross to a phone, a tray, or an authenticated + remote relay. + +Free-form text, screenshots, identifiers, and observed values deliberately do +not belong in either contract. Cloud-safe by construction -------------------------- -There is exactly one task model and it is the Cloud-safe one; there is no -"local extension" variant that a producer could accidentally relay. Every -string-typed field is a ``Literal``, an ``Enum`` member, or carries an explicit -``pattern``, and every model forbids unknown fields. Raw values, OCR text, -screenshots, and operator prose therefore have no field to travel in, and a new -field cannot quietly open one: ``tests/test_human_decision.py`` walks the -exported JSON Schema and fails on any string that is not closed by a pattern, -``const``, or ``enum``. +There is exactly one task model and exactly one receipt model, and both are the +Cloud-safe ones; there is no "local extension" variant that a producer could +accidentally relay. Every string-typed field is a ``Literal``, an ``Enum`` +member, or carries an explicit ``pattern``, and every model forbids unknown +fields. Raw values, OCR text, screenshots, and operator prose therefore have no +field to travel in, and a new field cannot quietly open one: +``tests/test_human_decision.py`` walks both exported JSON Schemas and fails on +any string that is not closed by a pattern, ``const``, or ``enum``. + +The receipt matters most here. A runtime's own decision record is an +append-only audit artifact that carries operator prose and an operator +principal on purpose (Flow's ``AttendedDecision`` has both). A producer must +therefore *rebuild* a receipt rather than redact its audit record field by +field: strip-on-send stays correct only until someone adds a field, whereas a +closed target type has no such failure mode. ``reason_code`` is a closed enum +precisely so a consumer renders deterministic copy instead of relaying a +runtime's free-text message. Signature canonicalization (normative) -------------------------------------- -An HMAC over this task must be reproducible byte-for-byte in any language, so -the encoding is fixed rather than left to a JSON library's defaults: +An HMAC over either contract must be reproducible byte-for-byte in any +language, so the encoding is fixed rather than left to a JSON library's +defaults. The rules below are written for the task; the receipt uses the same +rules verbatim, with its own domain separator: 1. Start from ``unsigned_payload()``: the model serialized in JSON mode with ``signature`` removed. Every other field is always present, including those @@ -37,8 +55,11 @@ 5. Encode the result as UTF-8. 6. Prepend the domain separator ``b"openadapt.human-decision-task/v1\\x00"`` before computing the HMAC, so a signature over this contract can never be - replayed as a signature over a different OpenAdapt payload. The digest in - :attr:`HumanDecisionTaskV1.digest` is taken over the canonical bytes + replayed as a signature over a different OpenAdapt payload. The receipt's + separator is ``b"openadapt.human-decision-receipt/v1\\x00"``, so a task + signature can never be replayed as a receipt signature either. The digests + in :attr:`HumanDecisionTaskV1.digest` and + :attr:`HumanDecisionReceiptV1.digest` are taken over the canonical bytes *without* the domain separator. Only integers and booleans appear as non-string scalars, and every integer is @@ -49,9 +70,10 @@ the signed bytes. ``tests/test_human_decision.py`` pins the exact canonical bytes and the exact -signature hex of a fixed task. Any change to field names, field order handling, -escaping, or the domain separator will fail that vector, which is the intended -signal to cut a new schema version rather than to silently re-sign. +signature hex of one fixed task and one fixed receipt. Any change to field +names, field order handling, escaping, or a domain separator will fail those +vectors, which is the intended signal to cut a new schema version rather than +to silently re-sign. """ from __future__ import annotations @@ -74,7 +96,9 @@ ) HUMAN_DECISION_TASK_SCHEMA = "openadapt.human-decision-task/v1" +HUMAN_DECISION_RECEIPT_SCHEMA = "openadapt.human-decision-receipt/v1" _SIGNING_DOMAIN = b"openadapt.human-decision-task/v1\x00" +_RECEIPT_SIGNING_DOMAIN = b"openadapt.human-decision-receipt/v1\x00" _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}$" @@ -294,7 +318,7 @@ def verify_hmac(self, key: bytes) -> bool: """Verify the task signature without granting execution authority.""" _validate_hmac_key(key) - expected = _hmac_signature(key, self.unsigned_payload()) + expected = _hmac_signature(key, self.unsigned_payload(), _SIGNING_DOMAIN) return hmac.compare_digest(expected, self.signature) @@ -303,8 +327,15 @@ def _validate_hmac_key(key: bytes) -> None: raise ValueError("human decision HMAC key must contain at least 32 bytes") -def _hmac_signature(key: bytes, payload: Mapping[str, Any]) -> str: - digest = hmac.new(key, _SIGNING_DOMAIN + _canonical_json(payload), hashlib.sha256) +def _hmac_signature(key: bytes, payload: Mapping[str, Any], domain: bytes) -> str: + """Sign one canonical payload under an explicit, contract-specific domain. + + ``domain`` is a required positional argument rather than a default so that + adding a third contract cannot silently reuse the task's separator and make + one contract's signature replayable as another's. + """ + + digest = hmac.new(key, domain + _canonical_json(payload), hashlib.sha256) return "hmac-sha256:" + digest.hexdigest() @@ -323,5 +354,212 @@ def sign_human_decision_task_hmac( {**unsigned, "signature": "hmac-sha256:" + "0" * 64} ) canonical_unsigned = validated.unsigned_payload() - signature = _hmac_signature(key, canonical_unsigned) + signature = _hmac_signature(key, canonical_unsigned, _SIGNING_DOMAIN) + return validated.model_copy(update={"signature": signature}) + + +class HumanDecisionReceiptState(str, Enum): + """Terminal outcome of one attended decision, as the runtime reports it. + + ``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two + non-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN`` + is the "may have been sent" state: it exists so that losing the network + after a tap can be reported honestly instead of collapsing into either a + success or a refusal. + + ``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states + rather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or + ``escalate`` decision is durably recorded and returns immediately without + any runner continuation pending, so folding them into "accepted, pending" + would tell an operator to wait for something that is never coming. + """ + + ACCEPTED_PENDING_RUNNER = "accepted_pending_runner" + COMPLETED = "completed" + REFUSED = "refused" + HALTED = "halted" + EXPIRED = "expired" + DELIVERY_UNCERTAIN = "delivery_uncertain" + DEMONSTRATION_REQUESTED = "demonstration_requested" + ESCALATED = "escalated" + + +class HumanDecisionReceiptReason(str, Enum): + """Closed cause for a terminal state; the consumer renders copy from it. + + This is the field that would otherwise be a free-text ``message``. It is a + closed enum, never an arbitrary string, so a runtime cannot relay operator + prose, an exception message, an observed value, or a record identifier + through the receipt's explanation. + """ + + PENDING_RUNNER = "pending_runner" + VERIFIED_AND_RESUMED = "verified_and_resumed" + SKIPPED_AND_RESUMED = "skipped_and_resumed" + CONTINUATION_HALTED = "continuation_halted" + REVALIDATION_REFUSED = "revalidation_refused" + EXPIRED = "expired" + DELIVERY_UNCERTAIN = "delivery_uncertain" + DEMONSTRATION_REQUESTED = "demonstration_requested" + ESCALATION_RECORDED = "escalation_recorded" + + +#: Which causes each terminal state may carry. +#: +#: ``state`` and ``reason_code`` are separate fields because one state can have +#: more than one cause -- ``completed`` is reached both by resuming after +#: verification and by resuming after a skip, and a consumer must be able to +#: tell those apart without parsing prose. They are not independent, though: an +#: unconstrained pair would let a producer emit ``completed`` with reason +#: ``expired``, so the permitted combinations are pinned here and enforced by a +#: model validator. +HUMAN_DECISION_RECEIPT_REASONS: Mapping[ + HumanDecisionReceiptState, frozenset[HumanDecisionReceiptReason] +] = { + HumanDecisionReceiptState.ACCEPTED_PENDING_RUNNER: frozenset( + {HumanDecisionReceiptReason.PENDING_RUNNER} + ), + HumanDecisionReceiptState.COMPLETED: frozenset( + { + HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, + HumanDecisionReceiptReason.SKIPPED_AND_RESUMED, + } + ), + HumanDecisionReceiptState.REFUSED: frozenset( + {HumanDecisionReceiptReason.REVALIDATION_REFUSED} + ), + HumanDecisionReceiptState.HALTED: frozenset( + {HumanDecisionReceiptReason.CONTINUATION_HALTED} + ), + HumanDecisionReceiptState.EXPIRED: frozenset({HumanDecisionReceiptReason.EXPIRED}), + HumanDecisionReceiptState.DELIVERY_UNCERTAIN: frozenset( + {HumanDecisionReceiptReason.DELIVERY_UNCERTAIN} + ), + HumanDecisionReceiptState.DEMONSTRATION_REQUESTED: frozenset( + {HumanDecisionReceiptReason.DEMONSTRATION_REQUESTED} + ), + HumanDecisionReceiptState.ESCALATED: frozenset( + {HumanDecisionReceiptReason.ESCALATION_RECORDED} + ), +} + +#: States that report a *successful* effect on the workflow. Everything else is +#: a non-success outcome, so a consumer that wants "did this work?" reads this +#: set rather than inferring from the absence of an error string. +HUMAN_DECISION_RECEIPT_SUCCESS_STATES: frozenset[HumanDecisionReceiptState] = frozenset( + {HumanDecisionReceiptState.COMPLETED} +) + + +class HumanDecisionReceiptV1(_StrictContract): + """The closed, PHI-free terminal outcome of one attended decision. + + This is the only decision result that may cross to a phone, a tray, or an + authenticated remote relay. It is a rebuilt value, not a filtered copy of + a runtime's audit record: there is no free-text field, no operator + identity, no workflow or step label, no parameter, no path, no observed + value, and no evidence. Protected content is structurally unrepresentable + rather than stripped on send, so a later field addition on the producer + side cannot reopen a hole here. + + ``action`` reuses :class:`HumanDecisionAction`, the same portable + vocabulary the task advertises in ``allowed_actions``, so a consumer + compares the receipt against the task it answered without translating an + engine-internal name. + + 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. + + ``signature`` is optional because a runtime that returns a receipt over an + authenticated loopback connection is already inside its own trust boundary + and does not sign. A receipt that arrives over a network must be signed, + and its consumer must verify it: see :meth:`verify_hmac`. + """ + + schema_version: Literal["openadapt.human-decision-receipt/v1"] = ( + HUMAN_DECISION_RECEIPT_SCHEMA + ) + task_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + task_revision: StrictInt = Field(default=1, ge=1) + pause_id: StrictStr = Field(pattern=_OPAQUE_ID_PATTERN) + capability_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + request_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + decision_digest: StrictStr = Field(pattern=_SHA256_PATTERN) + transition_receipt_digest: StrictStr | None = Field( + default=None, pattern=_SHA256_PATTERN + ) + action: HumanDecisionAction + state: HumanDecisionReceiptState + reason_code: HumanDecisionReceiptReason + report_success: StrictBool | None = None + decided_at: StrictStr = Field( + min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN + ) + signature_algorithm: Literal["hmac-sha256"] = "hmac-sha256" + signature: StrictStr | None = Field(default=None, pattern=_SIGNATURE_PATTERN) + + @model_validator(mode="after") + def _validate_outcome(self) -> "HumanDecisionReceiptV1": + permitted = HUMAN_DECISION_RECEIPT_REASONS[self.state] + if self.reason_code not in permitted: + raise ValueError( + 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 + ): + raise ValueError( + f"report_success cannot be true for state {self.state.value!r}" + ) + _parse_timestamp(self.decided_at, "decided_at") + return self + + @property + def succeeded(self) -> bool: + """Whether this receipt reports a successful workflow continuation.""" + + return self.state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES + + def unsigned_payload(self) -> dict[str, Any]: + """Return the deterministic language-agnostic signed payload.""" + + return self.model_dump(mode="json", exclude={"signature"}) + + def canonical_unsigned_bytes(self) -> bytes: + return _canonical_json(self.unsigned_payload()) + + @property + def digest(self) -> str: + return "sha256:" + hashlib.sha256(self.canonical_unsigned_bytes()).hexdigest() + + def verify_hmac(self, key: bytes) -> bool: + """Verify the receipt signature. An unsigned receipt never verifies.""" + + _validate_hmac_key(key) + if self.signature is None: + return False + expected = _hmac_signature( + key, self.unsigned_payload(), _RECEIPT_SIGNING_DOMAIN + ) + return hmac.compare_digest(expected, self.signature) + + +def sign_human_decision_receipt_hmac( + *, key: bytes, fields: Mapping[str, Any] +) -> HumanDecisionReceiptV1: + """Validate and sign a receipt using the local-v1 HMAC profile.""" + + _validate_hmac_key(key) + if "signature" in fields: + raise ValueError("fields must not contain a signature") + unsigned = dict(fields) + unsigned.setdefault("schema_version", HUMAN_DECISION_RECEIPT_SCHEMA) + unsigned.setdefault("signature_algorithm", "hmac-sha256") + validated = HumanDecisionReceiptV1.model_validate(unsigned) + signature = _hmac_signature( + key, validated.unsigned_payload(), _RECEIPT_SIGNING_DOMAIN + ) return validated.model_copy(update={"signature": signature}) diff --git a/openadapt_types/schemas/human-decision-receipt-v1.json b/openadapt_types/schemas/human-decision-receipt-v1.json new file mode 100644 index 0000000..807a074 --- /dev/null +++ b/openadapt_types/schemas/human-decision-receipt-v1.json @@ -0,0 +1,160 @@ +{ + "$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.", + "enum": [ + "verify_and_resume", + "skip", + "teach", + "escalate" + ], + "title": "HumanDecisionAction", + "type": "string" + }, + "HumanDecisionReceiptReason": { + "description": "Closed cause for a terminal state; the consumer renders copy from it.\n\nThis is the field that would otherwise be a free-text ``message``. It is a\nclosed enum, never an arbitrary string, so a runtime cannot relay operator\nprose, an exception message, an observed value, or a record identifier\nthrough the receipt's explanation.", + "enum": [ + "pending_runner", + "verified_and_resumed", + "skipped_and_resumed", + "continuation_halted", + "revalidation_refused", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalation_recorded" + ], + "title": "HumanDecisionReceiptReason", + "type": "string" + }, + "HumanDecisionReceiptState": { + "description": "Terminal outcome of one attended decision, as the runtime reports it.\n\n``ACCEPTED_PENDING_RUNNER`` and ``DELIVERY_UNCERTAIN`` are the two\nnon-success outcomes a phone must be able to show. ``DELIVERY_UNCERTAIN``\nis the \"may have been sent\" state: it exists so that losing the network\nafter a tap can be reported honestly instead of collapsing into either a\nsuccess or a refusal.\n\n``DEMONSTRATION_REQUESTED`` and ``ESCALATED`` are separate terminal states\nrather than variants of ``ACCEPTED_PENDING_RUNNER``: a ``teach`` or\n``escalate`` decision is durably recorded and returns immediately without\nany runner continuation pending, so folding them into \"accepted, pending\"\nwould tell an operator to wait for something that is never coming.", + "enum": [ + "accepted_pending_runner", + "completed", + "refused", + "halted", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalated" + ], + "title": "HumanDecisionReceiptState", + "type": "string" + } + }, + "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`.", + "properties": { + "action": { + "$ref": "#/$defs/HumanDecisionAction" + }, + "capability_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Capability Digest", + "type": "string" + }, + "decided_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": "Decided At", + "type": "string" + }, + "decision_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Decision Digest", + "type": "string" + }, + "pause_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Pause Id", + "type": "string" + }, + "reason_code": { + "$ref": "#/$defs/HumanDecisionReceiptReason" + }, + "report_success": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Report Success" + }, + "request_digest": { + "pattern": "^sha256:[0-9a-f]{64}$", + "title": "Request Digest", + "type": "string" + }, + "schema_version": { + "const": "openadapt.human-decision-receipt/v1", + "default": "openadapt.human-decision-receipt/v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "anyOf": [ + { + "pattern": "^hmac-sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Signature" + }, + "signature_algorithm": { + "const": "hmac-sha256", + "default": "hmac-sha256", + "title": "Signature Algorithm", + "type": "string" + }, + "state": { + "$ref": "#/$defs/HumanDecisionReceiptState" + }, + "task_id": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$", + "title": "Task Id", + "type": "string" + }, + "task_revision": { + "default": 1, + "minimum": 1, + "title": "Task Revision", + "type": "integer" + }, + "transition_receipt_digest": { + "anyOf": [ + { + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transition Receipt Digest" + } + }, + "required": [ + "task_id", + "pause_id", + "capability_digest", + "request_digest", + "decision_digest", + "action", + "state", + "reason_code", + "decided_at" + ], + "title": "HumanDecisionReceiptV1", + "type": "object" +} diff --git a/scripts/export_control_overlay_schemas.py b/scripts/export_control_overlay_schemas.py index f42177f..49a7d12 100644 --- a/scripts/export_control_overlay_schemas.py +++ b/scripts/export_control_overlay_schemas.py @@ -10,6 +10,7 @@ ControlOverlayFrameV2, ControlOverlayTimelineV1, ControlOverlayTimelineV2, + HumanDecisionReceiptV1, HumanDecisionTaskV1, ) @@ -21,6 +22,7 @@ "control-overlay-frame-v2.json": ControlOverlayFrameV2, "control-overlay-timeline-v2.json": ControlOverlayTimelineV2, "human-decision-task-v1.json": HumanDecisionTaskV1, + "human-decision-receipt-v1.json": HumanDecisionReceiptV1, } diff --git a/tests/test_human_decision.py b/tests/test_human_decision.py index d16f49a..65280cb 100644 --- a/tests/test_human_decision.py +++ b/tests/test_human_decision.py @@ -1,5 +1,7 @@ """Durable tests for the attended human-decision wire contract.""" +import hashlib +import hmac import json from importlib.resources import files @@ -7,15 +9,21 @@ from pydantic import ValidationError from openadapt_types import ( + HUMAN_DECISION_RECEIPT_REASONS, + HUMAN_DECISION_RECEIPT_SUCCESS_STATES, HumanDecisionAction, HumanDecisionDeliveryState, HumanDecisionEvidenceSummaryV1, HumanDecisionQuestionTemplate, HumanDecisionQuestionV1, + HumanDecisionReceiptReason, + HumanDecisionReceiptState, + HumanDecisionReceiptV1, HumanDecisionRequiredAuthn, HumanDecisionSafeSlotsV1, HumanDecisionTaskKind, HumanDecisionTaskV1, + sign_human_decision_receipt_hmac, sign_human_decision_task_hmac, ) @@ -149,13 +157,11 @@ def test_cloud_safe_task_rejects_each_forbidden_category(category: str) -> None: HumanDecisionTaskV1.model_validate({**base, "evidence": nested_evidence}) -def test_no_declared_string_field_accepts_free_text() -> None: - """The exported schema is the contract a non-Python consumer validates. +def unconstrained_string_paths(schema: object) -> list[str]: + """Walk an exported JSON Schema and report every string left open. - Closing unknown fields is not sufficient on its own: a *declared* string - with no pattern would let protected content travel inside an accepted - field. Every string in the contract must therefore be closed by a pattern, - a ``const``, or an ``enum``. + Shared by the task and the receipt so both contracts are held to the same + structural rule and neither can drift away from it independently. """ unconstrained: list[str] = [] @@ -171,8 +177,19 @@ def visit(node: object, path: str) -> None: for index, value in enumerate(node): visit(value, f"{path}/{index}") - visit(HumanDecisionTaskV1.model_json_schema(), "") - assert unconstrained == [] + visit(schema, "") + return unconstrained + + +def test_no_declared_string_field_accepts_free_text() -> None: + """The exported schema is the contract a non-Python consumer validates. + + Closing unknown fields is not sufficient on its own: a *declared* string + with no pattern would let protected content travel inside an accepted + field. Every string in the contract must therefore be closed by a pattern, + a ``const``, or an ``enum``. + """ + assert unconstrained_string_paths(HumanDecisionTaskV1.model_json_schema()) == [] @pytest.mark.parametrize( @@ -265,3 +282,435 @@ def test_delivery_uncertainty_is_a_first_class_value() -> None: fields={**_task_fields(), "delivery_state": HumanDecisionDeliveryState.UNKNOWN}, ) assert uncertain.model_dump(mode="json")["delivery_state"] == "unknown" + + +# ── HumanDecisionReceiptV1 ─────────────────────────────────────────── +# +# The receipt closes the round trip the task opened. The runtime's own +# decision record is an append-only audit artifact that carries a free-text +# message and the operator principal on purpose; neither may reach a phone or +# Cloud. Every test below asserts that the receipt cannot carry them at all, +# rather than that some producer removed them on the way out. + + +def _receipt_fields() -> dict[str, object]: + return { + "task_id": "task_12345678", + "pause_id": "pause_1234567", + "capability_digest": "sha256:" + "a" * 64, + "request_digest": "sha256:" + "c" * 64, + "decision_digest": "sha256:" + "d" * 64, + "transition_receipt_digest": "sha256:" + "e" * 64, + "action": HumanDecisionAction.VERIFY_AND_RESUME, + "state": HumanDecisionReceiptState.COMPLETED, + "reason_code": HumanDecisionReceiptReason.VERIFIED_AND_RESUMED, + "report_success": True, + "decided_at": "2026-07-26T12:03:00Z", + } + + +#: 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, +#: and is included because a consumer still has to render it. +ENGINE_TERMINAL_OUTCOMES: tuple[tuple[str, str, str], ...] = ( + ("prepared", "accepted_pending_runner", "pending_runner"), + ("delivery_started", "delivery_uncertain", "delivery_uncertain"), + ("delivery_uncertain", "delivery_uncertain", "delivery_uncertain"), + ("completed", "completed", "verified_and_resumed"), + ("completed/skip", "completed", "skipped_and_resumed"), + ("refused", "refused", "revalidation_refused"), + ("halted", "halted", "continuation_halted"), + ("needs_demonstration", "demonstration_requested", "demonstration_requested"), + ("escalated", "escalated", "escalation_recorded"), + ("", "expired", "expired"), +) + + +@pytest.mark.parametrize( + ("engine_status", "state", "reason_code"), ENGINE_TERMINAL_OUTCOMES +) +def test_receipt_represents_every_real_engine_terminal_outcome( + engine_status: str, state: str, reason_code: str +) -> None: + """No real terminal outcome has to be collapsed into a different one. + + ``demonstration_requested`` and ``escalated`` are separate states because + a ``teach`` or ``escalate`` decision is recorded and returned immediately + with no runner continuation pending; reporting them as + ``accepted_pending_runner`` would tell the operator to wait for something + that is never coming. + """ + del engine_status + receipt = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": state, + "reason_code": reason_code, + "report_success": state == "completed", + "action": ( + "skip" if reason_code == "skipped_and_resumed" else "verify_and_resume" + ), + } + ) + assert receipt.state.value == state + assert receipt.reason_code.value == reason_code + assert receipt.succeeded is (state == "completed") + + +def test_receipt_reason_code_is_closed_and_never_free_text() -> None: + """``reason_code`` is the field that would otherwise be a message.""" + assert {reason.value for reason in HumanDecisionReceiptReason} == { + "pending_runner", + "verified_and_resumed", + "skipped_and_resumed", + "continuation_halted", + "revalidation_refused", + "expired", + "delivery_uncertain", + "demonstration_requested", + "escalation_recorded", + } + + for prose in ( + "resumed after the operator fixed the coverage row", + "Patient J. Doe: claim 88213 adjudicated", + "", + "verified_and_resumed ", + ): + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "reason_code": prose} + ) + + schema = HumanDecisionReceiptV1.model_json_schema() + reason_schema = schema["$defs"]["HumanDecisionReceiptReason"] + assert set(reason_schema["enum"]) == { + reason.value for reason in HumanDecisionReceiptReason + } + assert "pattern" not in reason_schema + + +def test_receipt_has_no_free_text_field_at_all() -> None: + """Enumerated per field, so adding an open string fails here immediately.""" + schema = HumanDecisionReceiptV1.model_json_schema() + assert schema["additionalProperties"] is False + assert unconstrained_string_paths(schema) == [] + + # The two protected fields the runtime's audit record carries by design. + assert "message" not in schema["properties"] + assert "operator" not in schema["properties"] + for forbidden in ("screenshot", "ocr", "patient", "mrn"): + assert forbidden not in json.dumps(schema).lower() + + +@pytest.mark.parametrize("category", sorted(FORBIDDEN_EVIDENCE_FIELDS)) +def test_receipt_rejects_each_forbidden_category(category: str) -> None: + """The same enumerated categories the task refuses, refused structurally.""" + base = HumanDecisionReceiptV1.model_validate(_receipt_fields()).model_dump( + mode="json" + ) + for field, value in FORBIDDEN_EVIDENCE_FIELDS[category].items(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + HumanDecisionReceiptV1.model_validate({**base, field: value}) + + +@pytest.mark.parametrize( + "value", + [ + "J DOE MRN 0093211 XY", + "2026-07-26 patient jd", + "coverage active as of", + "2026-07-26T12:00:00", + ], +) +def test_receipt_timestamps_cannot_smuggle_text_or_drop_their_offset( + value: str, +) -> None: + """``decided_at`` carries the same RFC 3339 pattern 0.6.1 established. + + The pattern lives on the field, so it reaches the exported schema and a + non-Python consumer inherits it rather than only a length bound. + """ + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "decided_at": value} + ) + + exported = HumanDecisionReceiptV1.model_json_schema()["properties"]["decided_at"] + assert exported["pattern"] == ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$" + ) + + +def test_receipt_delivery_uncertainty_is_a_first_class_value() -> None: + """ "May have been sent" must be statable as a terminal outcome. + + The acceptance criterion is that losing the network after a tap shows + pending/uncertain and never success. A receipt that could not express + uncertainty would force the producer to pick success or refusal, so the + three-state vocabulary is asserted on both the task and the receipt. + """ + assert {state.value for state in HumanDecisionDeliveryState} == { + "not_delivered", + "delivered", + "unknown", + } + + uncertain = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": HumanDecisionReceiptState.DELIVERY_UNCERTAIN, + "reason_code": HumanDecisionReceiptReason.DELIVERY_UNCERTAIN, + "report_success": None, + "transition_receipt_digest": None, + } + ) + assert uncertain.model_dump(mode="json")["state"] == "delivery_uncertain" + assert uncertain.succeeded is False + + pending = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "state": HumanDecisionReceiptState.ACCEPTED_PENDING_RUNNER, + "reason_code": HumanDecisionReceiptReason.PENDING_RUNNER, + "report_success": None, + } + ) + assert pending.succeeded is False + + for required in ("state", "reason_code"): + fields = _receipt_fields() + del fields[required] + with pytest.raises(ValidationError, match=required): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_state_and_reason_code_cannot_be_paired_arbitrarily() -> None: + """Two fields, but not two independent fields. + + They are split because one state has more than one cause: ``completed`` is + reached both by resuming after verification and by resuming after a skip, + and a consumer must tell those apart without parsing prose. Leaving the + pair unconstrained would let a producer emit ``completed`` with reason + ``expired``, so the permitted combinations are pinned. + """ + assert set(HUMAN_DECISION_RECEIPT_REASONS) == set(HumanDecisionReceiptState) + covered = set().union(*HUMAN_DECISION_RECEIPT_REASONS.values()) + assert covered == set(HumanDecisionReceiptReason) + + for state, reasons in HUMAN_DECISION_RECEIPT_REASONS.items(): + for reason in HumanDecisionReceiptReason: + fields = { + **_receipt_fields(), + "state": state, + "reason_code": reason, + "report_success": None, + } + if reason in reasons: + assert HumanDecisionReceiptV1.model_validate(fields).state is state + else: + with pytest.raises(ValidationError, match="is not a cause of state"): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_cannot_report_success_outside_a_success_state() -> None: + """A non-success terminal outcome can never carry ``report_success``. + + "Runnable is not certified": an uncertain delivery, a halt, or a refusal + must not be renderable as a success by a consumer that only reads the + boolean. + """ + assert HUMAN_DECISION_RECEIPT_SUCCESS_STATES == frozenset( + {HumanDecisionReceiptState.COMPLETED} + ) + for state in HumanDecisionReceiptState: + reason = sorted(HUMAN_DECISION_RECEIPT_REASONS[state], key=lambda r: r.value)[0] + fields = { + **_receipt_fields(), + "state": state, + "reason_code": reason, + "report_success": True, + } + if state in HUMAN_DECISION_RECEIPT_SUCCESS_STATES: + assert HumanDecisionReceiptV1.model_validate(fields).report_success is True + else: + with pytest.raises(ValidationError, match="report_success cannot be true"): + HumanDecisionReceiptV1.model_validate(fields) + + +def test_receipt_action_reuses_the_portable_task_vocabulary() -> None: + """A consumer compares the receipt against the task without translating. + + The engine's internal ``continue`` has no representation here; the only + action names are the ones a task advertises in ``allowed_actions``. + """ + receipt_action = HumanDecisionReceiptV1.model_json_schema()["properties"]["action"] + assert receipt_action["$ref"] == "#/$defs/HumanDecisionAction" + assert {action.value for action in HumanDecisionAction} == { + "verify_and_resume", + "skip", + "teach", + "escalate", + } + with pytest.raises(ValidationError): + HumanDecisionReceiptV1.model_validate( + {**_receipt_fields(), "action": "continue"} + ) + + +def test_packaged_receipt_schema_matches_the_exported_contract() -> None: + schema = HumanDecisionReceiptV1.model_json_schema() + packaged = files("openadapt_types.schemas").joinpath( + "human-decision-receipt-v1.json" + ) + assert json.loads(packaged.read_text()) == schema + + +#: One frozen vector pinning the receipt canonicalization, exactly as the task +#: contract does. A cross-language implementation is correct when it reproduces +#: these bytes, this digest, and this signature hex. +RECEIPT_VECTOR_BYTES = ( + b'{"action":"verify_and_resume",' + b'"capability_digest":"sha256:' + b"a" * 64 + b'",' + b'"decided_at":"2026-07-26T12:03:00Z",' + b'"decision_digest":"sha256:' + b"d" * 64 + b'",' + b'"pause_id":"pause_1234567","reason_code":"verified_and_resumed",' + b'"report_success":true,' + b'"request_digest":"sha256:' + b"c" * 64 + b'",' + b'"schema_version":"openadapt.human-decision-receipt/v1",' + b'"signature_algorithm":"hmac-sha256","state":"completed",' + b'"task_id":"task_12345678","task_revision":1,' + b'"transition_receipt_digest":"sha256:' + b"e" * 64 + b'"}' +) +RECEIPT_VECTOR_DIGEST = ( + "sha256:1fc191fc298e155c305a744e816bf58c09ac07a68b9096d13f34aa0eba6797c6" +) +RECEIPT_VECTOR_SIGNATURE = ( + "hmac-sha256:385e7802b4c1ec8ff557f4c15e8b1e8c63d87fbffb08b9d61df27c59e9c44c34" +) + + +def test_receipt_canonical_bytes_and_signature_are_pinned_for_other_languages() -> None: + receipt = sign_human_decision_receipt_hmac(key=b"k" * 32, fields=_receipt_fields()) + + assert receipt.canonical_unsigned_bytes() == RECEIPT_VECTOR_BYTES + assert receipt.digest == RECEIPT_VECTOR_DIGEST + assert receipt.signature == RECEIPT_VECTOR_SIGNATURE + assert receipt.verify_hmac(b"k" * 32) + + +def test_receipt_canonicalization_matches_the_documented_rules() -> None: + """Assert the properties a reimplementation has to match, not just bytes.""" + canonical = sign_human_decision_receipt_hmac( + key=b"k" * 32, fields=_receipt_fields() + ).canonical_unsigned_bytes() + + assert canonical.decode("ascii") + assert b", " not in canonical and b": " not in canonical + payload = json.loads(canonical) + assert list(payload) == sorted(payload) + assert "signature" not in payload + # Optional fields are present with an explicit null, never omitted. + nullable = json.loads( + sign_human_decision_receipt_hmac( + key=b"k" * 32, + fields={ + **_receipt_fields(), + "transition_receipt_digest": None, + "report_success": None, + }, + ).canonical_unsigned_bytes() + ) + assert nullable["transition_receipt_digest"] is None + assert nullable["report_success"] is None + + +def test_receipt_signature_is_domain_separated_from_the_task_signature() -> None: + """A task signature can never be replayed as a receipt signature.""" + key = b"k" * 32 + receipt = sign_human_decision_receipt_hmac(key=key, fields=_receipt_fields()) + + assert receipt.verify_hmac(key) + assert not receipt.verify_hmac(b"j" * 32) + + tampered = receipt.model_copy(update={"state": HumanDecisionReceiptState.REFUSED}) + assert not tampered.verify_hmac(key) + + # Same canonical payload, task domain instead of the receipt domain. + task_domain_signature = ( + "hmac-sha256:" + + hmac.new( + key, + b"openadapt.human-decision-task/v1\x00" + + receipt.canonical_unsigned_bytes(), + hashlib.sha256, + ).hexdigest() + ) + assert task_domain_signature != receipt.signature + assert not receipt.model_copy( + update={"signature": task_domain_signature} + ).verify_hmac(key) + + +def test_unsigned_receipt_never_verifies() -> None: + """The local loopback receipt is unsigned; it must not pass as signed.""" + unsigned = HumanDecisionReceiptV1.model_validate(_receipt_fields()) + assert unsigned.signature is None + assert not unsigned.verify_hmac(b"k" * 32) + + with pytest.raises(ValueError, match="at least 32 bytes"): + unsigned.verify_hmac(b"short") + with pytest.raises(ValueError, match="must not contain a signature"): + sign_human_decision_receipt_hmac( + key=b"k" * 32, + fields={**_receipt_fields(), "signature": "hmac-sha256:" + "0" * 64}, + ) + + +#: The exact JSON body ``openadapt-flow``'s console decision route returns +#: (``console/human_decisions.decision_receipt(...).model_dump(mode="json")``), +#: transcribed from its closed local model. Pinning the producer's real payload +#: is what makes this a shared contract rather than a parallel one: if Flow's +#: half drifts, this fixture stops validating here. +FLOW_WIRE_RECEIPT: dict[str, object] = { + "schema_version": "openadapt.human-decision-receipt/v1", + "task_id": "task_ab12cd34ef56", + "task_revision": 1, + "pause_id": "ab12cd34ef56", + "capability_digest": "sha256:" + "1" * 64, + "request_digest": "sha256:" + "2" * 64, + "decision_digest": "sha256:" + "3" * 64, + "transition_receipt_digest": "sha256:" + "4" * 64, + "action": "verify_and_resume", + "state": "completed", + "reason_code": "verified_and_resumed", + "report_success": True, + "decided_at": "2026-07-26T12:03:04.123456+00:00", +} + + +def test_the_producers_real_wire_payload_validates_unchanged() -> None: + receipt = HumanDecisionReceiptV1.model_validate(FLOW_WIRE_RECEIPT) + + assert receipt.action is HumanDecisionAction.VERIFY_AND_RESUME + assert receipt.state is HumanDecisionReceiptState.COMPLETED + assert receipt.reason_code is HumanDecisionReceiptReason.VERIFIED_AND_RESUMED + assert receipt.succeeded is True + # A microsecond RFC 3339 instant with a numeric offset is what the runtime + # emits; the pattern must accept it without reformatting the signed bytes. + assert receipt.decided_at == FLOW_WIRE_RECEIPT["decided_at"] + + # The producer supplies every required field; the only fields it omits are + # the two optional signing fields, which the local loopback lane does not + # use. Nothing else may be missing, and nothing extra may be added. + supplied = set(FLOW_WIRE_RECEIPT) + declared = set(HumanDecisionReceiptV1.model_fields) + assert supplied <= declared + assert declared - supplied == {"signature_algorithm", "signature"} + required = { + name + for name, field in HumanDecisionReceiptV1.model_fields.items() + if field.is_required() + } + assert required <= supplied