Skip to content

fix(compliance): make a failed consent record visible and retryable - #78

Merged
aabusair merged 2 commits into
mainfrom
fix/record-signature-failure-is-retryable
Sep 24, 2026
Merged

aabusair merged 2 commits into
mainfrom
fix/record-signature-failure-is-retryable

Conversation

@aabusair

Copy link
Copy Markdown
Collaborator

Defects 7, 8 and 9 from the policy_selector rollout. Defects 4–6 are deliberately not here — they all require a fetch failure, and the measured rate is zero across 214 evaluations in 7 sweeps, which is why #72 was closed.

7 and 8 are one bug

record_signature() fails
  -> return False, discarded
  -> set_commit_status(success) runs unconditionally
  -> PR green, nothing in signatures/<doc>.json
  -> next sweep: #67 short-circuit sees success, returns early, never retries

7 creates the gap, 8 makes it permanent. The signature comment is evidence of intent; the JSON file is the record of record — and it was silently optional.

Fix. Capture the return value. On failure the status stays SUCCESS — the contributor signed, and an infrastructure fault on our side isn't theirs to pay for — but the description gains a record pending marker, and the short-circuit exempts marked statuses so the next sweep retries. A successful retry repaints the clean description, so the PR converges and stops being re-processed.

get_existing_status_state is renamed to get_existing_status, not just changed to return a tuple. A caller left comparing the old name to "success" would silently evaluate False against a tuple, the #67 short-circuit would stop firing, and the repaint loop it exists to prevent would come straight back. An AttributeError is the better failure mode.

9 — a failed sweep must not look like a clean one

Every PR is swept inside a try/except that logs and continues, so a sweep where every PR failed exited 0 and was indistinguishable from success in the Actions UI. On a 5-minute cron with nobody reading logs, that's invisible indefinitely.

Failures are now collected, named, and the sweep exits non-zero — behind SWEEPER_STRICT_EXIT so a transient fault can be quieted without reverting code or disabling the schedule. An empty installation list is now a failure too, rather than a quiet return.

Scope note, to avoid overclaiming: 9 catches exceptions, not failed record writes. record_signature returns False rather than raising, so a pending record does not redden the sweep. The retry and the status description are what surface that. The two changes are complementary, not the same mechanism.

An existing test was hiding the bug

test_correct_sentence_passes_and_is_recorded never gave its fake a writable signatures route, so record_signature was returning False on every run. The test asserted the clean description and passed — because the failure was invisible even inside the suite. It now uses writable routes, and the failure path has its own tests.

Verification

  • 126 tests, up from 101. cla_sweeper.py had no tests at all; it now has 11.
  • All 16 mutations caught by their named tests, tree restored clean after each:
Mutation Caught by
Discard the return value again test_failed_write_is_marked_in_the_description
Paint clean regardless of the write same
Block the contributor instead of passing test_failed_write_still_passes_the_contributor
Drop the pending-record exemption test_success_with_pending_record_is_reprocessed
has_pending_record pinned False test_marker_is_detected
has_pending_record pinned True test_plain_success_is_still_skipped + 1
Case-sensitive marker match test_detection_is_case_insensitive
Marker drifts from the painted text test_marker_is_detected
Skip on any status, not just success test_failure_and_pending_states_are_unaffected
Stop counting failures test_any_failed_pr_returns_nonzero
Force exit 0 same
Ignore the kill switch test_kill_switch_suppresses_the_nonzero_exit
Kill switch defaults off test_default_is_strict
Unrecognised value disables strict test_anything_else_leaves_strict_exit_on
Empty repo list quiet again test_no_repositories_is_a_failure_not_a_quiet_success
  • All 25 new tests provably killable — 21 by reverting a changed file, the 4 characterisation tests by targeted mutation.
  • Suite runtime 9s → 0.04s. The harness stubs the real time.sleep in the compliant path and record_signature's retry jitter. Also extracted ProcessSinglePrHarness so the new class doesn't inherit and re-run TestProcessSinglePr's 24 tests under its own name.

Behaviour worth a reviewer's judgement

