openfut.cfg is not the source of truth for the client's Blaze ports -- the launcher
is. It reconciles openfut.cfg from ~/.config/openfut-launcher/config.json,
fail-closed, immediately before every launch. So `staging` set the ports, verified
them, and the next launch silently reverted them.
Caught only because the capture harness cross-checks instead of trusting the screen.
The operator reported "the Transfers tile does not show Sold" -- which looked like a
clean negative result about the sold counter, and was in fact a reading of their
PRODUCTION club, where sold:0 is correct. Evidence chain:
* route-log delta contained 5 lines, all of them the harness's own GETs; the
client issued nothing to staging at all;
* `ss -tnp` on the client showed FIFA17.exe pid 39482 ESTAB to 10.10.0.120:42130
(production Blaze) plus TIME-WAIT to :8099 (production UTAS);
* the hook logged `blaze_redir=42127 blaze_main=42130`;
* openfut.cfg mtime was 2s before process start, sha back to the production value.
Had the harness reported the tile at face value, the sold counter recovered from
CardsDLL would now be recorded as refuted by a run that never reached the code.
Fixes: own the launcher config (source) before openfut.cfg (derived), with the same
record-before-mutate sidecar discipline on both; refuse to edit while the launcher is
running, since it holds config in memory and would write the stale values back;
report both files and both guards in `show`. launcher_running() matches the
kernel-truncated comm "openfut-launche" -- the full name exceeds 15 chars, which has
bitten this project before.
No production change; staging stack and its variant-A sold row untouched.
`pgrep -f FIFA17.exe` matched the remote shell executing it -- the SSH command line
contains the literal pattern -- so fifa_running() always returned True and the port
switcher could never edit openfut.cfg. It refused with "REFUSING to edit ... while a
FIFA client is running" moments after FIFA had actually exited.
Fail-closed, so nothing unsafe happened, but the guard was permanently stuck and
blocked the A/B entirely.
Now matches /proc/<pid>/comm exactly, which is the executable name: the invoking
shell reads as zsh and cannot self-match, while a genuine FIFA process still does.
Validated both directions with the same loop -- it found pid 36958 while FIFA was up,
and reports gone once it exited. Still fail-closed on read errors.
The lesson generalises: a pattern-matching process guard checked over a transport
that carries the pattern in its own argv is self-satisfying, and a guard that can
only ever say "yes" is not a guard.
The brief's gate: if the harness varies bidState AND coinsProcessed together, the
client's reaction is attributable to neither. The env knobs were already orthogonal
(--variant and --coins-processed are independent, cp defaults to 0), but
sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly
the contaminated A/B the brief forbids. Fixed: the primary pair now holds
coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState'].
New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic
sale, then re-reads every seller-facing surface under each variant by restarting only
the host (same Core, same DBs, same sale), and diffs with explicit classification --
MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?".
Two orthogonal pairs:
PRIMARY bidState highest vs buyNow, coinsProcessed held at 0
ORTHOGONAL coinsProcessed 0 vs 1, bidState held at highest
Result, 36/36: the ONLY finding on /tradePile is
VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs
in exactly the same one path; counts are byte-identical. The orthogonal pair's only
finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the
capture is reproducible rather than merely consistent.
Counts states the live run has to interpret, measured not guessed:
S1 0 active + 1 sold -> count 0, selling 0, sold 1
S2 1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode)
That divergence IS the open question for the client; production is unchanged.
scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is
built so restoration cannot depend on memory: it records the production values to a
sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing
if it is absent. It rewrites only known keys (a missing key is an error, never a
silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA
client is running because the hook reads the file at connect time.
Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface,
one file per variant so A can never overwrite B.
Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105
(pid 32188), and live-session mutual exclusion applies. The client config was NOT
touched -- the switcher's guard refused, as designed.
Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0,
counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.