Skip to content

captain-hold completion gate is unfixable once a call is answered: complete unions keys, verify reads only the live backlog #2920

Description

@AgardnerAU

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

  1. Run a scout that registers several captain-held tasks and attest them with complete <origin> <task-id>....
  2. Answer them. tasks-axi archives each row to data/done-archive.md.
  3. 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

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-prTriage: real bug or VISION-aligned feature, open for a PR

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions