From 17bd03b646ae5be3a5a8f4aeb4a4b155f345f70f Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 14:37:58 +0200 Subject: [PATCH 1/6] fix(bin): unify keyed decision resolution grammar --- bin/fm-classify-lib.sh | 107 ++++++++++++--------- bin/fm-pending-reply-lib.sh | 30 ++---- bin/fm-send.sh | 49 ++++------ tests/fm-classify-corr-token.test.sh | 2 +- tests/fm-classify-decision-key.test.sh | 56 +++++++++++ tests/fm-send-resolve-key.test.sh | 76 +++++++-------- tests/fm-wake-drain-open-decisions.test.sh | 24 +++-- 7 files changed, 188 insertions(+), 156 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 8bd4fe746ad..586f9a63ff9 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -216,8 +216,8 @@ status_is_paused_or_captain_held() { # # so a summary merely MENTIONING "[key=x]" cannot open or close that decision. # A line with no token in either position uses the key "default", preserving # the historical one-open-decision-per-task behavior (a bare "resolved:" closes -# "default"). A stated key whose slug fails the charset below is rejected (the -# folds skip the line), never rewritten to "default". +# "default"). A stated key whose slug fails status_decision_key_valid below is +# rejected (the folds skip the line), never rewritten to "default". # The parsers are pure reads of a single line. Status metadata may contain any # number of "[name=value]" tags before the colon, in any order, so verb parsing # ends at the first tag rather than special-casing "[key=...]". @@ -249,7 +249,7 @@ status_is_paused_or_captain_held() { # # Skipping unknown tokens would be the permissive road - it would let any # free-text word carrying an equals sign ("resolved x=1 [key=k]: ...") reduce to # a bare verb and impersonate a transition, which is the takeover the strict -# parse and _fm_decision_key_transition_allowed exist to prevent. Recognising +# parse and the reserved-key transition guard exist to prevent. Recognising # only what a firstmate library actually writes costs one more line here each # time a real new token shape is introduced, and that is the intended trade: a # new shape is a deliberate, reviewed edit rather than a silent widening. A line @@ -310,8 +310,8 @@ _fm_key_before_colon() { # } # Raw slug of a complete "[key=]" token at the head of the note (the # first thing after the line's first colon, ignoring whitespace). Fails when -# the line has no colon or no complete token there; slug charset validity is -# the caller's check via _fm_decision_slug_ok, exactly as for the before-colon +# the line has no colon or no complete token there; slug validity is the +# caller's check via status_decision_key_valid, exactly as for the before-colon # position. _fm_key_at_note_head() { # -> raw slug local rest @@ -325,8 +325,11 @@ _fm_key_at_note_head() { # -> raw slug *) return 1 ;; esac } -# 0 when a stated key slug is well-formed: nonempty, A-Za-z0-9._- only. -_fm_decision_slug_ok() { # +# 0 when a decision key is well-formed: nonempty, A-Za-z0-9._- only. +# This public predicate is the ONE owner of decision-key grammar. Status-line +# parsing and fm-send --resolve-key both call it, so a key accepted into the +# open set cannot be rejected later by a separately maintained send grammar. +status_decision_key_valid() { # case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; @@ -342,7 +345,7 @@ status_line_note() { # -> text after the first colon, trimmed # slug) is key metadata, not note text: strip it so both stated-key positions # yield the same note. if ! _fm_key_before_colon "$1" && k=$(_fm_key_at_note_head "$1") \ - && _fm_decision_slug_ok "$k"; then + && status_decision_key_valid "$k"; then n=${n#"[key=$k]"} n=${n#"${n%%[![:space:]]*}"} fi @@ -357,7 +360,7 @@ _fm_decision_key() { # -> key slug, or "default" when no token else k=$(_fm_key_at_note_head "$1") || { printf 'default'; return 0; } fi - _fm_decision_slug_ok "$k" || return 1 + status_decision_key_valid "$k" || return 1 printf '%s' "$k" } # Drop the record for from a newline-terminated "\t\t" set. @@ -384,38 +387,46 @@ EOF # rule, so the two consumption strategies can never drift apart on semantics. # Reserved decision-key namespaces, and the rule that makes them mean something. # -# A key like `pending-reply-` names a decision that one library raises and is -# the only thing that ever closes it. Every writer reaches this same stream: a -# local mate appends straight into it, and a remote mate's lines are mirrored -# into it verbatim. So without a rule here, any writer could claim a reserved -# key with an unrelated note, take the key over in this fold, and permanently -# block the owner's close - leaving a decision nothing will ever resolve - or -# clear the owner's decision with a bare resolution. +# A key like `pending-reply-` names a decision that one library raises. +# Every writer reaches this same stream: a local mate appends straight into it, +# and a remote mate's lines are mirrored into it verbatim. Without a guard, any +# writer could claim a reserved key with an unrelated needs-decision or blocked +# note and take the owner's open record over permanently. # # The rule is deliberately generic, so this fold needs no knowledge of any -# particular owner: a reserved key may only be opened or closed by a line whose +# particular owner. A reserved key may only be opened or closed by a line whose # note speaks that namespace's own vocabulary, which its owner states by -# beginning the note with a `...:` token. A line failing that is not a -# decision transition at all here and is folded as ordinary status. This is a -# consumer-side rule on purpose - it protects local and remote writers -# identically, and it can never fail a whole delta or wedge a stream the way a -# writer-side rejection would. +# beginning the note with a `...:` token. A rejected line remains +# ordinary status in the fold and status-span classification surfaces it as a +# reconciliation error, so it is never a silent manual no-op. FM_CLASSIFY_RESERVED_KEY_PREFIXES_DEFAULT='pending-reply-' +# Print the configured reserved prefix that owns , or fail when unreserved. +_fm_decision_reserved_prefix() { # + local key=$1 prefix + for prefix in ${FM_CLASSIFY_RESERVED_KEY_PREFIXES:-$FM_CLASSIFY_RESERVED_KEY_PREFIXES_DEFAULT}; do + case "$key" in "$prefix"*) printf '%s' "$prefix"; return 0 ;; esac + done + return 1 +} + # 0 when is not reserved, or is reserved and speaks its vocabulary. _fm_decision_key_transition_allowed() { # local key=$1 note=$2 prefix - for prefix in ${FM_CLASSIFY_RESERVED_KEY_PREFIXES:-$FM_CLASSIFY_RESERVED_KEY_PREFIXES_DEFAULT}; do - case "$key" in - "$prefix"*) - case "$note" in - "$prefix"*:*) return 0 ;; - *) return 1 ;; - esac - ;; - esac - done - return 0 + prefix=$(_fm_decision_reserved_prefix "$key") || return 0 + case "$note" in "$prefix"*:*) return 0 ;; esac + return 1 +} + +# Print a close note that the fold accepts for . This public helper is the +# ONE writer-side owner of reserved-close grammar: ordinary notes pass through, +# while a reserved key receives its namespace's explicit resolved vocabulary. +# fm-send uses it for every --resolve-key close rather than knowing any owner. +status_decision_close_note() { # + local key=$1 note=$2 prefix + status_decision_key_valid "$key" || return 1 + prefix=$(_fm_decision_reserved_prefix "$key") || { printf '%s' "$note"; return 0; } + printf '%sresolved: %s' "$prefix" "$note" } _fm_is_pending_reply_escalation() { # @@ -1427,10 +1438,11 @@ status_new_lines_since_cursor() { # [] } # 0 when a status line is an informational `note:` or a reserved-key -# pending-reply resolution. Those lines never fold into OPEN DECISIONS, so the -# drain's unread-status surface is their only guaranteed presentation. +# resolution attempt. Accepted closes leave the open set and rejected attempts +# leave the key there, so the drain presents either attempt once rather +# than silently burying its outcome under a later append. status_line_is_unread_surface() { # - local line=$1 verb key note resolve held prefix + local line=$1 verb key note resolve held [ -n "$line" ] || return 1 verb=$(status_line_verb "$line") [ "$verb" = note ] && return 0 @@ -1441,16 +1453,10 @@ status_line_is_unread_surface() { # *) return 1 ;; esac key=$(_fm_decision_key "$line") || return 1 + _fm_decision_reserved_prefix "$key" >/dev/null || return 1 + [ "$verb" = "$resolve" ] && return 0 note=$(status_line_note "$line") - for prefix in ${FM_CLASSIFY_RESERVED_KEY_PREFIXES:-$FM_CLASSIFY_RESERVED_KEY_PREFIXES_DEFAULT}; do - case "$key" in - "$prefix"*) - _fm_decision_key_transition_allowed "$key" "$note" - return - ;; - esac - done - return 1 + _fm_decision_key_transition_allowed "$key" "$note" } # Fleet-wide unread informational lines: one "\t" row per @@ -1666,8 +1672,17 @@ status_span_first_actionable_record() { # [record- _fm_span_needs_decision=1 continue fi - status_is_captain_relevant "$line" || continue verb=$(status_line_verb "$line") + if [ "$verb" = "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}" ]; then + key=$(_fm_decision_key "$line") || key='' + if [ -n "$key" ] && ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")"; then + [ -n "$events" ] && events="${events} ; " + events="${events}reconciliation-required: ${line}" + rc=0 + continue + fi + fi + status_is_captain_relevant "$line" || continue case "$verb" in needs-decision|blocked) key=$(_fm_decision_key "$line") || { diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 7e4b00e4948..f61a115251f 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -69,12 +69,9 @@ # request in every later OPEN DECISIONS fold. # That per-request key lives in a namespace the fold reserves to this library, so # no other writer into the same status stream - a local mate appending directly, -# or a remote mate's mirrored line - can take the key over or clear it; see the -# reserved-key rule in bin/fm-classify-lib.sh. -# The operator-facing close of that same keyed decision is still -# fm-send --resolve-key (bin/fm-send.sh header): it must speak the close note -# owned below (fm_pending_reply_resolved_note), because a bare answered: note is -# not a reserved-key transition and would leave the decision open. +# or a remote mate's mirrored line - can take the key over; see the reserved-key +# rule in bin/fm-classify-lib.sh. The universal explicit `resolved [key=...]` +# protocol remains the public manual close through fm-send --resolve-key. # # Sourced by bin/fm-send.sh, bin/fm-watch.sh, bin/fm-secondmate-report.sh, and # tests. No side effects on source. set -u / set -e safe. @@ -1022,11 +1019,9 @@ fm_pending_reply_escalation_key() { # printf 'pending-reply-%s' "$1" } -# Close-note body the reserved-key fold accepts as this library's resolution. -# The fold's guard (bin/fm-classify-lib.sh _fm_decision_key_transition_allowed) -# requires the note to begin with this namespace's vocabulary token; this is -# that token plus the stable task/id/via fields both the record close and the -# operator --resolve-key path write. Optional is appended after a space. +# Close-note body this library writes when its own pending-reply record resolves. +# The stable task/id/via fields distinguish an owner close from the universal +# public manual-close protocol. Optional is appended after a space. fm_pending_reply_resolved_note() { # [extra] printf 'pending-reply-resolved: task=%s pending-reply-id=%s via=%s' "$1" "$2" "$3" if [ -n "${4:-}" ]; then @@ -1034,19 +1029,6 @@ fm_pending_reply_resolved_note() { # [extra] fi } -# 0 and prints the close note when is in this library's reserved -# namespace (pending-reply-). fm-send --resolve-key uses this so an -# operator close speaks the same vocabulary as fm_pending_reply_close_escalation -# instead of writing a silent no-op answered: note. -fm_pending_reply_close_note_for_key() { # [extra] - case "$1" in - pending-reply-*) - fm_pending_reply_resolved_note "$2" "${1#pending-reply-}" "$3" "${4:-}" - ;; - *) return 1 ;; - esac -} - fm_pending_reply_escalation_payload() { # local rec=$1 kind=$2 task_id corr summary outcome token task_id=$(fm_pending_reply_get "$rec" task_id) diff --git a/bin/fm-send.sh b/bin/fm-send.sh index b4e61796959..13c58df7dfe 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -152,15 +152,14 @@ # appends the closing resolved line to that status file, so the captain-facing # OPEN DECISIONS record closes at answer time and never depends on the busy # worker writing a matching resolved line. Ordinary keys close with -# "resolved [key=]: answered: ". A reserved key -# (pending-reply-* today; bin/fm-classify-lib.sh's reserved-key guard) is -# closed with the owning library's vocabulary note -# (fm_pending_reply_close_note_for_key / fm_pending_reply_resolved_note), so -# the fold actually drops it; a bare answered: note is not a reserved-key -# transition and is never written for those keys. If this send cannot produce -# a note the guard will accept, or the structural key would be lost to the -# status-line cap, it refuses before sending and names the cause rather than -# exiting 0 on a silent no-op. After a delivered close it also +# "resolved [key=]: answered: ". A reserved key such as +# pending-reply-* receives the namespace vocabulary generated by +# status_decision_close_note in bin/fm-classify-lib.sh, so the same public +# interface closes every key the fold can open without teaching fm-send an +# owner-specific grammar. If the fold would reject the generated note, or the +# structural key would be lost to the status-line cap, fm-send refuses before +# sending and names the cause rather than exiting 0 on a silent no-op. After a +# delivered close it also # re-folds and fails loudly if the named key is still open. On the inbox plane # the close happens at ENQUEUE time, because enqueue is durable delivery to # the task's record; the worker reading the answer late is covered by the @@ -453,12 +452,10 @@ RESOLVE_KEYS= FIRE_AND_FORGET_ID= fm_send_add_resolve_key() { # local k=$1 - case "$k" in - ''|*[!A-Za-z0-9._-]*) - echo "error: --resolve-key '$k' is not a valid decision key (allowed: A-Z a-z 0-9 . _ -)" >&2 - return 1 - ;; - esac + if ! status_decision_key_valid "$k"; then + echo "error: --resolve-key '$k' is not a valid decision key (allowed: A-Z a-z 0-9 . _ -)" >&2 + return 1 + fi case " $RESOLVE_KEYS " in *" $k "*) echo "error: duplicate --resolve-key '$k'" >&2 @@ -556,18 +553,6 @@ fm_send_hold_resolved_id() { # return 1 } -# Close-note body for --resolve-key. Ordinary keys keep answered: . -# A pending-reply-* key uses the owning library's vocabulary so the reserved-key -# fold actually closes it (fm_pending_reply_close_note_for_key). -fm_send_resolve_close_note() { # - local k=$1 excerpt=$2 owned - if owned=$(fm_pending_reply_close_note_for_key "$k" "$RESOLVE_TASK_ID" operator-resolve-key "$excerpt"); then - printf '%s' "$owned" - return 0 - fi - printf 'answered: %s' "$excerpt" -} - if [ -n "$FIRE_AND_FORGET_ID" ]; then printf '%s' "$FIRE_AND_FORGET_ID" | grep -Eq '^[a-f0-9]{16}$' \ || { echo "error: --fire-and-forget delivery id must be 16 lowercase hex characters" >&2; exit 1; } @@ -610,13 +595,13 @@ if [ -n "$RESOLVE_KEYS" ]; then echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE, and no captain-held task '$k' or '$RESOLVE_TASK_ID-decision-$k' still open (already closed or mistyped). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2 exit 1 done - # Refuse before send when a named status-log key cannot actually close: a - # reserved key with an answered: note is a silent no-op in the fold. + # Refuse before send when the shared grammar cannot produce an effective + # close, or when its structural key cannot fit in one status line. resolve_excerpt=$(printf '%s' "$*" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') for k in $RESOLVE_STATUS_KEYS; do - probe=$(fm_send_resolve_close_note "$k" "$resolve_excerpt") + probe=$(status_decision_close_note "$k" "answered: $resolve_excerpt") if ! _fm_decision_key_transition_allowed "$k" "$probe"; then - echo "error: --resolve-key '$k' cannot take effect: this key is reserved for its owning library, and this send cannot produce a close note that library's fold will accept. Refusing rather than writing a silent no-op; nothing was sent." >&2 + echo "error: --resolve-key '$k' cannot take effect: the shared decision grammar cannot produce an accepted close note; nothing was sent." >&2 exit 1 fi probe_line="resolved [key=$k]: $probe" @@ -641,7 +626,7 @@ fm_send_close_resolved_keys() { # local note=$1 k line close_note append_rc still manual_close_cmd note=$(printf '%s' "$note" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') for k in $RESOLVE_STATUS_KEYS; do - close_note=$(fm_send_resolve_close_note "$k" "$note") + close_note=$(status_decision_close_note "$k" "answered: $note") line="resolved [key=$k]: $close_note" fm_cap_line_var "$line" printf -v manual_close_cmd "printf '%%s\\n' %q >> %q" "$FM_LINE_CAP_LINE" "$RESOLVE_STATUS_FILE" diff --git a/tests/fm-classify-corr-token.test.sh b/tests/fm-classify-corr-token.test.sh index b9bcc80a2a3..728c6eae129 100755 --- a/tests/fm-classify-corr-token.test.sh +++ b/tests/fm-classify-corr-token.test.sh @@ -165,7 +165,7 @@ test_prose_and_malformed_tokens_never_become_transitions() { # contract, not a gap in this one, and tightening it here would silently # narrow a separately reviewed rule. The strictness below is what keeps an # unbracketed token honest, and a bracketed impostor still has to get a - # well-formed key past _fm_decision_key_transition_allowed. + # well-formed key past the reserved-key transition guard. local -a impostors=( 'resolved the corr= issue yesterday [key=victim]' 'resolved corr= [key=victim]' diff --git a/tests/fm-classify-decision-key.test.sh b/tests/fm-classify-decision-key.test.sh index 8c4196a8a26..f57aef0ae29 100755 --- a/tests/fm-classify-decision-key.test.sh +++ b/tests/fm-classify-decision-key.test.sh @@ -108,6 +108,60 @@ test_blocked_is_position_tolerant_like_needs_decision() { pass "blocked [key=X] opens X in both key positions" } +# Reserved namespaces protect owner transitions while the public close-note +# helper lets every interface produce an accepted explicit resolution. A manual +# note that lacks that vocabulary stays open but surfaces an actionable error. +test_reserved_key_public_resolution_is_effective_and_rejections_surface() { + local dir f expected key offset rejected close_note + dir=$(case_dir reserved-resolution) + f="$dir/t.status" + key=pending-reply-abcdef0123456789 + printf 'blocked [key=%s]: pending-reply-missed: owner escalation\n' "$key" > "$f" + expected=$(printf '%s\tblocked\tpending-reply-missed: owner escalation\n' "$key") + assert_fold "$f" "$expected" "reserved owner open" + + printf 'working: prose says resolved [key=%s]: but is not a protocol line\n' "$key" >> "$f" + printf 'captain-held [key=%s]: foreign transfer claim\n' "$key" >> "$f" + assert_fold "$f" "$expected" "reserved key rejects prose and foreign transfer" + + offset=$(LC_ALL=C wc -c < "$f" | tr -d '[:space:]') + printf 'resolved [key=%s]: manually dismissed without owner vocabulary\n' "$key" >> "$f" + assert_fold "$f" "$expected" "rejected reserved resolution leaves the owner record open" + rejected=$(status_span_first_actionable "$f" "$offset") + assert_contains "$rejected" "reconciliation-required: resolved [key=$key]" \ + "a rejected manual resolution must produce an actionable diagnostic" + status_line_is_unread_surface "resolved [key=$key]: manually dismissed without owner vocabulary" \ + || fail "a rejected reserved resolution was absent from the unread-status surface" + + close_note=$(status_decision_close_note "$key" "answered: dismissed after inspection") + printf 'resolved [key=%s]: %s\n' "$key" "$close_note" >> "$f" + assert_fold "$f" "" "shared reserved close note" + pass "reserved closes use one public grammar and rejected manual notes surface actionably" +} + +# Prefix-related keys exercise exact membership and drop semantics. Closing one +# must leave its twin open, and reopening the first must make it authoritative +# again for both the full and persisted incremental folds. +test_twin_keys_close_exactly_and_reopen() { + local dir f expected + dir=$(case_dir twin-reopen) + f="$dir/t.status" + printf 'blocked [key=route]: first blocker\n' > "$f" + printf 'needs-decision [key=route-long]: second decision\n' >> "$f" + printf 'resolved [key=route]: first cleared\n' >> "$f" + expected=$(printf 'route-long\tneeds-decision\tsecond decision\n') + assert_fold "$f" "$expected" "close exact short twin" + + printf 'blocked [key=route]: reopened after verification\n' >> "$f" + printf 'resolved [key=route-long]: second answered\n' >> "$f" + expected=$(printf 'route\tblocked\treopened after verification\n') + assert_fold "$f" "$expected" "reopened short twin remains after long twin closes" + + printf 'resolved [key=route]: reopened blocker cleared\n' >> "$f" + assert_fold "$f" "" "reopened twin closes explicitly" + pass "twin keys close exactly and reopened keys remain open until their later resolution" +} + test_two_colon_form_decisions_stay_distinct() { local dir expected dir=$(case_dir distinct) @@ -266,6 +320,8 @@ test_stated_key_is_honored_in_both_positions test_bare_keyless_line_still_folds_to_default test_resolution_closes_across_positions test_blocked_is_position_tolerant_like_needs_decision +test_reserved_key_public_resolution_is_effective_and_rejections_surface +test_twin_keys_close_exactly_and_reopen test_two_colon_form_decisions_stay_distinct test_mid_note_prose_mention_is_not_a_stated_key test_malformed_stated_key_never_collapses_to_default diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index fc51a123a55..4f52b72e85c 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -26,10 +26,9 @@ # message crosses the stubbed ssh transport while the close is the same # local ledger append; a failed transport closes nothing. # 7. Flag misuse (--key, empty message, explicit backend target) refuses. -# 8. A reserved pending-reply-* decision actually closes through --resolve-key -# (the operator path the OPEN DECISIONS hint names), while an unrelated -# writer's answered: note still cannot hijack or clear that key. A reserved -# key this send cannot close refuses before anything is sent. +# 8. Every reserved key accepted by the fold also closes through --resolve-key +# with the universal explicit resolved protocol, while unrelated opens and +# prose cannot hijack or clear that key. set -u # shellcheck source=tests/lib.sh @@ -541,10 +540,9 @@ test_flag_misuse_refuses() { pass "fm-send --resolve-key: --key, empty message, explicit targets, and malformed keys refuse loudly" } -# The reported silent no-op: fm-send --resolve-key on a reserved pending-reply-* -# key used to write "answered: ..." and exit 0 while the classify fold left the -# decision open. The operator path must actually close it, using the owning -# library's vocabulary, without weakening the guard against an unrelated writer. +# The reported silent no-op: a generic explicit resolution of a reserved +# pending-reply-* key was ignored. The same public protocol fm-send uses for an +# ordinary key must close it, without weakening the guard against foreign opens. test_reserved_pending_reply_key_closes_through_resolve_key() { local dir fb log home rc out key corr dir="$TMP_ROOT/reserved-close"; mkdir -p "$dir" @@ -562,12 +560,9 @@ test_reserved_pending_reply_key_closes_through_resolve_key() { run_send "$fb" "$home" "$log" mate --resolve-key "$key" "ack, false escalation"; rc=$? expect_code 0 "$rc" "closing a reserved pending-reply key via --resolve-key should succeed" - grep -F "pending-reply-resolved: task=mate pending-reply-id=$corr via=operator-resolve-key" \ + grep -F "resolved [key=$key]: pending-reply-resolved: answered: ack, false escalation" \ "$home/state/mate.status" >/dev/null \ - || fail "the operator close did not write the owning library's close note:"$'\n'"$(cat "$home/state/mate.status")" - if grep -E "resolved \[key=$key\]: answered:" "$home/state/mate.status" >/dev/null; then - fail "the operator close still wrote a bare answered: note that the fold ignores:"$'\n'"$(cat "$home/state/mate.status")" - fi + || fail "the operator close did not write the universal resolved protocol:"$'\n'"$(cat "$home/state/mate.status")" out=$(drain_out "$home") if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then @@ -576,7 +571,7 @@ test_reserved_pending_reply_key_closes_through_resolve_key() { pass "fm-send --resolve-key: a reserved pending-reply key actually closes through the operator path" } -test_unrelated_writer_cannot_close_or_hijack_reserved_key() { +test_unrelated_writer_cannot_hijack_or_prose_close_reserved_key() { local dir fb log home rc out key corr dir="$TMP_ROOT/reserved-guard"; mkdir -p "$dir" fb=$(make_stubs "$dir"); log="$dir/send.log" @@ -588,13 +583,12 @@ test_unrelated_writer_cannot_close_or_hijack_reserved_key() { printf 'blocked [key=%s]: pending-reply-missed: task=mate pending-reply-id=%s request=ship it\n' \ "$key" "$corr" printf 'blocked [key=%s]: shipping is blocked on infra\n' "$key" - printf 'resolved [key=%s]: answered: operator thought this would close it\n' "$key" - printf 'resolved [key=%s]: all good now\n' "$key" + printf 'working: prose mentions resolved [key=%s]: but does not lead the line\n' "$key" } > "$home/state/mate.status" out=$(drain_out "$home") printf '%s' "$out" | grep -F "pending-reply-id=$corr" >/dev/null \ - || fail "an unrelated answered: resolution cleared a reserved decision: $out" + || fail "unrelated text cleared a reserved decision: $out" if printf '%s' "$out" | grep -F 'shipping is blocked on infra' >/dev/null; then fail "an unrelated writer took over a reserved decision key: $out" fi @@ -605,35 +599,37 @@ test_unrelated_writer_cannot_close_or_hijack_reserved_key() { if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then fail "the reserved key stayed open after the operator close: $out" fi - pass "fm-send --resolve-key: an unrelated writer cannot close or hijack a reserved key, and the operator close still can" + pass "fm-send --resolve-key: foreign opens and prose cannot hijack a reserved key, while an explicit close can" } -test_unclosable_reserved_key_refuses_before_send() { - local dir fb log home err rc out - dir="$TMP_ROOT/reserved-refuse"; mkdir -p "$dir" - fb=$(make_stubs "$dir"); log="$dir/send.log"; err="$dir/send.err" - home=$(setup_home reserved-refuse) +test_custom_reserved_key_recognized_by_fold_closes_through_send() { + local dir fb log home rc out + dir="$TMP_ROOT/reserved-custom"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log" + home=$(setup_home reserved-custom) fm_write_meta "$home/state/t1.meta" "window=sess:fm-t1" "kind=ship" printf 'blocked [key=secret-abc]: secret-held: keep this\n' > "$home/state/t1.status" + out=$(FM_CLASSIFY_RESERVED_KEY_PREFIXES='pending-reply- secret-' drain_out "$home") + printf '%s' "$out" | grep -F '[key=secret-abc]' >/dev/null \ + || fail "precondition: the custom reserved blocker was not recognized by the fold: $out" + : > "$log" env PATH="$fb:$PATH" \ FM_ROOT_OVERRIDE="$home" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \ FM_CLASSIFY_RESERVED_KEY_PREFIXES='pending-reply- secret-' \ - "$SEND" t1 --resolve-key secret-abc "this must not silently no-op" >/dev/null 2>"$err"; rc=$? - [ "$rc" -ne 0 ] || fail "a reserved key this send cannot close should refuse" - assert_contains "$(cat "$err")" "--resolve-key 'secret-abc'" "the refusal should name the reserved key" - assert_contains "$(cat "$err")" "cannot take effect" "the refusal should say the close cannot take effect" - assert_contains "$(cat "$err")" "nothing was sent" "the refusal should state nothing was sent" - [ ! -s "$log" ] || fail "a refused reserved-key close still typed text: $(cat "$log")" - [ ! -d "$home/state/t1.inbox" ] || fail "a refused reserved-key close still enqueued an inbox record" - if grep -F 'resolved' "$home/state/t1.status" >/dev/null; then - fail "a refused reserved-key close still wrote a resolved line: $(cat "$home/state/t1.status")" + "$SEND" t1 --resolve-key secret-abc "resolve through the public interface" >/dev/null 2>&1; rc=$? + expect_code 0 "$rc" "a reserved key recognized by the fold should be resolvable by fm-send" + grep -F 'resolved [key=secret-abc]: secret-resolved: answered: resolve through the public interface' \ + "$home/state/t1.status" >/dev/null \ + || fail "fm-send did not append the universal close: $(cat "$home/state/t1.status")" + grep -F 'resolve through the public interface' "$home/state/t1.inbox/001.msg" >/dev/null \ + || fail "the accepted reserved-key answer was not delivered" + out=$(FM_CLASSIFY_RESERVED_KEY_PREFIXES='pending-reply- secret-' drain_out "$home") + if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then + fail "the custom reserved key stayed open after the public close: $out" fi - out=$(drain_out "$home") - printf '%s' "$out" | grep -F '[key=secret-abc]' >/dev/null \ - || fail "the reserved decision disappeared after a refused close: $out" - pass "fm-send --resolve-key: a reserved key this send cannot close refuses loudly before anything is sent" + pass "fm-send --resolve-key: every reserved key recognized by the fold uses the same public close" } test_long_decision_key_refuses_before_send() { @@ -709,9 +705,9 @@ test_remote_reserved_pending_reply_key_closes_locally() { FM_SSH_BIN="$fb/fake-ssh" FM_SSH_LOG="$ssh_log" FM_FAKE_SSH_RC=0 \ "$SEND" rsm --resolve-key "$key" "ack the missed-reply hold" >/dev/null 2>&1; rc=$? expect_code 0 "$rc" "a remote reserved-key --resolve-key should succeed" - grep -F "pending-reply-resolved: task=rsm pending-reply-id=$corr via=operator-resolve-key" \ + grep -F "resolved [key=$key]: pending-reply-resolved: answered: ack the missed-reply hold" \ "$home/state/rsm.status" >/dev/null \ - || fail "the remote operator close did not write the owning library's close note: $(cat "$home/state/rsm.status")" + || fail "the remote operator close did not write the universal resolved protocol: $(cat "$home/state/rsm.status")" out=$(drain_out "$home") if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then fail "the remote reserved pending-reply decision still lists as open: $out" @@ -734,8 +730,8 @@ test_remote_reply_corr_tag_does_not_block_resolve_key test_remote_transport_failure_does_not_close test_flag_misuse_refuses test_reserved_pending_reply_key_closes_through_resolve_key -test_unrelated_writer_cannot_close_or_hijack_reserved_key -test_unclosable_reserved_key_refuses_before_send +test_unrelated_writer_cannot_hijack_or_prose_close_reserved_key +test_custom_reserved_key_recognized_by_fold_closes_through_send test_long_decision_key_refuses_before_send test_failed_close_recovery_command_is_shell_safe test_remote_reserved_pending_reply_key_closes_locally diff --git a/tests/fm-wake-drain-open-decisions.test.sh b/tests/fm-wake-drain-open-decisions.test.sh index 079858ffd37..a42176f86c8 100755 --- a/tests/fm-wake-drain-open-decisions.test.sh +++ b/tests/fm-wake-drain-open-decisions.test.sh @@ -54,16 +54,15 @@ test_explicit_resolution_closes_it() { pass "an explicit resolved [key=X] closes the keyed decision" } -test_reserved_key_namespace_is_owned_by_its_library() { +test_reserved_key_namespace_protects_owner_transitions() { local dir state out dir=$(make_case reserved-key) state="$dir/state" out="$dir/drain.out" - # `pending-reply-` names a decision bin/fm-pending-reply-lib.sh raises and - # is the only writer that closes it. Every writer reaches this same stream - a - # local mate appends into it directly, and a remote mate's lines are mirrored - # into it verbatim - so another writer must not be able to take that key over - # or clear it just by naming it. + # `pending-reply-` names a decision bin/fm-pending-reply-lib.sh raises. + # Every writer reaches this same stream, so another writer must not be able to + # take that key over with an unrelated opening or closing note. Public close + # writers use the namespace vocabulary supplied by fm-classify-lib.sh. printf 'blocked [key=pending-reply-abcdef0123456789]: pending-reply-missed: task=ios pending-reply-id=abcdef0123456789 request=ship it\n' > "$state/task9.status" printf 'blocked [key=pending-reply-abcdef0123456789]: shipping is blocked on infra\n' >> "$state/task9.status" printf 'resolved [key=pending-reply-abcdef0123456789]: all good now\n' >> "$state/task9.status" @@ -71,18 +70,17 @@ test_reserved_key_namespace_is_owned_by_its_library() { FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed on reserved-key lines" grep -F 'pending-reply-id=abcdef0123456789' "$out" >/dev/null \ - || fail "a foreign resolution cleared a reserved decision it does not own: $(cat "$out")" + || fail "an unrelated resolution cleared a reserved decision: $(cat "$out")" if grep -F 'shipping is blocked on infra' "$out" >/dev/null; then fail "a foreign line took over a reserved decision key: $(cat "$out")" fi - # The owner's own resolution, which speaks that namespace's vocabulary, closes it. - printf 'resolved [key=pending-reply-abcdef0123456789]: pending-reply-resolved: task=ios pending-reply-id=abcdef0123456789 via=status\n' >> "$state/task9.status" - FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed after the owner closed its decision" + printf 'resolved [key=pending-reply-abcdef0123456789]: pending-reply-resolved: manually dismissed after inspection\n' >> "$state/task9.status" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain failed after the accepted close" if grep -F 'OPEN DECISIONS' "$out" >/dev/null; then - fail "the owner's own resolution did not close its reserved decision: $(cat "$out")" + fail "the namespace resolution did not close the reserved decision: $(cat "$out")" fi - pass "a reserved decision key can only be opened or closed by its owning library" + pass "a reserved decision key accepts only namespace-owned opens and closes" } test_later_unrelated_terminal_line_does_not_close_it() { @@ -219,7 +217,7 @@ test_buried_decision_still_surfaces test_over_long_decision_note_is_capped_with_a_marker test_explicit_resolution_closes_it test_later_unrelated_terminal_line_does_not_close_it -test_reserved_key_namespace_is_owned_by_its_library +test_reserved_key_namespace_protects_owner_transitions test_no_open_decisions_prints_nothing test_open_decision_surfaces_even_with_an_unrelated_queued_wake test_buried_decision_surfaces_on_the_empty_queue_fast_path From 202fa06f357be29312457a5a1e4e21e73d2c96d7 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 15:57:50 +0200 Subject: [PATCH 2/6] no-mistakes(review): close reserved keys through the captain-held transfer grammar --- bin/fm-captain-hold.sh | 11 ++++++-- bin/fm-classify-lib.sh | 32 +++++++++++---------- tests/fm-captain-hold-lifecycle.test.sh | 36 ++++++++++++++++++++++++ tests/fm-classify-decision-key.test.sh | 37 +++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 3ebc13cb923..05d9db3975d 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -1524,7 +1524,7 @@ reconcile_note() { } command_complete() { - local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open raw_open has_meta=0 transfer_rc resolved + local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open raw_open has_meta=0 transfer_rc transfer_note resolved local resolved_how attested_by_prefix='' [ "$#" -ge 2 ] || { usage >&2; exit 2; } validate_slug origin-id "$origin" @@ -1590,13 +1590,18 @@ EOF # Call item. The transfer line is this home's own bookkeeping close, # written by the turn that just reviewed the inventory, so it uses the # guarded self-announced append (bin/fm-wake-lib.sh) and does not wake this - # same session; an append failure still fails this command loudly. + # same session; an append failure still fails this command loudly. The note + # is built through the shared close grammar so a reserved key (a + # `pending-reply-*` escalation, say) is really closed by this authoritative + # transfer instead of staying open in the fold beside its own hold. if [ -n "$keys" ]; then while IFS=$'\t' read -r key _verb _summary; do [ -n "$key" ] || continue transfer_rc=0 + transfer_note=$(status_decision_close_note "$key" "tracked by $keys") \ + || fail "cannot express the captain-held transfer for $origin/$key in the shared decision grammar" fm_wake_status_append_self_announced "$STATE" "$status_file" \ - "captain-held [key=$key]: tracked by $keys" || transfer_rc=$? + "captain-held [key=$key]: $transfer_note" || transfer_rc=$? [ "$transfer_rc" -ne 2 ] || fail "cannot append the captain-held transfer for $origin/$key" done < [] # leave the key there, so the drain presents either attempt once rather # than silently burying its outcome under a later append. status_line_is_unread_surface() { # - local line=$1 verb key note resolve held + local line=$1 verb key resolve held [ -n "$line" ] || return 1 verb=$(status_line_verb "$line") [ "$verb" = note ] && return 0 @@ -1453,10 +1453,7 @@ status_line_is_unread_surface() { # *) return 1 ;; esac key=$(_fm_decision_key "$line") || return 1 - _fm_decision_reserved_prefix "$key" >/dev/null || return 1 - [ "$verb" = "$resolve" ] && return 0 - note=$(status_line_note "$line") - _fm_decision_key_transition_allowed "$key" "$note" + _fm_decision_reserved_prefix "$key" >/dev/null } # Fleet-wide unread informational lines: one "\t" row per @@ -1665,6 +1662,21 @@ status_span_first_actionable_record() { # [record- while IFS= read -r line || [ -n "$line" ]; do line_number=$((line_number + 1)) case "$line" in *[![:space:]]*) ;; *) continue ;; esac + verb=$(status_line_verb "$line") + # The fold guards BOTH closing verbs on a reserved key, so both are checked + # here before either is treated as a close: a rejected close leaves its key + # open, and that outcome has to be visible rather than a silent no-op. + case "$verb" in + "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}"|"${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}") + key=$(_fm_decision_key "$line") || key='' + if [ -n "$key" ] && ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")"; then + [ -n "$events" ] && events="${events} ; " + events="${events}reconciliation-required: ${line}" + rc=0 + continue + fi + ;; + esac if status_is_captain_held "$line"; then # A transfer closes the status-log decision and remains non-actionable to # stale classification. The side-band marker lets signal routing surface @@ -1672,16 +1684,6 @@ status_span_first_actionable_record() { # [record- _fm_span_needs_decision=1 continue fi - verb=$(status_line_verb "$line") - if [ "$verb" = "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}" ]; then - key=$(_fm_decision_key "$line") || key='' - if [ -n "$key" ] && ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")"; then - [ -n "$events" ] && events="${events} ; " - events="${events}reconciliation-required: ${line}" - rc=0 - continue - fi - fi status_is_captain_relevant "$line" || continue case "$verb" in needs-decision|blocked) diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 254d356bc15..9a64fd8dce4 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -573,6 +573,41 @@ EOF pass "report-only unresolved captain call is reproduced and completion refuses before loss" } +# A reserved decision key (a `pending-reply-*` escalation) is transferred by the +# same completion gate. The status fold refuses a close note that does not speak +# the namespace's vocabulary, so the transfer has to be written through the +# shared close grammar or the decision would stay open in the status stream +# while a captain-held task already tracks it. +test_completion_transfers_a_reserved_decision_key() { + local home id key open + home=$(make_home reserved-transfer) + id=sample-reserved-review + key=pending-reply-abcdef0123456789 + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Investigate reserved escalations" --kind scout --repo sample --start >/dev/null \ + || fail "could not create investigation backlog fixture" + write_origin_meta "$home" "$id" + printf 'blocked [key=%s]: pending-reply-missed: task=%s request=ship it\n' "$key" "$id" \ + > "$home/state/$id.status" + + run_captain "$home" hold sample-reserved-call \ + --title "Answer the missed reply" --reason "the escalated reply is unanswered" \ + --repo sample --origin "$id" >/dev/null \ + || fail "could not register the captain-held task for a reserved key" + run_captain "$home" complete "$id" sample-reserved-call >/dev/null \ + || fail "completion failed for an origin holding a reserved decision key" + + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ + "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") + [ -z "$open" ] || fail "the reserved key stayed open beside its captain-held task: $open" + [ "$(grep -cF "captain-held [key=$key]: pending-reply-resolved: tracked by sample-reserved-call" \ + "$home/state/$id.status")" = 1 ] \ + || fail "the reserved transfer was not written once through the shared close grammar: $(cat "$home/state/$id.status")" + run_captain "$home" verify "$id" >/dev/null \ + || fail "verify did not accept the transferred reserved decision key" + pass "a reserved decision key transfers to its captain-held task and closes in the status fold" +} + # The completion gate on the collapsed primitive: an origin with open keyed # status decisions refuses --none, refuses an inventory naming absent tasks, # attests a verified inventory of captain-held task ids, and transfers every @@ -2604,6 +2639,7 @@ SH test_uninventoried_report_decision_refuses_completion test_completion_gate_attests_and_transfers +test_completion_transfers_a_reserved_decision_key test_answer_records_and_closes test_release_frees_held_work test_hold_stamp_precedes_hold_visibility diff --git a/tests/fm-classify-decision-key.test.sh b/tests/fm-classify-decision-key.test.sh index f57aef0ae29..bb6b9700ab2 100755 --- a/tests/fm-classify-decision-key.test.sh +++ b/tests/fm-classify-decision-key.test.sh @@ -121,8 +121,14 @@ test_reserved_key_public_resolution_is_effective_and_rejections_surface() { assert_fold "$f" "$expected" "reserved owner open" printf 'working: prose says resolved [key=%s]: but is not a protocol line\n' "$key" >> "$f" + offset=$(LC_ALL=C wc -c < "$f" | tr -d '[:space:]') printf 'captain-held [key=%s]: foreign transfer claim\n' "$key" >> "$f" assert_fold "$f" "$expected" "reserved key rejects prose and foreign transfer" + rejected=$(status_span_first_actionable "$f" "$offset") + assert_contains "$rejected" "reconciliation-required: captain-held [key=$key]" \ + "a rejected foreign transfer must produce an actionable diagnostic" + status_line_is_unread_surface "captain-held [key=$key]: foreign transfer claim" \ + || fail "a rejected foreign transfer was absent from the unread-status surface" offset=$(LC_ALL=C wc -c < "$f" | tr -d '[:space:]') printf 'resolved [key=%s]: manually dismissed without owner vocabulary\n' "$key" >> "$f" @@ -139,6 +145,36 @@ test_reserved_key_public_resolution_is_effective_and_rejections_surface() { pass "reserved closes use one public grammar and rejected manual notes surface actionably" } +# The captain-held transfer is the fold's OTHER closing verb, and it reaches a +# reserved key through the same public close grammar. An authoritative transfer +# must close the key exactly once and stay non-actionable, while the fold keeps +# rejecting a transfer that does not speak the namespace's vocabulary. +test_reserved_key_transfer_closes_through_the_shared_grammar() { + local dir f key expected offset close_note actionable rc + dir=$(case_dir reserved-transfer) + f="$dir/t.status" + key=pending-reply-abcdef0123456789 + printf 'blocked [key=%s]: pending-reply-missed: owner escalation\n' "$key" > "$f" + expected=$(printf '%s\tblocked\tpending-reply-missed: owner escalation\n' "$key") + assert_fold "$f" "$expected" "reserved owner open" + + offset=$(LC_ALL=C wc -c < "$f" | tr -d '[:space:]') + close_note=$(status_decision_close_note "$key" "tracked by sample-route-call") + printf 'captain-held [key=%s]: %s\n' "$key" "$close_note" >> "$f" + assert_fold "$f" "" "authoritative transfer closes the reserved key" + + rc=0 + actionable=$(status_span_first_actionable "$f" "$offset") || rc=$? + [ "$rc" -eq 1 ] \ + || fail "an accepted transfer must stay non-actionable: rc=$rc events='$actionable'" + + printf 'blocked [key=%s]: pending-reply-missed: escalated again\n' "$key" >> "$f" + printf 'captain-held [key=%s]: tracked by sample-route-call\n' "$key" >> "$f" + assert_fold "$f" "$(printf '%s\tblocked\tpending-reply-missed: escalated again\n' "$key")" \ + "a foreign transfer still cannot close a reserved key" + pass "an authoritative captain-held transfer closes a reserved key exactly once" +} + # Prefix-related keys exercise exact membership and drop semantics. Closing one # must leave its twin open, and reopening the first must make it authoritative # again for both the full and persisted incremental folds. @@ -321,6 +357,7 @@ test_bare_keyless_line_still_folds_to_default test_resolution_closes_across_positions test_blocked_is_position_tolerant_like_needs_decision test_reserved_key_public_resolution_is_effective_and_rejections_surface +test_reserved_key_transfer_closes_through_the_shared_grammar test_twin_keys_close_exactly_and_reopen test_two_colon_form_decisions_stay_distinct test_mid_note_prose_mention_is_not_a_stated_key From 2e39bf21f2313806d5f3e74e4a562f3698c4bb20 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 16:17:33 +0200 Subject: [PATCH 3/6] no-mistakes(review): keep rejected captain-held transfers marked needs-decision --- bin/fm-classify-lib.sh | 8 ++++++-- tests/fm-watch-triage.test.sh | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 562f5eab877..0a36d8e0c92 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1633,6 +1633,9 @@ _fm_status_open_decision_origins() { # status_span_first_actionable_record() { # [record-var] [needs-decision-var] local f=$1 start=${2:-0} output_var=${3-} needs_var=${4-} size ident cur_ident scratch chunk_file full_file prefix_file result local line verb key origins='' folded=0 rc=1 failed=0 prefix_lines=0 line_number=0 live_line='' events='' _line _key _fm_span_needs_decision=0 + local resolve held + resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} + held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} [ -e "$f" ] || { [ -L "$f" ] && return 2; return 1; } [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 2 ident=$(_fm_open_decisions_file_ident "$f") || return 2 @@ -1667,17 +1670,18 @@ status_span_first_actionable_record() { # [record- # here before either is treated as a close: a rejected close leaves its key # open, and that outcome has to be visible rather than a silent no-op. case "$verb" in - "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}"|"${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}") + "$resolve"|"$held") key=$(_fm_decision_key "$line") || key='' if [ -n "$key" ] && ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")"; then [ -n "$events" ] && events="${events} ; " events="${events}reconciliation-required: ${line}" + [ "$verb" = "$held" ] && _fm_span_needs_decision=1 rc=0 continue fi ;; esac - if status_is_captain_held "$line"; then + if [ "$verb" = "$held" ]; then # A transfer closes the status-log decision and remains non-actionable to # stale classification. The side-band marker lets signal routing surface # the captain-owned hold without changing that established stale verdict. diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 029b4a491f9..42df5176727 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1582,6 +1582,26 @@ test_captain_held_signal_payload_marked_for_branch_exclusion() { pass "a captain-held signal stays actionable while the crew is still working" } +# A captain-held transfer whose note does not speak a reserved key's owner +# vocabulary is REJECTED by the fold, so the decision stays open and the row is +# reported as a "reconciliation-required: " event. It is still a captain-owned +# decision row, so it keeps the main-only marker every other captain-held line +# gets - the reconciliation error must reach the captain, not the Pi +# supervision branch (docs/pi-supervision-branch.md). +test_rejected_captain_held_transfer_still_marked_for_branch_exclusion() { + local dir state fakebin out status_file pid + dir=$(make_case rejected-transfer-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'captain-held [key=pending-reply-abcdef0123456789]: tracked by call-1\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for a rejected captain-held transfer" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a rejected captain-held transfer was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a rejected captain-held transfer keeps its main-only routing marker" +} + test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion() { local dir state fakebin out status_file pid corr dir=$(make_case pending-reply-escalation-payload); state="$dir/state"; fakebin="$dir/fakebin" @@ -4414,6 +4434,7 @@ test_actionable_signal_surfaced test_needs_decision_signal_payload_marked_for_branch_exclusion test_needs_decision_reconciliation_required_still_marked test_captain_held_signal_payload_marked_for_branch_exclusion +test_rejected_captain_held_transfer_still_marked_for_branch_exclusion test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion test_ordinary_blocked_signal_payload_remains_branch_eligible test_routine_signal_payload_not_marked_needs_decision From 22a1b7a759504c5188c245fe4e37ed5f1a99bbdb Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 17:12:09 +0200 Subject: [PATCH 4/6] no-mistakes(review): make close-note verb-aware and mark rejected closes needs-decision --- bin/fm-captain-hold.sh | 5 +-- bin/fm-classify-lib.sh | 42 +++++++++++++++++-------- bin/fm-send.sh | 4 +-- tests/fm-captain-hold-lifecycle.test.sh | 2 +- tests/fm-classify-decision-key.test.sh | 11 +++++-- tests/fm-watch-triage.test.sh | 19 +++++++++++ 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 05d9db3975d..ad5847c030d 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -1593,12 +1593,13 @@ EOF # same session; an append failure still fails this command loudly. The note # is built through the shared close grammar so a reserved key (a # `pending-reply-*` escalation, say) is really closed by this authoritative - # transfer instead of staying open in the fold beside its own hold. + # transfer instead of staying open in the fold beside its own hold - stated + # in that namespace's transfer vocabulary, never as a resolution nobody gave. if [ -n "$keys" ]; then while IFS=$'\t' read -r key _verb _summary; do [ -n "$key" ] || continue transfer_rc=0 - transfer_note=$(status_decision_close_note "$key" "tracked by $keys") \ + transfer_note=$(status_decision_close_note captain-held "$key" "tracked by $keys") \ || fail "cannot express the captain-held transfer for $origin/$key in the shared decision grammar" fm_wake_status_append_self_announced "$STATE" "$status_file" \ "captain-held [key=$key]: $transfer_note" || transfer_rc=$? diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 0a36d8e0c92..ec26a3e3c48 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -418,15 +418,23 @@ _fm_decision_key_transition_allowed() { # return 1 } -# Print a close note that the fold accepts for . This public helper is the -# ONE writer-side owner of reserved-close grammar: ordinary notes pass through, -# while a reserved key receives its namespace's explicit resolved vocabulary. -# fm-send uses it for every --resolve-key close rather than knowing any owner. -status_decision_close_note() { # - local key=$1 note=$2 prefix +# Print a close note the fold accepts for under the closing . This +# public helper is the ONE writer-side owner of reserved-close grammar: +# ordinary notes pass through, while a reserved key receives its namespace's +# vocabulary for the transition actually being written. The verb itself is that +# vocabulary token, so a captain-held transfer states the hold rather than +# claiming the resolution vocabulary of an answer that nobody gave. Fails on an +# invalid key or on a verb that is not one of the fold's two closing verbs. +status_decision_close_note() { # + local verb=$1 key=$2 note=$3 prefix status_decision_key_valid "$key" || return 1 + case "$verb" in + "${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT}") ;; + "${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT}") ;; + *) return 1 ;; + esac prefix=$(_fm_decision_reserved_prefix "$key") || { printf '%s' "$note"; return 0; } - printf '%sresolved: %s' "$prefix" "$note" + printf '%s%s: %s' "$prefix" "$verb" "$note" } _fm_is_pending_reply_escalation() { # @@ -1437,10 +1445,13 @@ status_new_lines_since_cursor() { # [] return "$rc" } -# 0 when a status line is an informational `note:` or a reserved-key -# resolution attempt. Accepted closes leave the open set and rejected attempts -# leave the key there, so the drain presents either attempt once rather -# than silently burying its outcome under a later append. +# 0 when a status line is an informational `note:`, a reserved-key resolution, +# or a rejected close attempt on a reserved key. Those lines never stay in the +# OPEN DECISIONS fold as themselves, so the drain is the only place their +# outcome is presented rather than being buried under a later append. An +# ACCEPTED captain-held transfer is excluded: it hands the decision to the +# durable captain-held ledger, which presents it, so surfacing it here would +# repeat one still-tracked item on a second captain-facing surface. status_line_is_unread_surface() { # local line=$1 verb key resolve held [ -n "$line" ] || return 1 @@ -1453,7 +1464,9 @@ status_line_is_unread_surface() { # *) return 1 ;; esac key=$(_fm_decision_key "$line") || return 1 - _fm_decision_reserved_prefix "$key" >/dev/null + _fm_decision_reserved_prefix "$key" >/dev/null || return 1 + [ "$verb" = "$held" ] || return 0 + ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")" } # Fleet-wide unread informational lines: one "\t" row per @@ -1669,13 +1682,16 @@ status_span_first_actionable_record() { # [record- # The fold guards BOTH closing verbs on a reserved key, so both are checked # here before either is treated as a close: a rejected close leaves its key # open, and that outcome has to be visible rather than a silent no-op. + # Either way the row reports on a decision that is STILL OPEN, so it carries + # the same main-only marker an open decision does - a reconciliation error + # about a captain's own decision must never route to the supervision branch. case "$verb" in "$resolve"|"$held") key=$(_fm_decision_key "$line") || key='' if [ -n "$key" ] && ! _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")"; then [ -n "$events" ] && events="${events} ; " events="${events}reconciliation-required: ${line}" - [ "$verb" = "$held" ] && _fm_span_needs_decision=1 + _fm_span_needs_decision=1 rc=0 continue fi diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 13c58df7dfe..f32c14061f5 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -599,7 +599,7 @@ if [ -n "$RESOLVE_KEYS" ]; then # close, or when its structural key cannot fit in one status line. resolve_excerpt=$(printf '%s' "$*" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') for k in $RESOLVE_STATUS_KEYS; do - probe=$(status_decision_close_note "$k" "answered: $resolve_excerpt") + probe=$(status_decision_close_note resolved "$k" "answered: $resolve_excerpt") if ! _fm_decision_key_transition_allowed "$k" "$probe"; then echo "error: --resolve-key '$k' cannot take effect: the shared decision grammar cannot produce an accepted close note; nothing was sent." >&2 exit 1 @@ -626,7 +626,7 @@ fm_send_close_resolved_keys() { # local note=$1 k line close_note append_rc still manual_close_cmd note=$(printf '%s' "$note" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') for k in $RESOLVE_STATUS_KEYS; do - close_note=$(status_decision_close_note "$k" "answered: $note") + close_note=$(status_decision_close_note resolved "$k" "answered: $note") line="resolved [key=$k]: $close_note" fm_cap_line_var "$line" printf -v manual_close_cmd "printf '%%s\\n' %q >> %q" "$FM_LINE_CAP_LINE" "$RESOLVE_STATUS_FILE" diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 9a64fd8dce4..de8e164ee95 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -600,7 +600,7 @@ test_completion_transfers_a_reserved_decision_key() { open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") [ -z "$open" ] || fail "the reserved key stayed open beside its captain-held task: $open" - [ "$(grep -cF "captain-held [key=$key]: pending-reply-resolved: tracked by sample-reserved-call" \ + [ "$(grep -cF "captain-held [key=$key]: pending-reply-captain-held: tracked by sample-reserved-call" \ "$home/state/$id.status")" = 1 ] \ || fail "the reserved transfer was not written once through the shared close grammar: $(cat "$home/state/$id.status")" run_captain "$home" verify "$id" >/dev/null \ diff --git a/tests/fm-classify-decision-key.test.sh b/tests/fm-classify-decision-key.test.sh index bb6b9700ab2..75fa1efb11f 100755 --- a/tests/fm-classify-decision-key.test.sh +++ b/tests/fm-classify-decision-key.test.sh @@ -139,7 +139,7 @@ test_reserved_key_public_resolution_is_effective_and_rejections_surface() { status_line_is_unread_surface "resolved [key=$key]: manually dismissed without owner vocabulary" \ || fail "a rejected reserved resolution was absent from the unread-status surface" - close_note=$(status_decision_close_note "$key" "answered: dismissed after inspection") + close_note=$(status_decision_close_note resolved "$key" "answered: dismissed after inspection") printf 'resolved [key=%s]: %s\n' "$key" "$close_note" >> "$f" assert_fold "$f" "" "shared reserved close note" pass "reserved closes use one public grammar and rejected manual notes surface actionably" @@ -159,14 +159,21 @@ test_reserved_key_transfer_closes_through_the_shared_grammar() { assert_fold "$f" "$expected" "reserved owner open" offset=$(LC_ALL=C wc -c < "$f" | tr -d '[:space:]') - close_note=$(status_decision_close_note "$key" "tracked by sample-route-call") + close_note=$(status_decision_close_note captain-held "$key" "tracked by sample-route-call") printf 'captain-held [key=%s]: %s\n' "$key" "$close_note" >> "$f" assert_fold "$f" "" "authoritative transfer closes the reserved key" + case "$close_note" in + *resolved*) fail "a transfer note claimed resolution vocabulary: $close_note" ;; + esac + rc=0 actionable=$(status_span_first_actionable "$f" "$offset") || rc=$? [ "$rc" -eq 1 ] \ || fail "an accepted transfer must stay non-actionable: rc=$rc events='$actionable'" + if status_line_is_unread_surface "captain-held [key=$key]: $close_note"; then + fail "an accepted transfer was repeated on the unread-status surface: $close_note" + fi printf 'blocked [key=%s]: pending-reply-missed: escalated again\n' "$key" >> "$f" printf 'captain-held [key=%s]: tracked by sample-route-call\n' "$key" >> "$f" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 42df5176727..4b37d49ae92 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1602,6 +1602,24 @@ test_rejected_captain_held_transfer_still_marked_for_branch_exclusion() { pass "a rejected captain-held transfer keeps its main-only routing marker" } +# The same routing rule for the fold's OTHER closing verb: a `resolved` line +# whose note does not speak a reserved key's vocabulary leaves that decision +# open, so its reconciliation row is about a still-open captain decision and +# must reach the captain rather than the Pi supervision branch. +test_rejected_reserved_resolution_still_marked_for_branch_exclusion() { + local dir state fakebin out status_file pid + dir=$(make_case rejected-resolution-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'resolved [key=pending-reply-abcdef0123456789]: all good now\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for a rejected reserved resolution" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a rejected reserved resolution was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a rejected reserved-key resolution keeps its main-only routing marker" +} + test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion() { local dir state fakebin out status_file pid corr dir=$(make_case pending-reply-escalation-payload); state="$dir/state"; fakebin="$dir/fakebin" @@ -4435,6 +4453,7 @@ test_needs_decision_signal_payload_marked_for_branch_exclusion test_needs_decision_reconciliation_required_still_marked test_captain_held_signal_payload_marked_for_branch_exclusion test_rejected_captain_held_transfer_still_marked_for_branch_exclusion +test_rejected_reserved_resolution_still_marked_for_branch_exclusion test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion test_ordinary_blocked_signal_payload_remains_branch_eligible test_routine_signal_payload_not_marked_needs_decision From 39aebf530f296324f02659681adf2fabb1372c14 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 17:35:17 +0200 Subject: [PATCH 5/6] no-mistakes(document): correct reserved-key close grammar and unread-surface docs --- AGENTS.md | 2 +- bin/fm-classify-lib.sh | 9 +++++---- bin/fm-wake-drain.sh | 4 ++-- docs/architecture.md | 2 +- docs/captain-hold-lifecycle.md | 3 ++- docs/pi-supervision-branch.md | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca09ab7a4cf..dd4cec99263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ When that section reports its checks still in progress it names exactly what is Presented records remain durable until the handling turn runs the generation-bound acknowledgement printed by the drain. Every locked drain also prints a bounded fleet-wide `OPEN DECISIONS` section when durable decision records remain open, including when the queue itself is empty; reconcile those entries before continuing. A main drain may also print a bounded, one-shot `STATUS OUTCOME BACKSTOP` when a task's newest captain-facing status event has no covering supervision-branch outcome; handle it as a recovered wake even when no queue row remains. - The same drain prints every still-unread `note:` line and pending-reply resolution since the last presentation in an unbounded `UNREAD STATUS` section, so an answer buried under a later routine line is not dropped; those lines are not re-printed after that presentation. + The same drain prints every still-unread `note:` line, reserved-key resolution, and rejected reserved-key close since the last presentation in an unbounded `UNREAD STATUS` section, so an answer buried under a later routine line is not dropped; those lines are not re-printed after that presentation. It also prints a bounded `RECORD DIVERGENCE` section naming every captain call the status log reads as resolved while its backlog task is still held; nothing is closed for you, and `captain-hold-lifecycle` owns the reconciliation. When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. 4. **Supervision operating instructions** - after the wake queue and before both digests, the digest emits exactly one operating block for the detected primary harness, followed by the read-once contract that governs them. diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index ec26a3e3c48..7928af91823 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1339,9 +1339,9 @@ EOF # aborts presentation without advancing any offset. A trusted cursor at EOF # prints nothing, so already-presented bytes are not replayed as new. Teardown # retires a task's manifest row with its status file, so reusing a task ID starts -# the replacement log unread at byte 0. Informational `note:` lines and -# reserved-key pending-reply resolutions are the fleet-wide unread surface; -# they are not open decisions and are not persisted in the folded open-set. +# the replacement log unread at byte 0. status_line_is_unread_surface below +# owns which lines are that fleet-wide unread surface; none of them are open +# decisions and none are persisted in the folded open-set. # Read the legacy per-task open-decisions cursor used to seed the presentation # offset before the fleet manifest exists. A fold-version mismatch, identity @@ -1470,7 +1470,8 @@ status_line_is_unread_surface() { # } # Fleet-wide unread informational lines: one "\t" row per -# still-unread `note:` or pending-reply resolution, in glob (task id) order. +# still-unread line status_line_is_unread_surface accepts, in glob (task id) +# order. # Prints nothing when none are unread. Directory scan rejects status symlinks # the same way scan_open_decisions does. scan_unread_surface_lines() { # diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 8268bb917fa..2c7fb6a5b6b 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -431,8 +431,8 @@ EOF # fm-classify-lib.sh's status_open_decisions fold (via its cursor-backed # scan_open_decisions_incremental wrapper) rather than from the annotations # above, so a decision buried under later unrelated appends cannot be silently -# missed. Informational `note:` lines and pending-reply resolutions are not -# decisions; print_unread_status_section owns their one-shot surface. Runs on +# missed. The lines status_line_is_unread_surface accepts are not decisions of +# their own; print_unread_status_section owns their one-shot surface. Runs on # every drain - including the empty-queue fast path - because the decision can # still be open even when nothing new is queued for # its task this turn. The incremental wrapper bounds this scan's cost to bytes diff --git a/docs/architecture.md b/docs/architecture.md index 58b900786d4..a2e8ca54bec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ Crew status files are append-only wake-event logs, not current-state fields. Because of that, a per-wake read of only the latest line can bury an earlier still-open `needs-decision`/`blocked` under later unrelated appends; `fm-wake-drain.sh` prints a separate, fleet-wide OPEN DECISIONS section on every presentation (including the empty-queue path session-start relies on), built through `fm-classify-lib.sh`'s cursor-backed incremental scan using the authoritative `status_open_decisions` fold semantics so the buried decision keeps surfacing until it is explicitly resolved while each presentation folds only new status-log appends. The drain coordinates that fold and its annotations through a locked fleet-wide snapshot whose `.status-presentation-cursor` manifest records each status file's identity plus independent annotation and outcome-backstop byte offsets. [`pi-supervision-branch.md`](pi-supervision-branch.md#lost-wake-outcome-backstop) owns the bounded lost-wake backstop that uses the latter offset. -A queued signal annotation prints every status line still unread at that cursor, while the fleet-wide UNREAD STATUS section prints `note:` lines and reserved-key pending-reply resolutions once even on an empty-queue drain because those verbs never enter the OPEN DECISIONS fold. +A queued signal annotation prints every status line still unread at that cursor, while the fleet-wide UNREAD STATUS section prints `note:` lines, reserved-key resolutions, and close attempts the reserved-key guard rejected once even on an empty-queue drain, because none of those lines stay in the OPEN DECISIONS fold as themselves; an accepted `captain-held` transfer is excluded there because the durable captain-held ledger already presents it, and `fm-classify-lib.sh`'s `status_line_is_unread_surface` owns that membership rule. A third bounded section, RECORD DIVERGENCE, prints on the same drains for the opposite failure: the status fold went quiet on a key that the durable captain-held task still shows as open, so the status side reads as complete while the two records contradict each other; `bin/fm-captain-hold.sh diverged` decides what counts and closes nothing, and `docs/captain-hold-lifecycle.md` owns the mechanism. A failed read, output, or concurrent-replacement check prevents the snapshot cursor from advancing across uncertain bytes, and teardown retires a task's manifest row before that task ID can be reused. The explicit resolution is written by the actor that answers, not the busy worker: `fm-send`'s `--resolve-key` appends the closing `resolved` line to this home's own copy of the ledger at answer time, which covers crewmates, local secondmates, and remote secondmates identically because a remote mate's escalations reach that local copy through the parent-replies ingest and only the answer message itself crosses the transport. diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 41ee23d2243..73a62186f6c 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -25,7 +25,8 @@ A hold whose `--until` date has passed keeps those annotations while tasks-axi r The `complete` subcommand unions the reviewed captain-held task ids into `decision_keys=` and appends `decisions_reviewed=1` while originating task metadata is live. A post-teardown visual review can complete against the surviving report and durable tasks without recreating volatile task metadata. It accepts `--none` as an explicit semantic inventory result, refused while the origin still has a lifecycle-open keyed status decision, and verifies every listed task against tasks-axi before recording completion. -With a non-empty inventory it appends a `captain-held [key=]: tracked by ` transfer event for every still-open keyed status decision, which `bin/fm-classify-lib.sh` recognizes as closing the live status copy without claiming that the captain has answered it. +With a non-empty inventory it appends a `captain-held [key=]:` transfer event for every still-open keyed status decision, building its note through `bin/fm-classify-lib.sh`'s `status_decision_close_note`: `tracked by ` for an ordinary key, and the same text behind that namespace's own transfer vocabulary (`pending-reply-captain-held: ...`) for a reserved key such as `pending-reply-*`, whose fold otherwise refuses a foreign close. +Either form closes the live status copy without claiming that the captain has answered it. Scout teardown calls the read-only `verify` subcommand after checking for the report and before removing any source state. `verify` requires the recorded attestation, requires every recorded inventory entry to still be durable (actively captain-held, or carrying a recorded answer), and fails on any keyed status decision that opened after the last `complete`, which makes re-running `complete` the repair. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 76cb84a8b85..eb2522076f4 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -161,7 +161,7 @@ Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, si `tests/fm-teardown.test.sh` covers removal of the retired task's outcome index and the append-side rule that a post-teardown report does not recreate it. The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-main-only-rows, main-only-check-class, captain-held-stale-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two routing classes exercise `offerWakeToBranch`'s trigger-key cross-reference end to end), the recovery test remains in `tests/fm-session-start.test.sh`, and the per-actor consume regression remains in `tests/fm-wake-queue.test.sh`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. -`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: needs-decision, no-verb captain-held, and pending-reply second-mate escalation signal rows are marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (`fm-classify-lib.sh`'s `reconciliation-required:` wrapper) is still marked, and ordinary blocked or captain-relevant signals stay unmarked. +`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: needs-decision, no-verb captain-held, and pending-reply second-mate escalation signal rows are marked `needs-decision:`, any transition the reserved-key vocabulary rejected (`fm-classify-lib.sh`'s `reconciliation-required:` wrapper) is still marked - an opening `needs-decision` and a `resolved` or `captain-held` close attempt alike, because the decision each one reports on is still open - and ordinary blocked or captain-relevant signals stay unmarked. Live guards: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's immediate active-transcript appendEntry rendering, persistence, custom-entry model exclusion, branch-session surfaces, and watcher-owned fallback after rejected branch settlement. `FM_PI_BRANCH_RESPONSIVENESS_E2E=1 tests/fm-pi-branch-responsiveness-live-e2e.test.sh` answers the question only a real TUI can: it types into an isolated Pi pane while outcomes are delivered and fails if keystroke echo leaves the class of the same machine's extension-free floor. Record dated current results in [docs/verification/runtime-backends.md](verification/runtime-backends.md). From 587ffc8a344032daaced7db5634f9ff9752293cd Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 18:54:55 +0200 Subject: [PATCH 6/6] no-mistakes(document): Clarify rejected reserved-key transition routing documentation --- docs/pi-supervision-branch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index eb2522076f4..f40411a681b 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -161,7 +161,7 @@ Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, si `tests/fm-teardown.test.sh` covers removal of the retired task's outcome index and the append-side rule that a post-teardown report does not recreate it. The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-main-only-rows, main-only-check-class, captain-held-stale-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two routing classes exercise `offerWakeToBranch`'s trigger-key cross-reference end to end), the recovery test remains in `tests/fm-session-start.test.sh`, and the per-actor consume regression remains in `tests/fm-wake-queue.test.sh`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. -`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: needs-decision, no-verb captain-held, and pending-reply second-mate escalation signal rows are marked `needs-decision:`, any transition the reserved-key vocabulary rejected (`fm-classify-lib.sh`'s `reconciliation-required:` wrapper) is still marked - an opening `needs-decision` and a `resolved` or `captain-held` close attempt alike, because the decision each one reports on is still open - and ordinary blocked or captain-relevant signals stay unmarked. +`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: needs-decision, no-verb captain-held, and pending-reply second-mate escalation signal rows are marked `needs-decision:`, a `needs-decision` opening or a `resolved` or `captain-held` close attempt rejected by the reserved-key vocabulary (`fm-classify-lib.sh`'s `reconciliation-required:` wrapper) is still marked, while a rejected `blocked` opening and ordinary blocked or captain-relevant signals stay unmarked. Live guards: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's immediate active-transcript appendEntry rendering, persistence, custom-entry model exclusion, branch-session surfaces, and watcher-owned fallback after rejected branch settlement. `FM_PI_BRANCH_RESPONSIVENESS_E2E=1 tests/fm-pi-branch-responsiveness-live-e2e.test.sh` answers the question only a real TUI can: it types into an isolated Pi pane while outcomes are delivered and fails if keystroke echo leaves the class of the same machine's extension-free floor. Record dated current results in [docs/verification/runtime-backends.md](verification/runtime-backends.md).