Skip to content

feat(bin): refuse merges whose evidence commit is not the PR head - #2660

Open
clca wants to merge 5 commits into
kunchenguid:mainfrom
clca:fm/fm-merge-evidence-outruns-the-head
Open

feat(bin): refuse merges whose evidence commit is not the PR head#2660
clca wants to merge 5 commits into
kunchenguid:mainfrom
clca:fm/fm-merge-evidence-outruns-the-head

Conversation

@clca

@clca clca commented Aug 20, 2026

Copy link
Copy Markdown

Intent

Turn a merge-time check that firstmate currently performs BY HAND into a guard that cannot be skipped: refuse a merge when the evidence a worker reported was measured on a commit that is no longer the pull request's head.

Problem, with real instances: a worker measures its full-suite figure and the standing injection-exploit result, reports them, and the validation pipeline then commits AGAIN on top (a fix round, a document step, or a rebase onto a newer base). The figures in the report and in the PR description now describe a commit that is no longer the head that would merge. Observed four times across 2026-08-08 and 2026-08-09, and again on 2026-08-19. In each case a reported suite figure described a commit that a later fix round, document step or rebase had already superseded; one was overtaken twice. Every one was caught only because the reported commit was compared against the live head by hand.

Why it matters: the reported suite figure and the security-check result are the two things firstmate verifies before a merge, precisely because the pipeline has been observed declaring checks-passed on a red suite, and because a target repository may have no automated checks configured at all. A figure that silently describes an earlier commit turns that verification into theatre.

Approach, and what is explicitly NOT the fix: more discipline is not the fix - instructing every brief to "re-measure on the final head" has already been done and still loses the race, because the pipeline can commit after the worker's last action. The guard lives in firstmate's own merge path: bin/fm-pr-merge.sh refuses when the commit the evidence was measured on does not equal the pull request's head. The reported commit is recorded machine-readably in the durable task record (state/.meta) rather than the PR body, so the guard reads a record firstmate owns and no PR-description edit can forge. A guard that refuses must state exactly which commit it expected and which it found, or it will be worked around rather than fixed; the refusal message says what to re-measure and on which commit.

Acceptance criteria:

  1. A merge attempt whose recorded evidence commit differs from the live PR head is REFUSED, with both commits named in the message.
  2. A merge attempt whose evidence commit matches the head proceeds unchanged.
  3. The guard does not fire spuriously when no evidence commit has been recorded yet; that case refuses loudly rather than passing silently, and the refusal is documented and clearable by recording the measurement.
  4. bin/fm-brief.sh's scaffolds require the worker to record the commit its figures were measured on, in the form the guard reads.
  5. Colocated shell tests cover matching commit, mismatched commit, and absent record, exercising the real script rather than a re-implementation.
  6. shellcheck clean on every bin/ script touched.

Constraints: push remote is the fork clca/firstmate; the PR opens from that fork into kunchenguid/firstmate base main; never push to kunchenguid/firstmate; never force-push any branch but this one; never add an agent name as a commit co-author; do not touch projects/, data/, state/, or config/.

ACCEPTED DECISIONS ALREADY APPLIED AND COMMITTED - do not re-litigate these; they are settled requirements in their current form:

A. Identity-record parse SHAPE is deliberately tolerant, by decision. Three separate scripts append keys after pr= in state/.meta - bin/fm-evidence-record.sh (evidence_head=, evidence_note=), bin/fm-spawn.sh (traceparent=, control_relaunch_tx=), and bin/fm-decision-hold.sh (decisions_reviewed=, decision_keys=) - and nothing prevents a fourth. An enumerated allowlist was wrong three times running, and each future appender would be a latent refusal of a merge on valid work. So the parser tolerates any well-formed key=value line it does not recognise. Tolerance is NOT laxity: a line that is not key=value at all, a second pr=, an invalid pr_head=, or a truncated record are still refused loudly, and the keys the guard reads (pr=, evidence_head=) are still located correctly regardless of what surrounds them. A regression test parses and merges a record carrying a key the parser has never seen. Do not propose narrowing this back to an allowlist or adding another named key.

