bin/fm-captain-hold.sh complete unions its inventory with whatever is already stored, so an attested key can never be withdrawn. Every key it holds is then re-validated against the live backlog on each verify. But answering a captain call moves that task out of data/backlog.md and into data/done-archive.md.
The result: once a captain call is answered, the origin task's completion gate can never pass again, and a finished scout can never be torn down.
Reproduce
- Run a scout that registers several captain-held tasks and attest them with
complete <origin> <task-id>....
- Answer them.
tasks-axi archives each row to data/done-archive.md.
- Run
bin/fm-captain-hold.sh verify <origin>, or attempt teardown.
Observed here on a scout with sixteen attested keys, fifteen since answered:
$ bin/fm-teardown.sh agfp-triage-waves
fm-captain-hold: no captain-held task issue-2550-idle-timeout-gap and no legacy
identity agfp-triage-waves-decision-issue-2550-idle-timeout-gap in
/Users/.../data/backlog.md
fm-captain-hold: captain-held task is absent from /Users/.../data/backlog.md
REFUSED: scout task agfp-triage-waves has not passed the captain-call completion gate.
The archived row is intact and carries the recorded answer:
data/done-archive.md:797:
- [x] agfp-triage-waves-decision-issue-2550-idle-timeout-gap - #2550 canvas-only
idle-out: ... (kind: captain) (done 2026-08-23) (hold: Recommended: measure
first ...) (hold-kind: captain)
So the record is complete and correct. Only the lookup is wrong.
Why re-attesting cannot fix it
complete (around line 797):
previous=$(meta_value "$meta" decision_keys)
keys=$(sorted_key_union "$previous" "$supplied")
The union means a corrected, smaller inventory is silently merged back into the old one. Re-running complete with only the still-open key exits 0, writes nothing, and leaves decision_keys= unchanged - verified here:
$ bin/fm-captain-hold.sh complete agfp-triage-waves issue-2618-cage-material-model
... same two errors ... ; exit 0
$ grep decision_keys state/agfp-triage-waves.meta
decision_keys=issue-2550-idle-timeout-gap,issue-2575-..., # all 16, unchanged
Exiting 0 while failing is a second, smaller problem: a caller cannot tell the attestation was rejected.
Where the lookup goes wrong
verify_hold_durable (line 345):
show=$(task_show "$id") || fail "captain-held task $id is absent from $FM_HOME/data/backlog.md"
The function's own later branch shows the intent - it accepts a task whose body carries a recorded resolution:
if body_has_resolution_record "$body"; then
return 0
fi
That branch is simply unreachable for an answered call, because task_show cannot see the archived row in the first place. The policy the code means to enforce is already satisfied; only the source it reads is too narrow.
Suggested direction
- Have
task_show (or a resolution-aware fallback in verify_hold_durable) consult data/done-archive.md when the id is absent from the live backlog. That alone makes the existing body_has_resolution_record branch work as written and fixes the whole class.
- Let
complete replace rather than union, or offer an explicit way to withdraw a key. The union makes the attestation permanently unfixable by any supported command, which is what turns a lookup bug into a dead end.
- Return non-zero from
complete when a supplied key fails validation. It currently reports failure on stderr and exits 0.
Impact
The scout is finished, its report is written, and every one of its captain calls has been answered or is still deliberately open. It cannot be cleaned up, so it holds a live worker slot indefinitely. There is no safe supported way out: forcing teardown is exactly what the refusal exists to prevent, and hand-editing decision_keys is editing state the script owns.
Environment
- firstmate at
7b88520 (current origin/main at time of filing - this reproduces after the 51-commit catch-up, including the decision-to-captain-hold collapse)
- backlog backend: tasks-axi markdown
bin/fm-captain-hold.sh completeunions its inventory with whatever is already stored, so an attested key can never be withdrawn. Every key it holds is then re-validated against the live backlog on eachverify. But answering a captain call moves that task out ofdata/backlog.mdand intodata/done-archive.md.The result: once a captain call is answered, the origin task's completion gate can never pass again, and a finished scout can never be torn down.
Reproduce
complete <origin> <task-id>....tasks-axiarchives each row todata/done-archive.md.bin/fm-captain-hold.sh verify <origin>, or attempt teardown.Observed here on a scout with sixteen attested keys, fifteen since answered:
The archived row is intact and carries the recorded answer:
So the record is complete and correct. Only the lookup is wrong.
Why re-attesting cannot fix it
complete(around line 797):The union means a corrected, smaller inventory is silently merged back into the old one. Re-running
completewith only the still-open key exits 0, writes nothing, and leavesdecision_keys=unchanged - verified here:Exiting 0 while failing is a second, smaller problem: a caller cannot tell the attestation was rejected.
Where the lookup goes wrong
verify_hold_durable(line 345):The function's own later branch shows the intent - it accepts a task whose body carries a recorded resolution:
That branch is simply unreachable for an answered call, because
task_showcannot see the archived row in the first place. The policy the code means to enforce is already satisfied; only the source it reads is too narrow.Suggested direction
task_show(or a resolution-aware fallback inverify_hold_durable) consultdata/done-archive.mdwhen the id is absent from the live backlog. That alone makes the existingbody_has_resolution_recordbranch work as written and fixes the whole class.completereplace rather than union, or offer an explicit way to withdraw a key. The union makes the attestation permanently unfixable by any supported command, which is what turns a lookup bug into a dead end.completewhen a supplied key fails validation. It currently reports failure on stderr and exits 0.Impact
The scout is finished, its report is written, and every one of its captain calls has been answered or is still deliberately open. It cannot be cleaned up, so it holds a live worker slot indefinitely. There is no safe supported way out: forcing teardown is exactly what the refusal exists to prevent, and hand-editing
decision_keysis editing state the script owns.Environment
7b88520(currentorigin/mainat time of filing - this reproduces after the 51-commit catch-up, including the decision-to-captain-hold collapse)