Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fee39f1
Plan slice 006 before touching any shipping code
HackTuah Sep 8, 2026
c6cbd72
Read the Bandit harness on the protocol, not on a clock, and never ve…
HackTuah Sep 8, 2026
b07666c
Move the mutation harness into the repository, with no machine in its…
HackTuah Sep 8, 2026
4cb3281
Record slice 006: the rate, the mechanism, and a false KILLED reprodu…
HackTuah Sep 8, 2026
5822025
Correct the test count this slice made false, and two paths that reso…
HackTuah Sep 8, 2026
1793ddf
Close round 1: both lanes require changes, and both found the record …
HackTuah Sep 8, 2026
d16c8ea
Make the mutation instrument refuse a dirty target, hold a lock, and …
HackTuah Sep 8, 2026
2f3dc04
Repeat only an abort, and let a clean close with no answer raise
HackTuah Sep 8, 2026
fb33d7e
Record round 2: the measurements, the CI archive, and an archive that…
HackTuah Sep 8, 2026
d598647
Round 2 verdicts: both lanes approve, and both say they are one reader
HackTuah Sep 8, 2026
ad72c1d
Bind round 2's two verdicts to the tree they were written against
HackTuah Sep 8, 2026
ca6e541
Re-read the rebase delta before re-recording round 2's signoffs
HackTuah Sep 8, 2026
3d48ccd
Round 3: re-read the rebase delta, because the signoff guard refused …
HackTuah Sep 8, 2026
953d1de
Pin round 3's trees to the commit the lanes re-read
HackTuah Sep 8, 2026
4a6a380
Bind round 3's verdicts to the tree they were re-read against
HackTuah Sep 8, 2026
b7871a7
Untrack signoff/verify.txt: it is regenerated, and tracking it broke …
HackTuah Sep 8, 2026
b3a80eb
Round 4: forced by the signoff guard, and a limitation of the instrument
HackTuah Sep 8, 2026
7d8c111
Pin round 4's trees to the commit the lanes re-read
HackTuah Sep 8, 2026
d3a9891
Bind round 4's verdicts to the tree they were re-read against
HackTuah Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ erl_crash.dump
*.ez
beam_mcp-*.tar
/tmp/