B. merge-head TOCTOU: KEEP the local comparison. gh pr merge --match-head-commit exists, but bin/fm-pr-merge.sh merges through gh-axi pr merge, whose flag set has no head-match flag, and routing around gh-axi to plain gh contradicts the standing instruction to use gh-axi for GitHub operations. The gh-axi-side change is filed separately. The remaining window is seconds rather than the hours the manual habit left open, which is a strict improvement. Do not propose a partial atomic-merge implementation.

C. Evidence writes are interrupt-safe: fm_pr_evidence_write and bin/fm-evidence-record.sh mirror the cleanup trap bin/fm-pr-check.sh already wraps around the same operation, so two writers of one record do not have asymmetric interrupt behaviour.

D. Unconfirmable-head refusals are split: "gh is not on PATH" and "gh could not answer" are distinct messages with distinct actionable fix lines, so a host with gh-axi but no plain gh is not worse off than before this change and is not pointed at gh auth status to diagnose a missing binary.

E. bin/fm-brief.sh's definition of done records the measured commit BEFORE any finishing line, and recording is part of the finishing condition rather than an appendix, in both PR-based modes; a worker reading in order can no longer stop before reaching the instruction the guard depends on. The scout, local-only, and charter variants were checked and have no such ordering problem. A colocated assertion pins the ordering and the condition.

Known-good measurement carried forward: on commit 4268fc9 the full suite is 2259 pass / 11 fail across 150 suites, and all 11 failures reproduce unchanged at base 81bcb10 with the same assertions, so this change introduces zero new failures. The pre-existing failing suites are arm-pretool-check, backend-orca, composer-lib, gotmp, muse-harness, remote-secondmate-lifecycle-e2e, remote-secondmate-trace-context, teardown, turnend-guard, wake-queue, and watcher-lock.

