diff --git a/openadapt_types/human_decision.py b/openadapt_types/human_decision.py index 1ffaa6a..6992711 100644 --- a/openadapt_types/human_decision.py +++ b/openadapt_types/human_decision.py @@ -140,10 +140,25 @@ class HumanDecisionAction(str, Enum): ``verify_and_resume`` maps to Flow's attended ``continue`` operation. It means that the operator prepared the live state for fresh revalidation; it never authorizes blind repetition of a prior action. + + ``reject`` and ``escalate`` are deliberately separate members rather than + two labels on one action, because they do opposite things to the run. + ``escalate`` *parks* it: the durable pause stays intact and a qualified + colleague can still continue it. ``reject`` *terminates* it: the operator + looked at the live application and is asserting that this run must not + proceed at all. Collapsing the two would leave the recorded answer + distribution unable to distinguish "I don't know" from "stop", which is the + only reason a disagreement action is worth its cost. + + ``reject`` is scoped to one run. It is not a claim that the saved workflow + is wrong; that assertion changes future runs and belongs to ``teach``, + which carries the demonstration and requalification gate such authority + requires. """ VERIFY_AND_RESUME = "verify_and_resume" SKIP = "skip" + REJECT = "reject" TEACH = "teach" ESCALATE = "escalate" @@ -279,7 +294,11 @@ class HumanDecisionTaskV1(_StrictContract): substrate: HumanDecisionSubstrate = HumanDecisionSubstrate.UNKNOWN question: HumanDecisionQuestionV1 evidence: HumanDecisionEvidenceSummaryV1 - allowed_actions: tuple[HumanDecisionAction, ...] = Field(min_length=1, max_length=4) + #: Upper bound is the size of the closed vocabulary, not a policy: a pause + #: 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) required_authn: HumanDecisionRequiredAuthn created_at: StrictStr = Field( min_length=20, max_length=40, pattern=_TIMESTAMP_PATTERN @@ -372,6 +391,14 @@ class HumanDecisionReceiptState(str, Enum): ``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. + + ``REJECTED`` is the outcome of a ``reject`` decision and is distinct from + both of those and from ``HALTED``. ``ESCALATED`` and + ``DEMONSTRATION_REQUESTED`` leave the run paused and resumable; + ``REJECTED`` ends it. ``HALTED`` is the engine's own verdict after it acted + on an answer, whereas ``REJECTED`` records that a human ended the run + without the engine attempting anything further. A consumer that collapsed + them would tell the operator the machine stopped when in fact they did. """ ACCEPTED_PENDING_RUNNER = "accepted_pending_runner" @@ -382,6 +409,7 @@ class HumanDecisionReceiptState(str, Enum): DELIVERY_UNCERTAIN = "delivery_uncertain" DEMONSTRATION_REQUESTED = "demonstration_requested" ESCALATED = "escalated" + REJECTED = "rejected" class HumanDecisionReceiptReason(str, Enum): @@ -402,6 +430,14 @@ class HumanDecisionReceiptReason(str, Enum): DELIVERY_UNCERTAIN = "delivery_uncertain" DEMONSTRATION_REQUESTED = "demonstration_requested" ESCALATION_RECORDED = "escalation_recorded" + #: The only cause of ``REJECTED``. It is a single closed member on purpose: + #: a *reason* taxonomy for disagreement would be more informative, but + #: there is no evidence yet for what its members should be -- the reject + #: rate is the data needed to design them -- and a wrong taxonomy is worse + #: than none. Adding members later is additive here and does not reopen a + #: free-text hole, which is why the cause is an enum from the start rather + #: than a string that a later change could widen. + REJECTED_BY_OPERATOR = "rejected_by_operator" #: Which causes each terminal state may carry. @@ -441,6 +477,9 @@ class HumanDecisionReceiptReason(str, Enum): HumanDecisionReceiptState.ESCALATED: frozenset( {HumanDecisionReceiptReason.ESCALATION_RECORDED} ), + HumanDecisionReceiptState.REJECTED: frozenset( + {HumanDecisionReceiptReason.REJECTED_BY_OPERATOR} + ), } #: States that report a *successful* effect on the workflow. Everything else is diff --git a/openadapt_types/schemas/human-decision-receipt-v1.json b/openadapt_types/schemas/human-decision-receipt-v1.json index 807a074..21ab995 100644 --- a/openadapt_types/schemas/human-decision-receipt-v1.json +++ b/openadapt_types/schemas/human-decision-receipt-v1.json @@ -1,10 +1,11 @@ { "$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.", + "description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.", "enum": [ "verify_and_resume", "skip", + "reject", "teach", "escalate" ], @@ -22,13 +23,14 @@ "expired", "delivery_uncertain", "demonstration_requested", - "escalation_recorded" + "escalation_recorded", + "rejected_by_operator" ], "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.", + "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.\n\n``REJECTED`` is the outcome of a ``reject`` decision and is distinct from\nboth of those and from ``HALTED``. ``ESCALATED`` and\n``DEMONSTRATION_REQUESTED`` leave the run paused and resumable;\n``REJECTED`` ends it. ``HALTED`` is the engine's own verdict after it acted\non an answer, whereas ``REJECTED`` records that a human ended the run\nwithout the engine attempting anything further. A consumer that collapsed\nthem would tell the operator the machine stopped when in fact they did.", "enum": [ "accepted_pending_runner", "completed", @@ -37,7 +39,8 @@ "expired", "delivery_uncertain", "demonstration_requested", - "escalated" + "escalated", + "rejected" ], "title": "HumanDecisionReceiptState", "type": "string" diff --git a/openadapt_types/schemas/human-decision-task-v1.json b/openadapt_types/schemas/human-decision-task-v1.json index 3429d51..855105d 100644 --- a/openadapt_types/schemas/human-decision-task-v1.json +++ b/openadapt_types/schemas/human-decision-task-v1.json @@ -1,10 +1,11 @@ { "$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.", + "description": "Portable action names; the runtime remains authoritative.\n\n``verify_and_resume`` maps to Flow's attended ``continue`` operation. It\nmeans that the operator prepared the live state for fresh revalidation; it\nnever authorizes blind repetition of a prior action.\n\n``reject`` and ``escalate`` are deliberately separate members rather than\ntwo labels on one action, because they do opposite things to the run.\n``escalate`` *parks* it: the durable pause stays intact and a qualified\ncolleague can still continue it. ``reject`` *terminates* it: the operator\nlooked at the live application and is asserting that this run must not\nproceed at all. Collapsing the two would leave the recorded answer\ndistribution unable to distinguish \"I don't know\" from \"stop\", which is the\nonly reason a disagreement action is worth its cost.\n\n``reject`` is scoped to one run. It is not a claim that the saved workflow\nis wrong; that assertion changes future runs and belongs to ``teach``,\nwhich carries the demonstration and requalification gate such authority\nrequires.", "enum": [ "verify_and_resume", "skip", + "reject", "teach", "escalate" ], @@ -255,7 +256,7 @@ "items": { "$ref": "#/$defs/HumanDecisionAction" }, - "maxItems": 4, + "maxItems": 5, "minItems": 1, "title": "Allowed Actions", "type": "array" diff --git a/tests/test_human_decision.py b/tests/test_human_decision.py index 65280cb..40df72a 100644 --- a/tests/test_human_decision.py +++ b/tests/test_human_decision.py @@ -323,6 +323,7 @@ def _receipt_fields() -> dict[str, object]: ("halted", "halted", "continuation_halted"), ("needs_demonstration", "demonstration_requested", "demonstration_requested"), ("escalated", "escalated", "escalation_recorded"), + ("rejected", "rejected", "rejected_by_operator"), ("", "expired", "expired"), ) @@ -349,7 +350,11 @@ def test_receipt_represents_every_real_engine_terminal_outcome( "reason_code": reason_code, "report_success": state == "completed", "action": ( - "skip" if reason_code == "skipped_and_resumed" else "verify_and_resume" + "skip" + if reason_code == "skipped_and_resumed" + else "reject" + if reason_code == "rejected_by_operator" + else "verify_and_resume" ), } ) @@ -370,6 +375,7 @@ def test_receipt_reason_code_is_closed_and_never_free_text() -> None: "delivery_uncertain", "demonstration_requested", "escalation_recorded", + "rejected_by_operator", } for prose in ( @@ -550,6 +556,7 @@ def test_receipt_action_reuses_the_portable_task_vocabulary() -> None: assert {action.value for action in HumanDecisionAction} == { "verify_and_resume", "skip", + "reject", "teach", "escalate", } @@ -714,3 +721,57 @@ def test_the_producers_real_wire_payload_validates_unchanged() -> None: if field.is_required() } assert required <= supplied + + +def test_a_task_can_advertise_the_complete_action_vocabulary() -> None: + """``max_length`` bounds the vocabulary, never the offer. + + A pause that is skippable, re-verifiable, rejectable, teachable, and + escalatable is a legitimate pause. When ``reject`` was added, a bound left + at four would have silently made the most permissive pause unrepresentable + -- a refusal to issue the task at all, in the exact case the operator has + the most choice. + """ + fields = _task_fields() + 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 task.verify_hmac(b"k" * 32) + + +def test_rejected_is_terminal_and_distinct_from_escalated() -> None: + """Parking a run and ending it must not project to the same receipt. + + ``escalate`` leaves the durable pause intact for a colleague; ``reject`` + ends the run. A consumer reads the pair to decide whether to tell the + operator that someone will pick this up, and the two answers are opposite. + Neither is a success, and neither may carry ``report_success``. + """ + rejected = HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": HumanDecisionAction.REJECT, + "state": HumanDecisionReceiptState.REJECTED, + "reason_code": HumanDecisionReceiptReason.REJECTED_BY_OPERATOR, + "report_success": None, + } + ) + assert rejected.state is not HumanDecisionReceiptState.ESCALATED + assert rejected.state is not HumanDecisionReceiptState.HALTED + assert not rejected.succeeded + + for state in ( + HumanDecisionReceiptState.ESCALATED, + HumanDecisionReceiptState.HALTED, + HumanDecisionReceiptState.COMPLETED, + ): + with pytest.raises(ValidationError, match="is not a cause of state"): + HumanDecisionReceiptV1.model_validate( + { + **_receipt_fields(), + "action": HumanDecisionAction.REJECT, + "state": state, + "reason_code": HumanDecisionReceiptReason.REJECTED_BY_OPERATOR, + "report_success": None, + } + )