A permanently failing write means that PR is re-processed every sweep indefinitely — repainting bumps updated_at, keeping it in the lookback window. That's the #67 loop, deliberately re-opened for this narrow case, because the alternative is a consent record that never lands. It costs one status repaint per sweep for that PR, posts no comments, and is visible on the PR itself as record pending. I think that's the right trade, but it is a trade.

🤖 Generated with Claude Code

Amr AbuSair and others added 2 commits September 23, 2026 10:53
Defects 7, 8 and 9 from the policy_selector rollout.

7 and 8 are one bug. record_signature's return value was discarded, so a
failed write still painted the PR a clean success. The PR #67
short-circuit then skips any commit already carrying a successful status,
so nothing ever came back: green PR, no consent record in
signatures/<doc>.json, and no way to tell from the outside. 7 creates the
gap; 8 makes it permanent.

The signature comment is evidence of intent, but the JSON file is the
record of record, and it was silently optional.

Fix: capture the return value. On failure the status stays SUCCESS — the
contributor did sign, and an infrastructure fault on our side is not
theirs to pay for — but the description gains a "record pending" marker,
and the short-circuit exempts marked statuses so the next sweep retries
the write. A successful retry repaints the clean description, so the PR
converges and stops being re-processed.

get_existing_status_state is RENAMED to get_existing_status rather than
having its return type changed in place. A caller left comparing the old
name to "success" would silently evaluate False against a tuple, the #67
short-circuit would stop firing, and the repaint loop it exists to
prevent would come back. An AttributeError is the better failure.

9: every PR is swept inside a try/except that logs and continues, so a
sweep where every PR failed exited 0 and looked identical to a clean one.
On a 5-minute cron with nobody reading logs that is invisible
indefinitely. Failures are now collected and named, and the sweep exits
non-zero, behind SWEEPER_STRICT_EXIT so a transient fault can be quieted
without reverting code or disabling the schedule. An empty installation
list is also a failure now rather than a quiet return.

Note on scope: 9 catches EXCEPTIONS, not failed record writes —
record_signature returns False rather than raising, so a pending record
does not redden the sweep. The retry and the status description are what
surface that. The two changes are complementary, not the same mechanism.

Verification:
  * 126 tests, up from 101. cla_sweeper.py had no tests at all before
    this; it now has 11.
  * All 16 mutations caught by their named tests, tree restored clean
    after each: discarding the return value, painting clean regardless,
    blocking the contributor, dropping the exemption, pinning
    has_pending_record True/False, case-sensitive matching, drifting the
    marker away from the painted text, dropping failure counting,
    forcing exit 0, ignoring or inverting the kill switch, and making an
    empty repo list quiet again.
  * All 25 new tests provably killable — 21 by reverting a changed file,
    4 characterisation tests by targeted mutation.
  * Existing test test_correct_sentence_passes_and_is_recorded was itself
    exercising a FAILED write: its fake never provided a writable
    signatures route, so record_signature returned False and the test
    asserted the clean description anyway. It passed because the failure
    was invisible even in the suite. Now given writable routes.
  * Suite runtime 9s -> 0.04s: the harness stubs the real time.sleep in
    the compliant path and record_signature's retry jitter.
  * Extracted ProcessSinglePrHarness so the new class does not inherit
    TestProcessSinglePr's 24 tests and re-run them under its own name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A line-level coverage pass over this branch's diff found one genuinely
untested branch: the `...and N more` truncation in the sweep summary,
which only fires above 20 failures. Everything else flagged was either a
docstring continuation or an `else:` line the tracer does not emit an
event for, with both arms covered.

Both new tests mutation-proven: dropping the truncation, dropping the
count line, and truncating short lists are each caught.

Note the first version of the long-list test asserted 20 occurrences of
the failure text and found 50. That was the test being wrong, not the
code — each failure is logged inline as it happens AND up to 20 are
repeated in the end-of-run digest. The cap is deliberately on the digest
only; the full detail belongs in the log. The assertion now scopes itself
to the summary block.

128 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aabusair
aabusair merged commit b7b823a into main Sep 24, 2026
6 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