What Changed

  • bin/fm-pr-merge.sh now runs an evidence guard before it records any state or arms any poll: it reads the task's evidence_head= from state/<id>.meta, fetches the pull request's live head, and refuses when the two differ (printing both commits, the recorded note, and the re-record command), when no evidence commit is recorded, when the record is malformed, and when the head cannot be confirmed — with separate messages for gh missing from PATH and gh failing to answer.
  • New bin/fm-evidence-record.sh is the only writer of that record, backed by fm_pr_evidence_read/fm_pr_evidence_write/fm_pr_evidence_note_valid in bin/fm-pr-lib.sh, which replace the record atomically under the shared per-task metadata lock with an interrupt-safe cleanup trap, validate the SHA, and bound the optional note to one control-character-free line of 200 characters (non-ASCII allowed, counted by character). fm_pr_metadata_identity_parse now tolerates any well-formed key=value line after pr= instead of an enumerated allowlist, while still refusing non-record lines, a second pr=, and an invalid pr_head=.
  • bin/fm-brief.sh emits an evidence-recording section in both PR-based ship scaffolds (no-mistakes and direct-PR), shell-quoted and bound to the scaffolding FM_HOME/state directory, placed above every finishing line and folded into the definition of done; AGENTS.md, docs/architecture.md, and docs/scripts.md document the guard and its remedy, and tests add tests/fm-evidence-record.test.sh plus new cases in the merge, brief, and PR-identity suites covering matching, mismatched, absent, and unconfirmable heads against the real scripts (registered in bin/fm-test-run.sh's pr-forge family).

Risk Assessment

✅ Low: The change is well-bounded and fail-closed by design: every new refusal happens before any state is written or poll armed, the tolerant metadata parse was verified to still reject the injection shapes it claims to (second pr=, non-record lines) while surviving fm-spawn.sh's relaunch rewrite, tests drive the real scripts rather than re-implementations, and every source-verifiable acceptance criterion checks out - the three remaining findings are informational nits, none of which affects the guard's decision on a normally configured host.

Testing

Baseline targeted suites (fm-pr-merge, fm-evidence-record, fm-brief, fm-pr-check-security) all passed, and the lane coverage guard accepts the new suite. On top of that I drove the real bin/fm-pr-merge.sh and bin/fm-evidence-record.sh through a sandboxed replay of that incident: the merge refuses when nothing is recorded, proceeds when the recorded commit is the live head, and refuses naming both the measured commit and the moved head after a simulated pipeline commit — with no gh-axi merge submitted on any refusal. The refusal's printed recording command was executed verbatim from a shell carrying none of the merge's environment and cleared the block, proving the guard is escapable only by re-measuring. I also confirmed the tolerant metadata parse (an unseen key keeps the armed poll valid and merges; a non-record line is refused) and the two distinct unconfirmable-head refusals for a failing gh versus an absent gh, and captured real generated briefs showing the recording section above every finishing line in both PR-based modes. Evidence is two CLI transcripts in the evidence directory. Criterion 6 (shellcheck) was deliberately not exercised: this phase is barred from running linters and the lint phase owns it. No UI surface is involved, so no visual artifact applies.

Evidence: Merge evidence guard — end-to-end CLI transcript (replays the 2026-08-09 incident)

Source: Merge evidence guard — end-to-end CLI transcript (replays the 2026-08-09 incident)

=== firstmate merge evidence guard - end-to-end walkthrough (replays the 2026-08-09 incident, 2026-08-09) ===

--- Scene 1: no evidence recorded yet. A merge must refuse loudly, not pass silently. ---
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
error: refusing to merge https://github.com/kunchenguid/firstmate/pull/119: no verification evidence commit is recorded for task task-p119
  expected: the commit the reported verification was measured on
  found:    no evidence record
  fix: re-run the verification you intend to merge on, then record it:
    FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'task-p119' <commit it was measured on> '<what was measured>'
[exit 1]

--- Scene 2: the worker measures on a291594a... and records it. ---
$ bin/fm-evidence-record.sh task-p119 a291594a... 'full suite 4202 pass; injection exploit blocked'
recorded: task-p119 evidence measured on a291594aa291594aa291594aa291594aa291594a (full suite 4202 pass; injection exploit blocked)
[exit 0]

$ grep evidence_ state/task-p119.meta
evidence_head=a291594aa291594aa291594aa291594aa291594a
evidence_note=full suite 4202 pass; injection exploit blocked

--- Scene 3: evidence commit == live PR head. The merge proceeds unchanged. ---
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 300s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
armed: state/task-p119.check.sh
[exit 0]

$ cat gh-axi.log   # the merge really was submitted
gh-axi pr merge 119 --repo kunchenguid/firstmate --squash

--- Scene 4: the validation pipeline commits again (rebase + docs commit). The PR head moves
    to 44c3c637..., so the reported 4202 now describes a commit that is no longer the head. ---
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
error: refusing to merge https://github.com/kunchenguid/firstmate/pull/119: the reported evidence was measured on a commit that is no longer this pull request's head
  evidence measured on: a291594aa291594aa291594aa291594aa291594a (full suite 4202 pass; injection exploit blocked)
  pull request head:    44c3c63744c3c63744c3c63744c3c63744c3c637
  fix: re-run that verification on 44c3c63744c3c63744c3c63744c3c63744c3c637, then record the result:
    FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'task-p119' 44c3c63744c3c63744c3c63744c3c63744c3c637 '<what was measured>'
[exit 1]

$ cat gh-axi.log   # empty: no merge was submitted
(end of gh-axi.log)

--- Scene 5: the worker re-measures on the head the refusal named, and runs the printed
    remedy VERBATIM from a shell carrying none of the merge's environment. ---
$ FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'task-p119' 44c3c63744c3c63744c3c63744c3c63744c3c637 'full suite 4212 pass; injection exploit blocked'
recorded: task-p119 evidence measured on 44c3c63744c3c63744c3c63744c3c63744c3c637 (full suite 4212 pass; injection exploit blocked)
[exit 0]

$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
armed: state/task-p119.check.sh
[exit 0]

$ cat gh-axi.log   # the merge proceeds once the evidence describes the real head
gh-axi pr merge 119 --repo kunchenguid/firstmate --squash

$ cat state/task-p119.meta   # durable record firstmate owns; no PR-description edit can forge it
window=fm-task-p119
worktree=/tmp/fm-evidence-demo.DcsJgM/wt
project=/tmp/fm-evidence-demo.DcsJgM/project
kind=ship
mode=no-mistakes
evidence_head=44c3c63744c3c63744c3c63744c3c63744c3c637
evidence_note=full suite 4212 pass; injection exploit blocked
pr=https://github.com/kunchenguid/firstmate/pull/119
pr_head=44c3c63744c3c63744c3c63744c3c63744c3c637

--- Scene 6: a future writer appends a key this parser has never seen, after pr=.
    Tolerated by decision: an unknown key must not become a refused merge on valid work. ---
$ cat state/task-p119.meta   (baseline: evidence recorded, merge poll armed)
window=fm-task-p119
worktree=/tmp/fm-evidence-demo.DcsJgM/wt
project=/tmp/fm-evidence-demo.DcsJgM/project
kind=ship
mode=no-mistakes
evidence_head=44c3c63744c3c63744c3c63744c3c63744c3c637
evidence_note=full suite 4212 pass; injection exploit blocked
pr=https://github.com/kunchenguid/firstmate/pull/119
pr_head=44c3c63744c3c63744c3c63744c3c63744c3c637
armed merge-poll record: VALID (the watcher acts on it)

$ echo 'some_future_writer_key=whatever it records' >> state/task-p119.meta
armed merge-poll record: VALID (the watcher acts on it)
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
WARNING: watcher still down (same stale episode; last beat: never, grace 300s) - full banner already printed this episode.
armed: state/task-p119.check.sh
[exit 0]
gh-axi pr merge 119 --repo kunchenguid/firstmate --squash

    Tolerance is not laxity: a line that is not a key=value record at all is still refused.
$ echo 'this is not a record line' >> state/task-p119.meta
armed merge-poll record: REFUSED (the watcher will not act on it)


--- Scene 7: the guard needs the live head. The two ways it can be unreadable are
    separate refusals, so a host with gh-axi but no plain gh is not sent to an auth check. ---
(7a) gh is installed but cannot answer (lost or expired GitHub access):
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
error: refusing to merge https://github.com/kunchenguid/firstmate/pull/119: the pull request head could not be confirmed
  the recorded evidence commit for task task-p119 is 44c3c63744c3c63744c3c63744c3c63744c3c637
  gh is installed but did not answer with a commit for this pull request
  without the live head there is nothing to compare it against, so the merge stops here
  fix: restore GitHub access (gh auth status), then merge again
[exit 1]  merge calls submitted: 0

(7b) plain gh is not installed at all (host has gh-axi only):
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
error: refusing to merge https://github.com/kunchenguid/firstmate/pull/119: the merge guard reads the pull request head with the GitHub CLI (gh), which is not on PATH
  the recorded evidence commit for task task-p119 is 44c3c63744c3c63744c3c63744c3c63744c3c637
  without the live head there is nothing to compare it against, so the merge stops here
  fix: install the GitHub CLI (gh), then merge again
[exit 1]  merge calls submitted: 0
Evidence: Generated fm-brief.sh scaffolds — evidence-recording section and ordering, all three ship modes

Source: Generated fm-brief.sh scaffolds — evidence-recording section and ordering, all three ship modes

=== bin/fm-brief.sh scaffolds: the worker is told to record the commit its figures were measured on ===

--------------------------------------------------------------------------
### mode=no-mistakes  (generated at data/brief-no-mistakes/brief.md)

# Definition of done
Delivery contract: mode=no-mistakes
The task is complete only when committed on your branch AND the commit your reported verification was measured on is recorded.

## Record the commit your evidence was measured on
Whatever you report as verification - a full-suite figure, a targeted test result, an exploit that stays blocked, a benchmark - firstmate merges on the strength of it, so it must name the commit it describes.
Immediately after each such run, from this worktree, record it:
`` `
FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'brief-no-mistakes' "$(git rev-parse HEAD)" '<what you measured, one line>'
`` `
Record it again after EVERY re-measurement, and after anything that moves your branch head - a review fix round, a documentation commit, a rebase onto a newer base - because your earlier figures then describe a commit that is no longer the head.
The merge refuses when the recorded commit is not the pull request's head, and it refuses when nothing is recorded, so an unrecorded measurement stops the task rather than shipping unverified.

When you believe it is complete, append `done: {summary}` to the status file and stop.
Firstmate will then instruct you to run /no-mistakes to validate and ship a PR.

You drive no-mistakes by responding to its gates, not by implementing fixes.
Follow the guidance no-mistakes itself provides for the mechanics: it loads when you invoke /no-mistakes, and `no-mistakes axi run --help` plus the `help` lines in each `axi` response are authoritative and version-matched to the installed binary.
When starting no-mistakes, make `--intent` preserve all relevant content from this brief's `# Task` section plus every later accepted Firstmate requirement, clarification, constraint, exclusion, and supersession, carrying only each requirement's current accepted form; retain direct requirements instead of substituting a diff summary, and exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific.
Do not hand-edit, commit, or fix findings yourself while a run is active - the pipeline applies every fix.

Two firstmate-specific rules layer on top of that guidance:
- ask-user findings are never yours to answer: escalate to firstmate (rule 6) and stop.
  Firstmate applies the authority contract in its `AGENTS.md` and obtains any required captain decision.
  When the decision comes back, feed it to the gate with `no-mistakes axi respond` and let the pipeline apply it - do not route the question to "the user" or implement the fix yourself.
- Avoid `--yes`: it would silently bypass firstmate's authority check and any required captain escalation.

After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), re-record the commit your reported verification was measured on as above - the pipeline has almost certainly committed since you last measured - and only then append `done: PR {url} checks green` and stop. You are finished.

-- ordering check (line numbers as the worker reads them) --
62:FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'brief-no-mistakes' "$(git rev-parse HEAD)" '<what you measured, one line>'
67:When you believe it is complete, append `done: {summary}` to the status file and stop.
81:After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), re-record the commit your reported verification was measured on as above - the pipeline has almost certainly committed since you last measured - and only then append `done: PR {url} checks green` and stop. You are finished.

--------------------------------------------------------------------------
### mode=direct-PR  (generated at data/brief-direct-pr/brief.md)

# Definition of done
Delivery contract: mode=direct-PR
This task ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline.
The task is complete only when committed on your branch AND the commit your reported verification was measured on is recorded.

## Record the commit your evidence was measured on
Whatever you report as verification - a full-suite figure, a targeted test result, an exploit that stays blocked, a benchmark - firstmate merges on the strength of it, so it must name the commit it describes.
Immediately after each such run, from this worktree, record it:
`` `
FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'brief-direct-pr' "$(git rev-parse HEAD)" '<what you measured, one line>'
`` `
Record it again after EVERY re-measurement, and after anything that moves your branch head - a review fix round, a documentation commit, a rebase onto a newer base - because your earlier figures then describe a commit that is no longer the head.
The merge refuses when the recorded commit is not the pull request's head, and it refuses when nothing is recorded, so an unrecorded measurement stops the task rather than shipping unverified.

When it is implemented and committed, push your branch and open a PR with `gh-axi`, record the commit your reported verification was measured on as above, then append `done: PR {url}` to the status file and stop.
Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome.

-- ordering check (line numbers as the worker reads them) --
62:FM_HOME='/tmp/fm-evidence-demo.DcsJgM/home' '/Users/clca/.no-mistakes/worktrees/ed7a060052e8/01M0EP4TGFKRJR441WW7XJXKQX/bin/fm-evidence-record.sh' 'brief-direct-pr' "$(git rev-parse HEAD)" '<what you measured, one line>'
67:When it is implemented and committed, push your branch and open a PR with `gh-axi`, record the commit your reported verification was measured on as above, then append `done: PR {url}` to the status file and stop.

--------------------------------------------------------------------------
### mode=local-only  (generated at data/brief-local-only/brief.md)

(no evidence-recording section: this mode never reaches the merge path)
# Definition of done
Delivery contract: mode=local-only
This task ships **local-only**: no remote, no PR, no pipeline.
The task is complete only when committed on your branch `fm/brief-local-only`. Do NOT push, do NOT open a PR, do NOT merge.
Keep your branch a clean fast-forward onto the current default branch - if `main` has advanced, rebase onto it so the eventual merge stays a fast-forward.
When it is implemented and committed, append `done: ready in branch fm/brief-local-only` to the status file and stop.

