From 52058014245cd1ca02fbea10a641e92cc68faacd Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:20:42 +0200 Subject: [PATCH 1/4] test(backend): a deterministic witness for the capacity boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capacity.oversell-by-one` was judged by a spec that hammers concurrent joins — declared flaky in the mutation runner, and ambiguous in both directions: a MISMATCH could be the flake firing, and the flake once stayed GREEN under the very mutation it exists to catch. The half an off-by-one cap actually breaks needs no concurrency: it is where the last seat IS, not whether two arrivals can share it. This fills an event to exactly its capacity one join at a time, reading the roster back from the DB after each, and asserts the next arrival queues with the confirmed count unmoved. Three capacities, because a rule that is off by one is off by one at every cap; the seated joins at capacity 2 and 3 are the positive control against an event that simply queues everybody. The concurrency spec stays — it pins a real race that once oversold. --- .../backend/internal/service/capacity_test.go | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/components/backend/internal/service/capacity_test.go b/components/backend/internal/service/capacity_test.go index 3ace0100..4a3bfc10 100644 --- a/components/backend/internal/service/capacity_test.go +++ b/components/backend/internal/service/capacity_test.go @@ -286,6 +286,67 @@ var _ = Describe("Capacity", func() { Expect(resp.GetWaitlisted()).To(BeTrue()) }) + // The seat accounting AT the boundary, walked one join at a time. + // + // This is the deterministic twin of the concurrency spec below. That one + // asks whether two simultaneous joins can share the last place; this one + // asks where the last place IS, which is the half an off-by-one cap breaks + // and the half that needs no goroutines to see. The claim is read back from + // the DB after every join rather than inferred from the responses, because + // "Join answered waitlisted" and "the roster holds N" are different facts — + // the same distinction the race spec makes when it counts rows at the end. + // + // Three capacities, because a rule that is off by one is off by one at every + // cap and a single number cannot tell "the boundary moved" from "this + // particular event was set up wrong". Capacity 1 has no free seat to hand + // out at all; capacities 2 and 3 do, and their confirmed joins are the + // positive control that keeps the waitlist assertions from agreeing with an + // event that simply queues everybody. + It("seats exactly the capacity, counting the roster after every join", func() { + for _, capacity := range []int{1, 2, 3} { + hid := createHackathon(int32(capacity)) + + // The creator already holds seat 1, so capacity-1 more joiners fill + // the room exactly. Each one must be seated, and the confirmed count + // must be exactly the seat they took. + for seat := 2; seat <= capacity; seat++ { + _, uctx := newJoiner(fmt.Sprintf("seats-%d-%d", capacity, seat)) + resp, err := client.Join(uctx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To( + BeFalse(), + "capacity %d: seat %d is still free, so it is handed out", capacity, seat, + ) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To( + Equal(seat), + "capacity %d: confirmed roster after filling seat %d", capacity, seat, + ) + Expect(waiting).To(Equal(0)) + } + + // The room is now exactly full. The next arrival queues, and the + // confirmed roster does NOT move: capacity is the number of seats, + // not the number of seats plus one. + _, overCtx := newJoiner(fmt.Sprintf("seats-%d-over", capacity)) + resp, err := client.Join(overCtx, &msgs.JoinRequest{HackathonId: hid}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetWaitlisted()).To( + BeTrue(), + "capacity %d: there is no seat %d — the room is full", capacity, capacity+1, + ) + Expect(resp.GetQueuePosition()).To(BeInt32(1)) + + confirmed, waiting := rosterCounts(hid) + Expect(confirmed).To( + Equal(capacity), + "capacity %d: the confirmed roster equals the capacity, never one more", capacity, + ) + Expect(waiting).To(Equal(1)) + } + }) + It("never oversells the last place under simultaneous joins", func() { // Capacity 3, creator + one joiner confirmed: ONE place left. hid := createHackathon(3) From a497ca3fe3496711d98743de18831d10a0a7c3b5 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:38:14 +0200 Subject: [PATCH 2/4] chore(e2e): judge capacity.oversell-by-one by a witness that cannot flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry's expectReds named the concurrency spec — the runner's one KNOWN_FLAKY entry — which made the verdict a coin flip in both directions. A MISMATCH could be the flake firing rather than a regression, and the flake has stayed GREEN under this very mutation, which is an expected red that never arrived and no excuse list can reach: expectReds is by design where the filter does not apply. Recorded rather than predicted (--record, then read). The observed set under the mutation is 7 reds; 6 are frozen and the race spec is left out, so its red now lands in the extras column, is excused with its reason, and is PRINTED on every run of this entry. The evidence stays on screen without being on the ballot. The rule that survived is written down in both places that used to teach the opposite: a verdict may only rest on witnesses that cannot flake. The runner's own "put it in expectReds" hint said the thing that caused this, so it now says what to do instead. The flake itself is untouched and still reds CI — a test-side race, tracked separately. --- .claude/CLAUDE.md | 58 ++++++++++++------- .../hackathon-e2e/mutations/manifest.jsonl | 2 +- .../skills/hackathon-e2e/mutations/run.mjs | 35 +++++++---- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 354818e9..f5a61a2f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -193,7 +193,7 @@ directories under it are ignored (`node_modules/`, `.state/`, `.artifacts/`, | journey (465-action recipe) | **469 passed / 0 failed / 0 skipped** | 2026-08-15 | | smoke | **148 passed / 0 failed / 0 did not run** | 2026-08-14 | | mobile | **121 passed** | 2026-08-10 | -| backend `go test -tags "test unittest" ./internal/...` | all 6 packages ok in ~11 s — service 336/337 specs (one pending), capability 37, middleware 46 | 2026-08-15 | +| backend `go test -tags "test unittest" ./internal/...` | all 6 packages ok in ~10 s — service 337/338 specs (one pending), capability 37, middleware 46 | 2026-08-16 | | openreplay (9 tests) | **13 passed / 0 skipped** | 2026-08-11 | | frontend units (29 files) | **488 passed** | 2026-08-14 | @@ -240,13 +240,15 @@ of this branch) and both report `ok` under the runner. What fails today is one SPEC, and it is the declared flake: `Capacity > never oversells the last place under simultaneous joins` -(`capacity_test.go:326`) — the entry in the mutation runner's `KNOWN_FLAKY`, +(`capacity_test.go:350`) — the entry in the mutation runner's `KNOWN_FLAKY`, which fails roughly one run in five under in-memory SQLite. Three consecutive re-runs of `./internal/service/` after it went green. The rest of that run: service 335 passed / 1 failed / 1 pending of 337, capability 37/37, middleware 46/46, config 6/6, audit and storage `ok`. CI runs this command, so **that flake is a red CI run whenever it lands** — it is a test-side race to fix, not a -runner quirk to route around. +runner quirk to route around. As of 2026-08-16 no mutation VERDICT depends on it +any more (see the capacity witness below), which removes the ambiguity from the +manifest and changes nothing about CI. **API-to-UI coverage: 101 of 108 RPC declarations have a frontend caller.** The seven without one are accounted for in `docs/testing.md` — @@ -887,24 +889,38 @@ don't count is precisely the shape that could hide a real one. Adding a line to `KNOWN_FLAKY` is a claim about the SUITE that wants justifying — never a way to quieten a mutation that is genuinely over-broad. -⚠ **A flaky test can still be a genuine witness, and that trap fired within the -hour.** That capacity spec hammers concurrent joins against a cap — which is -precisely what `capacity.oversell-by-one` breaks — so under THAT mutation its -failure is the evidence, and the first freeze had stripped it as noise. The -rule: when a listed test really does witness a mutation, it belongs in that -mutation's `expectReds`, where the excuse cannot reach it (the filter only ever -looks at reds that are NOT expected). Excusing an extra prints that reminder -every time. - -⚠ **And it flakes in BOTH directions, which no filter can excuse.** Observed -2026-08-14: `capacity.oversell-by-one` came back MISMATCH with that same spec -under "expected but stayed GREEN" — its witness had passed under the mutation. -Same root cause (a join that errors out under SQLite contention seats one fewer, -so the oversell never materialises), opposite symptom, and the `KNOWN_FLAKY` -list cannot help: an expected red that does not arrive is exactly what a -MISMATCH is for. Three re-runs of that one id came back EXACT. So a MISMATCH -naming ONLY a `KNOWN_FLAKY` test in the "stayed GREEN" column wants a re-run -before it is believed — the same courtesy the extras column already gets. +⚠ **A flaky test can be genuine evidence and still be the wrong thing to judge +by — closed 2026-08-16.** That capacity spec hammers concurrent joins against a +cap, which is precisely what `capacity.oversell-by-one` breaks, so under THAT +mutation its failure is the evidence and the first freeze had stripped it as +noise. The fix then was to list it in the entry's own `expectReds`, where the +excuse cannot reach it (the filter only ever looks at reds that are NOT +expected). That bought a correct reading in one direction and a coin flip in the +other: **it flakes in BOTH directions, and no filter can excuse the second +one.** Observed 2026-08-14, `capacity.oversell-by-one` came back MISMATCH with +that same spec under "expected but stayed GREEN" — its witness had PASSED under +the mutation. Same root cause (a join that errors out under SQLite contention +seats one fewer, so the oversell never materialises), opposite symptom, and an +expected red that does not arrive is exactly what a MISMATCH is for. + +So the entry is judged by something that cannot flake. `Capacity > seats exactly +the capacity, counting the roster after every join` makes the same END-STATE +claim the race spec makes — the confirmed roster equals the cap, read back from +the DB — by filling an event to exactly its capacity one join at a time and +asserting the next arrival queues with the count unmoved. Three capacities, +because a rule that is off by one is off by one at every cap, and the seated +joins at 2 and 3 are the positive control against an event that simply queues +everybody. It fails under the mutation in 0.012 s with no goroutines anywhere. +Five consecutive `mutate.sh run capacity.oversell-by-one` came back EXACT. + +**The rule that survived: a verdict may only rest on witnesses that cannot +flake.** The race spec is KEPT as a test — it pins `capacityMu`, which once +genuinely oversold — and it is out of `expectReds`, so its red now lands in the +extras column, is excused by `KNOWN_FLAKY`, and is PRINTED with its reason on +every run of that entry. The evidence is still on screen; it is no longer on the +ballot. ⚠ The flake itself is still open and still reds CI (see +`just check::test -c backend` above) — this changes what the MUTATION is judged +by, not the underlying test-side race. ## Container traps (Windows/macOS hosts) — read before touching compose diff --git a/.claude/skills/hackathon-e2e/mutations/manifest.jsonl b/.claude/skills/hackathon-e2e/mutations/manifest.jsonl index fae69e97..0d455444 100644 --- a/.claude/skills/hackathon-e2e/mutations/manifest.jsonl +++ b/.claude/skills/hackathon-e2e/mutations/manifest.jsonl @@ -14,7 +14,7 @@ {"id": "auth.require-user.admits-anon", "property": "RequireUser rejects the anonymous subject: endpoints that act on a PERSON must not accept 'anonymous' as an identity.", "arena": "go", "tier": "fast", "file": "components/backend/internal/middleware/auth.go", "find": "\tif sub == AnonSubject {\n\t\treturn \"\", nil, status.Error(codes.Unauthenticated, \"sign in to do that\")\n\t}", "replace": "\tif sub == AnonSubject && false {\n\t\treturn \"\", nil, status.Error(codes.Unauthenticated, \"sign in to do that\")\n\t}", "crossRef": ["act2.anonymous.register", "act4.team.anon", "act6.submit.anon"], "expectReds": ["middleware::Auth Middleware > RequireUser Function > refuses the anonymous subject", "service::TeamService with no credentials > tells an anonymous caller to sign in rather than whether a submission exists", "service::TeamService with no credentials > tells an anonymous caller to sign in rather than whether a team exists"]} {"comment":"─── Capacity: the FCFS rule and the fairness of the queue ───"} {"id":"capacity.queue-fairness","property":"Once anyone is waiting, a new joiner queues BEHIND them rather than sniping a freed seat.","arena":"go","tier":"fast","file":"components/backend/internal/service/capacity.go","find":"\treturn confirmed >= int(*maxParticipants) || waiting > 0","replace":"\treturn confirmed >= int(*maxParticipants)","expectReds":["service::Capacity > leaves a freed place to the organizer and keeps the queue unjumped","service::joinLandsWaitlisted > queues behind existing waiters even when a place is free"],"crossRef":["act2.cap.race","act2.cap.ui.queued"]} -{"id": "capacity.oversell-by-one", "property": "The capped event seats exactly max_participants — the full test is >=, and > sells one seat too many.", "arena": "go", "tier": "fast", "file": "components/backend/internal/service/capacity.go", "find": "\treturn confirmed >= int(*maxParticipants) || waiting > 0", "replace": "\treturn confirmed > int(*maxParticipants) || waiting > 0", "expectReds": ["service::Capacity > hands out places first-come-first-served on a capped event", "service::Capacity > leaves a freed place to the organizer and keeps the queue unjumped", "service::Capacity > lets an organizer approve past capacity", "service::Capacity > never oversells the last place under simultaneous joins", "service::Capacity > reports the current state on an idempotent re-join", "service::joinLandsWaitlisted > waitlists once the confirmed roster reaches capacity"], "note": "The capacity concurrency spec is on the runner's KNOWN_FLAKY list AND is a genuine witness here: this mutation makes the cap off-by-one, which is exactly what that spec hammers. It is listed explicitly so the flake excuse — which only applies to UNEXPECTED reds — cannot swallow the evidence."} +{"id": "capacity.oversell-by-one", "property": "The capped event seats exactly max_participants — the full test is >=, and > sells one seat too many.", "arena": "go", "tier": "fast", "file": "components/backend/internal/service/capacity.go", "find": "\treturn confirmed >= int(*maxParticipants) || waiting > 0", "replace": "\treturn confirmed > int(*maxParticipants) || waiting > 0", "expectReds": ["service::Capacity > hands out places first-come-first-served on a capped event", "service::Capacity > leaves a freed place to the organizer and keeps the queue unjumped", "service::Capacity > lets an organizer approve past capacity", "service::Capacity > reports the current state on an idempotent re-join", "service::Capacity > seats exactly the capacity, counting the roster after every join", "service::joinLandsWaitlisted > waitlists once the confirmed roster reaches capacity"], "note": "`service::Capacity > never oversells the last place under simultaneous joins` also goes red under this mutation and is deliberately NOT listed. It is the runner's one KNOWN_FLAKY entry, and it flakes in BOTH directions — it has stayed GREEN under this very mutation, which is a MISMATCH no excuse list can reach, because expectReds is by design where the flake filter does not apply. Every witness that decides a verdict has to be one that cannot flake. The property is carried instead by `seats exactly the capacity, counting the roster after every join`, which makes the same end-state claim (the confirmed roster equals the cap, read back from the DB) by walking the seats one join at a time. The race spec stays as a test — it pins capacityMu, which once genuinely oversold — and its red still PRINTS on this entry, excused with its reason, so the evidence is on screen without being on the ballot."} {"id":"capacity.unlimited-confirms","property":"Zero or unset capacity means UNLIMITED and keeps the approval model — everyone lands on the waiting list for an organizer to confirm.","arena":"go","tier":"fast","file":"components/backend/internal/service/capacity.go","find":"\t\t// Unlimited: the approval model — everyone starts on the waiting list.\n\t\treturn true","replace":"\t\t// Unlimited: the approval model — everyone starts on the waiting list.\n\t\treturn false","expectReds":["service::Capacity > clears the capacity back to unlimited with 0","service::Capacity > keeps the approval model for uncapped events","service::HackathonService > Join > allows authorized user to join hackathon","service::HackathonService > Join > returns success if user already joined (idempotent)","service::joinLandsWaitlisted > treats zero and negative capacity as unlimited","service::joinLandsWaitlisted > waitlists everyone when capacity is unset (the approval model)"],"crossRef":["act2.join.bob"]} {"comment":"─── Deadline windows, and the now-anchored overrides ───"} {"id": "window.never-closes", "property": "A hackathon with a windows row and a passed deadline refuses the action with FailedPrecondition.", "arena": "go", "tier": "fast", "file": "components/backend/internal/service/config_service.go", "find": "\t\tif closes == nil || !now.After(*closes) {\n\t\t\treturn false\n\t\t}", "replace": "\t\tif closes == nil || !now.After(*closes) || true {\n\t\t\treturn false\n\t\t}", "crossRef": ["act5.window.regclosed", "act6.window.sublate", "act4.window.preflate"], "expectReds": ["service::Window enforcement > refuses a join once the registration window has closed", "service::Window enforcement > stops honouring an override once it has expired"]} diff --git a/.claude/skills/hackathon-e2e/mutations/run.mjs b/.claude/skills/hackathon-e2e/mutations/run.mjs index 567b11b4..d410e4d6 100644 --- a/.claude/skills/hackathon-e2e/mutations/run.mjs +++ b/.claude/skills/hackathon-e2e/mutations/run.mjs @@ -680,15 +680,24 @@ const ARENAS = { * is a claim about the SUITE that someone has to justify — not a way to quieten * a mutation that is genuinely over-broad. * - * ⚠ **A flaky test can still be a genuine witness**, and that trap fired within - * an hour of this list existing. The capacity spec below hammers concurrent - * joins against a cap — which is exactly what `capacity.oversell-by-one` breaks, - * so under THAT mutation its failure is the evidence, not noise. The rule is: - * when a listed test is a real witness for a mutation, it goes in that - * mutation's `expectReds`, where the excuse does not apply (this filter only - * ever looks at reds that are NOT expected). Excusing an extra therefore prints - * a warning saying so, because "ignored" is the one word that could hide the - * thing the mutation was written to find. + * ⚠ **A flaky test can be genuine evidence and still be the wrong thing to + * judge by**, and the capacity spec below has been both. It hammers concurrent + * joins against a cap — exactly what `capacity.oversell-by-one` breaks — so its + * red under THAT mutation is the evidence, not noise. The first fix was to list + * it in that entry's `expectReds`, where this filter cannot reach it (it only + * ever looks at reds that are NOT expected). That bought a correct reading in + * one direction and a coin flip in the other: an expected red that does not + * ARRIVE is a MISMATCH, and this spec flakes green under the mutation too (a + * join that errors out under SQLite contention seats one fewer, so the oversell + * never materialises). No excuse list can help there, by construction. + * + * The rule that survived: **a verdict may only rest on witnesses that cannot + * flake.** Write a deterministic witness for the same property, name THAT in + * `expectReds`, and leave the flaky one to be excused here — where its red is + * still printed, with its reason, on every entry it touches. That is what + * `Capacity > seats exactly the capacity, counting the roster after every join` + * is for. Excusing an extra prints a warning precisely because "ignored" is the + * one word that could hide the thing the mutation was written to find. */ const KNOWN_FLAKY = [ { @@ -1082,8 +1091,12 @@ function main() { console.log(C.dim(` declared in KNOWN_FLAKY: ${flakyReason(x)}`)) console.log( C.dim( - ` if this test is a REAL witness for this mutation, put it in ` + - `expectReds — the excuse only applies to unexpected reds.`, + ` if this test is a REAL witness for this mutation, that is worth ` + + `knowing — but do NOT answer it by listing a flaky test in ` + + `expectReds. The excuse cannot reach a listed test, so the entry ` + + `then flakes in both directions (a red that stays green is a ` + + `MISMATCH). Write a deterministic witness for the same property ` + + `and name that one instead.`, ), ) } else { From e93f1c181ca0b307a6946de2973a6632f5426e93 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:45:15 +0200 Subject: [PATCH 3/4] docs(e2e): two traps found rebuilding the social card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were hit while getting the running :8081 server onto the current static/og-default.jpg, and both were previously written down as something else. The 9p EPERM on the atomic swap is not only the filesystem. With the :8081 adapter-node server up, `frontend-build.sh build` burned all five rename attempts and gave up; one `prod-frontend.sh stop` later the very next attempt succeeded, with the :8082 server still running. When every retry fails, ask who is serving the tree. The order is stop, build, start — which is what prod-frontend.sh's `start` already does, so the case that hits the wall is calling the helper directly. And swapping the tree in does not reach a server that booted against the old one. adapter-node serves the client dir through sirv, which builds its manifest once at boot, so afterwards it streams the CURRENT file while advertising the PREVIOUS length and ETag: the tunnel's :8082 server answered every request with Content-Length 85099, an ETag to match, and 58130 correct bytes. Hashing the download says MATCH — the body really is the new card — while every client sees a truncated image and every cache honours a false ETag. Compare headers, and read curl's exit code. Restarted both servers; verified byte-for-byte and header-for-header against the static file. --- .claude/CLAUDE.md | 28 ++++++++++++++++++++++++++++ .claude/skills/lib/frontend-build.sh | 16 ++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f5a61a2f..b944728e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1136,6 +1136,34 @@ Two things measured while building that, both worth keeping: retries, and rolls the old tree back if the second rename fails, so `build/service` is never left missing. Anything else here that renames a directory on this mount needs the same treatment. +- ⚠ **But one cause of that EPERM IS reproducible — a server still SERVING the + tree** (2026-08-16). `frontend-build.sh build` with the :8081 adapter-node + server up burned all five attempts and gave up; `prod-frontend.sh stop`, then + the very next attempt, succeeded — with the :8082 server still running. So + when the retries all fail, the question is who is serving it, not the + filesystem. The order is **stop, build, start**, which is what + `prod-frontend.sh start` already does; calling the helper directly against a + live server is the case that hits the wall. + +⚠ **Swapping the tree in does NOT reach a server that booted against the old +one, and the bytes will still be right** (2026-08-16). adapter-node serves +`build/service/client` through `sirv`, which builds its manifest — sizes and +ETags included — ONCE at boot. After a swap it streams the file that is on disk +NOW while advertising the length and ETag of the file that was there when it +started. Measured on the tunnel's :8082 server after rebuilding the social card: +`Content-Length: 85099`, `ETag: W/"85099-…"`, and 58,130 correct bytes on the +wire, every request, `curl: (18) transfer closed with 26969 bytes remaining`. + +**This is a silent-green trap aimed straight at how you would check.** Hash what +arrived and it MATCHES the file on disk — the body is genuinely the new card — +so a `sha256sum` of the download says the deploy worked while every real client +sees a truncated image and every cache honours an ETag that is a lie. The card +exists for link-preview crawlers, which is exactly the audience that would have +got the broken one. **Compare the HEADERS, and read curl's exit code, not just +its output.** Any rebuild has to restart every server on that tree — +`prod-frontend.sh start` for :8081 and +`prod-serve.sh start --no-build` for :8082 — because there are two of +them and only one of them is ever the one you were thinking about. **6. An empty list is not an answer — say "I could not ask"** (fixed 2026-08-13). The built :8081 server keeps ONE module-scope gRPC channel diff --git a/.claude/skills/lib/frontend-build.sh b/.claude/skills/lib/frontend-build.sh index 291988ed..44cbb269 100644 --- a/.claude/skills/lib/frontend-build.sh +++ b/.claude/skills/lib/frontend-build.sh @@ -123,10 +123,18 @@ build_locked() { # Permission denied # # Observed 2026-08-13 mid-run and NOT reproducible a minute later with the same - # processes running and no open descriptors anywhere under the tree — so it is - # the filesystem, not a lock we could take or a handle we could close. An - # abort here is safe (the working tree is untouched) but it fails a build for a - # reason that clears itself, which is its own kind of flake. + # processes running and no open descriptors anywhere under the tree — so at + # least some of it is the filesystem, not a lock we could take or a handle we + # could close. An abort here is safe (the working tree is untouched) but it + # fails a build for a reason that clears itself, which is its own kind of flake. + # + # ⚠ ONE cause IS reproducible, found 2026-08-16: a server still SERVING this + # tree. `frontend-build.sh build` called with the :8081 adapter-node server up + # failed all five attempts; `prod-frontend.sh stop` and the very next attempt + # succeeded, with the :8082 server still running. So the retries are not the + # answer when a live server holds it — the order is stop, build, start, which + # is exactly what prod-frontend.sh's `start` already does. Calling this script + # directly against a running server is the case that hits the wall. local old="$OUT_PARENT/.service-old-$$" i rm -rf "$old" for i in 1 2 3 4 5; do From f98838eb4b2c054f2bab045c2702fd7028d75637 Mon Sep 17 00:00:00 2001 From: caviri <45425937+caviri@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:48:44 +0200 Subject: [PATCH 4/4] chore: reflow CLAUDE.md after the trap notes The two traps recorded in e93f1c18 were hand-wrapped; prettier owns .claude now (the exemption came off during the develop merge), and CI runs the formatter with --fail-on-change. Layout only. --- .claude/CLAUDE.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b944728e..7ad6f0cc 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -903,15 +903,16 @@ the mutation. Same root cause (a join that errors out under SQLite contention seats one fewer, so the oversell never materialises), opposite symptom, and an expected red that does not arrive is exactly what a MISMATCH is for. -So the entry is judged by something that cannot flake. `Capacity > seats exactly -the capacity, counting the roster after every join` makes the same END-STATE -claim the race spec makes — the confirmed roster equals the cap, read back from -the DB — by filling an event to exactly its capacity one join at a time and -asserting the next arrival queues with the count unmoved. Three capacities, -because a rule that is off by one is off by one at every cap, and the seated -joins at 2 and 3 are the positive control against an event that simply queues -everybody. It fails under the mutation in 0.012 s with no goroutines anywhere. -Five consecutive `mutate.sh run capacity.oversell-by-one` came back EXACT. +So the entry is judged by something that cannot flake. +`Capacity > seats exactly the capacity, counting the roster after every join` +makes the same END-STATE claim the race spec makes — the confirmed roster equals +the cap, read back from the DB — by filling an event to exactly its capacity one +join at a time and asserting the next arrival queues with the count unmoved. +Three capacities, because a rule that is off by one is off by one at every cap, +and the seated joins at 2 and 3 are the positive control against an event that +simply queues everybody. It fails under the mutation in 0.012 s with no +goroutines anywhere. Five consecutive `mutate.sh run capacity.oversell-by-one` +came back EXACT. **The rule that survived: a verdict may only rest on witnesses that cannot flake.** The race spec is KEPT as a test — it pins `capacityMu`, which once @@ -1161,9 +1162,9 @@ sees a truncated image and every cache honours an ETag that is a lie. The card exists for link-preview crawlers, which is exactly the audience that would have got the broken one. **Compare the HEADERS, and read curl's exit code, not just its output.** Any rebuild has to restart every server on that tree — -`prod-frontend.sh start` for :8081 and -`prod-serve.sh start --no-build` for :8082 — because there are two of -them and only one of them is ever the one you were thinking about. +`prod-frontend.sh start` for :8081 and `prod-serve.sh start --no-build` +for :8082 — because there are two of them and only one of them is ever the one +you were thinking about. **6. An empty list is not an answer — say "I could not ask"** (fixed 2026-08-13). The built :8081 server keeps ONE module-scope gRPC channel