Skip to content

feat(bin): raise a decision as separate question, options and recommendation - #10

Merged
roswellmb merged 10 commits into
mainfrom
fm/fm-decision-option-grammar
Aug 21, 2026
Merged

feat(bin): raise a decision as separate question, options and recommendation#10
roswellmb merged 10 commits into
mainfrom
fm/fm-decision-option-grammar

Conversation

@roswellmb

@roswellmb roswellmb commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Intent

GOAL: Let a crewmate raise a decision as question, options and recommendation - separate fields, carried intact to wherever the captain answers it.

WHY. The captain looked at the deck and said "It structured some metadata but it is still a wall of text for a decision." His acceptance test, verbatim, is the criterion this work exists to make possible: "If he has to READ to find the question, it failed. A decision should present the question, the options, and the recommendation. Everything else - the reasoning chain, the evidence, the worker's account, the alternatives considered - belongs BEHIND it, reachable in one action, never in front of it." The deck cannot do that today and it is not the deck's fault: a decision arrives as one prose blob because the crewmate wrote prose and firstmate forwarded prose. To present "the question" from prose a surface would have to guess which sentence is the question - inventing structure it cannot verify, the one thing that surface must never do. Something has to PRODUCE the fields. That is this task.

REQUIRED: a structured way to raise a decision with at minimum the question (what is actually being asked, in one statement), the options (each distinctly identified, not prose that mentions alternatives), and the recommendation (which option and why, kept separable from the question). Carried intact through the whole relay: crewmate status log, the fold, firstmate's own escalation to the parent home, and out to whatever renders it. A field that survives the first hop and is flattened at the second is worth nothing.

CONSTRAINT 1 - a crewmate's prose may never name a button. Untrusted text must not control what a surface says. But that constrains the WORDS, not the PAYLOAD - the structure belongs in the payload, where no crewmate prose is being rendered as a control. An option must have a stable identity separate from whatever text describes it.

CONSTRAINT 2 - free-text decisions must keep working. Most decisions are not multiple choice, and a worker that writes an ordinary needs-decision line must not break or be forced into a shape that does not fit. The structure is available, not compulsory. The PR must say what an unstructured decision looks like after this lands.

LATER ACCEPTED REQUIREMENT from firstmate, treated as load-bearing rather than a nicety: this only works if a crewmate raising a decision actually FILLS the fields. A required field with nothing forcing it becomes a field containing the whole blob pasted three times. So the EMPTY OR DEGENERATE case must be made VISIBLE rather than silently absorbed - a decision that arrived without a real question must look wrong AT THE RELAY, to firstmate, not arrive at the captain's phone looking structured. In firstmate's words: fields that can be faked are prose with extra steps. Design for that explicitly; the PR must say how a degenerate decision is detected and what it looks like when it is. Do NOT solve it by making the fields mandatory in a way that pushes workers to paste - detection and visibility beat compulsion here.

ADJACENT WORK: fm-deck-answer-referent is making an approval carry the identity of the decision it answers. An option identity here and a referent there are two halves of the same problem and must agree on what identifies an option rather than inventing two schemes. (That branch had only posted a baseline when this work started, so no design existed to conform to; its own brief explicitly defers this richer half to firstmate. The identity contract is therefore published here for that half to adopt.)

NOT IN SCOPE: rendering. Do not design the deck's surface; produce the fields it needs.