-- ordering check (line numbers as the worker reads them) --
58:When it is implemented and committed, append `done: ready in branch fm/brief-local-only` to the status file and stop.
Evidence: Stale-evidence refusal (excerpt from the transcript)
$ bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119
error: refusing to merge https://github.com/kunchenguid/firstmate/pull/119: the reported evidence was measured on a commit that is no longer this pull request's head
evidence measured on: a291594aa291594aa291594aa291594aa291594a (full suite 4202 pass; injection exploit blocked)
pull request head: 44c3c63744c3c63744c3c63744c3c63744c3c637
fix: re-run that verification on 44c3c63744c3c63744c3c63744c3c63744c3c637, then record the result:
FM_HOME='...' '.../bin/fm-evidence-record.sh' 'task-p119' 44c3c63744c3c63744c3c63744c3c63744c3c637 '<what was measured>'
[exit 1]

$ cat gh-axi.log # empty: no merge was submitted

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ⚠️ bin/fm-brief.sh:373 - The scaffolded recording command is emitted as a bare $FM_ROOT/bin/fm-evidence-record.sh $ID ... — neither shell-quoted nor bound to the home the brief was scaffolded against — unlike every other absolute path this scaffold emits (STATUS_FILE at line 189 and HERDR_LAB_HELPER at line 280 both go through shell_quote, and tests/fm-brief.test.sh:472 pins that invariant with a root literally named firstmate helper&#39;s root). Two consequences: (1) a firstmate root containing a space or apostrophe emits a command line the worker cannot run as printed; (2) fm-brief.sh resolves FM_ROOT (line 102) and FM_HOME (line 103) independently and derives STATE from FM_HOME (line 112) — it bakes $STATE/$ID.status absolute for exactly that reason — but the emitted command carries no home binding, and bin/fm-evidence-record.sh falls back to FM_HOME=${FM_HOME:-$FM_ROOT} with crew panes inheriting no FM_HOME (bin/fm-spawn.sh:2762 injects it only for KIND=secondmate; only GOTMPDIR and TRACEPARENT are exported to crew panes at lines 2790/2795). Fix: emit the command through shell_quote and prefix it with the resolved home, e.g. FM_HOME=&lt;quoted $FM_HOME&gt; &lt;quoted $FM_ROOT&gt;/bin/fm-evidence-record.sh ..., matching the FM_HOME=&lt;home&gt; &lt;home&gt;/bin/... convention documented in docs/configuration.md:505.
  • ℹ️ bin/fm-pr-lib.sh:228 - fm_pr_evidence_note_valid matches ^[[:print:]]*$ under local LC_ALL=C, so any non-ASCII byte is rejected — verified: a note like suite 4208 pass — exploit blocked is refused. The refusal in bin/fm-evidence-record.sh:82 says only "the note must be one printable line of at most 200 characters", which does not describe the actual ASCII-only constraint, so a worker whose note contains an em dash or other common non-ASCII punctuation sees a message that appears to contradict its own input. Behavior is fail-loud and re-runnable, so this is friction rather than a hole; either widen the class or say "printable ASCII" in the message.
  • ℹ️ bin/fm-pr-merge.sh:123 - The live-head lookup is now implemented twice against the same forge fact with different semantics: bin/fm-pr-merge.sh:123 uses gh pr view &lt;number&gt; --repo &lt;owner&gt;/&lt;repo&gt;, while bin/fm-pr-check.sh:77 uses gh pr view &lt;url&gt; from inside the task worktree and only when that worktree still exists. Both feed the same merge invocation moments apart, so they can disagree (worktree gone -> no pr_head= recorded even though the guard read a head fine). Consolidating the read into one fm_pr_lib helper would keep the guard and the recorder from drifting; the current divergence is benign because pr_head= is documented as optional, so this is a cleanup rather than a defect.

🔧 Fix: quote and home-bind evidence commands, widen note charset
3 infos still open:

  • ℹ️ bin/fm-pr-merge.sh:139 - The guard's live-head read is gh pr view &#34;$PR_NUMBER&#34; --repo &#34;$PR_OWNER/$PR_REPO&#34;, which does not pin the forge host: with --repo OWNER/REPO (no host), gh resolves the repository against its default host, which GH_HOST in the environment overrides. fm_pr_url_parse (bin/fm-pr-lib.sh:178) already guarantees this URL is on github.com, and the sibling read in bin/fm-pr-check.sh:77 pins the host by passing the full $URL. So on a host where GH_HOST names an enterprise instance, the guard compares the recorded evidence commit against a head read from a different forge - normally a spurious refusal, but a same-numbered PR there whose head coincided would pass stale evidence through the check that exists to stop exactly that. Fix is one argument, not the helper consolidation that was deferred: pass the canonical &#34;$URL&#34; instead of &#34;$PR_NUMBER&#34; --repo &#34;$PR_OWNER/$PR_REPO&#34;, matching fm-pr-check.sh.
  • ℹ️ tests/fm-brief.test.sh:372 - assert_evidence_precedes_every_finish checks the ordering per finishing line, but enforces the second half of its own claim - "each finishing line must make recording part of its own condition, not an appendix" - with a single file-wide grep -q. The generated no-mistakes brief has two finishing lines (append \done: {summary}`andappend `done: PR {url} checks green`); only the second names recording, and the first passes solely because the other line matched somewhere in the file. Decision E requires recording to be part of the finishing condition in both PR-based modes; that holds today via the DOD opener (&#34;complete only when committed ... AND the commit ... is recorded&#34;), but the assertion does not pin it - deleting that opener would leave done: {summary}` with no recording condition and the test would still pass. Make the condition check per finishing line, or assert the DOD opener explicitly.
  • ℹ️ tests/fm-evidence-record.test.sh:166 - The matrix at the top of the file claims case (d) covers "a short, uppercase, or non-hex commit", but the loop's uppercase entry is &#34;$(printf &#39;%s&#39; &#34;$SHA_A&#34; | tr &#39;a-f&#39; &#39;A-F&#39;)zz&#34; and SHA_A is forty 1s, so tr is a no-op and the value is refused only for being 42 characters. Nothing exercises an uppercase SHA at all - and the actual behavior is the opposite of what the matrix says: bin/fm-evidence-record.sh:70 deliberately lowercases before validating, so an uppercase SHA is accepted and normalized. That normalization is what makes the guard's string equality against the lowercased live head (bin/fm-pr-merge.sh:140) hold, and it is currently untested; dropping the tr at line 70 would break a worker who pasted an uppercase SHA and no test would catch it. Either record a genuinely uppercase valid SHA and assert it reads back lowercased, or correct the matrix wording.
✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-pr-merge.test.sh tests/fm-evidence-record.test.sh tests/fm-brief.test.sh tests/fm-pr-check-security.test.sh — 4 suites, 0 failures (merge-guard matrix incl. matching/stale/absent/unconfirmable-head, evidence-record writer and tolerant-parse regressions, brief ordering assertion, PR identity + poll security suite)
  • bin/fm-test-run.sh --check-coverage — confirms tests/fm-evidence-record.test.sh is claimed by a CI lane (FM_TEST_COVERAGE ok total=151)
  • Manual end-to-end walkthrough in a throwaway FM_HOME sandbox with gh/gh-axi mocks, replaying PR 119: bin/fm-pr-merge.sh task-p119 https://github.com/kunchenguid/firstmate/pull/119 with no record, then bin/fm-evidence-record.sh task-p119 &lt;sha&gt; &#39;&lt;note&gt;&#39;, then merge on a matching head, then moving the mocked live head and merging again
  • Ran the refusal's printed remedy command verbatim in bash -c with FM_HOME/FM_ROOT_OVERRIDE/FM_STATE_OVERRIDE unset, then re-ran the merge to confirm the refusal clears
  • Appended some_future_writer_key=... after pr= and re-checked fm_pr_poll_artifacts_valid plus a live merge (tolerated); appended a non-key=value line and re-checked (refused)
  • Ran the merge with a gh that fails and with no gh on PATH at all, confirming two distinct refusals and zero gh-axi merge calls in either case
  • Generated real briefs via bin/fm-brief.sh &lt;id&gt; firstmate --mode no-mistakes|direct-PR|local-only and inspected the Definition of done ordering and finishing lines
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

clca and others added 5 commits August 20, 2026 06:16
A worker measures its suite figure and standing exploit result, reports
them, and the validation pipeline then commits again on top - a fix round,
a documentation step, a rebase onto a newer base. The reported figures now
describe a commit that is no longer the head that would merge. That was
caught four times by hand (PR 115 and PR 119 on 2026-08-09, PR 160 on
2026-08-19), and only because firstmate compared the reported commit
against the live head every single time.

More discipline is not the fix: briefing every worker to "re-measure on the
final head" already loses the race, because the pipeline can commit after
the worker's last action. The guard goes in firstmate's own merge path.

- bin/fm-evidence-record.sh records evidence_head=<sha> plus an optional
  one-line evidence_note= into the task's durable metadata. The durable
  record is the store rather than the PR body: it is firstmate-private, it
  already holds the parallel pr= and pr_head= values, and the PR body is
  written by the pipeline and editable afterwards by anyone.
- bin/fm-pr-merge.sh reads the live head from the forge and refuses unless
  it equals the recorded commit, before recording any state or arming any
  poll. The refusal names both commits, quotes what was measured, and gives
  the exact re-record command for the live head.
- An absent record refuses on the same path. Passing silently would leave
  the guard defeatable by never recording, and nothing distinguishes "no
  claim was made" from "the claim was lost". The remedy is that one
  command, not a bypass flag, so a task predating this record is never
  stranded. An unconfirmable head refuses too: with nothing to compare
  against, the merge stops.
- bin/fm-brief.sh gives both PR-based ship modes the matching worker
  contract - record at measurement time, re-record after every
  re-measurement. local-only and scout scaffolds omit it because neither
  reaches this merge path and neither has a pipeline that can commit after
  the worker.
- fm_pr_metadata_identity_parse now tolerates the evidence keys after pr=.
  Re-recording happens while the merge poll is already armed, which appends
  those lines after pr=; without this the watcher's revalidation of the
  armed poll would fail on exactly the remedy the refusal asks for.

Tests cover matching, mismatched, absent, and unconfirmable heads, the
refuse -> re-measure -> merge round trip on an armed task, and the
recorder's own write, replace, refuse, and survival behavior, all through
the real scripts.
Two defects in the merge-evidence guard, both found by review.

The identity parse enumerated which keys may appear after pr=, and that
allowlist was wrong three times running: the evidence recorder, fm-spawn's
traceparent and relaunch transaction, and fm-decision-hold's review record all
append there, and nothing stops a fourth. Every such writer was a latent
refusal of a merge on valid work, discovered only when a correct merge was
wrongly blocked. Replace the allowlist with a shape test: any well-formed
key=value line is tolerated, while a line that is not a record line, a second
pr=, and an invalid pr_head= are still refused. The two test fixtures that
built an "ambiguous" record out of an unknown key after pr= now use two
irreconcilable pr= lines, which is the ambiguity their name claims.

The brief's evidence-recording section sat after the line telling the worker it
was finished, so a worker reading the definition of done in order stopped
before reaching the instruction the whole guard depends on. Move it above every
finishing line in both PR modes and fold recording into each finishing
condition. The scout, local-only, and charter variants were checked and have no
such ordering. A colocated assertion now pins the ordering and the condition.
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