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/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/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.