DECISIONS AND TRADEOFFS MADE, which a reviewer reading only the diff would not know:

  • The fields live in an append-only SIDECAR (state/.decisions) joined to the status log by (task id, decision key), rather than in a longer status line. This is deliberate: bin/fm-classify-lib.sh already owns the open/resolved grammar and must NOT gain a second reader, and fm-deck keeps a cross-checked duplicate of that fold. Leaving the status-line grammar untouched is what keeps every existing reader working byte for byte.
  • Option identity is the triple (task id, decision key, option id). The option id is a slug over the same charset as a decision key, assigned at raise time, immutable for the life of the record, and separate from the prose label. Ids default to the 1-based ordinal so the common case needs no ceremony. It is an identifier, never a label - safe in a URL, a payload or an answer, and never rendered as a control's name.
  • Degeneracy is enforced from BOTH ends by one validator (fm_decision_defects): the writer refuses to record a padded decision at all, and every reader re-checks on the way out so a record that reached the file some other way prints a MALFORMED banner at the relay with its fields withheld. Write-time refusal is not compulsion: each refusal names the defect and points at the plain free-text line as the honest alternative, so the pressure is never to pad the fields harder.
  • The raise refuses while a key is already open, because two option sets under one key is exactly the ambiguity that makes an answer a coin flip.
  • Before writing, the raise asks the FOLD ITSELF (via a probe file through status_open_decisions) whether the line would actually open that key, so a key in a library-owned namespace cannot produce a record plus a status line that opens nothing. No fold rule is restated here.
  • The status note defaults to the question, so even a reader that knows nothing about this record now sees the question in the log instead of a blob.
  • The wake digest renders the fields where firstmate actually reads decisions, and discloses any detail it had to cut rather than letting a bounded view read as complete.
  • Teardown removes the record with the status log it belongs to, so a reused task id cannot join a stale record.
  • A guard asserts each sourced library function exists, because on the bash 3.2 that stock macOS ships a missing sibling source can end a script silently - a raise that wrote nothing while appearing to succeed would be the worst outcome on this path. (The general fix for that class is in flight upstream in PR fix: fail closed on missing teardown dependencies kunchenguid/firstmate#2646; this assertion is correct either way.)

PROJECT CONSTRAINTS ACCEPTED: firstmate-coding-guidelines was loaded before editing this shared tracked material. Work started from origin/main. Tests exercise behaviour through the executable interface, are colocated in tests/ as .test.sh, and never assert implementation-source bytes. bin/fm-lint.sh must pass. A test that runs git can escape into a real repository (GIT_DIR outranks git -C; this wiped 345 files off the captain's repository on 2026-08-20), so this suite builds its environment explicitly, runs no git at all, and that was verified empirically by passing the suite with git sabotaged to fail loudly. No new backlog tasks were filed - findings are folded in or stated in the PR.

HONESTY TEST this work is judged by: a decision raised through the new path, relayed the whole way, arriving with its question, its options and its recommendation still separate and still true to what the crewmate meant - shown end to end rather than asserted - and showing what happens to a decision that does not fit the shape, because that case is the majority and it must not degrade.

KNOWN PRE-EXISTING, NOT INTRODUCED HERE: tests/fm-teardown.test.sh "herdr-preflight-missing-adapter" already fails on origin/main; verified against the pristine file. Open PR kunchenguid#2646 targets that exact defect class.

What Changed

  • Added bin/fm-decision-raise.sh (raise, show, show --json, option) and the shared bin/fm-decision-lib.sh, which owns the wire format for an append-only sidecar at state/<id>.decisions, the (task id, decision key, option id) option identity that an answering surface adopts rather than inventing its own, and the single fm_decision_defects degeneracy check. raise appends the ordinary needs-decision [key=...] status line unchanged (defaulting the note to the question), asks the existing fold through a probe file whether that line really opens the key, refuses while a key is already open, and refuses a padded record by naming the defect and pointing at the plain free-text line instead. Option ids default to the 1-based ordinal, stay separate from the prose label, and every field is length-bounded and TAB/newline-escaped so a worker's text cannot forge a field, a row or a rendered line.
  • bin/fm-wake-drain.sh now renders Q:, the option rows and -> recommends (id) why under each folded OPEN DECISIONS entry, flattening and capping every untrusted value, disclosing how many decisions lost their fields to the byte cap and where to read them, and printing a MALFORMED DECISION RECORD (<defects>) banner with the fields withheld when a record on disk fails the same check the writer applies. A decision with no record is unchanged free text and folds, renders and closes exactly as before; bin/fm-classify-lib.sh gains no second reader and the status-line grammar is byte-for-byte identical.
  • bin/fm-teardown.sh removes <id>.decisions alongside the status log on all three destroying paths so a reused task id cannot join a stale record; the secondmate, scout and ship briefs in bin/fm-brief.sh tell workers when to raise fields instead of prose (including the --note "corr=..." correlation contract); and AGENTS.md, docs/architecture.md and docs/scripts.md document the sidecar. New tests/fm-decision-raise.test.sh covers the raise, the relay and the degenerate cases through the executable interface, enumerating declared tests from the shell rather than a hand-kept list, and tests/fm-status-decision-close.test.sh pins the teardown removal.

The invariant this change rests on

A crewmate's text is data, never structure. No value a worker writes may become an identifier, a field boundary, or a rendered line: identity comes only from the payload, values stay encoded until their single point of use, every rendered value is flattened to one line before printing, and no worker-supplied value reaches an expensive transform before a length check can reject it.

It is stated as a rule here rather than left implied because the implementation broke it six times in one sitting: a newline in an option label minted options with attacker-chosen ids; record fields rendered verbatim let prose forge digest lines; and four separate value helpers processed unbounded worker input on the path that runs at the top of every wake-handling turn. The design was right every time and the implementation forgot it. A reader checking new code against the rule catches the seventh instance; a reader checking against a list of six fixes catches none.

Prose is not enforcement, so the rule is held by tests rather than by this paragraph:

  • an option label can never mint an option or an option id, written or read - asserts the resulting option set, ids and count, on the write path and the read path.
  • no field's prose can forge a line in the relay digest - drives the real drain and counts option-shaped lines.
  • every worker-controlled entrance stays bounded against a hostile value - table-driven across every entry point, with a coverage check that fails when a new subcommand appears untested.

Deliberate choices, not scope drift

The secondmate charter brief was extended on purpose. Scout and ship briefs teach the structured raise; the charter is the hop that actually feeds the captain's decision surface - a crewmate raises a gate, a secondmate relays it to the primary home, and it is rendered from there. Teaching two briefs of three would leave precisely that hop a blob, so the fields would die at the relay rather than at the source. This is the accepted intent applied to its most important caller.

The over-long note is refused rather than truncated. The length bound exists because an 8000-tab --note took 54 seconds; bounding it then introduced silent truncation of a durable append-only log at 401 characters. Refusal is what keeps that performance fix from being paid for in lost content. A marker tells an author their words were cut after they are already gone; a refusal at write time leaves the words still in front of them, to shorten or split. A 54 second raise is real, and so is losing someone's words - neither is traded away silently here. Truncation is now structurally unreachable for any stored value, because the raw-length check sits below the cut and runs before it, and the refusal names both the limit and the actual length so shortening is one edit.

One caveat this change does not close. bin/fm-gate-refuse-lib.sh records which entrypoints source the gate guard as a hand-maintained list in its header, and this change is the second entry to drift out of it. Adding the entry fixes the instance; nothing checks the list, so it is unauditable by construction. Said here rather than letting a green tick imply that boundary is sound.

Not this change's to fix. tests/fm-teardown.test.sh case herdr-preflight-missing-adapter fails on this branch and fails identically on a pristine tree at base commit 7d4f76c, same 12-pass/1-fail split; upstream PR kunchenguid#2646 targets that defect class. The pre-existing status-note fold cost in bin/fm-classify-lib.sh is likewise left alone, since this change does not own that file.

Risk Assessment

✅ Low: Both round-7 findings are fixed and independently verified - an over-long note is now refused promptly with both numbers and nothing written, truncation is structurally unreachable for any stored value because the 400-character check sits below the 401-character cut and runs on the raw argument, and every declared test now runs with nothing lost from the previous list - leaving only a narrow regex gap in the new test runner that skips no test that exists today.

Testing

I ran the change's own suite (27 cases) plus every adjacent suite the diff touches - wake-drain open decisions and cursor, status-decision-close, decision-hold, brief, teardown-endpoint-safety, wake-queue, wake-daemon e2e and documentation-audiences - and all pass; tests/fm-teardown.test.sh has one failure that I reproduced identically on a pristine base-commit tree, so it is pre-existing. Because no test covered the stated decision that teardown removes the sidecar record, I added one to tests/fm-status-decision-close.test.sh that raises a structured decision, closes it, runs a real teardown and asserts the record leaves with the log; it passes. For end-user evidence I drove the real scripts through the whole relay and captured a CLI transcript: the crewmate's raise, the unchanged status line, the sidecar record's bytes, firstmate's wake digest showing question, options and recommendation as separate lines, firstmate's escalation to the parent home where the fields arrive intact, the JSON payload a rendering surface reads, option-id resolution and refusal, the free-text decision rendering exactly as before, the padded blob refused with nothing written, a planted record showing MALFORMED at the relay, and key reuse after resolution. A second artifact renders the same decision through the base commit's drain (a truncated wall of text) beside the new one, and shows the pre-change drain still reading the branch's status log unchanged. There is no UI surface in this change - the end-user surfaces are the status log, the wake digest and the CLI - so the evidence is CLI transcripts rather than screenshots.

Evidence: End-to-end relay transcript (crewmate raise -> status log -> fold/digest -> parent-home escalation -> JSON payload; plus free-text, degenerate and key-reuse cases)
End-to-end walkthrough: a decision raised as separate fields, relayed the whole way.
Generated by running the real scripts on this branch (fm/fm-decision-option-grammar).

== 1. A crewmate raises a decision as separate fields ==

$ "$RAISE" raise --status "$CHILD/importer.status" --key retry-budget \
    --question 'Should the importer retry per request or per session?' \
    --option-id per-request 'Retry per request - stateless, matches the gateway' \
    --option-id per-session 'Retry per session - better under burst, needs a store' \
    --recommend per-request \
    --because 'The gateway already carries a per-request token; a store is new infrastructure.'
raised needs-decision [key=retry-budget] with 2 options; recommendation: per-request
[exit 0]

== 2. Hop 1 - the crewmate status log (grammar unchanged, note is the question) ==

$ cat "$CHILD/importer.status"
needs-decision [key=retry-budget]: Should the importer retry per request or per session?
[exit 0]

$ cat state/importer.decisions      # tabs shown as [TAB]
decision[TAB]1[TAB]retry-budget[TAB]1787281400
question[TAB]Should the importer retry per request or per session?
option[TAB]per-request[TAB]Retry per request - stateless, matches the gateway
option[TAB]per-session[TAB]Retry per session - better under burst, needs a store
recommend[TAB]per-request[TAB]The gateway already carries a per-request token; a store is new infrastructure.
end[TAB]retry-budget

== 3. Hop 2 - the fold + firstmate's wake digest for the crewmate's home ==

$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # OPEN DECISIONS section
OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: Should the importer retry per request or per session?
    (per-request) Retry per request - stateless, matches the gateway
    (per-session) Retry per session - better under burst, needs a store
    -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'

== 4. Hop 3 - firstmate escalates the same decision to the PARENT home ==

$ "$RAISE" raise --status "$PARENT/importer.status" --key retry-budget \
    --question 'Should the importer retry per request or per session?' \
    --option-id per-request 'Retry per request - stateless, matches the gateway' \
    --option-id per-session 'Retry per session - better under burst, needs a store' \
    --recommend per-request \
    --because 'The gateway already carries a per-request token; a store is new infrastructure.' \
    --note 'corr=deck-91 importer retry budget needs a call'
raised needs-decision [key=retry-budget] with 2 options; recommendation: per-request
[exit 0]

$ cat "$PARENT/importer.status"
needs-decision [key=retry-budget]: corr=deck-91 importer retry budget needs a call
[exit 0]

$ FM_STATE_OVERRIDE=<parent state> bin/fm-wake-drain.sh   # the captain-side relay
OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: corr=deck-91 importer retry budget needs a call
    Q: Should the importer retry per request or per session?
    (per-request) Retry per request - stateless, matches the gateway
    (per-session) Retry per session - better under burst, needs a store
    -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'

== 5. Hop 4 - out to whatever renders it: the payload, fields still separate ==

$ "$RAISE" show --status "$PARENT/importer.status" --key retry-budget --json
{"key":"retry-budget","structured":true,"note":"corr=deck-91 importer retry budget needs a call","question":"Should the importer retry per request or per session?","options":[{"id":"per-request","label":"Retry per request - stateless, matches the gateway"},{"id":"per-session","label":"Retry per session - better under burst, needs a store"}],"recommend":{"option":"per-request","because":"The gateway already carries a per-request token; a store is new infrastructure."},"degenerate":[]}
[exit 0]

== 6. Option identity - the contract fm-deck-answer-referent's referent adopts ==

$ "$RAISE" option --status "$PARENT/importer.status" --key retry-budget --id per-session
Retry per session - better under burst, needs a store
[exit 0]

$ "$RAISE" option --status "$PARENT/importer.status" --key retry-budget --id per-somethingelse
fm-decision-raise: option id per-somethingelse is not an option of [key=retry-budget]
[exit 1]

== 7. Human-readable read-back ==

$ "$RAISE" show --status "$PARENT/importer.status" --key retry-budget
question: Should the importer retry per request or per session?
option per-request: Retry per request - stateless, matches the gateway
option per-session: Retry per session - better under burst, needs a store
recommend: per-request
because: The gateway already carries a per-request token; a store is new infrastructure.
[exit 0]

== 8. THE MAJORITY CASE - an ordinary free-text decision, untouched ==

$ printf 'needs-decision [key=scope]: should the legacy importer move in this pass?\n' >> "$CHILD/plain.status"
[exit 0]

$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # both decisions, one digest
OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: Should the importer retry per request or per session?
    (per-request) Retry per request - stateless, matches the gateway
    (per-session) Retry per session - better under burst, needs a store
    -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
plain [key=scope] needs-decision: should the legacy importer move in this pass?
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'

$ "$RAISE" show --status "$CHILD/plain.status" --key scope
unstructured decision [key=scope] (no structured record - this is normal)
note: should the legacy importer move in this pass?
[exit 0]

$ "$RAISE" show --status "$CHILD/plain.status" --key scope --json
{"key":"scope","structured":false,"note":"should the legacy importer move in this pass?","degenerate":[]}
[exit 0]

== 9. DEGENERATE AT WRITE TIME - the blob pasted into every field is refused ==

$ "$RAISE" raise --status "$CHILD/blob.status" --key blob \
    --question 'there are a few ways to go here and I am not sure which' \
    --option 'there are a few ways to go here and I am not sure which' \
    --recommend 1 --because 'there are a few ways to go here and I am not sure which'
fm-decision-raise: refusing to record a decision that is not actually structured.
Nothing was written - no record and no status line.

  question-duplicated      the question text is repeated in an option or in --because - fields filled with the same blob are not separate fields
  rationale-duplicated     the rationale repeats an option label verbatim
  too-few-options          fewer than two options - a choice needs at least two; if it is not a choice, raise it as free text

If this decision genuinely is not a set of options, that is normal and
supported - raise it as free text instead, and it will fold, wake and
close exactly as before:
  echo 'needs-decision [key=blob]: <the question>' >> /var/folders/xl/gfd9bj992zj1qjv30f3rdv8w0000gn/T/no-mistakes-evidence/01M0GWC2BNXVF02ZX7QM2F8JKA/demo-home/child/state/blob.status
[exit 2]

$ ls "$CHILD" | grep blob || echo '(no blob.status, no blob.decisions - nothing was written)'
(no blob.status, no blob.decisions - nothing was written)
[exit 0]

== 10. DEGENERATE ON DISK - a record that reached the file some other way ==

$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # it must look WRONG here
OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
faked [key=faked] needs-decision: there are a few ways to go and I am not sure
    MALFORMED DECISION RECORD (question-duplicated too-few-options recommend-unknown-option no-rationale) - not structured; relay the note above, not these as options
importer [key=retry-budget] needs-decision: Should the importer retry per request or per session?
    (per-request) Retry per request - stateless, matches the gateway
    (per-session) Retry per session - better under burst, needs a store
    -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
plain [key=scope] needs-decision: should the legacy importer move in this pass?
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'

$ "$RAISE" show --status "$CHILD/faked.status" --key faked
MALFORMED DECISION RECORD [key=faked] - do not present these fields as structure
  question-duplicated      the question text is repeated in an option or in --because - fields filled with the same blob are not separate fields
  too-few-options          fewer than two options - a choice needs at least two; if it is not a choice, raise it as free text
  recommend-unknown-option the recommendation names an option id that does not exist
  no-rationale             no rationale - say why with --because
question: there are a few ways to go and I am not sure
option 1: there are a few ways to go and I am not sure
recommend: 9
because: 
[exit 1]

$ "$RAISE" option --status "$CHILD/faked.status" --key faked --id 1
fm-decision-raise: decision [key=faked] is malformed (question-duplicated too-few-options recommend-unknown-option no-rationale); its options are not trustworthy
[exit 1]

== 11. One key never carries two option sets ==

$ "$RAISE" raise --status "$CHILD/importer.status" --key retry-budget \
    --question 'Should the importer retry per request or per session, revisited?' \
    --option 'Keep per request' --option 'Move to per session' \
    --recommend 1 --because 'Nothing has changed since the first raise landed.'
fm-decision-raise: decision key 'retry-budget' is already open for this task; answer it first, or raise this question under its own key
[exit 1]

== 12. Once the key is resolved, it is reusable - and the new option set is its own ==

$ printf 'resolved [key=retry-budget]: captain chose per-request\n' >> "$CHILD/importer.status"
[exit 0]

$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # retry-budget is closed now
OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
faked [key=faked] needs-decision: there are a few ways to go and I am not sure
    MALFORMED DECISION RECORD (question-duplicated too-few-options recommend-unknown-option no-rationale) - not structured; relay the note above, not these as options
plain [key=scope] needs-decision: should the legacy importer move in this pass?
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'

$ "$RAISE" raise --status "$CHILD/importer.status" --key retry-budget \
    --question 'Now that retries are per request, how should the importer back off?' \
    --option 'Fixed 2s delay - predictable, slower to recover' \
    --option 'Exponential with jitter - recovers faster, harder to reason about' \
    --recommend 2 --because 'The gateway already sheds load, so faster recovery wins.'
raised needs-decision [key=retry-budget] with 2 options; recommendation: 2
[exit 0]

$ "$RAISE" show --status "$CHILD/importer.status" --key retry-budget
question: Now that retries are per request, how should the importer back off?
option 1: Fixed 2s delay - predictable, slower to recover
option 2: Exponential with jitter - recovers faster, harder to reason about
recommend: 2
because: The gateway already sheds load, so faster recovery wins.
[exit 0]
Evidence: Before/after at the relay, same decision, base commit vs this branch (plus pre-change reader compatibility)

BEFORE - prose decision, pre-change relay at base 7d4f76c: importer [key=retry-budget] needs-decision: we could retry per request which is stateless and matches the gateway, or per session which handles bursts better but needs a store; I lean per request because the [truncated] AFTER - raised through bin/fm-decision-raise.sh, at the parent home after firstmate's escalation: importer [key=retry-budget] needs-decision: corr=deck-91 importer retry budget needs a call Q: Should the importer retry per request or per session? (per-request) Retry per request - stateless, matches the gateway (per-session) Retry per session - better under burst, needs a store -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure. COMPATIBILITY - branch status log read by the PRE-CHANGE drain (base 7d4f76c): importer [key=retry-budget] needs-decision: corr=deck-91 importer retry budget needs a call

BEFORE - the same decision as a crewmate writes it today (prose), rendered by the
pre-change relay at base commit 7d4f76c (bin/fm-wake-drain.sh, unmodified):

OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: we could retry per request which is stateless and matches the gateway, or per session which handles bursts better but needs a store; I lean per request because the [truncated]
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'


AFTER - the same decision raised through bin/fm-decision-raise.sh on this branch,
rendered by the relay firstmate actually reads:

OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: corr=deck-91 importer retry budget needs a call
    Q: Should the importer retry per request or per session?
    (per-request) Retry per request - stateless, matches the gateway
    (per-session) Retry per session - better under burst, needs a store
    -> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'


COMPATIBILITY - the branch status log read by the PRE-CHANGE relay (base 7d4f76c):
the status-line grammar is untouched, and the sidecar record is simply not read.

OPEN DECISIONS (still open, folded from the durable status logs - not just the latest line):
importer [key=retry-budget] needs-decision: corr=deck-91 importer retry budget needs a call
OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'
OPEN DECISIONS: no worker left to answer? close it deliberately: bin/fm-status-decision-close.sh <task> --key <key> --answered-elsewhere '<answer>' | --moot '<why>'
Evidence: Degenerate decision, both ends: write-time refusal and relay visibility
$ fm-decision-raise.sh raise --key blob --question '<blob>' --option '<blob>' --recommend 1 --because '<blob>'
fm-decision-raise: refusing to record a decision that is not actually structured.
Nothing was written - no record and no status line.

question-duplicated the question text is repeated in an option or in --because - fields filled with the same blob are not separate fields
rationale-duplicated the rationale repeats an option label verbatim
too-few-options fewer than two options - a choice needs at least two; if it is not a choice, raise it as free text

If this decision genuinely is not a set of options, that is normal and
supported - raise it as free text instead, and it will fold, wake and
close exactly as before:
echo 'needs-decision [key=blob]: <the question>' >> .../state/blob.status
[exit 2]

$ ls state | grep blob
(no blob.status, no blob.decisions - nothing was written)

# a record that reached the file some other way, seen at the relay:
faked [key=faked] needs-decision: there are a few ways to go and I am not sure
MALFORMED DECISION RECORD (question-duplicated too-few-options recommend-unknown-option no-rationale) - not structured; relay the note above, not these as options
Evidence: Free-text decision after this lands (majority case, unchanged)
$ printf 'needs-decision [key=scope]: should the legacy importer move in this pass?\n' >> state/plain.status

$ bin/fm-wake-drain.sh # renders exactly as before, beside the structured one
importer [key=retry-budget] needs-decision: Should the importer retry per request or per session?
(per-request) Retry per request - stateless, matches the gateway
(per-session) Retry per session - better under burst, needs a store
-> recommends (per-request) The gateway already carries a per-request token; a store is new infrastructure.
plain [key=scope] needs-decision: should the legacy importer move in this pass?

$ fm-decision-raise.sh show --key scope
unstructured decision [key=scope] (no structured record - this is normal)
note: should the legacy importer move in this pass?

$ fm-decision-raise.sh show --key scope --json
{"key":"scope","structured":false,"note":"should the legacy importer move in this pass?","degenerate":[]}
Evidence: Reproduction script for the walkthrough (runs the real scripts against a scratch home)
#!/usr/bin/env bash
# End-to-end walkthrough of a structured decision, from the crewmate that raises
# it to the payload a rendering surface reads. Runs the real scripts.
set -u
ROOT=$1
EV=$2
RAISE="$ROOT/bin/fm-decision-raise.sh"
DRAIN="$ROOT/bin/fm-wake-drain.sh"
export FM_GATE_REFUSE_BYPASS=1

DEMO="$EV/demo-home"
rm -rf "$DEMO"
mkdir -p "$DEMO/child/state" "$DEMO/parent/state"
CHILD="$DEMO/child/state"
PARENT="$DEMO/parent/state"

say() { printf '\n== %s ==\n' "$*"; }
run() { printf '\n$ %s\n' "$*"; eval "$@"; printf '[exit %s]\n' "$?"; }
drain() { FM_STATE_OVERRIDE="$1" FM_GUARD_GRACE=99999 "$DRAIN" 2>/dev/null | sed -n '/^OPEN DECISIONS (/,/^$/p'; }

say "1. A crewmate raises a decision as separate fields"
run "\"\$RAISE\" raise --status \"\$CHILD/importer.status\" --key retry-budget \\
    --question 'Should the importer retry per request or per session?' \\
    --option-id per-request 'Retry per request - stateless, matches the gateway' \\
    --option-id per-session 'Retry per session - better under burst, needs a store' \\
    --recommend per-request \\
    --because 'The gateway already carries a per-request token; a store is new infrastructure.'"

say "2. Hop 1 - the crewmate status log (grammar unchanged, note is the question)"
run "cat \"\$CHILD/importer.status\""
printf '\n$ cat state/importer.decisions      # tabs shown as [TAB]\n'
tr '\t' '\001' < "$CHILD/importer.decisions" | sed 's/\x01/[TAB]/g'

say "3. Hop 2 - the fold + firstmate's wake digest for the crewmate's home"
printf '\n$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # OPEN DECISIONS section\n'
drain "$CHILD"

say "4. Hop 3 - firstmate escalates the same decision to the PARENT home"
run "\"\$RAISE\" raise --status \"\$PARENT/importer.status\" --key retry-budget \\
    --question 'Should the importer retry per request or per session?' \\
    --option-id per-request 'Retry per request - stateless, matches the gateway' \\
    --option-id per-session 'Retry per session - better under burst, needs a store' \\
    --recommend per-request \\
    --because 'The gateway already carries a per-request token; a store is new infrastructure.' \\
    --note 'corr=deck-91 importer retry budget needs a call'"
run "cat \"\$PARENT/importer.status\""
printf '\n$ FM_STATE_OVERRIDE=<parent state> bin/fm-wake-drain.sh   # the captain-side relay\n'
drain "$PARENT"

say "5. Hop 4 - out to whatever renders it: the payload, fields still separate"
run "\"\$RAISE\" show --status \"\$PARENT/importer.status\" --key retry-budget --json"

say "6. Option identity - the contract fm-deck-answer-referent's referent adopts"
run "\"\$RAISE\" option --status \"\$PARENT/importer.status\" --key retry-budget --id per-session"
run "\"\$RAISE\" option --status \"\$PARENT/importer.status\" --key retry-budget --id per-somethingelse"

say "7. Human-readable read-back"
run "\"\$RAISE\" show --status \"\$PARENT/importer.status\" --key retry-budget"

say "8. THE MAJORITY CASE - an ordinary free-text decision, untouched"
run "printf 'needs-decision [key=scope]: should the legacy importer move in this pass?\\n' >> \"\$CHILD/plain.status\""
printf '\n$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # both decisions, one digest\n'
drain "$CHILD"
run "\"\$RAISE\" show --status \"\$CHILD/plain.status\" --key scope"
run "\"\$RAISE\" show --status \"\$CHILD/plain.status\" --key scope --json"

say "9. DEGENERATE AT WRITE TIME - the blob pasted into every field is refused"
run "\"\$RAISE\" raise --status \"\$CHILD/blob.status\" --key blob \\
    --question 'there are a few ways to go here and I am not sure which' \\
    --option 'there are a few ways to go here and I am not sure which' \\
    --recommend 1 --because 'there are a few ways to go here and I am not sure which'"
run "ls \"\$CHILD\" | grep blob || echo '(no blob.status, no blob.decisions - nothing was written)'"

say "10. DEGENERATE ON DISK - a record that reached the file some other way"
printf 'needs-decision [key=faked]: there are a few ways to go and I am not sure\n' >> "$CHILD/faked.status"
{
  printf 'decision\t1\tfaked\t1787000000\n'
  printf 'question\tthere are a few ways to go and I am not sure\n'
  printf 'option\t1\tthere are a few ways to go and I am not sure\n'
  printf 'recommend\t9\t\n'
  printf 'end\tfaked\n'
} >> "$CHILD/faked.decisions"
printf '\n$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # it must look WRONG here\n'
drain "$CHILD"
run "\"\$RAISE\" show --status \"\$CHILD/faked.status\" --key faked"
run "\"\$RAISE\" option --status \"\$CHILD/faked.status\" --key faked --id 1"

say "11. One key never carries two option sets"
run "\"\$RAISE\" raise --status \"\$CHILD/importer.status\" --key retry-budget \\
    --question 'Should the importer retry per request or per session, revisited?' \\
    --option 'Keep per request' --option 'Move to per session' \\
    --recommend 1 --because 'Nothing has changed since the first raise landed.'"

say "12. Once the key is resolved, it is reusable - and the new option set is its own"
run "printf 'resolved [key=retry-budget]: captain chose per-request\\n' >> \"\$CHILD/importer.status\""
printf '\n$ FM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh   # retry-budget is closed now\n'
drain "$CHILD"
run "\"\$RAISE\" raise --status \"\$CHILD/importer.status\" --key retry-budget \\
    --question 'Now that retries are per request, how should the importer back off?' \\
    --option 'Fixed 2s delay - predictable, slower to recover' \\
    --option 'Exponential with jitter - recovers faster, harder to reason about' \\
    --recommend 2 --because 'The gateway already sheds load, so faster recovery wins.'"
run "\"\$RAISE\" show --status \"\$CHILD/importer.status\" --key retry-budget"
- Evidence: Persisted state produced by the walkthrough (status logs and .decisions sidecars for child and parent homes) (local file: /var/folders/xl/gfd9bj992zj1qjv30f3rdv8w0000gn/T/no-mistakes-evidence/01M0GWC2BNXVF02ZX7QM2F8JKA/demo-home) - Outcome: ⚠️ 1 warning across 1 run (8m17s)

Pipeline

Updates from git push no-mistakes

... (3 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

⚠️ **Review** - 1 info

🔧 Fix: fix decision option identity, digest forgery, and JSON escaping
2 issues (1 error, 1 warning) still open:

  • 🚨 bin/fm-decision-lib.sh:171 - The fork-free rewrite of fm_decision_normalise_var replaced a linear tr pipeline with 26 whole-string ${s//X/x} substitutions, and bash 3.2's pattern substitution rebuilds the string per match, so the function is quadratic. This is a NEW regression from the fix commit, on the path that runs at the top of every wake-handling turn. Isolated on /bin/bash 3.2.57 (the stock macOS bash this codebase explicitly targets): the 26 case-fold substitutions alone take 2707 ms on a 4000-character string, while the old tr &#39;[:upper:]&#39; &#39;[:lower:]&#39; | tr -s &#39;[:space:]&#39; &#39; &#39; | sed ... pipeline takes 18 ms. The other new builtin loops are fine (3 whitespace substitutions 16 ms, the space-collapse loop 17 ms) - only the case fold is affected, because A-Z actually match in real text. On realistic prose the curve is 564 chars -> 19 ms, 2256 -> 35 ms, 6768 -> 480 ms per call, against a flat 18 ms for tr; the crossover where the new version stops being cheaper is around 300 characters. Reproduced through fm-wake-drain.sh: a 3 KB status note on a key that has a structured record makes one drain take 1.31 s, and a 6 KB question inside a record makes the drain take 114 s to emit MALFORMED DECISION RECORD (question-too-long) (a first attempt with a 64 KB note was killed at a 2-minute timeout). Nothing bounds the input before normalise runs: fm_decision_defects_var calls it on the question, the rationale and every option label BEFORE the too-long checks that would reject them, and print_open_decisions_section calls it on the raw, uncapped status-line note. FM_DECISION_MAX_FILE_BYTES permits a 256 KB record, and an ordinary free-text worker can append a status note of any length, so both triggers are reachable without a malicious writer. Keep the case fold linear - bound the value with ${s:0:N} before folding (the length defects use ${#...} and do not need the full string), or fold with a single tr when the value exceeds a short threshold. bin/fm-decision-raise.sh:133 json_str has the same shape but guards each control-char substitution with a case test, so only its 7 unconditional escapes are exposed; bound it the same way.
  • ⚠️ bin/fm-decision-lib.sh:308 - A record row that is entirely empty is counted as an option but rendered by nobody, so a one-option decision passes the degeneracy check as clean structure. fm_decision_record_read increments blk_count for every option line and publishes it as FM_DECISION_OPTION_COUNT (line 325), which is what [ &#34;$FM_DECISION_OPTION_COUNT&#34; -ge 2 ] || d=&#34;$d too-few-options&#34; (line 399) tests. Every consumer, however, skips a row with [ -n &#34;$id$label&#34; ] || continue, and a row written as option\t\t has both fields empty. Reproduced with a hand-written record containing option\t\t plus one real option: show --json returns &#34;structured&#34;:true with a single option and &#34;degenerate&#34;:[] (exit 0), and the wake digest renders (2) Use the new one under a normal structured heading with no MALFORMED banner - a single-option "choice" presented to firstmate as a real one. The widened [ -n &#34;$id$label&#34; ] guard added by this commit closes the neighbouring case where an empty id carries a label (that row now reaches fm_decision_option_id_valid and trips bad-option-id), but not this one. The writer cannot produce it (--option &#34;&#34; assigns the ordinal id and trips empty-option), so this is specifically the read-path re-check the intent makes load-bearing for "a record that reached the file some other way". Count the rows the accumulator actually yields, or flag a mismatch between that count and blk_count as a defect.

🔧 Fix: bound decision record parsing cost and option counting
1 error still open:

  • 🚨 bin/fm-decision-lib.sh:111 - This commit bounded fm_decision_normalise_var but left the sibling fm_decision_decode_var on the same path unbounded, so the drain can still be wedged by a single record field. The rewrite makes decoding cheap for text with no escapes (one pass), but for a value with k escapes the loop still runs k+1 times and each iteration's case/${s%%...}/${s#...} copies the whole remaining string, so cost grows with kn. Measured on /bin/bash 3.2.57: 250 escapes 53 ms, 500 -> 236 ms, 1000 -> 1540 ms, 2000 -> 11965 ms (a clean quadratic), against 17 ms for an escape-free 4000-character value. Reproduced through fm-wake-drain.sh: a record whose question is 3000 \n escapes (9000 encoded characters) makes one drain take 75 s to emit MALFORMED DECISION RECORD (question-too-long). Nothing bounds it - fm_decision_record_read decodes question and recommend at parse time and fm_decision_defects_var decodes every option label, all BEFORE the length checks that would reject them, and FM_DECISION_MAX_FILE_BYTES admits a 262143-byte record, i.e. roughly 131000 escapes in one field, which extrapolates from the curve above to hours. This is the same defect class as the normalise blowup fixed in this commit, in the function next to it, on the path that runs at the top of every wake-handling turn; the author's own comment on the function names the exact hazard ("Every string operation in bash copies the whole remaining string ... this runs on every field of every open decision on every drain") but the mitigation only covers the escape-free case. Apply the same bound: reject an encoded field longer than 2*cap+1 as too-long before decoding it (encoding at most doubles length, so this cannot change any verdict), or cut the encoded input to that bound the way FM_DECISION_NORMALISE_BOUND does. The new regression at tests/fm-decision-raise.test.sh:627 does not pin this: its 8 KB field is escape-free prose - precisely the case the rewrite made fast - and its tolerance is baseline + 5 seconds, so it passes while a 75 s wedge is reachable. Extend it with an escape-dense value so the bound the user asked for is actually held.

🔧 Fix: bound encoded field length before decoding decision records
1 warning still open:

  • ⚠️ bin/fm-decision-lib.sh:298 - fm_decision_option_label is the one decode site this commit did not put behind the new gate, so the invariant the commit documents is not actually universal. Lines 135-137 assert of fm_decision_decode_var that "every caller gates the encoded value through fm_decision_encoded_within_bound first, so no value reaches this loop that a length cap has not already had the chance to reject", and there are four gate sites (365 question, 385 option label, 400 rationale, 480 the defects loop) - but line 298 calls fm_decision_decode_var "$label" with no gate. On the READ path this is harmless because fm_decision_record_read drops an oversized label before it ever enters FM_DECISION_OPTIONS, and I confirmed the drain stays bounded there (a 240 KB escape-dense option label yields MALFORMED in 0.146 s). On the WRITE path it is live: cmd_raise builds FM_DECISION_OPTIONS straight from argv with no gate, fm_decision_defects_var skips an oversized label via the line-480 gate, and then its recommendation check calls fm_decision_option_label "$FM_DECISION_RECOMMEND_ID", which decodes exactly the label the recommendation names. Measured on /bin/bash 3.2.57 with a 3000-tab label (6000 encoded characters, 3000 escapes): raise --option &#34;&lt;label&gt;&#34; --option &#34;B two&#34; --recommend 1 takes 37.5 s, while the identical raise with --recommend 2 takes 1.17 s - a 32x difference attributable to that single ungated decode. Both print the same option-too-long oversized-field refusal, so the outcome is correct and only the wait is wrong, but the cost is unbounded because nothing caps an argv value before this point: a worker who pastes a large blob into --option and recommends it hangs its own raise instead of promptly getting the refusal that points at the free-text line, which is the diagnostic the design leans on so "the pressure is never to pad the fields harder". This is firstmate's crewmate rather than firstmate's supervision path, so it does not reopen the drain wedge, but it is the last site of the defect class this run has now fixed three times. Call fm_decision_encoded_within_bound before line 298 and return 1 for a label past the ceiling (its callers already treat a lookup miss as recommend-unknown-option, and the record is degenerate anyway once oversized-field fires), or apply the gate where cmd_raise builds the accumulator so the write path matches the read path.

🔧 Fix: move encoded-length bound inside the decision value decoder
1 warning still open:

  • ⚠️ bin/fm-decision-raise.sh:205 - cmd_raise encodes each --option value at argument-parse time, before anything checks its length, so the raise still pays an unbounded cost on a crewmate-controlled value - the same ordering defect the last three rounds removed everywhere else. Lines 205 and 212 call fm_decision_encode "$2"/"$3" inline while building opts, and fm_decision_encode's four whole-string ${s//.../...} substitutions are quadratic on bash 3.2 when the substituted characters are dense. Measured on /bin/bash 3.2.57: 1000 tabs 71 ms, 2000 -> 408 ms, 4000 -> 3100 ms, 8000 -> 26176 ms, against 22 ms for 8000 escape-free characters. Through the real command with a realistic 19799-character multi-line paste (299 newlines): --option &#34;&lt;blob&gt;&#34; takes 5.5 s to refuse, while the identical blob passed as --question refuses in 0.02 s and as --because in 0.02 s - because those two are stored raw and only encoded after fm_decision_defects has already rejected them. So the defect is specific to the --option/--option-id handlers, and the fix is symmetric with what the rest of the library now does: test the raw value against a cap (or FM_DECISION_MAX_ENCODED, since a value past the cap cannot produce a sound field) BEFORE calling fm_decision_encode, and fail with the existing option-too-long / oversized-field codes. This matters because the raise's refusal is load-bearing per the intent - "each refusal names the defect and points at the plain free-text line as the honest alternative, so the pressure is never to pad the fields harder" - and a pointer that takes half a minute to arrive does not do that job for the worker most likely to trigger it, namely one pasting a blob into a field. It is the worker's own process rather than firstmate's drain, which is why this is a warning and not an error, and it has been present since 85d0642 rather than introduced by this commit. Note that the new tests/fm-decision-raise.test.sh:740 test_raise_refuses_a_recommended_blob_promptly asserts precisely this property with a 5-second ceiling but does not catch it: its 3000-tab blob lands at 1.4 s, while the identical raise at 8000 tabs takes 25.8 s. Raising that test's blob size once the encode is gated would make the assertion hold at any size.

🔧 Fix: bound raw value length inside the decision encoder
1 warning still open:

  • ⚠️ bin/fm-decision-lib.sh:215 - fm_decision_flatten_var is the one function in the swept class that does not apply the bound, and the worker's --note reaches it straight from argv. Its three siblings now all bound their own input - fm_decision_encode with ${1:0:$FM_DECISION_MAX_RAW}, fm_decision_normalise_var with the same cut, fm_decision_decode_var with the fm_decision_encoded_within_bound gate - but fm_decision_flatten_var performs five whole-string ${s//pat/rep} substitutions plus the space-collapse loop on whatever it is handed. Measured on /bin/bash 3.2.57: 2000 tabs 975 ms, 4000 -> 6606 ms, 8000 -> 55094 ms, against 38 ms for 8000 escape-free characters - the same quadratic shape as every earlier instance. It is reachable through the real command: bin/fm-decision-raise.sh:190 collapse() calls it unbounded, and bin/fm-decision-raise.sh:278 calls collapse on $note, which is arbitrary argv from --note. A raise with an 8000-tab --note and otherwise valid fields SUCCEEDS after 53.7 s and writes a 25-byte status line, so the entire wait is spent flattening a value that collapses to nothing; argv has no ceiling, so 16000 tabs is roughly four minutes and a megabyte paste is unbounded. Unlike the previous five instances this one is on a success path rather than a refusal, so there is no delayed diagnostic - the worker simply waits - and --note is a documented, encouraged parameter: the secondmate charter brief this change adds tells secondmates to pass --note "corr={id} {one short line}". The read path is fine and I confirmed it: bin/fm-wake-drain.sh:152, :162 and :169 pass values fm_decision_record_read has already bounded to 800 encoded characters, and a drain over one decision whose four fields are each at that maximum and whitespace-dense takes 0.855 s - bounded, as required. NOTE ON THE FIX: this site cannot take the blind ${1:0:N} cut its siblings use. fm_decision_encode's cut is provably invisible because a truncated value is longer than every cap and therefore always refused, and fm_decision_normalise_var's output only feeds comparisons - but flatten's output IS the status-line note that gets written and that other readers consume, so cutting it there would silently shorten a legitimate long note. Either refuse an over-long --note through the existing validator, or bound the flatten work without discarding content. Finally, the round-5 instruction asked that the commit message name the sites swept so the sweep is auditable; the commit message is subject-only with no body, and this is the site an enumerated sweep would have surfaced.

🔧 Fix: bound flatten input and add entrance-wide bound test
2 warnings still open:

  • ⚠️ tests/fm-decision-raise.test.sh:990 - This commit silently stopped running an existing regression test. The diff replaced the invocation line test_unrenderable_option_row_is_not_counted_as_an_option with the new bound-test block, so the function is still defined at line 591 but is never called: comparing every ^test_[a-z_]*() { definition against every ^test_[a-z_]*$ invocation in the file yields exactly one orphan, and it is that one. It is the guard added in round 3 for the round-2 empty-option-row-bypasses-too-few-options finding - the case where a record row every consumer skips was still counted by the validator, letting a one-option decision pass the degeneracy check and reach firstmate looking like a real choice. That is a bypass of the degeneracy check the intent makes load-bearing ("a decision that arrived without a real question must look wrong AT THE RELAY"). The underlying fix is intact and I confirmed it directly: a hand-written record with option\t\t plus one real option still returns too-few-options from show --json (exit 1) and still renders MALFORMED DECISION RECORD (too-few-options) in the drain - so only the protection was lost, not the behaviour. Restore the invocation alongside the others in the list at lines 990-1001. Worth noting the new entrance test does not subsume it: that test feeds hostile OVERSIZED values and asserts promptness, while this one covers a small malformed record whose defect is structural rather than size-related.
  • ⚠️ bin/fm-decision-lib.sh:224 - Bounding fm_decision_flatten_var with ${1:0:$FM_DECISION_MAX_RAW} fixes the round-6 cost problem (an 8000-tab --note went from 53.7 s to 0.055 s, confirmed), but it also introduces new user-visible behaviour: because collapse() at bin/fm-decision-raise.sh:190 feeds this function the worker's --note and its output IS the status line that gets written, a note longer than 401 characters is now silently truncated in a durable append-only log. Reproduced with a realistic note - a corr token plus one sentence of escalation context, 519 characters - which is written as exactly 401 characters with nothing indicating it was cut; before this commit the whole note was written. The commit documents the choice ("what it does bound is the status note, which is one line of a log and is cut to the same ceiling as the longest field a decision may carry"), and two mitigations do hold: the default note is the question, capped at 240, so it is never cut, and the corr token the secondmate charter brief requires sits at the start of the note and survives. What is missing is the marker. bin/fm-line-cap-lib.sh - this repo's single owner of bounded rendering for exactly these digests - appends ' [truncated]' and documents that "truncation stays recoverable because the session-start digest prints each task's full status log path"; here the cut is unmarked and the full text exists nowhere, so the fold, fm-deck's cross-checked duplicate fold and the wake digest all read a note the worker did not write, with no way to tell. The distinction matters because the sibling bounds are provably invisible - fm_decision_encode's cut only ever touches values already past every cap and therefore always refused, and fm_decision_normalise_var's output only feeds comparisons - whereas this one discards content that gets stored. Since the author chose this tradeoff deliberately and documented it, confirming it is the right call is theirs: either accept it and add the established ' [truncated]' marker so the loss is visible, or refuse an over-long note through the existing validator the way every other over-long field is handled.

🔧 Fix: refuse over-long status notes; run every declared test
1 info still open:

  • ℹ️ tests/fm-decision-raise.test.sh:1038 - The new fm_run_declared_tests loop removes the hand-kept invocation list, which is the right fix for the round-7 dropped-invocation defect - but its own filter is narrower than the thing it enumerates, leaving a small residue of the same silent-skip. declare -F | sed -n &#39;s/^declare -f \(test_[a-z_]*\)$/\1/p&#39; anchors on [a-z_]*$, so a test function whose name contains a digit or an uppercase letter is enumerated by declare -F and then dropped by the sed, silently. I confirmed this directly: defining test_alpha_only and test_with2_digit and running that exact pipeline prints only test_alpha_only. There is no impact today - all 27 currently declared names are lowercase-and-underscore, and I verified each one matches the pattern, so the loop runs every test including the round-7 orphan test_unrenderable_option_row_is_not_counted_as_an_option and the new test_over_long_note_is_refused_rather_than_truncated (25 previously-invoked names became 27 declared, with nothing lost). But the captain's instruction for this round was to make the failure mode structurally impossible in this file, and a name like test_option_id2_resolves would reintroduce exactly it: defined, looking present, never running. Widening the character class to [A-Za-z0-9_] closes it, and costs nothing since the loop already asks the shell which functions exist rather than reading the file's text.
⚠️ **Test** - 1 warning
  • ⚠️ tests/fm-teardown.test.sh - tests/fm-teardown.test.sh case "herdr-preflight-missing-adapter" fails (12 ok, 1 not ok). Not introduced by this change: I extracted the pristine base tree at 7d4f76c and ran the same suite there, which fails identically with the same case and the same 12/1 split. The only edit this branch makes to bin/fm-teardown.sh is adding "$ID.decisions" to three rm lists, which is unrelated to the herdr preflight path. Tracked upstream by PR fix: fail closed on missing teardown dependencies kunchenguid/firstmate#2646 per the change's own notes.
  • bash tests/fm-decision-raise.test.sh - 27 cases, all pass
  • bash tests/fm-status-decision-close.test.sh - 14 cases including the test I added, test_teardown_removes_the_structured_decision_record
  • bash tests/fm-wake-drain-open-decisions.test.sh and bash tests/fm-wake-drain-open-decisions-cursor.test.sh
  • bash tests/fm-decision-hold-lifecycle.test.sh, bash tests/fm-brief.test.sh, bash tests/fm-teardown-endpoint-safety.test.sh, bash tests/fm-wake-queue.test.sh, bash tests/fm-wake-daemon-lifecycle-e2e.test.sh, bash tests/fm-documentation-audiences.test.sh
  • bash tests/fm-teardown.test.sh - 12 pass, 1 pre-existing failure (herdr-preflight-missing-adapter)
  • Pre-existing check: git archive 7d4f76c | tar -x into a scratch tree, then bash tests/fm-teardown.test.sh there - identical single failure on base
  • Manual end-to-end relay: bin/fm-decision-raise.sh raise --status &lt;child&gt;/importer.status --key retry-budget --question ... --option-id per-request ... --option-id per-session ... --recommend per-request --because ..., then FM_STATE_OVERRIDE=&lt;child state&gt; bin/fm-wake-drain.sh, then the same raise against the parent home's status file with --note &#39;corr=deck-91 ...&#39;, then FM_STATE_OVERRIDE=&lt;parent state&gt; bin/fm-wake-drain.sh
  • Manual renderer hop: bin/fm-decision-raise.sh show --status &lt;parent&gt;/importer.status --key retry-budget --json and the human-readable show
  • Manual option-identity contract: bin/fm-decision-raise.sh option --key retry-budget --id per-session (resolves to its label, exit 0) and --id per-somethingelse (refused, exit 1)
  • Manual free-text case: plain needs-decision [key=scope]: ... appended to a status log, rendered in the same digest, and show / show --json reporting it as unstructured (not a defect)
  • Manual degenerate-at-write case: a raise with the same blob in --question, --option and --because - refused with exit 2 naming question-duplicated, rationale-duplicated and too-few-options, with no status file and no record created
  • Manual degenerate-on-disk case: a hand-planted record in state/faked.decisions - MALFORMED banner at the relay with fields withheld, show exit 1, option --id 1 refused
  • Manual key-lifecycle case: a second raise under an open key refused; after a resolved [key=...] line the key is reusable and the new record's own option set is what reads back
  • Manual before/after: the same decision rendered by the base commit's bin/fm-wake-drain.sh (prose, truncated at the byte cap) vs this branch's, plus the branch's status log read by the pre-change drain to confirm the status-line grammar is unchanged
⚠️ **Document** - 1 info
  • ℹ️ AGENTS.md:162 - Judgment call on placement: firstmate's always-loaded AGENTS.md contract (the OPEN DECISIONS reconciliation lines at AGENTS.md:162 and 389) says nothing about the new MALFORMED banner or the structured fields. I deliberately did not add it there - AGENTS.md is reserved for knowledge useful to almost every session, a degenerate record is rare, and the banner is self-describing at the point of use (it prints "not structured; relay the note above, not these as options"). The mechanism now lives in docs/architecture.md with bin/fm-decision-lib.sh and bin/fm-wake-drain.sh headers as owners. If the team wants the relay-time reaction to be a standing firstmate rule rather than an in-band instruction, that is a one-line AGENTS.md addition, not a doc gap I could resolve unilaterally.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

roswellmb and others added 10 commits August 21, 2026 02:40
…ommendation

A decision reached the captain as one line of prose, because the worker wrote
prose and firstmate forwarded prose. A surface cannot present "the question"
from a blob without guessing which sentence that is - inventing structure it
cannot verify - so something has to produce the fields.

bin/fm-decision-raise.sh raises a decision with the question, each option and
the recommendation as separate fields, recorded in an append-only sidecar
(state/<task>.decisions) joined to the status log by (task id, decision key).
bin/fm-decision-lib.sh owns the record format, the option-identity rule and the
degeneracy check.

The status-line grammar is unchanged and fm-classify-lib.sh keeps sole ownership
of the open/resolved fold, so every existing reader - including fm-deck's
cross-checked duplicate - keeps working byte for byte. A decision with no record
is an ordinary free-text decision and is reported as unstructured, never as
broken; that remains the majority case and is unchanged.

An option's identity is (task id, decision key, option id), where the option id
is a slug over the same charset as a decision key, immutable for the life of the
record, and separate from the prose label that describes it - so a surface can
offer options without a worker's untrusted text ever naming a control.

Fields that can be faked are prose with extra steps, so the degenerate case is
made visible rather than absorbed. fm_decision_defects is the one place that
decides whether a record is real: the writer refuses to record a padded decision
at all and points at the free-text line instead of pushing the worker to pad
harder, and every reader re-checks on the way out, so a record that reached the
file some other way prints a MALFORMED banner at the relay - naming each defect
and saying not to relay it as options - rather than reaching the captain looking
structured.

The wake digest renders the fields where firstmate actually reads decisions, and
discloses any it had to cut rather than letting a bounded view read as complete.
Teardown removes the record with the status log it belongs to.
@roswellmb
roswellmb merged commit d22cd34 into main Aug 21, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant