From 926fd633aed8e2b29a5539a4b50cc02c4a92318f Mon Sep 17 00:00:00 2001 From: Cloud Practitioner Date: Wed, 7 Oct 2026 12:12:40 +0000 Subject: [PATCH 1/5] fix(captain-hold): refuse a previous origin's answer after an incomplete hold move A hold moved to a new origin wrote its stamp and origin before the backend hold. A process stopped between those writes, or a refused hold whose rollback also failed, left the task associated with the new origin while it carried only the earlier call's answer, and complete and verify accepted that answer. Verification now treats an open, unheld task that leads with a hold-set stamp above a recorded answer as a re-hold that never completed, so the answer no longer satisfies the new origin however the move was interrupted. A refused hold also restores the body the attempt started from, and the origin lookup runs before any write, so a clean failure leaves the earlier answer valid. Closes #6461 --- bin/fm-captain-hold.sh | 55 ++++++++++++++---- docs/captain-hold-lifecycle.md | 4 ++ tests/fm-captain-hold-lifecycle.test.sh | 74 +++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index c80b9979c34..6b647af3c00 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -523,6 +523,11 @@ resolution_block() { # # Durable state of one captain call: an active captain hold (annotations # surviving even when a date gate has expired) or a recorded captain answer. +# A recorded answer only counts when no newer hold lifecycle began after it: +# an open, unheld task that leads with a hold-set stamp above its resolution +# record is a re-hold that never completed, so the record answered an earlier +# call. A finished answer restores resolution-first ordering, and a closed task +# keeps its record because a close only ever follows an answer. verify_hold_durable() { # local id=$1 show state hold_kind body task_show "$id" || fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)" @@ -530,10 +535,14 @@ verify_hold_durable() { # state=$(show_field "$show" state) hold_kind=$(show_field_value "$show" hold_kind) body=$(show_field "$show" body) - if body_has_resolution_record "$body"; then + if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then return 0 fi - if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then + if body_has_resolution_record "$body"; then + if [ "$state" != "done" ] \ + && [ -n "$(body_hold_set_timestamp "$(decode_shown_value "$body")")" ]; then + fail "captain-held task $id carries a recorded answer from an earlier call, but its newer hold never completed; re-run the hold or answer it again" + fi return 0 fi fail "captain-held task $id is neither held for the captain nor closed with a recorded captain answer" @@ -845,7 +854,25 @@ write_hold_origin() { # fi if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then rm -f -- "$tmp" - fail "could not record the hold origin on $id" + printf 'fm-captain-hold: could not record the hold origin on %s\n' "$id" >&2 + return 1 + fi + rm -f -- "$tmp" +} + +# Put back the body a hold attempt started from, so an attempt that does not +# complete leaves neither its new stamp nor its new origin behind. tasks-axi +# cannot write an empty body, and a task that began empty carries no earlier +# answer for a stale stamp to mislabel, so that case keeps its body. +restore_hold_body() { # + local id=$1 body tmp + body=$(decode_shown_value "$2") || return 1 + [ -n "$body" ] || return 0 + tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-restore.XXXXXX") || return 1 + if ! printf '%s\n' "$body" > "$tmp" || ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then + rm -f -- "$tmp" + printf 'fm-captain-hold: could not restore the body of %s after an incomplete hold\n' "$id" >&2 + return 1 fi rm -f -- "$tmp" } @@ -902,7 +929,7 @@ verify_entry_durable() { # ; prints " &2; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -975,15 +1002,20 @@ command_hold() { # Publish the timestamp before the captain-hold annotation. A concurrent # snapshot may see the harmless stamp by itself, but can never see a newly # held task without the timestamp that defines this hold lifecycle's age. + # Resolve the origin identity before any write, so a lookup failure leaves + # the task untouched. + if [ -n "$origin" ]; then + origin=$(task_identity "$origin") || exit $? + fi task_show_or_fail "$id" "task $id disappeared before recording its hold-set stamp" + original_body=$(show_field "$show" body) write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" "$preserve_hold_set" task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp" [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id did not retain its hold-set stamp" if [ -n "$origin" ]; then - origin=$(task_identity "$origin") || exit $? - previous_origin=$(body_hold_origin "$(show_field_value "$show" body)") - write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $? + write_hold_origin "$id" "$(show_field "$show" body)" "$origin" \ + || { restore_hold_body "$id" "$original_body" || true; exit 1; } fi if [ -n "$until" ]; then tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \ @@ -993,11 +1025,10 @@ command_hold() { || hold_status=$? fi if [ "$hold_status" -ne 0 ]; then - # A refused re-hold must not associate the previous hold or answer with a - # new origin. Restore the old line verbatim, without resolving it again. - if [ -n "$origin" ]; then - write_hold_origin "$id" "$(show_field "$show" body)" "$previous_origin" || exit $? - fi + # A refused re-hold must neither associate the previous answer with a new + # origin nor leave a stamp that starts a hold lifecycle which never began. + # Restore the body the attempt started from, without resolving it again. + restore_hold_body "$id" "$original_body" || true fail "could not hold task $id for the captain" fi task_show "$id" || fail "task $id disappeared while holding it" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 5c19372265c..817728c4dd7 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -65,6 +65,10 @@ Repeat and edge cases: - `--until` stores the captain's own deferral date through tasks-axi's date gate. - Before the backend hold runs, `--origin` records the origin the call is held for on its own `Captain hold origin:` body line, which `complete` and `verify` check using backend identities rather than alias spellings. If that write fails, the backend hold is not attempted. +- If the origin write or the backend hold fails, `hold` restores the body the attempt started from, so a refused re-hold leaves neither the new origin nor the new stamp behind. +- An open task that is not held and leads with a hold-set stamp above a recorded answer is a re-hold that never completed. + `complete` and `verify` refuse it, so a process killed between the stamp or origin write and the backend hold cannot make the earlier call's answer satisfy the new origin. + Re-running `hold` completes the move, and re-running the same `answer --release` normalizes a release interrupted before its stamp was removed. - The reason may contain parentheses, semicolons, quotes, and line breaks. [`bin/fm-hold-reason-lib.sh`](../bin/fm-hold-reason-lib.sh) owns the storage encoding and compatibility rules; [`bin/fm-tasks-axi.sh --help`](../bin/fm-tasks-axi.sh) owns the public read commands and output contract. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 2864a3e30eb..6ed12f884d8 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4350,6 +4350,79 @@ SH pass "new, active, and released holds require the origin first with and without deferral" } +# A hold moved to a new origin must not let the previous origin's answer satisfy +# the new one when the move never completed. The backend hold is refused and +# every later body write fails, so the rollback cannot run and the task keeps +# the new stamp and origin above the earlier answer. +test_interrupted_origin_move_does_not_inherit_the_previous_answer() { + local home id=sample-moved-call shown + home=$(make_home interrupted-origin-move) + for o in origin-a origin-b; do + tasks_in "$home" add "$o" "Review $o" --kind scout --repo sample >/dev/null \ + || fail "could not create $o" + write_origin_meta "$home" "$o" + done + run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for A" \ + --origin origin-a >/dev/null || fail "could not hold the call for A" + printf 'Release this work.\n' > "$home/answer.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not release the call for A" + run_captain "$home" complete origin-a "$id" >/dev/null \ + || fail "the genuine answer for A did not complete A" + + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ -f "$FM_HOME/hold-refused" ] && [ "${1:-}" = update ]; then + exit 9 +fi +if [ "${1:-}" = hold ] && [ "${2:-}" != --help ] && [ -f "$FM_HOME/fail-hold" ]; then + : > "$FM_HOME/hold-refused" + exit 9 +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + : > "$home/fail-hold" + if run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b \ + > "$home/hold.out" 2> "$home/hold.err"; then + fail "the hold succeeded although the backend refused it" + fi + assert_present "$home/hold-refused" "the failure did not reach the backend hold" + shown=$(tasks_in "$home" show "$id" --full) + assert_contains "$shown" 'held: no' "the refused hold left the task held" + assert_contains "$shown" 'Captain hold origin: origin-b' \ + "the interrupted move did not leave B's origin behind, so it reproduces nothing" + rm "$home/fail-hold" "$home/hold-refused" + rm "$home/fakebin/tasks-axi" + + if run_captain "$home" complete origin-b "$id" > "$home/complete.out" 2> "$home/complete.err"; then + fail "complete accepted the previous origin's answer for B" + fi + assert_grep "newer hold never completed" "$home/complete.err" \ + "the refusal does not name the incomplete move" + assert_no_grep "decisions_reviewed=1" "$home/state/origin-b.meta" \ + "the refused completion recorded an attestation for B" + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$id" >> "$home/state/origin-b.meta" + if run_captain "$home" verify origin-b > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted the previous origin's answer for B" + fi + + # Completing the interrupted move holds the call for B; B's own answer then + # completes it. + run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b >/dev/null \ + || fail "the interrupted move could not be completed" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "a held call for B did not complete B" + printf 'Ship it for B.\n' > "$home/answer-b.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer-b.txt" >/dev/null \ + || fail "could not release the call for B" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "the genuine answer for B did not complete B" + run_captain "$home" verify origin-b >/dev/null \ + || fail "the genuine answer for B did not verify B" + pass "an interrupted origin move cannot satisfy the new origin with the previous answer" +} + test_historical_self_inventory_has_workable_repair() { local home origin=sample-review keep=retained-call replacement=repair-call meta before out home=$(make_home historical-self-inventory) @@ -4631,6 +4704,7 @@ SH test_hold_reason_round_trips_awkward_characters test_hold_origins_precede_backend_holds +test_interrupted_origin_move_does_not_inherit_the_previous_answer test_historical_self_inventory_has_workable_repair test_inventory_compares_backend_identities test_origin_is_never_its_own_inventory_entry From bbd2fcfd5993ee9cdff7420b91ca09e8bb50c3f6 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Wed, 7 Oct 2026 12:50:51 +0000 Subject: [PATCH 2/5] no-mistakes(review): Publish hold origins only after backend success --- bin/fm-captain-hold.sh | 19 +- docs/captain-hold-lifecycle.md | 21 ++- tests/fm-captain-hold-lifecycle.test.sh | 234 ++++++++++++++++-------- 3 files changed, 178 insertions(+), 96 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 6b647af3c00..77fbefe1ce7 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -526,8 +526,7 @@ resolution_block() { # # A recorded answer only counts when no newer hold lifecycle began after it: # an open, unheld task that leads with a hold-set stamp above its resolution # record is a re-hold that never completed, so the record answered an earlier -# call. A finished answer restores resolution-first ordering, and a closed task -# keeps its record because a close only ever follows an answer. +# call. A finished answer restores resolution-first ordering. verify_hold_durable() { # local id=$1 show state hold_kind body task_show "$id" || fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)" @@ -830,7 +829,7 @@ task_identity() { printf '%s' "$id" } -write_hold_origin() { # +write_hold_origin() { # local id=$1 body=$2 origin=$3 stamp rest new_body tmp body=$(decode_shown_value "$body") \ || fail "could not decode the existing body for $id" @@ -839,10 +838,7 @@ write_hold_origin() { # || fail "task $id lost its hold-set stamp before its origin was recorded" rest=$(printf '%s\n' "$body" | sed 1d | awk '!/^Captain hold origin: /' \ | awk 'NF || started { started = 1; print }') - new_body=$stamp - if [ -n "$origin" ]; then - new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin") - fi + new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin") if [ -n "$rest" ]; then new_body=$(printf '%s\n\n%s' "$new_body" "$rest") fi @@ -1013,10 +1009,6 @@ command_hold() { task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp" [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id did not retain its hold-set stamp" - if [ -n "$origin" ]; then - write_hold_origin "$id" "$(show_field "$show" body)" "$origin" \ - || { restore_hold_body "$id" "$original_body" || true; exit 1; } - fi if [ -n "$until" ]; then tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \ || hold_status=$? @@ -1035,9 +1027,12 @@ command_hold() { show=$TASK_SHOW_OUTPUT hold_kind=$(show_field_value "$show" hold_kind) [ "$hold_kind" = captain ] || fail "task $id did not retain its captain hold" - occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id lost its hold-set stamp while being held" + if [ -n "$origin" ]; then + write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $? + fi + occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) publish_parent_hold "$id" "$occurrence" needs-decision "$reason" printf '%s\n' "$id" } diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 817728c4dd7..b5ff7305e14 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -51,9 +51,9 @@ It works in this order: 1. It uses an existing task, or creates one when nothing exists to hold. 2. It records the task's UTC hold-set timestamp as the leading line of the task body. -3. When `--origin` is supplied, it records the origin on the task, replacing any previous association. -4. It invokes the underlying tasks-axi hold operation. -5. It verifies the hold and timestamp. +3. It invokes the underlying tasks-axi hold operation. +4. It verifies the captain-hold annotations and timestamp. +5. When `--origin` is supplied, it records the origin on the task, replacing any previous association only after the backend hold succeeds. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. @@ -63,12 +63,15 @@ Repeat and edge cases: - Re-holding released work starts a new timestamped lifecycle. - A closed task is refused rather than reopened. - `--until` stores the captain's own deferral date through tasks-axi's date gate. -- Before the backend hold runs, `--origin` records the origin the call is held for on its own `Captain hold origin:` body line, which `complete` and `verify` check using backend identities rather than alias spellings. - If that write fails, the backend hold is not attempted. -- If the origin write or the backend hold fails, `hold` restores the body the attempt started from, so a refused re-hold leaves neither the new origin nor the new stamp behind. -- An open task that is not held and leads with a hold-set stamp above a recorded answer is a re-hold that never completed. - `complete` and `verify` refuse it, so a process killed between the stamp or origin write and the backend hold cannot make the earlier call's answer satisfy the new origin. - Re-running `hold` completes the move, and re-running the same `answer --release` normalizes a release interrupted before its stamp was removed. +- `--origin` resolves the backend origin identity before writing the hold-set stamp and publishes it on its own `Captain hold origin:` body line only after the backend hold succeeds. + `complete` and `verify` check that line using backend identities rather than alias spellings. + An interrupted or refused backend hold therefore cannot publish the new association, even when an old captain hold is still active or date-expired. +- If the backend hold fails, `hold` attempts to restore the previous body. + If restoration also fails, the new stamp can remain, but the previous origin is unchanged. + If the subsequent origin write fails, the successful captain hold and its stamp remain with the previous association (or with no recorded origin for a new task); re-running `hold` retries publication. +- An open task without captain-hold annotations that leads with a hold-set stamp above a recorded answer is refused by `complete` and `verify` because the answer alone cannot establish that the newer call was held. + Replaying the earlier `answer --release` can remove that stamp, and normal work completion can retain the answer, but neither transition changes the stored origin: neither can certify the failed move's new origin. + Re-running `hold` completes the move, and re-running the same `answer --release` also normalizes a release interrupted before its stamp was removed. - The reason may contain parentheses, semicolons, quotes, and line breaks. [`bin/fm-hold-reason-lib.sh`](../bin/fm-hold-reason-lib.sh) owns the storage encoding and compatibility rules; [`bin/fm-tasks-axi.sh --help`](../bin/fm-tasks-axi.sh) owns the public read commands and output contract. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 6ed12f884d8..7062d6b02a4 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4234,8 +4234,8 @@ test_complete_refuses_an_entry_held_for_another_origin() { pass "complete refuses an entry held for another origin and flags one with none recorded" } -test_hold_origins_precede_backend_holds() { - local home phase timing failure id shown origin until_args=() +test_hold_origins_follow_backend_holds() { + local home phase timing failure id shown origin out until_args=() for phase in new active released; do for timing in plain dated; do home=$(make_home "origin-failure-$phase-$timing") @@ -4261,12 +4261,16 @@ if [ "${1:-}" = show ] && [ "${2:-}" = origin-b ] && [ -f "$FM_HOME/fail-lookup" printf 'error: origin read failed\ncode: READ_FAILED\n' >&2 exit 2 fi -if [ "${1:-}" = update ] && [ -f "$FM_HOME/fail-write" ]; then +if [ "${1:-}" = update ]; then previous='' for arg in "$@"; do if [ "$previous" = --body-file ] && grep -qx 'Captain hold origin: origin-b' "$arg"; then - : > "$FM_HOME/write-refused" - exit 9 + [ -f "$FM_HOME/backend-held" ] || exit 8 + "$REAL_TASKS_AXI" show "$2" --full > "$FM_HOME/before-origin-write" || exit $? + if [ -f "$FM_HOME/fail-write" ]; then + : > "$FM_HOME/write-refused" + exit 9 + fi fi previous=$arg done @@ -4277,32 +4281,40 @@ if [ "${1:-}" = hold ] && [ "${2:-}" != --help ]; then : > "$FM_HOME/hold-refused" exit 9 fi + "$REAL_TASKS_AXI" "$@" || exit $? + : > "$FM_HOME/backend-held" + exit 0 fi exec "$REAL_TASKS_AXI" "$@" SH chmod +x "$home/fakebin/tasks-axi" until_args=() [ "$timing" != dated ] || until_args=(--until 2099-01-01) - for failure in lookup write hold; do + for failure in lookup hold write; do : > "$home/fail-$failure" if run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for B" \ --origin origin-b ${until_args[@]+"${until_args[@]}"} > "$home/hold.out" 2> "$home/hold.err"; then fail "$phase $timing hold succeeded despite an origin $failure failure" fi assert_present "$home/$failure-refused" "the failure did not reach the origin $failure" - if [ "$failure" = hold ]; then + if [ "$failure" = lookup ]; then + assert_absent "$home/before-backend-hold" "$phase $timing origin lookup failure reached the backend hold" + else assert_present "$home/before-backend-hold" "$phase $timing failure never reached the backend hold" - assert_grep 'Captain hold origin: origin-b' "$home/before-backend-hold" \ - "the failed backend hold did not see the new association" + assert_no_grep 'Captain hold origin: origin-b' "$home/before-backend-hold" \ + "the backend hold saw the new association before succeeding" rm "$home/before-backend-hold" - else - assert_absent "$home/before-backend-hold" "$phase $timing origin $failure failure reached the backend hold" + fi + if [ "$failure" = write ]; then + assert_present "$home/backend-held" "the origin write preceded backend success" + assert_grep 'held: yes' "$home/before-origin-write" "the origin write did not follow a live hold" + assert_grep 'hold_kind: captain' "$home/before-origin-write" "the origin write did not follow a captain hold" fi shown=$(tasks_in "$home" show "$id" --full) assert_not_contains "$shown" 'Captain hold origin: origin-b' \ "$phase $timing origin $failure failure published the new association" - if [ "$phase" = active ]; then - assert_contains "$shown" 'held: yes' "an origin $failure failure lifted an existing hold" + if [ "$phase" = active ] || [ "$failure" = write ]; then + assert_contains "$shown" 'held: yes' "an origin $failure failure lost the successful hold" else assert_contains "$shown" 'held: no' "$phase $timing origin $failure failure left the task held" fi @@ -4311,6 +4323,14 @@ SH "$phase $timing origin $failure failure lost the original association" fi rm "$home/fail-$failure" + rm -f "$home/backend-held" "$home/before-origin-write" + if [ "$phase" = new ] && [ "$failure" = write ]; then + out=$(run_captain "$home" complete origin-b "$id") \ + || fail "the unrecorded successful hold was not durable" + assert_contains "$out" "no recorded origin on: $id" "the unrecorded hold was claimed to have an origin" + run_captain "$home" verify origin-b >/dev/null || fail "the unrecorded hold could not verify" + continue + fi if [ "$phase" = new ] && run_captain "$home" complete origin-a "$id" \ > "$home/unrelated.out" 2> "$home/unrelated.err"; then fail "$timing origin $failure failure satisfied an unrelated inventory" @@ -4333,8 +4353,11 @@ SH ${until_args[@]+"${until_args[@]}"} >/dev/null || fail "$phase $timing successful retry failed" assert_present "$home/before-backend-hold" "the successful retry did not reach the backend hold" shown=$(cat "$home/before-backend-hold") - assert_contains "$shown" 'Captain hold origin: origin-b' "the backend hold ran before the new origin was recorded" - assert_not_contains "$shown" 'Captain hold origin: origin-a' "the backend hold ran with the old association" + assert_not_contains "$shown" 'Captain hold origin: origin-b' "the backend hold ran with the new association already published" + if [ "$phase" != new ]; then + assert_contains "$shown" 'Captain hold origin: origin-a' "the backend hold lost the old association before succeeding" + fi + assert_grep 'held: yes' "$home/before-origin-write" "the successful origin write preceded a live hold" shown=$(tasks_in "$home" show "$id" --full) assert_contains "$shown" 'held: yes' "the successful retry did not hold the task" assert_contains "$shown" 'Captain hold origin: origin-b' "a successful hold lost its association" @@ -4347,80 +4370,141 @@ SH fi done done - pass "new, active, and released holds require the origin first with and without deferral" + pass "new, active, and released holds publish origins after backend success with and without deferral" } -# A hold moved to a new origin must not let the previous origin's answer satisfy -# the new one when the move never completed. The backend hold is refused and -# every later body write fails, so the rollback cannot run and the task keeps -# the new stamp and origin above the earlier answer. test_interrupted_origin_move_does_not_inherit_the_previous_answer() { - local home id=sample-moved-call shown - home=$(make_home interrupted-origin-move) - for o in origin-a origin-b; do - tasks_in "$home" add "$o" "Review $o" --kind scout --repo sample >/dev/null \ - || fail "could not create $o" - write_origin_meta "$home" "$o" - done - run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for A" \ - --origin origin-a >/dev/null || fail "could not hold the call for A" - printf 'Release this work.\n' > "$home/answer.txt" - run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ - || fail "could not release the call for A" - run_captain "$home" complete origin-a "$id" >/dev/null \ - || fail "the genuine answer for A did not complete A" + local home id=sample-moved-call shown phase failure o rc until_args=() + for phase in active expired released-replay released-done; do + for failure in refused interrupted; do + home=$(make_home "interrupted-origin-move-$phase-$failure") + for o in origin-a origin-b; do + tasks_in "$home" add "$o" "Review $o" --kind scout --repo sample >/dev/null \ + || fail "could not create $o" + write_origin_meta "$home" "$o" + done + run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for A" \ + --origin origin-a >/dev/null || fail "could not hold the call for A" + printf 'Release this work.\n' > "$home/answer.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not release the call for A" + until_args=() + [ "$phase" != expired ] || until_args=(--until 2000-01-01) + case "$phase" in + active|expired) + run_captain "$home" hold "$id" --reason "Choose again for A" --origin origin-a \ + ${until_args[@]+"${until_args[@]}"} >/dev/null || fail "could not re-hold the call for A" + ;; + esac + run_captain "$home" complete origin-a "$id" >/dev/null \ + || fail "the genuine call for A did not complete A" - cat > "$home/fakebin/tasks-axi" <<'SH' + cat > "$home/fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash if [ -f "$FM_HOME/hold-refused" ] && [ "${1:-}" = update ]; then + : > "$FM_HOME/restore-refused" exit 9 fi -if [ "${1:-}" = hold ] && [ "${2:-}" != --help ] && [ -f "$FM_HOME/fail-hold" ]; then +if [ "${1:-}" = hold ] && [ "${2:-}" != --help ]; then + "$REAL_TASKS_AXI" show "$2" --full > "$FM_HOME/before-backend-hold" || exit $? : > "$FM_HOME/hold-refused" + if [ -f "$FM_HOME/interrupt-hold" ]; then + kill -TERM "$(cat "$FM_HOME/hold-pid")" + fi exit 9 fi exec "$REAL_TASKS_AXI" "$@" SH - chmod +x "$home/fakebin/tasks-axi" - : > "$home/fail-hold" - if run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b \ - > "$home/hold.out" 2> "$home/hold.err"; then - fail "the hold succeeded although the backend refused it" - fi - assert_present "$home/hold-refused" "the failure did not reach the backend hold" - shown=$(tasks_in "$home" show "$id" --full) - assert_contains "$shown" 'held: no' "the refused hold left the task held" - assert_contains "$shown" 'Captain hold origin: origin-b' \ - "the interrupted move did not leave B's origin behind, so it reproduces nothing" - rm "$home/fail-hold" "$home/hold-refused" - rm "$home/fakebin/tasks-axi" + chmod +x "$home/fakebin/tasks-axi" + rc=0 + if [ "$failure" = interrupted ]; then + : > "$home/interrupt-hold" + PATH="$home/fakebin:$PATH" REAL_TASKS_AXI="$TASKS_AXI_BIN" \ + FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" bash -c \ + 'printf "%s\n" "$$" > "$FM_HOME/hold-pid"; exec "$@"' _ \ + "$ROOT/bin/fm-captain-hold.sh" hold "$id" --reason "Choose for B" --origin origin-b \ + > "$home/hold.out" 2> "$home/hold.err" || rc=$? + [ "$rc" -eq 143 ] || fail "the hold was not interrupted by TERM: $rc" + assert_absent "$home/restore-refused" "an interrupted hold attempted restoration" + else + run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b \ + > "$home/hold.out" 2> "$home/hold.err" || rc=$? + [ "$rc" -ne 0 ] || fail "the hold succeeded although the backend refused it" + assert_present "$home/restore-refused" "the refused hold did not attempt restoration" + fi + assert_present "$home/hold-refused" "the failure did not reach the backend hold" + assert_no_grep 'Captain hold origin: origin-b' "$home/before-backend-hold" \ + "the move published B before the backend hold" + shown=$(tasks_in "$home" show "$id" --full) + assert_contains "$shown" 'Captain hold origin: origin-a' "the failed move lost A's association" + assert_not_contains "$shown" 'Captain hold origin: origin-b' "the failed move published B's association" + assert_contains "$shown" 'Captain hold set:' "the fixture did not retain the uncommitted stamp" + assert_contains "$shown" 'Release this work.' "the failed move lost the earlier answer" + if [ "$phase" = active ]; then + assert_contains "$shown" 'held: yes' "the failed move lifted A's active hold" + else + assert_contains "$shown" 'held: no' "the failed move created a live hold" + fi + if [ "$phase" = expired ]; then + assert_contains "$shown" 'hold_kind: captain' "the expired fixture lost A's captain annotations" + fi + rm "$home/fakebin/tasks-axi" - if run_captain "$home" complete origin-b "$id" > "$home/complete.out" 2> "$home/complete.err"; then - fail "complete accepted the previous origin's answer for B" - fi - assert_grep "newer hold never completed" "$home/complete.err" \ - "the refusal does not name the incomplete move" - assert_no_grep "decisions_reviewed=1" "$home/state/origin-b.meta" \ - "the refused completion recorded an attestation for B" - printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$id" >> "$home/state/origin-b.meta" - if run_captain "$home" verify origin-b > "$home/verify.out" 2> "$home/verify.err"; then - fail "verify accepted the previous origin's answer for B" - fi + if run_captain "$home" complete origin-b "$id" > "$home/complete.out" 2> "$home/complete.err"; then + fail "complete accepted the previous call for B" + fi + assert_no_grep "decisions_reviewed=1" "$home/state/origin-b.meta" \ + "the refused completion recorded an attestation for B" + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$id" >> "$home/state/origin-b.meta" + if run_captain "$home" verify origin-b > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted the previous call for B" + fi + case "$phase" in + released-*) + if run_captain "$home" complete origin-a "$id" >/dev/null 2> "$home/incomplete.err"; then + fail "the verification boundary accepted a released answer beneath an incomplete hold" + fi + assert_grep "newer hold never completed" "$home/incomplete.err" "the stamp guard did not refuse the incomplete hold" + if run_captain "$home" verify origin-a >/dev/null 2> "$home/incomplete-verify.err"; then + fail "verify accepted a released answer beneath an incomplete hold" + fi + if [ "$phase" = released-replay ]; then + run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not replay A's earlier answer" + else + tasks_in "$home" done "$id" --keep 1 >/dev/null || fail "could not complete the released work" + fi + shown=$(tasks_in "$home" show "$id" --full) + assert_contains "$shown" 'Captain hold origin: origin-a' "the subsequent transition changed A's association" + if run_captain "$home" complete origin-b "$id" >/dev/null 2> "$home/transition-complete.err"; then + fail "$phase certified B after a failed move" + fi + assert_grep "was held for origin origin-a, not origin-b" "$home/transition-complete.err" \ + "the transition did not refuse B on origin provenance" + if run_captain "$home" verify origin-b >/dev/null 2> "$home/transition-verify.err"; then + fail "$phase verified B after a failed move" + fi + ;; + esac + run_captain "$home" complete origin-a "$id" >/dev/null || fail "the surviving call could not complete A" + run_captain "$home" verify origin-a >/dev/null || fail "the surviving call could not verify A" + [ "$phase" != released-done ] || continue - # Completing the interrupted move holds the call for B; B's own answer then - # completes it. - run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b >/dev/null \ - || fail "the interrupted move could not be completed" - run_captain "$home" complete origin-b "$id" >/dev/null \ - || fail "a held call for B did not complete B" - printf 'Ship it for B.\n' > "$home/answer-b.txt" - run_captain "$home" answer "$id" --release --decision-file "$home/answer-b.txt" >/dev/null \ - || fail "could not release the call for B" - run_captain "$home" complete origin-b "$id" >/dev/null \ - || fail "the genuine answer for B did not complete B" - run_captain "$home" verify origin-b >/dev/null \ - || fail "the genuine answer for B did not verify B" - pass "an interrupted origin move cannot satisfy the new origin with the previous answer" + run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b >/dev/null \ + || fail "the interrupted move could not be completed" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "a held call for B did not complete B" + printf 'Ship it for B.\n' > "$home/answer-b.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer-b.txt" >/dev/null \ + || fail "could not release the call for B" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "the genuine answer for B did not complete B" + run_captain "$home" verify origin-b >/dev/null \ + || fail "the genuine answer for B did not verify B" + done + done + pass "failed and interrupted origin moves cannot inherit active, expired, replayed, or completed calls" } test_historical_self_inventory_has_workable_repair() { @@ -4703,7 +4787,7 @@ SH } test_hold_reason_round_trips_awkward_characters -test_hold_origins_precede_backend_holds +test_hold_origins_follow_backend_holds test_interrupted_origin_move_does_not_inherit_the_previous_answer test_historical_self_inventory_has_workable_repair test_inventory_compares_backend_identities From 2dac5fdf6759dd04c8b3e66b25f6b71e02f2df58 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Wed, 7 Oct 2026 13:14:52 +0000 Subject: [PATCH 3/5] no-mistakes(review): Publish verified holds before origin writes --- bin/fm-captain-hold.sh | 4 ++-- docs/captain-hold-lifecycle.md | 7 +++++-- tests/fm-captain-hold-lifecycle.test.sh | 21 ++++++++++++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 77fbefe1ce7..48563c73251 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -1029,11 +1029,11 @@ command_hold() { [ "$hold_kind" = captain ] || fail "task $id did not retain its captain hold" [ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \ || fail "task $id lost its hold-set stamp while being held" + occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) + publish_parent_hold "$id" "$occurrence" needs-decision "$reason" if [ -n "$origin" ]; then write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $? fi - occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 )) - publish_parent_hold "$id" "$occurrence" needs-decision "$reason" printf '%s\n' "$id" } diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index b5ff7305e14..8779b6a6ce7 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -53,7 +53,8 @@ It works in this order: 2. It records the task's UTC hold-set timestamp as the leading line of the task body. 3. It invokes the underlying tasks-axi hold operation. 4. It verifies the captain-hold annotations and timestamp. -5. When `--origin` is supplied, it records the origin on the task, replacing any previous association only after the backend hold succeeds. +5. In a secondmate home, it publishes the verified hold to the parent channel. +6. When `--origin` is supplied, it records the origin on the task, replacing any previous association only after the backend hold succeeds. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. @@ -68,7 +69,9 @@ Repeat and edge cases: An interrupted or refused backend hold therefore cannot publish the new association, even when an old captain hold is still active or date-expired. - If the backend hold fails, `hold` attempts to restore the previous body. If restoration also fails, the new stamp can remain, but the previous origin is unchanged. - If the subsequent origin write fails, the successful captain hold and its stamp remain with the previous association (or with no recorded origin for a new task); re-running `hold` retries publication. + If the subsequent origin write fails, the command returns nonzero, but the successful captain hold and its stamp remain with the previous association (or with no recorded origin for a new task). + Parent publication is attempted before that write in a secondmate home, so an origin-write failure cannot skip delivery; a channel failure retains the existing `actionable:` diagnostic. + Re-running `hold` retries origin publication without duplicating the parent decision. - An open task without captain-hold annotations that leads with a hold-set stamp above a recorded answer is refused by `complete` and `verify` because the answer alone cannot establish that the newer call was held. Replaying the earlier `answer --release` can remove that stamp, and normal work completion can retain the answer, but neither transition changes the stored origin: neither can certify the failed move's new origin. Re-running `hold` completes the move, and re-running the same `answer --release` also normalizes a release interrupted before its stamp was removed. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 7062d6b02a4..a3e1feaf41f 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4235,10 +4235,14 @@ test_complete_refuses_an_entry_held_for_another_origin() { } test_hold_origins_follow_backend_holds() { - local home phase timing failure id shown origin out until_args=() + local home phase timing failure id shown origin out parent channel occurrence until_args=() for phase in new active released; do for timing in plain dated; do home=$(make_home "origin-failure-$phase-$timing") + parent=$(make_home "origin-parent-$phase-$timing") + channel="$parent/state/origin-mate.status" + occurrence=1 + [ "$phase" != released ] || occurrence=2 id=sample-call for origin in origin-a origin-b; do tasks_in "$home" add "$origin" "Review $origin" --kind scout --repo sample >/dev/null \ @@ -4268,6 +4272,8 @@ if [ "${1:-}" = update ]; then [ -f "$FM_HOME/backend-held" ] || exit 8 "$REAL_TASKS_AXI" show "$2" --full > "$FM_HOME/before-origin-write" || exit $? if [ -f "$FM_HOME/fail-write" ]; then + channel="$(sed -n 's/^parent_home=//p' "$FM_HOME/.fm-secondmate-parent")/state/origin-mate.status" + [ ! -f "$channel" ] || cp "$channel" "$FM_HOME/parent-before-origin-write" : > "$FM_HOME/write-refused" exit 9 fi @@ -4291,6 +4297,11 @@ SH until_args=() [ "$timing" != dated ] || until_args=(--until 2099-01-01) for failure in lookup hold write; do + if [ "$failure" = write ]; then + printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' "$parent" \ + > "$home/.fm-secondmate-parent" + printf 'origin-mate\n' > "$home/.fm-secondmate-home" + fi : > "$home/fail-$failure" if run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for B" \ --origin origin-b ${until_args[@]+"${until_args[@]}"} > "$home/hold.out" 2> "$home/hold.err"; then @@ -4309,6 +4320,12 @@ SH assert_present "$home/backend-held" "the origin write preceded backend success" assert_grep 'held: yes' "$home/before-origin-write" "the origin write did not follow a live hold" assert_grep 'hold_kind: captain' "$home/before-origin-write" "the origin write did not follow a captain hold" + assert_present "$home/parent-before-origin-write" "the verified hold was not published before the origin write" + assert_grep "needs-decision [key=captain-hold-$id-$occurrence]: captain hold $id: Choose for B" \ + <(sed -E 's/ \[at=[0-9]+\]//' "$home/parent-before-origin-write") \ + "the origin-write failure stranded the hold without its parent event" + assert_equals 1 "$(grep -Fc "needs-decision [key=captain-hold-$id-$occurrence]" "$channel")" \ + "the failed origin write did not publish exactly one parent decision" fi shown=$(tasks_in "$home" show "$id" --full) assert_not_contains "$shown" 'Captain hold origin: origin-b' \ @@ -4362,6 +4379,8 @@ SH assert_contains "$shown" 'held: yes' "the successful retry did not hold the task" assert_contains "$shown" 'Captain hold origin: origin-b' "a successful hold lost its association" assert_not_contains "$shown" 'Captain hold origin: origin-a' "a successful hold retained the old association" + assert_equals 1 "$(grep -Fc "needs-decision [key=captain-hold-$id-$occurrence]" "$channel")" \ + "retrying the origin write duplicated the parent decision" run_captain "$home" complete origin-b "$id" >/dev/null \ || fail "a successful hold could not complete B" run_captain "$home" verify origin-b >/dev/null || fail "a successful hold could not verify B" From 0cc92fe653fd7cfc75f42f06d11e368c36025ee0 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Wed, 7 Oct 2026 14:14:43 +0000 Subject: [PATCH 4/5] no-mistakes(document): Clarify hold-origin durability documentation and regression coverage --- bin/fm-captain-hold.sh | 27 +++++++++++++-------------- docs/captain-hold-lifecycle.md | 15 ++++++++++----- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 48563c73251..388a6844c5a 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -138,7 +138,7 @@ # `--none` is an explicit semantic attestation that the just-reviewed surface # has no unresolved captain call, and is refused while the origin still has an # open keyed status decision. With a non-empty inventory, every listed task is -# verified durable (captain-held, or carrying a recorded resolution), +# verified durable (docs/captain-hold-lifecycle.md owns the inventory checks), # is never the origin itself, and, when `hold --origin` recorded one, was held # for this origin; a hold with no recorded origin is accepted on durability # alone and named in the output, @@ -523,10 +523,10 @@ resolution_block() { # # Durable state of one captain call: an active captain hold (annotations # surviving even when a date gate has expired) or a recorded captain answer. -# A recorded answer only counts when no newer hold lifecycle began after it: -# an open, unheld task that leads with a hold-set stamp above its resolution -# record is a re-hold that never completed, so the record answered an earlier -# call. A finished answer restores resolution-first ordering. +# Without captain annotations, an open task's leading stamp can signal an +# incomplete re-hold or unfinished release normalization; the old record alone +# cannot vouch for that state. Done tasks retain their recorded resolution as +# evidence, and verify_entry_durable separately checks the stored origin. verify_hold_durable() { # local id=$1 show state hold_kind body task_show "$id" || fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)" @@ -856,10 +856,10 @@ write_hold_origin() { # rm -f -- "$tmp" } -# Put back the body a hold attempt started from, so an attempt that does not -# complete leaves neither its new stamp nor its new origin behind. tasks-axi -# cannot write an empty body, and a task that began empty carries no earlier -# answer for a stale stamp to mislabel, so that case keeps its body. +# Best-effort restoration removes a refused hold attempt's stamp while +# preserving the prior body; the new origin has not been published yet. +# tasks-axi cannot write an empty body, and a task that began empty carries no +# earlier answer for a stale stamp to mislabel, so that case keeps its body. restore_hold_body() { # local id=$1 body tmp body=$(decode_shown_value "$2") || return 1 @@ -998,8 +998,8 @@ command_hold() { # Publish the timestamp before the captain-hold annotation. A concurrent # snapshot may see the harmless stamp by itself, but can never see a newly # held task without the timestamp that defines this hold lifecycle's age. - # Resolve the origin identity before any write, so a lookup failure leaves - # the task untouched. + # Resolve the origin identity before stamping or holding. A lookup failure + # leaves an existing task untouched, but a missing row was already created. if [ -n "$origin" ]; then origin=$(task_identity "$origin") || exit $? fi @@ -1017,9 +1017,8 @@ command_hold() { || hold_status=$? fi if [ "$hold_status" -ne 0 ]; then - # A refused re-hold must neither associate the previous answer with a new - # origin nor leave a stamp that starts a hold lifecycle which never began. - # Restore the body the attempt started from, without resolving it again. + # Restore the body without resolving its old origin again. The new origin + # is still unpublished even if this best-effort restoration fails. restore_hold_body "$id" "$original_body" || true fail "could not hold task $id for the captain" fi diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 8779b6a6ce7..68ad6665005 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -64,15 +64,15 @@ Repeat and edge cases: - Re-holding released work starts a new timestamped lifecycle. - A closed task is refused rather than reopened. - `--until` stores the captain's own deferral date through tasks-axi's date gate. -- `--origin` resolves the backend origin identity before writing the hold-set stamp and publishes it on its own `Captain hold origin:` body line only after the backend hold succeeds. - `complete` and `verify` check that line using backend identities rather than alias spellings. - An interrupted or refused backend hold therefore cannot publish the new association, even when an old captain hold is still active or date-expired. +- `--origin` resolves the backend origin identity before writing the hold-set stamp. + The association lives on its own `Captain hold origin:` body line; [completion inventory checks](#recording-a-reviewed-inventory-complete) own how it is verified. + The ordering above prevents an interrupted or refused backend hold from publishing the new association, even when an old captain hold is still active or date-expired. - If the backend hold fails, `hold` attempts to restore the previous body. If restoration also fails, the new stamp can remain, but the previous origin is unchanged. If the subsequent origin write fails, the command returns nonzero, but the successful captain hold and its stamp remain with the previous association (or with no recorded origin for a new task). Parent publication is attempted before that write in a secondmate home, so an origin-write failure cannot skip delivery; a channel failure retains the existing `actionable:` diagnostic. Re-running `hold` retries origin publication without duplicating the parent decision. -- An open task without captain-hold annotations that leads with a hold-set stamp above a recorded answer is refused by `complete` and `verify` because the answer alone cannot establish that the newer call was held. +- If an incomplete hold leaves a stamp above an earlier answer, the [completion durability boundary](#recording-a-reviewed-inventory-complete) applies. Replaying the earlier `answer --release` can remove that stamp, and normal work completion can retain the answer, but neither transition changes the stored origin: neither can certify the failed move's new origin. Re-running `hold` completes the move, and re-running the same `answer --release` also normalizes a release interrupted before its stamp was removed. - The reason may contain parentheses, semicolons, quotes, and line breaks. @@ -118,9 +118,12 @@ A post-teardown visual review can complete against the surviving report and dura `complete` accepts `--none` as an explicit semantic inventory result. `--none` is refused while the origin still has a lifecycle-open keyed status decision. Before recording completion, `complete` verifies every listed task against tasks-axi. +A durable entry is an open task with captain-hold annotations (including a date-expired hold), or a task carrying a recorded resolution. +For an open task without those annotations, a leading hold-set stamp above the resolution is refused: the answer alone cannot establish durability while the hold or release normalization is incomplete. +A Done task still requires a recorded resolution, but is not subject to that stamp refusal. The origin is never its own inventory entry, so a hold that failed cannot be vouched for by the origin row. For a historical inventory that names its own origin, hold a separate captain task with `--origin`, replace only the invalid entry in the final `decision_keys=` line of the origin metadata with that task id while preserving all other entries, and re-run `complete`. -An entry whose recorded origin differs from the one being completed is refused. +An entry whose recorded origin differs from the one being completed is refused, comparing backend identities rather than alias spellings. An entry with no recorded origin, such as a hold made before origins were recorded or without `--origin`, is accepted on the durability check alone and named in the output. With a non-empty inventory, `complete` appends a `captain-held [key=]` transfer event for every still-open keyed status decision. @@ -535,6 +538,8 @@ The suite does not test the accepted merge-to-cleanup re-hold window or asynchro - A report-only unresolved captain call refuses `--none` completion before teardown can erase the source. - Non-forced scout teardown always requires the durable inventory verification. - The recorded-answer guard holds: a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call. +- `test_hold_origins_follow_backend_holds` exercises lookup, backend-hold, and origin-write failures for new, active, and released holds, including secondmate publication and retry deduplication. +- `test_interrupted_origin_move_does_not_inherit_the_previous_answer` exercises failed restoration, TERM interruption, active and expired holds, earlier-answer replay, and normal released-work completion against the [hold failure semantics](#creating-a-hold-hold) and inventory checks above. ### Answers, stamps, and deferral From c87e1644ebc37495a5f1785f4d933d7c045435b0 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Wed, 7 Oct 2026 14:15:44 +0000 Subject: [PATCH 5/5] no-mistakes(lint): Quote done subcommand to resolve ShellCheck warning --- tests/fm-captain-hold-lifecycle.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index a3e1feaf41f..c9848c616be 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -4492,7 +4492,7 @@ SH run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ || fail "could not replay A's earlier answer" else - tasks_in "$home" done "$id" --keep 1 >/dev/null || fail "could not complete the released work" + tasks_in "$home" "done" "$id" --keep 1 >/dev/null || fail "could not complete the released work" fi shown=$(tasks_in "$home" show "$id" --full) assert_contains "$shown" 'Captain hold origin: origin-a' "the subsequent transition changed A's association"