fix: close the attended terminal-outcome contract gap with HumanDecisionReceiptV1 - #13
Merged
Merged
Conversation
…ionReceiptV1 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last shared-contract gap blocking Cloud from validating the terminal
outcome
openadapt-flownow produces. The signed task contract shipped in0.6.0/0.6.1; the receipt that closes the round trip did not, so Flow
PR #290 had to define
its half locally and mark the seam. This lands the shared type.
The shape is derived from Flow's real terminal decision states, not designed
fresh. Both of Flow's proposed departures from the design memo were verified
against its branch (
feat/mobile-attended-decision-e2e) rather than accepted:demonstration_requested/escalatedare genuinely terminal. The enginewrites
AttendedDecision(status="needs_demonstration")andstatus="escalated"atruntime/durable/attended.py:1123and:1144, bothreturning immediately with no runner continuation pending. Folding them
into
accepted_pending_runnerwould tell an operator to wait for somethingthat never arrives. Confirmed — kept as distinct states.
statefromreason_codeis necessary: one state has more thanone cause (
completedis reached both byverified_and_resumedand byskipped_and_resumed). Confirmed — kept, with one change (below).The shape
How each protected category is made structurally unrepresentable
The engine's
AttendedDecisionis an append-only audit record that carries afree-text
message(attended.py:245) and anoperatorprincipal(
attended.py:233) on purpose. Neither may reach a phone or Cloud. Thereceipt is therefore a rebuilt value, not a filtered copy — strip-on-send
stays correct only until someone adds a field; a closed target type has no such
failure mode. This mirrors the task contract exactly (one model, and it is the
Cloud-safe one) rather than introducing a second pattern.
messagereason_code, a closed 9-value enumtest_receipt_reason_code_is_closed_and_never_free_text,test_receipt_has_no_free_text_field_at_alloperatorfield exists; asserted by nametest_receipt_has_no_free_text_field_at_allextra="forbid"; each category enumerated with representative field names and real-disclosure valuestest_receipt_rejects_each_forbidden_category(6 categories, same table the task uses)pattern,const, orenum. The 0.6.1 structural guard now runs over both contracts via a sharedunconstrained_string_paths()walker — extended, not weakenedtest_receipt_has_no_free_text_field_at_alldecided_atcarries the same RFC 3339 pattern 0.6.1 put oncreated_at/expires_at, so it reaches the exported schema a non-Python consumer validates against — not only a length boundtest_receipt_timestamps_cannot_smuggle_text_or_drop_their_offsetUncertainty stays first-class
Acceptance criterion: "loss of network after tap shows pending/uncertain, never
success."
delivery_uncertainis a required terminal state (the field cannot beomitted),
accepted_pending_runneris distinct from it, andsucceededisfalse for both.
HumanDecisionDeliveryState's three-value vocabulary(
not_delivered/delivered/unknown) is re-asserted alongside.→
test_receipt_delivery_uncertainty_is_a_first_class_valueTwo additions beyond Flow's proposal
stateandreason_codeare two fields but not two independent fields.Flow's proposal left the pair unconstrained, which would let a producer emit
state=completed, reason_code=expired.HUMAN_DECISION_RECEIPT_REASONSpinsthe permitted combinations and a model validator enforces them; the map is
total over both enums. Every pair Flow's
_RECEIPT_STATEproduces ispermitted, so Flow needs no change.
→
test_receipt_state_and_reason_code_cannot_be_paired_arbitrarilyreport_successcannot be true outside a success state. Runnable is notcertified: an uncertain delivery, halt, or refusal must not be renderable as
success by a consumer that only reads the boolean. Verified against Flow:
report_success=resumed.successis always paired withstatus="completed" if resumed.success else "halted", and every refusal pathsets
report_success=False. Flow needs no change.→
test_receipt_cannot_report_success_outside_a_success_stateSigning and the canonicalization vector
Same normative canonicalization as 0.6.1 (sorted ASCII keys, no insignificant
whitespace,
ensure_ascii, nulls never omitted,signatureexcluded), reusedverbatim — the module docstring now covers both contracts.
_hmac_signature()takes the domain separator as a required argument so a third contract cannot
silently reuse the task's.
b"openadapt.human-decision-receipt/v1\x00"— a tasksignature can never be replayed as a receipt signature.
→
test_receipt_signature_is_domain_separated_from_the_task_signaturetest_receipt_canonical_bytes_and_signature_are_pinned_for_other_languages),digest and signature computed independently from the pinned byte literal:
sha256:1fc191fc298e155c305a744e816bf58c09ac07a68b9096d13f34aa0eba6797c6hmac-sha256:385e7802b4c1ec8ff557f4c15e8b1e8c63d87fbffb08b9d61df27c59e9c44c34(keyb"k"*32)(
CANONICAL_VECTOR_BYTES/_DIGEST/_SIGNATURE) is untouched and stillpasses;
human-decision-task-v1.jsonhas a zero-line diff.signatureis optional because Flow's console returns the receipt overauthenticated loopback (
console/server.pybinds127.0.0.1only) and does notsign. An unsigned receipt never verifies
(
test_unsigned_receipt_never_verifies), so a remote consumer that requires asignature is not weakened by the default.
Acceptance criteria satisfied
receipt can state
delivery_uncertainandaccepted_pending_runnerasterminal outcomes, and neither reports success.
/ unknown fields — enumerated per category, rejected structurally.
VERIFIEDimpossible without the complete contract —report_successisunrepresentable as true outside
completed.Producer parity
test_the_producers_real_wire_payload_validates_unchangedpins Flow's exactroute body and asserts it validates with nothing missing and nothing extra: the
only fields Flow omits are the two optional signing fields. If Flow's half
drifts, that fixture stops validating here.
Version intent: 0.6.2 (patch)
openadapt-flowpinsopenadapt-types>=0.6.0,<0.7.0. Adding a model isadditive, so this must be a patch: 0.7.0 would strand every consumer and force a
coordinated bump. This repo's
semantic_releaseconfig mapsfix→ patch andfeat→ minor, so the commit is deliberately typedfix:— the contract gapis a real defect (a shipped producer whose payload no consumer can validate),
and the type is what selects the version. No consumer pin is touched.
Gates
uv run pytest— 181 passed (149 before; +32), uv 0.11.29 / Python3.12, matching
.github/workflows/test.ymlexactly.ruff 0.15.22 check— 4 findings, byte-identical toorigin/main; zero new.ruff 0.15.22 format— the set of unformatted files is byte-identical toorigin/main; all new code is format-clean.mypy 1.19.0(pydantic plugin) — one new diagnostic at theLiteral[...] = CONSTANTdefault, the identical idiom already used byHumanDecisionTaskV1and both control-overlay contracts. No new error class.(Neither ruff nor mypy is a CI gate in this repo; both were run for parity.)
uv build— the newhuman-decision-receipt-v1.jsonships in both the wheeland the sdist.
Mutation-checked guards
Every new guard was removed, its test confirmed failing, and the guard restored.
All 10 detected:
extra="forbid"·reason_codeclosed enum ·decided_atRFC 3339 pattern ·state/reason pairing validator ·
report_successsuccess-state guard · receiptsigning-domain separation ·
delivery_uncertainas a first-class state ·portable action vocabulary · unsigned-receipt verification · packaged-schema
drift.
What Cloud must do to consume it
openadapt-types>=0.6.2,<0.7.0(inside the existing pin range).HumanDecisionReceiptV1/openadapt_types/schemas/human-decision-receipt-v1.json.Reject on failure — never coerce, never ignore unknown fields.
(state, reason_code)only. There is nomessage to display, by design.
succeeded(i.e.state == "completed") as the only success.202+delivery_uncertainandaccepted_pending_runnerare not failures andnot successes; show "may have been sent" and do not auto-retry.
signatureand verify it withverify_hmac()under the receipt domain separator. Keep tenant/runner/event-sequence binding in the signed transport envelope — the receipt
deliberately does not duplicate those identifiers.
task_id/pause_id/capability_digest, whichCloud already holds from the signed task projection.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM