diff --git a/AGENTS.md b/AGENTS.md index 67ec0d69609..9f4878be4da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,7 @@ The worker reports the PR when CI first becomes green rather than waiting for me For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. Run `bin/fm-pr-check.sh ` - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. +`bin/fm-pr-merge.sh` refuses any merge whose recorded evidence commit is not the pull request's live head, and refuses when no evidence commit is recorded; clear either refusal by re-measuring on the pull request's current head and recording that commit with `bin/fm-evidence-record.sh`, never by working around the guard. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, a concise outcome summary, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 206e5a947ae..231bee265ed 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -49,6 +49,16 @@ # declared-external-wait verb (FM_CLASSIFY_PAUSED_VERB, default "paused") from # "blocked:": pause for a known external wait expected to clear on its own, # blocked when firstmate must act. +# PR-based ship briefs (no-mistakes and direct-PR) carry an evidence-recording +# section: the worker records the commit each reported verification was measured +# on through bin/fm-evidence-record.sh, and bin/fm-pr-merge.sh refuses to merge +# unless that commit is still the PR head. local-only and scout scaffolds omit it +# because neither reaches that merge path. +# That section must stay ABOVE every line that tells the worker it is finished, +# and each finishing line must name recording as part of its own condition. A +# worker reads the definition of done in order and stops at the first line that +# says it may; an evidence section placed after that line is unreachable in the +# ordinary case, which is exactly how the merge guard's own input went missing. # Ship tasks include a project-memory section so durable project-intrinsic # learnings can be committed to AGENTS.md through the project's delivery path; # it carries the AGENTS.md authoring bar (widely useful knowledge only, pointers @@ -348,6 +358,37 @@ echo "scaffolded: $BRIEF (scout; replace {TASK})" exit 0 fi +# Every PR-based ship mode carries the same evidence-recording contract, because +# bin/fm-pr-merge.sh refuses to merge unless the recorded commit equals the pull +# request's live head. The worker is the only party that knows which commit its +# figures came from, and the pipeline can commit after the worker's last action, +# so the contract is stated as "record at measurement time, re-record after every +# re-measurement" rather than "measure the final head", which cannot win that +# race. local-only ships no PR and scouts ship no change, so neither carries it. +# +# The command has to run exactly as printed from the worker's pane, so it is +# shell-quoted like every other absolute path this scaffold emits and it carries +# the home this brief was scaffolded against. A crew pane inherits no FM_HOME, +# and the recorder falls back to its own root, so an unbound command would +# record into a different firstmate home than the one the merge guard reads - +# the same reason $STATE/$ID.status is baked in absolute above. +EVIDENCE_RECORD_CMD="FM_HOME=$(shell_quote "$FM_HOME")" +if [ "$STATE" != "$FM_HOME/state" ]; then + EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD FM_STATE_OVERRIDE=$(shell_quote "$STATE")" +fi +EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD $(shell_quote "$FM_ROOT/bin/fm-evidence-record.sh") $(shell_quote "$ID")" +IFS= read -r -d '' EVIDENCE_SECTION <' +\`\`\` +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. +EOF +EVIDENCE_SECTION=${EVIDENCE_SECTION%$'\n'} + # Ship task: shape Setup / Rule 1 / Definition of done by this task's explicit # delivery mode, validated above. The generated DOD opens with the fixed # "Delivery contract: mode=" line that bin/fm-spawn.sh checks against its own @@ -360,8 +401,11 @@ case "$MODE" in # 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. -When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done: PR {url}\` to the status file and stop. +The task is complete only when committed on your branch AND the commit your reported verification was measured on is recorded. + +$EVIDENCE_SECTION + +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. EOF ;; @@ -385,7 +429,10 @@ EOF IFS= read -r -d '' DOD < plus an optional +# one-line evidence_note=. Re-running replaces the previous +# record, so a re-measurement is recorded the same way as the first one. +# +# bin/fm-pr-merge.sh refuses to merge unless evidence_head equals the pull +# request's live head. A validation pipeline can commit after the worker's last +# measurement - a fix round, a documentation step, a rebase onto a newer base - +# which silently turns a reported suite figure or exploit result into a +# description of an earlier commit. Recording the measured commit is what makes +# that staleness detectable instead of a manual comparison someone has to +# remember to perform. +# +# Run it from the task worktree immediately after the verification run it +# describes, and run it again after every re-measurement: +# bin/fm-evidence-record.sh "$(git rev-parse HEAD)" 'full suite 4208 pass; injection exploit blocked' +# +# The note is one line of at most 200 characters and is quoted into the refusal +# message so a stale merge says what has to be re-measured. Its text is not +# restricted to ASCII: an em dash or an accented word is ordinary in a note and +# is accepted as written. A note carrying a newline, a tab, or any other control +# character is refused rather than trimmed, because a silently trimmed note is a +# silently wrong instruction. +# Usage: fm-evidence-record.sh [one-line note] +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +usage() { + awk ' + NR == 1 { next } + /^#/ { sub(/^# ?/, ""); print; next } + { exit } + ' "$0" +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +# An interrupted write must leave the state directory exactly as it found it: +# no half-written temp beside the metadata, and no per-task lock another +# process would have to reclaim through stale-owner recovery. This mirrors +# bin/fm-pr-check.sh, the other writer of this same record. +trap fm_pr_evidence_cleanup EXIT +trap 'exit 1' HUP INT TERM + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "error: invalid evidence record request" >&2 + echo "usage: fm-evidence-record.sh [one-line note]" >&2 + exit 2 +fi +ID=$1 +RAW_HEAD=$2 +NOTE=${3-} + +if ! fm_pr_task_id_valid "$ID"; then + echo "error: invalid evidence record request" >&2 + exit 2 +fi +HEAD_SHA=$(printf '%s' "$RAW_HEAD" | tr '[:upper:]' '[:lower:]') +if ! fm_pr_head_valid "$HEAD_SHA"; then + echo "error: '$RAW_HEAD' is not a full commit SHA; pass \"\$(git rev-parse HEAD)\"" >&2 + exit 2 +fi +if ! fm_pr_evidence_note_valid "$NOTE"; then + echo "error: the note must be a single line of at most 200 characters with no control characters" >&2 + echo " non-ASCII text such as an em dash is accepted; a line break, a tab, or an escape is not" >&2 + exit 2 +fi + +# Task-derived paths are constructed only after the canonical ID validation. +META="$STATE/$ID.meta" +if [ ! -f "$META" ] || [ -L "$META" ]; then + echo "error: task metadata is unavailable" >&2 + exit 1 +fi + +if ! fm_pr_evidence_write "$META" "$HEAD_SHA" "$NOTE"; then + echo "error: could not record the evidence commit for task $ID" >&2 + exit 1 +fi + +if [ -n "$NOTE" ]; then + printf 'recorded: %s evidence measured on %s (%s)\n' "$ID" "$HEAD_SHA" "$NOTE" +else + printf 'recorded: %s evidence measured on %s\n' "$ID" "$HEAD_SHA" +fi diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index b70d8468894..fb973f4871d 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -213,6 +213,111 @@ fm_pr_head_valid() { [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]] } +# evidence_head= records the exact commit a task's reported verification +# evidence was measured on, and optional evidence_note= records what +# that measurement was. bin/fm-evidence-record.sh is the only writer and +# bin/fm-pr-merge.sh the only reader: a merge is refused unless evidence_head +# equals the pull request's live head, so a suite figure or exploit result +# measured before a later fix, documentation, or rebase commit can never be +# presented as the merging head's result. +FM_PR_EVIDENCE_HEAD= +FM_PR_EVIDENCE_NOTE= +FM_PR_EVIDENCE_TMP= +FM_PR_EVIDENCE_LOCK= +FM_PR_EVIDENCE_LOCK_HELD=0 + +# fm_pr_evidence_note_valid : a note is one line of at most 200 +# characters carrying no control character, so it can never split the key=value +# record it is stored in nor drive the terminal a refusal is printed to. +# The text itself is not restricted to ASCII, because workers routinely write an +# em dash or an accented word and a guard that refuses valid work is worked +# around rather than fixed. The bound counts characters, which is what the +# refusal claims it counts: matching is byte-wise under LC_ALL=C, so a UTF-8 +# sequence is folded to its single leading byte by dropping continuation bytes +# before the length is taken. +fm_pr_evidence_note_valid() { + local note=${1-} + local LC_ALL=C + local characters + if [[ "$note" =~ [[:cntrl:]] ]]; then + return 1 + fi + characters=${note//[$'\x80'-$'\xbf']/} + [ "${#characters}" -le 200 ] +} + +# fm_pr_evidence_read : load the task's evidence record into +# FM_PR_EVIDENCE_HEAD and FM_PR_EVIDENCE_NOTE. Returns 0 with an empty head when +# no record exists, and non-zero when the metadata is unreadable or the recorded +# head is malformed, so a corrupt record is never mistaken for a matching one. +fm_pr_evidence_read() { + local meta=${1-} head note + FM_PR_EVIDENCE_HEAD= + FM_PR_EVIDENCE_NOTE= + [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 + head=$(grep '^evidence_head=' "$meta" | tail -1 || true) + [ -n "$head" ] || return 0 + head=${head#evidence_head=} + fm_pr_head_valid "$head" || return 1 + note=$(grep '^evidence_note=' "$meta" | tail -1 || true) + note=${note#evidence_note=} + fm_pr_evidence_note_valid "$note" || return 1 + FM_PR_EVIDENCE_HEAD=$head + FM_PR_EVIDENCE_NOTE=$note +} + +# fm_pr_evidence_cleanup: remove any in-progress evidence temp and release the +# per-task metadata lock if this process still holds it. Callers install it as +# their EXIT trap, so an interrupted write leaves neither a stray temp file in +# the state directory nor a lock another process has to reclaim through +# stale-owner recovery. Releasing is idempotent: the held flag is cleared here, +# so the normal path and the trap together release exactly once. +fm_pr_evidence_cleanup() { + [ -z "$FM_PR_EVIDENCE_TMP" ] || rm -f -- "$FM_PR_EVIDENCE_TMP" + FM_PR_EVIDENCE_TMP= + if [ "$FM_PR_EVIDENCE_LOCK_HELD" = 1 ]; then + fm_lock_release "$FM_PR_EVIDENCE_LOCK" || true + FM_PR_EVIDENCE_LOCK_HELD=0 + fi +} + +# fm_pr_evidence_write [note]: atomically replace the task's +# evidence record, preserving every other metadata line, and re-read the result +# so a partially written record can never be reported as recorded. Requires +# bin/fm-wake-lib.sh for the shared per-task metadata lock. +fm_pr_evidence_write() { + local meta=$1 head=$2 note=${3-} dir base rc=0 + fm_pr_head_valid "$head" || return 1 + fm_pr_evidence_note_valid "$note" || return 1 + [ -f "$meta" ] && [ ! -L "$meta" ] || return 1 + [ "$(fm_pr_file_link_count "$meta")" = 1 ] || return 1 + dir=${meta%/*} + base=${meta##*/} + [ "$dir" != "$meta" ] || dir=. + FM_PR_EVIDENCE_LOCK=$(fm_meta_lock_path "$meta") || return 1 + fm_lock_acquire_wait "$FM_PR_EVIDENCE_LOCK" + FM_PR_EVIDENCE_LOCK_HELD=1 + FM_PR_EVIDENCE_TMP=$(mktemp "$dir/.${base}.fm-evidence.XXXXXX") \ + || { fm_pr_evidence_cleanup; return 1; } + while :; do + [ -f "$meta" ] && [ ! -L "$meta" ] && [ "$(fm_pr_file_link_count "$meta")" = 1 ] || { rc=1; break; } + fm_pr_regular_destination_or_absent "$meta" || { rc=1; break; } + { grep -vE '^evidence_head=|^evidence_note=' "$meta" || true; } > "$FM_PR_EVIDENCE_TMP" || { rc=1; break; } + printf 'evidence_head=%s\n' "$head" >> "$FM_PR_EVIDENCE_TMP" || { rc=1; break; } + if [ -n "$note" ]; then + printf 'evidence_note=%s\n' "$note" >> "$FM_PR_EVIDENCE_TMP" || { rc=1; break; } + fi + chmod 0600 "$FM_PR_EVIDENCE_TMP" || { rc=1; break; } + mv -f -- "$FM_PR_EVIDENCE_TMP" "$meta" || { rc=1; break; } + FM_PR_EVIDENCE_TMP= + break + done + fm_pr_evidence_cleanup + [ "$rc" = 0 ] || return 1 + fm_pr_evidence_read "$meta" || return 1 + [ "$FM_PR_EVIDENCE_HEAD" = "$head" ] && [ "$FM_PR_EVIDENCE_NOTE" = "$note" ] +} + fm_pr_file_mode() { if [ "$(uname)" = Darwin ]; then stat -f %Lp "$1" 2>/dev/null @@ -285,6 +390,32 @@ fm_pr_regular_destination_on_device_or_absent() { [ ! -e "$path" ] || [ "$(fm_pr_file_device "$path")" = "$device" ] } +# fm_pr_meta_key_line : true when a metadata line is a well-formed +# `key=value` record line, where the key is a bare identifier. Every metadata +# key firstmate writes has that shape, so the test admits a key this parser has +# never seen while still rejecting a line that is not a record line at all. +fm_pr_meta_key_line() { + local LC_ALL=C + [[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] +} + +# What this parse tolerates after pr=, and what it still refuses. +# +# Tolerated: any well-formed `key=value` line whose key this parser does not +# recognise. Enumerating the permitted keys was tried and was wrong three times +# running - the evidence recorder, bin/fm-spawn.sh's traceparent and relaunch +# transaction, and bin/fm-decision-hold.sh's review record each append after +# pr=, and nothing stops a fourth writer appearing. Every such writer was a +# latent refusal of a merge on valid work, found only when a correct merge was +# wrongly blocked. Position in the record carries no meaning for keys this +# function does not read, so an unrecognised key is not evidence of corruption. +# +# Still refused, loudly: a line that is not a record line at all (no `key=` +# prefix, an empty line, or a fragment left by a truncated write), a second pr= +# line, a pr= whose URL does not parse, and a pr_head= that is not a commit. +# Those are the shapes a value carrying an embedded newline would inject, and +# they remain the reason this positional check exists. Do not relax this into +# accepting any line: the `key=value` shape is the whole remaining guard. fm_pr_metadata_identity_parse() { local file=$1 line value pr_count=0 seen_pr=0 post_pr_invalid=0 FM_PR_META_PROVIDER= @@ -315,10 +446,10 @@ fm_pr_metadata_identity_parse() { fm_pr_head_valid "$value" || post_pr_invalid=1 fi ;; - x_request=*|x_request_ts=*|x_followups=*|x_platform=*|x_reply_max_chars=*) - ;; *) - [ "$seen_pr" -eq 0 ] || post_pr_invalid=1 + if [ "$seen_pr" -eq 1 ] && ! fm_pr_meta_key_line "$line"; then + post_pr_invalid=1 + fi ;; esac done < "$file" diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8226798a673..e06cb65d5b0 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -7,6 +7,26 @@ # Merge method defaults to --squash when the caller passes none of --squash, # --merge, --rebase, or --method after the optional -- separator. Extra args # must not include --repo or -R because the repository comes only from the URL. +# +# The merge is refused unless the task's recorded evidence commit +# (evidence_head=, written by bin/fm-evidence-record.sh) equals the pull +# request's live head. A validation pipeline can commit after the worker's last +# measurement, which silently turns a reported suite figure or exploit result +# into a description of an earlier commit; this guard is what makes that +# staleness stop a merge instead of depending on a manual comparison. +# +# Reading that live head requires the plain GitHub CLI (gh) on PATH, which this +# path therefore depends on in addition to gh-axi: gh-axi cannot report a head +# commit at all, so a host with only gh-axi is blocked from merging here. The +# refusal names the missing binary, because the alternative to blocking is +# merging unverified, which is the exact failure this guard exists to prevent. +# +# An absent record refuses too, rather than warning and merging. A guard that +# passes when nothing was recorded is defeatable by simply never recording, and +# nothing distinguishes "no claim was made" from "the claim was lost". The +# remedy is one command that records the commit the evidence was measured on, +# not a bypass flag, so a task that predates this record is never stranded and +# the guarantee is never traded away to unblock one merge. # Usage: fm-pr-merge.sh [-- ] set -eu @@ -63,6 +83,22 @@ reject_repo_overrides() { reject_repo_overrides "$@" || exit 1 +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +# Every refusal below prints the command that clears it, and that command is the +# whole remedy, so it must run exactly as printed: quoted against a path holding +# a space, and bound to the home and state directory this merge read, not to +# whichever home the reader's environment happens to name. +EVIDENCE_RECORD_CMD="FM_HOME=$(shell_quote "$FM_HOME")" +if [ "$STATE" != "$FM_HOME/state" ]; then + EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD FM_STATE_OVERRIDE=$(shell_quote "$STATE")" +fi +EVIDENCE_RECORD_CMD="$EVIDENCE_RECORD_CMD $(shell_quote "$SCRIPT_DIR/fm-evidence-record.sh") $(shell_quote "$ID")" + # Task-derived paths are constructed only after the canonical ID validation. META="$STATE/$ID.meta" if [ ! -f "$META" ] || [ -L "$META" ]; then @@ -70,6 +106,61 @@ if [ ! -f "$META" ] || [ -L "$META" ]; then exit 1 fi +# Evidence guard, before any state is recorded or any poll is armed: a refused +# merge must leave the task exactly as it found it. +if ! fm_pr_evidence_read "$META"; then + echo "error: refusing to merge $URL: the evidence record for task $ID is unreadable or malformed" >&2 + echo " fix: re-record the commit the reported verification was measured on:" >&2 + echo " $EVIDENCE_RECORD_CMD ''" >&2 + exit 1 +fi +EVIDENCE_HEAD=$FM_PR_EVIDENCE_HEAD +EVIDENCE_NOTE=$FM_PR_EVIDENCE_NOTE + +if [ -z "$EVIDENCE_HEAD" ]; then + echo "error: refusing to merge $URL: no verification evidence commit is recorded for task $ID" >&2 + echo " expected: the commit the reported verification was measured on" >&2 + echo " found: no evidence record" >&2 + echo " fix: re-run the verification you intend to merge on, then record it:" >&2 + echo " $EVIDENCE_RECORD_CMD ''" >&2 + exit 1 +fi + +# The live head is read here rather than taken from a recorded pr_head=, so the +# comparison is always against what would actually merge. +LIVE_HEAD= +if ! command -v gh >/dev/null 2>&1; then + echo "error: refusing to merge $URL: the merge guard reads the pull request head with the GitHub CLI (gh), which is not on PATH" >&2 + echo " the recorded evidence commit for task $ID is $EVIDENCE_HEAD" >&2 + echo " without the live head there is nothing to compare it against, so the merge stops here" >&2 + echo " fix: install the GitHub CLI (gh), then merge again" >&2 + exit 1 +fi +if REMOTE_HEAD=$(gh pr view "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" --json headRefOid -q .headRefOid 2>/dev/null); then + LIVE_HEAD=$(printf '%s' "$REMOTE_HEAD" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') +fi +if ! fm_pr_head_valid "$LIVE_HEAD"; then + echo "error: refusing to merge $URL: the pull request head could not be confirmed" >&2 + echo " the recorded evidence commit for task $ID is $EVIDENCE_HEAD" >&2 + echo " gh is installed but did not answer with a commit for this pull request" >&2 + echo " without the live head there is nothing to compare it against, so the merge stops here" >&2 + echo " fix: restore GitHub access (gh auth status), then merge again" >&2 + exit 1 +fi + +if [ "$LIVE_HEAD" != "$EVIDENCE_HEAD" ]; then + echo "error: refusing to merge $URL: the reported evidence was measured on a commit that is no longer this pull request's head" >&2 + if [ -n "$EVIDENCE_NOTE" ]; then + echo " evidence measured on: $EVIDENCE_HEAD ($EVIDENCE_NOTE)" >&2 + else + echo " evidence measured on: $EVIDENCE_HEAD" >&2 + fi + echo " pull request head: $LIVE_HEAD" >&2 + echo " fix: re-run that verification on $LIVE_HEAD, then record the result:" >&2 + echo " $EVIDENCE_RECORD_CMD $LIVE_HEAD ''" >&2 + exit 1 +fi + "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL" grep -qxF "pr=$URL" "$META" || { echo "error: PR metadata recording failed" >&2 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index ef21cda8335..cd0b9eb8a54 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -205,6 +205,7 @@ family_for_basename() { fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; + fm-evidence-record.test.sh|\ fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ fm-teardown.test.sh|fm-x-mode.test.sh) printf '%s\n' pr-forge diff --git a/docs/architecture.md b/docs/architecture.md index a1d7d77753f..0035029c864 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -246,7 +246,11 @@ A ship brief records its mode as a fixed machine-readable line and the spawn ref When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. -PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`. +PR-based task merges go through `bin/fm-pr-merge.sh`, which refuses any merge whose recorded evidence commit is not the pull request's live head, then records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling `gh-axi pr merge`. +That evidence commit is `evidence_head=` in the task's metadata, written only by [`bin/fm-evidence-record.sh`](../bin/fm-evidence-record.sh) and read only by the merge: a validation pipeline can commit after the worker's last measurement, so a reported suite figure or exploit result silently becomes a description of an earlier commit unless something compares the two. +The refusal names both commits and the command that records a re-measurement, and an absent record refuses on the same path because a guard that passes when nothing was recorded is defeatable by omission; the remedy is that one command rather than a bypass, so a task predating the record is never stranded. +The guard reads that live head with the plain GitHub CLI (`gh`), so a host that merges through this path needs `gh` on PATH in addition to `gh-axi`, which cannot report a head commit at all; a host without `gh` is refused by name rather than merged unverified. +PR-based ship briefs carry the matching worker contract, and [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns the guard's exact refusal conditions. The helper requires a full `https://github.com///pull/` URL, invokes `gh-axi pr merge --repo /`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state; a well-formed GitLab merge request URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) is refused too, explicitly, rather than sent to the wrong forge. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. [`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure. diff --git a/docs/scripts.md b/docs/scripts.md index e94ccb0e16a..5cdc0cd4cc3 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -105,7 +105,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | -| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | +| `fm-pr-merge.sh` | Refuse a merge whose recorded evidence commit is not the PR head, then record PR metadata and merge | +| `fm-evidence-record.sh` | Record the commit a task's reported verification evidence was measured on | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index c5ee3d00f05..0c204ac9b35 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -354,6 +354,117 @@ test_no_mistakes_dod_wording() { pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" } +# assert_evidence_precedes_every_finish