feat(bin): let a captain answer require the exact call it was shown - #233
Merged
Merged
Conversation
fm-captain-hold.sh answer --expect-identity compares the task's current open --identity value under the answer's own task lock and exits 3, recording nothing, when the call changed, closed, or was re-held.
…rmed-absent calls
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.
Intent
Spoken by the captain via Starship Voice on 2026-09-28 (relayed): a fresh-eyes review of the whole Starship Voice app as it stands now (build 32 and the Mac side), partly adversarial (what breaks, what's confusing, what's slow or unreliable, where it could fail him away from the Mac) and partly improvements, covering conversation feel, timing and cut-offs, pocket and glasses mode, approval cards and progress, accuracy of what the voice agent reports, and design; changes needing his approval go on cards. His standing instruction is to keep work moving and land what firstmate judges sound. The fused Astra+Opus review lists work needing no captain choice under section 2, Start now. This task is the Firstmate-repo half of lane 2 item 1: a phone tap must only answer the exact question it was shown, so the answer command needs an atomic compare-and-answer.
Substance of the referenced review item (lane 2 item 1, lifecycle-bound approvals): each phone approval card carries the captain call's
fm-captain-hold.sh open --identityvalue (the hold-set stamp plus the answer count); before answering, the app re-reads that identity and refuses with "The question changed. Look at it again." if it differs, with tests for a changed question, a same-worded re-hold, and a re-hold racing a tap. A truly atomic compare-and-answer needs a smallfm-captain-hold.sh answer --expect-identitychange in the Firstmate repo, which is this task.What Changed
fm-captain-hold.sh answertakes a new--expect-identity <identity>option. Under the task lock thatansweralready holds, it compares the call's currentopen --identityvalue (hold-set stamp#answer count) with the given one. If they differ, it exits 3, explains why on stderr, and records nothing. That covers closed, released, re-held, and already-answered calls, plus a task the backlog confirms is absent. If the backlog read itself fails, it still returns the ordinary failure exit.open --identityandanswernow build the identity with the same sharedcaptain_call_identityhelper.fm-captain-hold.sh holdnow keeps the existing hold-set stamp only when an active captain hold is repeated with the same reason (changing only--untilalso keeps it). Re-holding a live call with a reworded reason starts a new stamp. The stamp is written before the new reason is recorded and again after it, so a tap on the old wording is refused even if the hold is interrupted between the two writes. New stamps always come after the old one: if the clock hasn't moved past the old stamp, the new one is set one second later.docs/captain-hold-lifecycle.mddocuments the new option and the updated stamp rules.tests/fm-captain-hold-lifecycle.test.shadds cases for stale identities, including a same-worded re-hold, a reworded live call, and a confirmed-absent task, and for a failed backlog read keeping the ordinary failure exit.🤖 Generated with Claude Code
Risk Assessment
Testing
I stood up a disposable lab FM_HOME with the repo's
fm-lab-home.shand drove the real captain-hold CLI with the real tasks-axi markdown backend through every approval-card scenario in the intent. A matching tap answers. Any stale identity is refused with exit 3 and records nothing: a mismatched card, a same-worded re-hold of released work, rewords in the same second (identities12:00:00Z#0,12:00:02Z#0,12:00:04Z#0, all distinct), a replay, and a task that no longer exists. A re-hold with the same reason and only a new--untilkeeps the identity. The option is additive, an empty value is rejected, and a backlog read failure exits 1 rather than 3. The concurrent reword-vs-tap races came out right in 32 of 32 iterations: each tap either landed on the question shown or was refused. The first race run flagged failures, but that was my checker, which wrongly treated a closed call's refused re-hold as a failure. A later run hit a task-id collision in setup. After fixing the checker and the ids, both modes passed; the first flawed transcript is kept as evidence. The three new automated tests (and the tests after them in that file) pass. This is CLI-only, so there is no visual artifact; evidence is the CLI transcripts. The lab home was removed.answer card-a --release --expect-identity 2026-09-28T15:23:52Z#0->released: card-a, exit 0, Resolution mode: released...15:23:52Z#0vs new...15:23:57Z#1; old tap exit 3, new tapreleasedanswered: card-canswered: card-btwice with exit 0; empty value -> 'expect-identity must not be empty' exit 1Evidence: Live CLI transcript: scenarios S1-S9 against a lab home
Source: Live CLI transcript: scenarios S1-S9 against a lab home
Evidence: Live reword-vs-tap race transcript (closing answer and --release modes)
Source: Live reword-vs-tap race transcript (closing answer and --release modes)
Evidence: Earlier race run whose failures came from a checker bug, kept for transparency
Source: Earlier race run whose failures came from a checker bug, kept for transparency
Evidence: Scenario driver script
Source: Scenario driver script
Evidence: Race driver script
Source: Race driver script
Evidence: New expect-identity tests run output
Source: New expect-identity tests run output
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-captain-hold.sh:1051- The compare-and-answer only compares the hold-set stamp and the count of recorded answers (captain_call_identity, bin/fm-captain-hold.sh:775). Whenholdruns on a task that is already held for the captain, it keeps the existing stamp (preserve_hold_set=1 at bin/fm-captain-hold.sh:874), and it records no answer. So the identity stays the same even when the question itself changes. Documented callers take this path: the 'later' deferral re-holds a live call with a new --reason and --until (.agents/skills/captain-hold-lifecycle/SKILL.md:34), and stow refreshes a held item's body withupdate --body-fileand then holds it again (.agents/skills/stow/SKILL.md:196). Example: the phone card shows reason A with identityT#0. The item is then re-held with reason B, or its body is rewritten. The captain taps the old card,answer --expect-identity T#0matches, and the tap answers a question the captain never saw. That breaks the intent's rule that 'a phone tap must only answer the exact question it was shown'. The same-worded re-hold and closed-call cases are handled correctly. Fixing this needs a policy decision, and the fix is what needs authorization: either put a digest of the hold reason and body into the identity, which also changes whatopen --identityreturns to bin/fm-watch.sh:1790, or make re-holding a held call with different words start a new stamp. Otherwise, confirm that lifecycle-only identity is the intended scope.bin/fm-captain-hold.sh:1034- With --expect-identity set, any non-zerotask_showexcept 124 gets exit 3 and the message 'changed since it was shown (… now absent)'. But fm_backlog_row_show (bin/fm-backlog-transition-lib.sh:365) returns non-zero for backlog addressing and resolution errors and for any tasks-axi failure, not only for a missing row. So if the backend fails while the call is still open and unchanged, the caller gets the 'question changed' exit code, and the phone tells the captain 'The question changed. Look at it again.' when nothing changed. The voice agent then reports something false, which works against the intent's accuracy goal.openalready makes this distinction: it uses fm_backlog_row_probe and FM_BACKLOG_ROW_RESULT=not_found (bin/fm-captain-hold.sh:1985) and exits 2 on read errors. The answer path should do the same: return exit 3 only for a confirmed not_found, and keep the ordinary fail (exit 1) for read errors.🔧 Fix applied.
4 warnings still open:
bin/fm-captain-hold.sh:1051- The compare-and-answer only compares the hold-set stamp and the count of recorded answers (captain_call_identity, bin/fm-captain-hold.sh:775). Whenholdruns on a task that is already held for the captain, it keeps the existing stamp (preserve_hold_set=1 at bin/fm-captain-hold.sh:874), and it records no answer. So the identity stays the same even when the question itself changes. Documented callers take this path: the 'later' deferral re-holds a live call with a new --reason and --until (.agents/skills/captain-hold-lifecycle/SKILL.md:34), and stow refreshes a held item's body withupdate --body-fileand then holds it again (.agents/skills/stow/SKILL.md:196). Example: the phone card shows reason A with identityT#0. The item is then re-held with reason B, or its body is rewritten. The captain taps the old card,answer --expect-identity T#0matches, and the tap answers a question the captain never saw. That breaks the intent's rule that 'a phone tap must only answer the exact question it was shown'. The same-worded re-hold and closed-call cases are handled correctly. Fixing this needs a policy decision, and the fix is what needs authorization: either put a digest of the hold reason and body into the identity, which also changes whatopen --identityreturns to bin/fm-watch.sh:1790, or make re-holding a held call with different words start a new stamp. Otherwise, confirm that lifecycle-only identity is the intended scope.bin/fm-captain-hold.sh:1034- With --expect-identity set, any non-zerotask_showexcept 124 gets exit 3 and the message 'changed since it was shown (… now absent)'. But fm_backlog_row_show (bin/fm-backlog-transition-lib.sh:365) returns non-zero for backlog addressing and resolution errors and for any tasks-axi failure, not only for a missing row. So if the backend fails while the call is still open and unchanged, the caller gets the 'question changed' exit code, and the phone tells the captain 'The question changed. Look at it again.' when nothing changed. The voice agent then reports something false, which works against the intent's accuracy goal.openalready makes this distinction: it uses fm_backlog_row_probe and FM_BACKLOG_ROW_RESULT=not_found (bin/fm-captain-hold.sh:1985) and exits 2 on read errors. The answer path should do the same: return exit 3 only for a confirmed not_found, and keep the ordinary fail (exit 1) for read errors.bin/fm-captain-hold.sh:802- Introduced by fix round 1. The user's fix decision said the new stamp "must still differ so the identity changes - advance it deterministically rather than reusing it". But write_hold_set_stamp only advances when the wall-clock stamp is exactly equal to the existing one, so a stamp that has already been pushed ahead of the clock can be followed by an older value that an earlier card already carries. Concrete sequence in one wall-clock second: hold with reason A at 12:00:00 gives stamp 12:00:00, and the phone card shows A with identity12:00:00Z#0. A reword to B at 12:00:00 equals the existing stamp, so it advances to 12:00:01. A second reword to C, still at 12:00:00, differs from the existing 12:00:01, so it writes 12:00:00 and the identity is12:00:00Z#0again. A tap on the A card now matches and answers question C, which is exactly the outcome the intent forbids. A backward clock step (NTP) produces the same ABA without the rapid rewords. Smallest fix: when the new stamp is not later than the existing one (compare epochs with fm_utc_iso_to_epoch), use the existing stamp plus one second, so a reworded or released re-hold always gets a strictly later stamp. Also update the header wording at bin/fm-captain-hold.sh:52-53 ('when the clock has not moved on') to match. The released-work re-hold path goes through this same branch, but its identity also changes because the answer count grows, so only the reworded-live path is exposed.bin/fm-captain-hold.sh:924- Introduced by fix round 1. When a live captain call is reworded, command_hold writes the new hold-set stamp at :924 beforetasks_axi holdstores the new reason at :929-934. So for the length of one or two tasks-axi calls, the persisted row pairs the NEW identity (S2#n) with the OLD reason A.open --identityand the snapshot or hold_reason readers that build the phone card do not take the task control lock. A card built in that window shows wording A with identity S2#n. When hold finishes, the call is B with identity S2#n, so a tap on that card passesanswer --expect-identityand records an answer to B, a question the captain never saw. Reversing the order for this case (apply the new reason first, then advance the stamp, still under the same lock) is fail-safe.answerwaits on the task lock, so no tap can be judged while the row is torn, and a card read in the window pairs wording B with the old identity S1, which the finished row refuses with exit 3. The comment at :919-921 ('never see a newly held task without the timestamp') still holds, because a live call already has its old stamp during the window. Scope: only the reworded-live path (reason differs and hold_kind=captain and held=yes). New holds and re-holds of released work keep today's stamp-first order.🔧 Fix applied.
5 warnings still open:
bin/fm-captain-hold.sh:1051- The compare-and-answer only compares the hold-set stamp and the count of recorded answers (captain_call_identity, bin/fm-captain-hold.sh:775). Whenholdruns on a task that is already held for the captain, it keeps the existing stamp (preserve_hold_set=1 at bin/fm-captain-hold.sh:874), and it records no answer. So the identity stays the same even when the question itself changes. Documented callers take this path: the 'later' deferral re-holds a live call with a new --reason and --until (.agents/skills/captain-hold-lifecycle/SKILL.md:34), and stow refreshes a held item's body withupdate --body-fileand then holds it again (.agents/skills/stow/SKILL.md:196). Example: the phone card shows reason A with identityT#0. The item is then re-held with reason B, or its body is rewritten. The captain taps the old card,answer --expect-identity T#0matches, and the tap answers a question the captain never saw. That breaks the intent's rule that 'a phone tap must only answer the exact question it was shown'. The same-worded re-hold and closed-call cases are handled correctly. Fixing this needs a policy decision, and the fix is what needs authorization: either put a digest of the hold reason and body into the identity, which also changes whatopen --identityreturns to bin/fm-watch.sh:1790, or make re-holding a held call with different words start a new stamp. Otherwise, confirm that lifecycle-only identity is the intended scope.bin/fm-captain-hold.sh:1034- With --expect-identity set, any non-zerotask_showexcept 124 gets exit 3 and the message 'changed since it was shown (… now absent)'. But fm_backlog_row_show (bin/fm-backlog-transition-lib.sh:365) returns non-zero for backlog addressing and resolution errors and for any tasks-axi failure, not only for a missing row. So if the backend fails while the call is still open and unchanged, the caller gets the 'question changed' exit code, and the phone tells the captain 'The question changed. Look at it again.' when nothing changed. The voice agent then reports something false, which works against the intent's accuracy goal.openalready makes this distinction: it uses fm_backlog_row_probe and FM_BACKLOG_ROW_RESULT=not_found (bin/fm-captain-hold.sh:1985) and exits 2 on read errors. The answer path should do the same: return exit 3 only for a confirmed not_found, and keep the ordinary fail (exit 1) for read errors.bin/fm-captain-hold.sh:802- Introduced by fix round 1. The user's fix decision said the new stamp "must still differ so the identity changes - advance it deterministically rather than reusing it". But write_hold_set_stamp only advances when the wall-clock stamp is exactly equal to the existing one, so a stamp that has already been pushed ahead of the clock can be followed by an older value that an earlier card already carries. Concrete sequence in one wall-clock second: hold with reason A at 12:00:00 gives stamp 12:00:00, and the phone card shows A with identity12:00:00Z#0. A reword to B at 12:00:00 equals the existing stamp, so it advances to 12:00:01. A second reword to C, still at 12:00:00, differs from the existing 12:00:01, so it writes 12:00:00 and the identity is12:00:00Z#0again. A tap on the A card now matches and answers question C, which is exactly the outcome the intent forbids. A backward clock step (NTP) produces the same ABA without the rapid rewords. Smallest fix: when the new stamp is not later than the existing one (compare epochs with fm_utc_iso_to_epoch), use the existing stamp plus one second, so a reworded or released re-hold always gets a strictly later stamp. Also update the header wording at bin/fm-captain-hold.sh:52-53 ('when the clock has not moved on') to match. The released-work re-hold path goes through this same branch, but its identity also changes because the answer count grows, so only the reworded-live path is exposed.bin/fm-captain-hold.sh:924- Introduced by fix round 1. When a live captain call is reworded, command_hold writes the new hold-set stamp at :924 beforetasks_axi holdstores the new reason at :929-934. So for the length of one or two tasks-axi calls, the persisted row pairs the NEW identity (S2#n) with the OLD reason A.open --identityand the snapshot or hold_reason readers that build the phone card do not take the task control lock. A card built in that window shows wording A with identity S2#n. When hold finishes, the call is B with identity S2#n, so a tap on that card passesanswer --expect-identityand records an answer to B, a question the captain never saw. Reversing the order for this case (apply the new reason first, then advance the stamp, still under the same lock) is fail-safe.answerwaits on the task lock, so no tap can be judged while the row is torn, and a card read in the window pairs wording B with the old identity S1, which the finished row refuses with exit 3. The comment at :919-921 ('never see a newly held task without the timestamp') still holds, because a live call already has its old stamp during the window. Scope: only the reworded-live path (reason differs and hold_kind=captain and held=yes). New holds and re-holds of released work keep today's stamp-first order.bin/fm-captain-hold.sh:947- Introduced by fix round 2 (reword-stamp-written-before-new-reason). On the reworded-live path, command_hold now stores the new reason B withtasks_axi hold(:940-946) before it advances the stamp with stamp_hold_set (:947). If the stamp write then fails, the row keeps reason B with the OLD stamp S1. The write can fail becausetasks_axi updatefails in write_hold_set_stamp, because a task_show_or_fail hits the read bound, or because the process is killed. Because the reason is already B, a retry of the samehold --reason B, which is the normal response to a failed hold, finds hold_reason == reason. It sets preserve_hold_set=1 (:904-905), so the stamp is never advanced. The call then stays reason B with identity S1#n for good, and a phone card built earlier showing wording A with identity S1#n passesanswer --expect-identityand answers question B. The old stamp-first order recovered from this: a crash after the stamp left reason A, so the retry still counted as a reword and advanced again. Smallest fix that keeps the round-2 torn-read protection: on the reword path, advance the stamp both before and after the new reason is stored (stamp_hold_set before the hold and again after it; write_hold_set_stamp's strict monotonic advance makes the second stamp newer). A failure after the hold then leaves a stamp newer than any card showing A, and a card read between the two writes (new stamp S2, old wording A) is still refused once the final stamp S3 lands. Siblings: only this reword branch. New holds and re-holds of released work keep stamp-first and recover on retry. The round-2 test that checks the stamp at hold time (tests/fm-captain-hold-lifecycle.test.sh:4140-4156) would need to assert that it is no longer the first card's stamp, rather than equal to the previous one.🔧 Fix applied.
6 issues (5 warnings, 1 info) still open:
bin/fm-captain-hold.sh:1051- The compare-and-answer only compares the hold-set stamp and the count of recorded answers (captain_call_identity, bin/fm-captain-hold.sh:775). Whenholdruns on a task that is already held for the captain, it keeps the existing stamp (preserve_hold_set=1 at bin/fm-captain-hold.sh:874), and it records no answer. So the identity stays the same even when the question itself changes. Documented callers take this path: the 'later' deferral re-holds a live call with a new --reason and --until (.agents/skills/captain-hold-lifecycle/SKILL.md:34), and stow refreshes a held item's body withupdate --body-fileand then holds it again (.agents/skills/stow/SKILL.md:196). Example: the phone card shows reason A with identityT#0. The item is then re-held with reason B, or its body is rewritten. The captain taps the old card,answer --expect-identity T#0matches, and the tap answers a question the captain never saw. That breaks the intent's rule that 'a phone tap must only answer the exact question it was shown'. The same-worded re-hold and closed-call cases are handled correctly. Fixing this needs a policy decision, and the fix is what needs authorization: either put a digest of the hold reason and body into the identity, which also changes whatopen --identityreturns to bin/fm-watch.sh:1790, or make re-holding a held call with different words start a new stamp. Otherwise, confirm that lifecycle-only identity is the intended scope.bin/fm-captain-hold.sh:1034- With --expect-identity set, any non-zerotask_showexcept 124 gets exit 3 and the message 'changed since it was shown (… now absent)'. But fm_backlog_row_show (bin/fm-backlog-transition-lib.sh:365) returns non-zero for backlog addressing and resolution errors and for any tasks-axi failure, not only for a missing row. So if the backend fails while the call is still open and unchanged, the caller gets the 'question changed' exit code, and the phone tells the captain 'The question changed. Look at it again.' when nothing changed. The voice agent then reports something false, which works against the intent's accuracy goal.openalready makes this distinction: it uses fm_backlog_row_probe and FM_BACKLOG_ROW_RESULT=not_found (bin/fm-captain-hold.sh:1985) and exits 2 on read errors. The answer path should do the same: return exit 3 only for a confirmed not_found, and keep the ordinary fail (exit 1) for read errors.bin/fm-captain-hold.sh:802- Introduced by fix round 1. The user's fix decision said the new stamp "must still differ so the identity changes - advance it deterministically rather than reusing it". But write_hold_set_stamp only advances when the wall-clock stamp is exactly equal to the existing one, so a stamp that has already been pushed ahead of the clock can be followed by an older value that an earlier card already carries. Concrete sequence in one wall-clock second: hold with reason A at 12:00:00 gives stamp 12:00:00, and the phone card shows A with identity12:00:00Z#0. A reword to B at 12:00:00 equals the existing stamp, so it advances to 12:00:01. A second reword to C, still at 12:00:00, differs from the existing 12:00:01, so it writes 12:00:00 and the identity is12:00:00Z#0again. A tap on the A card now matches and answers question C, which is exactly the outcome the intent forbids. A backward clock step (NTP) produces the same ABA without the rapid rewords. Smallest fix: when the new stamp is not later than the existing one (compare epochs with fm_utc_iso_to_epoch), use the existing stamp plus one second, so a reworded or released re-hold always gets a strictly later stamp. Also update the header wording at bin/fm-captain-hold.sh:52-53 ('when the clock has not moved on') to match. The released-work re-hold path goes through this same branch, but its identity also changes because the answer count grows, so only the reworded-live path is exposed.bin/fm-captain-hold.sh:924- Introduced by fix round 1. When a live captain call is reworded, command_hold writes the new hold-set stamp at :924 beforetasks_axi holdstores the new reason at :929-934. So for the length of one or two tasks-axi calls, the persisted row pairs the NEW identity (S2#n) with the OLD reason A.open --identityand the snapshot or hold_reason readers that build the phone card do not take the task control lock. A card built in that window shows wording A with identity S2#n. When hold finishes, the call is B with identity S2#n, so a tap on that card passesanswer --expect-identityand records an answer to B, a question the captain never saw. Reversing the order for this case (apply the new reason first, then advance the stamp, still under the same lock) is fail-safe.answerwaits on the task lock, so no tap can be judged while the row is torn, and a card read in the window pairs wording B with the old identity S1, which the finished row refuses with exit 3. The comment at :919-921 ('never see a newly held task without the timestamp') still holds, because a live call already has its old stamp during the window. Scope: only the reworded-live path (reason differs and hold_kind=captain and held=yes). New holds and re-holds of released work keep today's stamp-first order.bin/fm-captain-hold.sh:947- Introduced by fix round 2 (reword-stamp-written-before-new-reason). On the reworded-live path, command_hold now stores the new reason B withtasks_axi hold(:940-946) before it advances the stamp with stamp_hold_set (:947). If the stamp write then fails, the row keeps reason B with the OLD stamp S1. The write can fail becausetasks_axi updatefails in write_hold_set_stamp, because a task_show_or_fail hits the read bound, or because the process is killed. Because the reason is already B, a retry of the samehold --reason B, which is the normal response to a failed hold, finds hold_reason == reason. It sets preserve_hold_set=1 (:904-905), so the stamp is never advanced. The call then stays reason B with identity S1#n for good, and a phone card built earlier showing wording A with identity S1#n passesanswer --expect-identityand answers question B. The old stamp-first order recovered from this: a crash after the stamp left reason A, so the retry still counted as a reword and advanced again. Smallest fix that keeps the round-2 torn-read protection: on the reword path, advance the stamp both before and after the new reason is stored (stamp_hold_set before the hold and again after it; write_hold_set_stamp's strict monotonic advance makes the second stamp newer). A failure after the hold then leaves a stamp newer than any card showing A, and a card read between the two writes (new stamp S2, old wording A) is still refused once the final stamp S3 lands. Siblings: only this reword branch. New holds and re-holds of released work keep stamp-first and recover on retry. The round-2 test that checks the stamp at hold time (tests/fm-captain-hold-lifecycle.test.sh:4140-4156) would need to assert that it is no longer the first card's stamp, rather than equal to the previous one.bin/fm-captain-hold.sh:948- This residual gap comes from the round-3 fix (reword-crash-leaves-old-stamp-permanently). That fix closes the case its finding described: a card read before the reword shows S1 and is refused. It leaves one narrower case open. On the reword path the order is now stamp S2 (:940), thentasks_axi holdwith reason B (:941-947), then stamp S3 (:948). Suppose a lockless card read (open --identitytogether with the snapshot reason) lands between the first stamp and the hold, so it shows wording A with identity S2#n. If the second stamp_hold_set then fails because of an update error, the read bound, or the process being killed, the row is left as B/S2. Retrying the samehold --reason Bthen sets preserve_hold_set=1 (:905-906) and keeps S2. A tap on the A/S2 card then passesanswer --expect-identityand answers B. The header at :86-89 ('even when the hold stops between the two') overstates coverage for this window. It needs a torn read and a failed write in the same ~one-call window, and the phone side already builds the card from reads that are not atomic, so this is informational only. A durable close would need a persisted 'reword pending' marker, which is new state, so no fix is proposed here.✅ **Test** - passed
✅ No issues found.
answer card-a --release --expect-identity 2026-09-28T15:23:52Z#0->released: card-a, exit 0, Resolution mode: released...15:23:52Z#0vs new...15:23:57Z#1; old tap exit 3, new tapreleasedanswered: card-canswered: card-btwice with exit 0; empty value -> 'expect-identity must not be empty' exit 1drive-expect-identity.sh <worktree>: minted a lab home withbin/fm-lab-home.sh createand ran the realfm-captain-hold.sh hold/open --identity/answer --expect-identitywith the real tasks-axi (no fakes), scenarios S1-S9race-expect-identity.sh <worktree> <lab> 8: a rewordedhold --reason "question B"andanswer --expect-identity <card A identity>launched concurrently with alternating stagger (plain answer, which closes the call)race-expect-identity.sh <worktree> <lab> 8 --release: the same race usinganswer --release, so the reword re-holds released work as a new callSubset run of tests/fm-captain-hold-lifecycle.test.sh: the three new tests (test_answer_expect_identity_answers_only_the_call_shown,test_answer_expect_identity_follows_rewording,test_answer_expect_identity_read_error_is_not_a_change) plus the tests after them in the fileTore down the lab home withrm -rfand confirmed the worktree is clean✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.