# signoff.sh rewrites this on every `verify`, so tracking it would make the next run
# refuse on its own output, and its bytes are a restatement of the .signoff records beside it.
slices/*/signoff/verify.txt
2 changes: 1 addition & 1 deletion HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ it is the answer.
reuse (231 tracked; 58 in scope, 56 headered + 2 sidecar), licence files. Every step line reads
`pass`, read as lines and not as an exit code. `logs/gate-rebased.txt`; the pre-rebase run is
`logs/gate-release.txt` and is kept rather than overwritten.
- **160 tests, 0 failures.** Was 158 before the fifth defect, 146 when this slice's second half
- **162 tests, 0 failures.** Was 160 before slice 006's two anchors, 158 before the fifth defect, 146 when this slice's second half
began, 138 at the 0.2.x-era handoff.
- Version **`0.3.1`**, unreleased. **`0.3.0` is published on Hex and tagged `v0.3.0`**, and its
changelog section is dated and not amended.
Expand Down
680 changes: 680 additions & 0 deletions slices/006-harness-honesty/FINDINGS.md

Large diffs are not rendered by default.

136 changes: 136 additions & 0 deletions slices/006-harness-honesty/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<!--
SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
SPDX-License-Identifier: Apache-2.0
-->

# Slice 006 — harness honesty: a gate run must be a measurement, not a sample

**Issue:** SCR-289. **Written before any code.**

**006 is the number; it runs FIRST.** Slice 005 (tool identity) already started — its PLAN is
committed and PR #15 is open — so 005 is taken. Under the numbering rule, a number is identity
assigned when a slice starts, and **order is not number**: this slice is 006 and it goes ahead of
005. That separation is the rule working rather than an exception to it.

**Why it goes first.** A test defect reaching CI means every *"gate green, every step line reading
pass"* in this repository is currently a **sample rather than a measurement**. That sentence is
the evidence behind every slice record here. Nothing that depends on mutation scoring — 005's
acceptance criterion 2 does — can be recorded honestly until it is true again.

---

## 0. What is actually wrong, read from the code rather than the issue

`test/beam_mcp/transport/http_bandit_test.exs`, `drain/2`:

@first_byte_ms 10_000
@quiet_ms 700

defp drain(sock, acc) do
timeout = if acc == "", do: @first_byte_ms, else: @quiet_ms
case :gen_tcp.recv(sock, 0, timeout) do
{:ok, data} -> drain(sock, acc <> data)
{:error, :timeout} -> {acc, :open}
{:error, :closed} -> {acc, :closed}
end
end

**The read terminates on a wall-clock silence, not on a protocol event.** Once any byte has
arrived, a gap longer than 700 ms is interpreted as "the server is done and holding the
connection". Under load a gap that long is ordinary, so the function returns a **partial buffer**
and reports `:open`, and every assertion downstream is then made against bytes that had not
finished arriving.

This is already a documented repair of a previous instance of itself. The comment above those two
attributes records that a **single** 700 ms window made the 9 MB case flaky, and that it presented
as *"a SURVIVING mutant scoring as KILLED"*. The repair split one window into two and made the
first generous. **It reduced the rate and did not remove the mechanism**, which is why the same
test failed again in CI at `max_cases: 8`.

That is the finding to keep in front of the work: the previous fix was a threshold change, and a
threshold change cannot fix a race. This slice replaces the mechanism.

## 1. The three observations this slice must account for

| when | where | how it presented |
|---|---|---|
| slice 003 round 3 | dev machine, `max_cases: 64` | `Mc2`, a **survivor**, scored as **KILLED** |
| slice 003 re-score | dev machine, 40 back-to-back suites | `M13rev` read 2 (record 1), `Mr2` read 4 (record 3) |
| PR #15 push run | **GitHub Actions, `max_cases: 8`** | `test FAIL (exit 2)`, `assert String.contains?(bytes, "HTTP/1.1 403")`, on a commit containing **no code** |

One mechanism, three load profiles, and the error always has the same direction: **it adds a
failure.** A flake that removes a failure makes a table pessimistic and someone investigates. One
that adds a failure makes a table read all-killed, and nobody does.

## 2. Red first, and the red is a RATE

An intermittent defect has no single transcript. The red is a measured rate, captured whole:

1. **N consecutive runs of the Bandit test file**, on the development machine, recording *k*
spurious failures and naming the failing test each time.
2. **The same at `--max-cases 8`**, because that is CI's profile and where the consequential
failure was caught. A rate measured only at 64 has not been measured where it failed.
3. **N is derived from the observed rate, not chosen.** One failure in ~20 CI runs and two in 40
local suites are the starting estimates; the PLAN's first act is to measure the rate properly
so that "no failures after the fix" is a claim with power behind it rather than a short run
that got lucky.

## 3. The fix: remove the wall-clock window, do not retune it

**Requirement, not a candidate.** `drain/2` must terminate on something the protocol determines,
not on elapsed silence. The shape to settle in this PLAN before code:

- read until the socket closes, or until the expected number of **complete** HTTP responses has
been parsed — `Content-Length` is present on every response this suite asserts against, so
completeness is decidable from the bytes;
- keep a timeout **only** as a stuck-test backstop, long enough that reaching it is a failure of
the test rather than a verdict of it, and assert on reaching it rather than returning a partial
buffer silently;
- `:open` versus `:closed` must remain distinguishable, since three tests assert on it. Deciding
how, without a silence window, is the design question this slice answers.

**The rejected alternatives are named so they are not revisited:** raising `@quiet_ms`, running the
mutation set with `--max-cases 1`, and running mutants in isolation with a settle interval. Each
lowers the rate on one profile and leaves the mechanism. The repository has already bought the
threshold fix once.

## 4. Acceptance criterion, as a measurement

1. **The rate is measured before the fix** (§2), at both `max_cases` profiles, captured whole.
2. **After the fix, zero spurious failures over a run derived from that rate** — the population
comes from the measurement, not from a round number.
3. **A known SURVIVOR still reports as a survivor under the conditions that produced the false
kill.** `M2never` or `Mc2` from slice 003, scored under sustained back-to-back load. This is the
criterion that matters: the direction of the error is what makes it dangerous, and a fix that is
only shown not to *add* failures on a quiet machine has not been tested where it lied.
4. **Demonstrated at CI's `max_cases: 8`**, in CI, not only locally.
5. **The mutation harness is committed**, under `tools/`. Slice 003's record cites `$S/mut.sh
<name>`, a scratchpad path no future reader can resolve. A scoring instrument that cannot be
re-run is not evidence — the same rule that governs archives: written by a command that can
fetch it, or it does not exist. The nine mutant scripts and the pristine-file discipline move
with it.
6. **Gate green, and green repeatedly.** A single green run is precisely the claim this slice
exists to stop trusting, so the gate is run enough times to have caught the measured rate.

## 5. What this slice does NOT do

- **It does not re-score slice 003.** That table is the isolated runs, its two survivors are
recorded as survivors with their arguments, and its record already carries the instability.
- **It does not re-run PR #15's red check.** That failure is standing evidence and stays red until
this slice lands. A green re-run would destroy the only artefact showing a no-code commit failing.
- It does not touch `lib/`. If the fix requires a change to shipping code, that is a finding and
the slice stops and reports rather than widening.

## 6. Process

Two lanes, three rounds maximum, closing rule written before round 1 opens. Every round writes both
a verdict and a tree pin per lane, bound with `tools/signoff.sh`.

**This brief supersedes the earlier "do not spawn further subagents": the lanes may spawn.** A lane
that cannot says so in its own verdict rather than letting "two lanes" imply independence it does
not have.

**One caution specific to this slice.** Its own acceptance criteria are measured with the
instrument it is repairing. A run that says the fix worked is subject to the same doubt as a run
that said a survivor was killed — so criterion 3 is the load-bearing one, because it checks the
instrument against a value already known by other means rather than against itself.
Loading