Skip to content

A captain-approved supersession can pass CI, on a signature - #62

Merged
kirangathani merged 8 commits into
mainfrom
fm/ci-supersession-honour-x2
Aug 13, 2026
Merged

kirangathani merged 8 commits into
mainfrom
fm/ci-supersession-honour-x2

Conversation

@kirangathani

@kirangathani kirangathani commented Aug 13, 2026 •

Copy link
Copy Markdown
Owner

The problem

A captain-approved test-assertion supersession cannot pass CI today.

bin/fm-pr-merge.sh reads data/supersessions/<project>.md, excuses the covered findings and proceeds. The required check Base assertions re-verified re-runs the same base assertions on a GitHub runner, cannot see that record - it is captain-private and gitignored by design - reports the same findings and goes red.

Nothing the branch pushes fixes that, because the branch is not what is wrong: it is the approval that CI cannot read. The only ways out were a mislabelled CI waiver or a GitHub-UI merge, which is the exact bypass that check exists to prevent.

Live instance: #61, whose 5 approved entries cover all 66 findings. The merge gate passes; the check is red.

What this adds

A captain-signed attestation that carries the approvals into CI, following the CI waiver's existing pattern rather than inventing a second one.

  • bin/fm-supersession-attest.sh - publish a repository's key, sign the approvals for one commit, attest a task (reads the PR's current head, signs, appends the line to the body, prints the command that re-runs the check).
  • bin/fm-supersession-verify.sh - decides in CI which approvals carry a valid signature for the current head.
  • bin/fm-supersession-attest-lib.sh - the signed payload, the entry token, the published line.
  • bin/fm-supersession-lib.sh - unchanged grammar, now also exposing the one matcher and a canonical entry line, so the merge gate, the viewer and CI answer "is this covered?" identically. There is no second parser.
  • bin/fm-reverify-base.sh - excuses covered findings, blocks uncovered ones exactly as before, names every override, and renders a fourth outcome, superseded.

The properties that matter

The signature is the authority, not the file. Minting a line needs the master key at config/ci-waiver-secret and a fully-formed entry in the captain's private record. A worker holds neither: no brief, scaffold or status protocol points at that record, which is the standing data/projects.md already has at the attestation exemption, and the key is never handed to a worker in any form. bin/fm-ci-waiver-lib.sh's residual same-user limit applies unchanged.

No dispatch flag gates it, deliberately. A CI waiver is decided at dispatch, so a token minted there is the right authority for it. A supersession can only be decided after the gate reports which assertion the branch supersedes, so there is nothing to have flagged in advance. The record is the decision.

The record is not published. The line carries each entry's matching half - its identifier or glob and the class it excuses - and never the captain's stated reason or the approval date.

Never a blanket green. A finding no entry covers still blocks, in its own class. An entry approved for one class does not excuse another. An approval this fleet's matcher cannot read is could-not-verify, never "no approvals".

A separate published key. The Actions secret is FM_SUPERSESSION_SECRET, derived from the same master under its own domain, not the repository's FM_CI_WAIVER_SECRET. The waiver key skips a PR's whole test suite; this one only excuses findings the captain named, so a theft of this one cannot escalate into that one. Cost: one publish per repository, once.

The secret is never readable by the branch's own scripts. The re-verification job runs them by construction, so two properties keep the secret out of reach, and the tests assert both together: the verifier is the base's own copy, checked out separately exactly as ci.yml's waiver job does, and it runs before any step that executes the branch's scripts, while a step's env: reaches only that step.

It is a step of that job rather than a second job, and this PR is why. A second job hands its verdict over through needs:; a required job that needs: another is skipped when that one fails; a skipped required check never reports, so branch protection waits forever; and the repair for that is a job-level condition - which the base's own test forbids, for the same reason. Changing that assertion would have needed a captain supersession, and a supersession cannot be honoured until this change is on main. The PR adding the escape hatch would have needed the escape hatch. Keeping the verification in the required job removes the dependency instead.

The body is read live. An approval always arrives as an edit to an open PR, editing a body triggers no workflow, and re-running the check replays the payload the PR had before the edit. Only a live read sees it.

Different words for different things. A waiver says there is no test evidence; a supersession says there is and the captain overrode a specific assertion. The check reports reverify: waived or reverify: superseded, and the separate HMAC domain means neither line can ever verify as the other.

What it does not answer

An attestation is a snapshot of the approvals as they stood when it was signed, so revoking an entry afterwards does not invalidate a line already published for that same commit. Nothing merges on that: bin/fm-pr-merge.sh reads the live record at merge time and refuses there. The check reports; the record decides.

Tests

tests/fm-supersession-attest.test.sh (22 cases) covers both directions: a covered finding with a valid signature passes, and no signature, a forged signature, another commit, another task id, a token edited after signing, the master key, another repository's key, that repository's CI-waiver key, and a repository the task does not push to each fail. It also covers the signer's refusals (no record, no fully-formed entry, no key), the live body read, CRLF bodies, and exit 0 on every verdict.

tests/fm-reverify-base.test.sh (39 cases, 8 new) covers the excusal itself - class narrowing, the unreadable-approval refusal, the findings-do-not-account-for-the-counts refusal, inertness with no attestation - and the workflow shape the secret separation depends on.

Full behaviour suite and bin/fm-lint.sh both clean.

Verified against the live case

