feat(bin): raise a decision as separate question, options and recommendation - #10
Merged
Conversation
…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.
…ecture and toolbelt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
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:
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
bin/fm-decision-raise.sh(raise,show,show --json,option) and the sharedbin/fm-decision-lib.sh, which owns the wire format for an append-only sidecar atstate/<id>.decisions, the(task id, decision key, option id)option identity that an answering surface adopts rather than inventing its own, and the singlefm_decision_defectsdegeneracy check.raiseappends the ordinaryneeds-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.shnow rendersQ:, the option rows and-> recommends (id) whyunder 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 aMALFORMED 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.shgains no second reader and the status-line grammar is byte-for-byte identical.bin/fm-teardown.shremoves<id>.decisionsalongside the status log on all three destroying paths so a reused task id cannot join a stale record; the secondmate, scout and ship briefs inbin/fm-brief.shtell workers when to raise fields instead of prose (including the--note "corr=..."correlation contract); andAGENTS.md,docs/architecture.mdanddocs/scripts.mddocument the sidecar. Newtests/fm-decision-raise.test.shcovers the raise, the relay and the degenerate cases through the executable interface, enumerating declared tests from the shell rather than a hand-kept list, andtests/fm-status-decision-close.test.shpins 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
--notetook 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.shrecords 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.shcaseherdr-preflight-missing-adapterfails on this branch and fails identically on a pristine tree at base commit7d4f76c, same 12-pass/1-fail split; upstream PR kunchenguid#2646 targets that defect class. The pre-existing status-note fold cost inbin/fm-classify-lib.shis 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)
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 callEvidence: Degenerate decision, both ends: write-time refusal and relay visibility
Evidence: Free-text decision after this lands (majority case, unchanged)
Evidence: Reproduction script for the walkthrough (runs the real scripts against a scratch home)
/var/folders/xl/gfd9bj992zj1qjv30f3rdv8w0000gn/T/no-mistakes-evidence/01M0GWC2BNXVF02ZX7QM2F8JKA/demo-home) - Outcome: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.)
🔧 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 lineartrpipeline 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 oldtr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' | 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 emitMALFORMED 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 singletrwhen 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 acasetest, 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 everyoptionline and publishes it as FM_DECISION_OPTION_COUNT (line 325), which is what[ "$FM_DECISION_OPTION_COUNT" -ge 2 ] || d="$d too-few-options"(line 399) tests. Every consumer, however, skips a row with[ -n "$id$label" ] || continue, and a row written asoption\t\thas both fields empty. Reproduced with a hand-written record containingoption\t\tplus one real option:show --jsonreturns"structured":truewith a single option and"degenerate":[](exit 0), and the wake digest renders(2) Use the new oneunder a normal structured heading with no MALFORMED banner - a single-option "choice" presented to firstmate as a real one. The widened[ -n "$id$label" ]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 ""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'scase/${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\nescapes (9000 encoded characters) makes one drain take 75 s to emitMALFORMED DECISION RECORD (question-too-long). Nothing bounds it - fm_decision_record_read decodesquestionandrecommendat 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 isbaseline + 5seconds, 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 "<label>" --option "B two" --recommend 1takes 37.5 s, while the identical raise with--recommend 2takes 1.17 s - a 32x difference attributable to that single ungated decode. Both print the sameoption-too-long oversized-fieldrefusal, 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 buildingopts, 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 "<blob>"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🔧 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 linetest_unrenderable_option_row_is_not_counted_as_an_optionwith 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 withoption\t\tplus one real option still returns too-few-options from show --json (exit 1) and still rendersMALFORMED 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 's/^declare -f \(test_[a-z_]*\)$/\1/p'anchors on[a-z_]*$, so a test function whose name contains a digit or an uppercase letter is enumerated bydeclare -Fand 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.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 passbash tests/fm-status-decision-close.test.sh- 14 cases including the test I added,test_teardown_removes_the_structured_decision_recordbash tests/fm-wake-drain-open-decisions.test.shandbash tests/fm-wake-drain-open-decisions-cursor.test.shbash 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.shbash tests/fm-teardown.test.sh- 12 pass, 1 pre-existing failure (herdr-preflight-missing-adapter)Pre-existing check:git archive 7d4f76c | tar -xinto a scratch tree, thenbash tests/fm-teardown.test.shthere - identical single failure on baseManual end-to-end relay:bin/fm-decision-raise.sh raise --status <child>/importer.status --key retry-budget --question ... --option-id per-request ... --option-id per-session ... --recommend per-request --because ..., thenFM_STATE_OVERRIDE=<child state> bin/fm-wake-drain.sh, then the same raise against the parent home's status file with--note 'corr=deck-91 ...', thenFM_STATE_OVERRIDE=<parent state> bin/fm-wake-drain.shManual renderer hop:bin/fm-decision-raise.sh show --status <parent>/importer.status --key retry-budget --jsonand the human-readableshowManual 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: plainneeds-decision [key=scope]: ...appended to a status log, rendered in the same digest, andshow/show --jsonreporting 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 createdManual degenerate-on-disk case: a hand-planted record in state/faked.decisions - MALFORMED banner at the relay with fields withheld,showexit 1,option --id 1refusedManual key-lifecycle case: a second raise under an open key refused; after aresolved [key=...]line the key is reusable and the new record's own option set is what reads backManual before/after: the same decision rendered by the base commit'sbin/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 unchangedAGENTS.md:162- Judgment call on placement: firstmate's always-loaded AGENTS.md contract (theOPEN DECISIONSreconciliation 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 withbin/fm-decision-lib.shandbin/fm-wake-drain.shheaders 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.