PR 61's 66 findings, read from its own failed run's log, matched against the captain's real record through the same extraction the signer uses and the same matcher CI applies: 66 covered, 0 uncovered. Identifiers outside the approvals - including in the same two files, for the class the globs do not cover - still block.

Making that PR itself green needs, in order: this PR landing; PR 61 merging main forward, since a pull_request run uses the PR's own workflow file and the base must carry the verifier; bin/fm-supersession-attest.sh publish kirangathani/firstmate once; bin/fm-supersession-attest.sh attest <task-id>; and a re-run of the Base re-verification workflow.

The required check `Base assertions re-verified` re-runs the base's
assertions on a GitHub runner. It cannot see data/supersessions/<project>.md,
which is captain-private and gitignored by design, so a branch the captain has
approved superseding reports the same findings there and stays red forever.
Nothing the branch pushes fixes it, because the branch is not what is wrong:
it is the approval CI cannot read. The only ways out were a mislabelled CI
waiver or a GitHub-UI merge - the exact bypass that check exists to prevent.

This follows the CI waiver's own pattern rather than inventing a second one:
a master key that never leaves the captain's machine, a key derived per
repository for its Actions secrets, and a signed line in the PR body.

- bin/fm-supersession-lib.sh stays the one parser and gains the one matcher,
  so the merge gate, the viewer, and CI answer "is this covered?" identically.
  Its canonical entry line is the wire between the record and a reader that
  cannot see it, and it carries no date and no reason.
- bin/fm-supersession-attest-lib.sh owns the attestation's payload, entry
  token and published line. Its HMAC domain is separate from the waiver's, and
  so is the key it publishes: the waiver key skips a whole suite, this one
  only excuses findings the captain named, so a theft of one cannot escalate
  into the other.
- bin/fm-supersession-attest.sh signs from the record itself, which is the
  authority: a worker holds neither the key nor a path anything invites it to
  write that record on.
- bin/fm-supersession-verify.sh reads the PR body LIVE, because an approval
  always arrives as a body edit and a re-run replays the original payload.
- bin/fm-reverify-base.sh excuses covered findings, blocks uncovered ones
  unchanged, names every override, and renders a fourth outcome, superseded,
  rather than reporting an authorized override as a pass.

The workflow wiring and its tests are the next commits.
The verifying job checks out the BASE, like ci.yml's waiver job: the
re-verification job runs the branch's own scripts by construction, so a secret
in its environment would be a secret every PR could read. It hands over only
its non-secret verdict.

The required job takes a `needs:` on it, so it also takes `if: always()` - a
required check that is skipped never reports, and branch protection then waits
on it forever. A failed verifying job therefore yields no approvals and every
finding blocks, which is the state before this change.

tests/fm-supersession-attest.test.sh covers both directions: a covered finding
with a valid signature passes, and no signature, a wrong signature, a wrong
commit, a wrong task, a widened token, the master key, another repository's
key, that repository's CI-waiver key, and an uncovered finding each still
fail. tests/fm-reverify-base.test.sh covers the excusal itself, including the
class narrowing and the unreadable-approval refusal, and asserts the workflow
shape the secret separation depends on.
docs/configuration.md owns the secret's provisioning, as it does for the CI
waiver; the script headers keep the payload, verdict and signing mechanics.
Every other mention is a cross-reference: AGENTS.md gains one line telling
firstmate what to run when an approved entry clears the merge gate and leaves
the PR's own check red, and docs/architecture.md and bin/fm-pr-merge.sh's
header each gain a pointer rather than a second copy of the contract.
An attestation is a snapshot of the approvals as they stood when it was
signed, so a revocation does not invalidate a line already published for that
same commit. Nothing merges on that - the merge gate reads the live record and
refuses there - which is what makes the snapshot acceptable, and saying so is
what stops a later reader mistaking the check's green for the record's word.
Renaming the three-outcome assertion would delete an assertion the base has,
which is precisely the finding this whole change exists to let a captain
approve - and approving it would have been the wrong answer here, since the
older assertion is still true. It stays exactly as the base wrote it, and the
superseded outcome gets an assertion of its own.
always() also runs a job when the whole run was CANCELLED, so a superseded run
would execute the re-verification, see its dependency as cancelled, and leave a
spurious red required check that only clears on the next push.
.github/workflows/no-mistakes-required.yml already carries that reasoning for
the same shape; this follows it rather than restating it.
The job that reads the PR body is the one that already succeeded, so
`gh run rerun --failed` would re-run only the verdict job and reuse an
attestation verdict taken before the line existed. The printed next step now
says all jobs, and why.
CI caught this on this PR itself: a second job has to hand its verdict over
through `needs:`, a required job that `needs:` another is skipped when that one
fails, and the repair for that is a job-level condition - which the base's own
test forbids, because a job-level condition is also how a required check stops
reporting. Changing that assertion would have needed a captain supersession,
and a supersession cannot be honoured until this change is on main, so the PR
adding the escape hatch would have needed the escape hatch.

Keeping the verification in the required job removes the dependency rather than
patching it. The secret stays out of the branch's reach on two properties the
tests now assert together: the verifier is the BASE's copy, checked out
separately, and it runs before any step that executes the branch's own scripts,
while a step's env reaches only that step.
@kirangathani
kirangathani merged commit e2f3a9d into main Aug 13, 2026
12 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant