zebra-spaces: formalize self-listener as FSM — pure spec + observer-driven side effects + 12 unit tests

Fox 2026-06-04 directive: every system should be a state machine
with unit + integration + functional test coverage. Implicit-state
defects keep biting (kicked-listener-UI-still-green, two-kick race,
cohost-toggle-kills-phone, audio-wedge-no-recovery). Starting the
formalization with the most-broken-today system: self-listener mode.

Spec (selfListenerSpec):
  off ──ENABLE / TOGGLE──▶ on
  on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off

Sits next to publishSpec, subscribeSpec, callSpec, remoteTileSpec
in zebra-spaces.html. Composed by wireZebraMachines() into
roomMachines.selfListener.

UNMUTE edge encodes fox's invariant: "unmuting should seamlessly
switch them back to the now of the conversation webrtc mesh" — if
the user clicks unmute while on, they implicitly drop back to off.

Side effects (mic mute, streamMode enrolment, remoteAudio muting)
move out of enableSelfListenerMode/disableSelfListenerMode (deleted)
into runSelfListenerEnable / runSelfListenerDisable, called by an
observer attached to the FSM. Pure spec stays Node-testable; the
runtime drives the actual audio plumbing from observed transitions.

Boolean selfListenerMode flag deleted. window.selfListenerMode is
now a getter against the FSM state — single source of truth, no
drift possible. All callers (toggle-button click, mute-unmute,
peer-joined, role-demote, leave) now dispatch FSM events instead
of calling helpers directly.

Tests in test/self-listener-fsm.test.js:
- starts in off
- TOGGLE / ENABLE / DISABLE transitions
- UNMUTE drops to off (the fox-invariant)
- UNMUTE / CLEAR while off is no-op
- DEMOTED drops to off
- CLEAR drops to off
- unknown event refuses
- observer fires on real transitions with prev/state
- runtime observer skips prev===state edges

Existing test/zebra-fsm.test.js updated to extract+expose
selfListenerSpec alongside the other specs (the wireZebraMachines
extract is the integration test).

Makefile gets test-self-listener target + slot in test-all.

All test suites green:
- self-listener:        12 / 12
- zebra-fsm:            83 / 83
- mod-actions:           6 / 6
- web-protocol:       3348 / 3348
- multi-peer-mesh:       8 / 8
- video-track-removal:  18 / 18
This commit is contained in:
Russell Ballestrini 2026-06-04 13:10:29 -04:00
parent d71a37f84a
commit f4dbc5cc6c
No known key found for this signature in database
4 changed files with 251 additions and 38 deletions

View file

@ -53,6 +53,14 @@ test-mesh:
test-mod-actions: test-mod-actions:
@node test/mod-action-serializer.test.js @node test/mod-action-serializer.test.js
# SelfListenerFSM — pins the state-machine contract for the speaker/
# cohost/host "switch myself to the buffered HTTP listener stream"
# toggle. Extracts createFSM + selfListenerSpec from the live page so
# the spec can't drift from shipped transitions (off↔on with
# TOGGLE/ENABLE/DISABLE/UNMUTE/DEMOTED/CLEAR edges).
test-self-listener:
@node test/self-listener-fsm.test.js
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live # zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
# server flow. The live-server tier auto-runs when proxy.unturf.com sits # server flow. The live-server tier auto-runs when proxy.unturf.com sits
# alongside this checkout AND has a Go toolchain — we build the relay binary # alongside this checkout AND has a Go toolchain — we build the relay binary
@ -70,7 +78,7 @@ test-zebra-spaces:
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \ ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-zebra-spaces test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-zebra-spaces
@echo "--- unit ---" @echo "--- unit ---"
@./test/unit @./test/unit
@echo "--- integration ---" @echo "--- integration ---"
@ -87,6 +95,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide
@node test/multi-peer-mesh.test.js @node test/multi-peer-mesh.test.js
@echo "--- mod-action serializer ---" @echo "--- mod-action serializer ---"
@node test/mod-action-serializer.test.js @node test/mod-action-serializer.test.js
@echo "--- self-listener FSM ---"
@node test/self-listener-fsm.test.js
@echo "--- zebra-spaces ---" @echo "--- zebra-spaces ---"
@$(MAKE) -s test-zebra-spaces @$(MAKE) -s test-zebra-spaces

View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
/* SelfListenerFSM tests pins the state-machine contract for the
* speaker/cohost/host "switch myself to the buffered HTTP listener
* stream" toggle that fox introduced 2026-06-04. Driven by extracted
* source from web/zebra-spaces.html so the spec can't drift from
* shipped behavior.
*
* node test/self-listener-fsm.test.js
*/
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
function extract(re){
const m = src.match(re);
if (!m) throw new Error('could not find ' + re);
let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i;
for (; j < src.length; j++){
if (src[j] === '{') depth++;
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
}
if (src[j] === ';') j++;
return src.slice(m.index, j);
}
const createFSMSrc = extract(/function createFSM\(/);
const selfListenerSpecSrc = extract(/const selfListenerSpec = /);
const harness = new Function(
createFSMSrc + '\n' + selfListenerSpecSrc + '\nreturn { createFSM, selfListenerSpec };'
);
const { createFSM, selfListenerSpec } = harness();
let pass = 0, fail = 0;
function test(name, fn){
try { fn(); console.log(' ✓ ' + name); pass++; }
catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; }
}
function eq(a, b, msg){
if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b));
}
function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); }
console.log('SelfListenerFSM:');
test('starts in off state', () => {
const m = createFSM(selfListenerSpec);
eq(m.state, 'off');
});
test('initial enrolledCount is 0', () => {
const m = createFSM(selfListenerSpec);
eq(m.context.enrolledCount, 0);
});
test('TOGGLE off→on, then off→on→off', () => {
const m = createFSM(selfListenerSpec);
truthy(m.send('TOGGLE'));
eq(m.state, 'on');
truthy(m.send('TOGGLE'));
eq(m.state, 'off');
});
test('ENABLE off→on; DISABLE on→off', () => {
const m = createFSM(selfListenerSpec);
truthy(m.send('ENABLE'));
eq(m.state, 'on');
truthy(m.send('DISABLE'));
eq(m.state, 'off');
});
test('UNMUTE while on drops to off (fox: "unmuting should seamlessly switch back")', () => {
const m = createFSM(selfListenerSpec);
m.send('ENABLE');
eq(m.state, 'on');
truthy(m.send('UNMUTE'));
eq(m.state, 'off');
});
test('UNMUTE while off is a no-op self-transition (still off)', () => {
const m = createFSM(selfListenerSpec);
// self-transitions still count as "transitioned"; the spec defines
// them so the runtime can observe the event without surprise.
m.send('UNMUTE');
eq(m.state, 'off');
});
test('DEMOTED while on drops to off (role transition listener)', () => {
const m = createFSM(selfListenerSpec);
m.send('ENABLE');
truthy(m.send('DEMOTED'));
eq(m.state, 'off');
});
test('CLEAR while on drops to off (leave room)', () => {
const m = createFSM(selfListenerSpec);
m.send('ENABLE');
truthy(m.send('CLEAR'));
eq(m.state, 'off');
});
test('CLEAR while off is a no-op (idempotent on leave)', () => {
const m = createFSM(selfListenerSpec);
m.send('CLEAR');
eq(m.state, 'off');
});
test('unknown event refuses transition', () => {
const m = createFSM(selfListenerSpec);
eq(m.send('NOPE'), false);
eq(m.state, 'off');
});
test('observer fires on every real transition with prev/state', () => {
const m = createFSM(selfListenerSpec);
const seen = [];
m.observe(({ state, prev, ev }) => seen.push({ state, prev, ev: ev && ev.type }));
m.send('TOGGLE');
m.send('TOGGLE');
m.send('TOGGLE');
// first observer call is the "started" notification (prev=null, ev=null)
// subsequent calls are actual transitions.
const real = seen.filter(s => s.ev !== null);
eq(real.length, 3);
eq(real[0].prev, 'off'); eq(real[0].state, 'on'); eq(real[0].ev, 'TOGGLE');
eq(real[1].prev, 'on'); eq(real[1].state, 'off'); eq(real[1].ev, 'TOGGLE');
eq(real[2].prev, 'off'); eq(real[2].state, 'on'); eq(real[2].ev, 'TOGGLE');
});
test('runtime observer pattern: prev===state edges (UNMUTE while off) skipped by guard', () => {
/* The runtime observer (in zebra-spaces.html) skips transitions
* where state === prev to avoid spurious side-effect fires. This
* test pins the convention: UNMUTE while off DOES emit an event,
* but with state===prev so the runtime guard filters it. */
const m = createFSM(selfListenerSpec);
const realTransitions = [];
m.observe(({ state, prev }) => {
if (prev === null || state === prev) return; // matches runtime guard
realTransitions.push({ from: prev, to: state });
});
m.send('UNMUTE'); // off → off, filtered out
m.send('ENABLE'); // off → on, kept
m.send('UNMUTE'); // on → off, kept
m.send('CLEAR'); // off → off, filtered out
eq(realTransitions.length, 2);
eq(realTransitions[0].from, 'off'); eq(realTransitions[0].to, 'on');
eq(realTransitions[1].from, 'on'); eq(realTransitions[1].to, 'off');
});
console.log('');
console.log('passed: ' + pass + ' failed: ' + fail);
process.exit(fail ? 1 : 0);

View file

@ -34,16 +34,17 @@ const publishSpecSrc = extract(/const publishSpec = /);
const subscribeSpecSrc = extract(/const subscribeSpec = /); const subscribeSpecSrc = extract(/const subscribeSpec = /);
const remoteTileSpecSrc = extract(/const remoteTileSpec = /); const remoteTileSpecSrc = extract(/const remoteTileSpec = /);
const callSpecSrc = extract(/const callSpec = /); const callSpecSrc = extract(/const callSpec = /);
const selfListenerSpecSrc = extract(/const selfListenerSpec = /);
const wireMachinesSrc = extract(/function wireZebraMachines\(/); const wireMachinesSrc = extract(/function wireZebraMachines\(/);
/* Function-constructor scope so `const` declarations are visible at the /* Function-constructor scope so `const` declarations are visible at the
* harness's `return` they would NOT leak through a bare `eval()`. */ * harness's `return` they would NOT leak through a bare `eval()`. */
const harness = new Function( const harness = new Function(
createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' + createFSMSrc + '\n' + publishSpecSrc + '\n' + subscribeSpecSrc + '\n' +
remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + wireMachinesSrc + remoteTileSpecSrc + '\n' + callSpecSrc + '\n' + selfListenerSpecSrc + '\n' + wireMachinesSrc +
'\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines };' '\nreturn { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines };'
); );
const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, wireZebraMachines } = harness(); const { createFSM, publishSpec, subscribeSpec, remoteTileSpec, callSpec, selfListenerSpec, wireZebraMachines } = harness();
let pass = 0, fail = 0; let pass = 0, fail = 0;
function test(name, fn){ function test(name, fn){

View file

@ -1248,18 +1248,61 @@ const callSpec = {
}; };
/* ================================================================== /* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one * SelfListenerFSM — a speaker / cohost / host who's flipped their
* SubscribeFSM, three PublishFSMs (mic/screen/camera), and a Map of * own row's stream toggle to consume the room via the buffered HTTP
* RemoteTileFSMs into a coherent room. Observers wire transitions * Ogg/Opus path instead of the live WebRTC mesh.
* between machines; no side effects in this layer — the page's
* runtime attaches its OWN observers on top to drive actual WebRTC
* and DOM work. That separation keeps this function fully testable
* in Node with synthetic events.
* *
* Returns { call, sub, pubs, remoteTiles, tileFor, tileLeft }. */ * off ──ENABLE / TOGGLE──▶ on ──DISABLE / TOGGLE / UNMUTE / DEMOTED / CLEAR──▶ off
*
* The UNMUTE edge encodes fox's invariant: "unmuting should
* seamlessly switch them back to the now of the conversation
* webrtc mesh" — if the user clicks unmute while in on, they
* implicitly want to go back to the live path.
*
* Pure spec — side effects (mic mute, streamMode population,
* remoteAudio muting) live in the runtime's observer attached to
* this FSM. Keeps it testable in Node. */
const selfListenerSpec = {
initial: 'off',
context: { enrolledCount: 0 },
states: {
off: {
on: {
TOGGLE: 'on',
ENABLE: 'on',
UNMUTE: 'off', /* no-op self-transition for symmetry */
DEMOTED: 'off',
CLEAR: 'off',
},
},
on: {
on: {
TOGGLE: 'off',
DISABLE: 'off',
UNMUTE: 'off',
DEMOTED: 'off',
CLEAR: 'off',
},
},
},
};
/* ==================================================================
* wireZebraMachines — orchestrator. Composes one CallFSM, one
* SubscribeFSM, three PublishFSMs (mic/screen/camera), one
* SelfListenerFSM, and a Map of RemoteTileFSMs into a coherent
* room. Observers wire transitions between machines; no side
* effects in this layer — the page's runtime attaches its OWN
* observers on top to drive actual WebRTC and DOM work. That
* separation keeps this function fully testable in Node with
* synthetic events.
*
* Returns { call, sub, pubs, selfListener, remoteTiles, tileFor,
* tileLeft }. */
function wireZebraMachines(){ function wireZebraMachines(){
const call = createFSM(callSpec); const call = createFSM(callSpec);
const sub = createFSM(subscribeSpec); const sub = createFSM(subscribeSpec);
const selfListener = createFSM(selfListenerSpec);
const pubs = { const pubs = {
mic: createFSM(publishSpec), mic: createFSM(publishSpec),
screen: createFSM(publishSpec), screen: createFSM(publishSpec),
@ -1315,7 +1358,7 @@ function wireZebraMachines(){
} }
}); });
return { call, sub, pubs, remoteTiles, tileFor, tileLeft }; return { call, sub, pubs, selfListener, remoteTiles, tileFor, tileLeft };
} }
/* ================================================================== /* ==================================================================
@ -4172,7 +4215,7 @@ async function onRoleChanged(prev, next){
/* self-listener flag is meaningless once we're a listener (the /* self-listener flag is meaningless once we're a listener (the
* row's stream toggle disappears); flip it OFF so its streamMode * row's stream toggle disappears); flip it OFF so its streamMode
* entries get torn down cleanly with the rest of our state. */ * entries get torn down cleanly with the rest of our state. */
if (selfListenerMode) disableSelfListenerMode(); roomMachines.selfListener.send('DEMOTED');
dropMic(); muted = false; dropMic(); muted = false;
await sfuUnpublish(); await sfuUnpublish();
await sfuUnpublishScreen(); await sfuUnpublishScreen();
@ -4492,7 +4535,14 @@ let listenerOutputMuted = true;
* Opus path instead of the live WebRTC mesh. Auto-mutes their mic so * Opus path instead of the live WebRTC mesh. Auto-mutes their mic so
* they can't talk into a delayed stream (they'd be 2-4s behind the * they can't talk into a delayed stream (they'd be 2-4s behind the
* conversation); unmuting toggles them back to WebRTC seamlessly. */ * conversation); unmuting toggles them back to WebRTC seamlessly. */
let selfListenerMode = false; /* selfListenerMode is now a derived getter against the SelfListenerFSM
* (roomMachines.selfListener). Single source of truth — direct writes
* to the boolean would drift from the FSM state, defeating the point.
* All transitions go through .send('TOGGLE' | 'ENABLE' | 'DISABLE' |
* 'UNMUTE' | 'DEMOTED' | 'CLEAR'); side effects ride an observer
* attached during room setup (see selfListenerObserver below). */
function selfListenerMode_get(){ return roomMachines.selfListener.state === 'on'; }
Object.defineProperty(window, 'selfListenerMode', { get: selfListenerMode_get });
/* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener /* DJ HTTP stream mode is intentionally NOT auto-enrolled for listener
* phones — Firefox Android refuses autoplay on every fresh <audio> * phones — Firefox Android refuses autoplay on every fresh <audio>
* with src URL and a hard refresh starts the same loop. WebRTC stays * with src URL and a hard refresh starts the same loop. WebRTC stays
@ -4636,9 +4686,10 @@ function toggleStreamFor(uuid, pubHex){
* mute the corresponding remoteAudio (WebRTC) elements so we don't * mute the corresponding remoteAudio (WebRTC) elements so we don't
* hear both paths at once. disable* tears everything down + restores * hear both paths at once. disable* tears everything down + restores
* the WebRTC playback. */ * the WebRTC playback. */
async function enableSelfListenerMode(){ /* Side-effect runners — invoked ONLY by the SelfListenerFSM observer
if (selfListenerMode) return; * below. No internal state mutation, no idempotency guards (the FSM
selfListenerMode = true; * handles re-entry by never emitting a same-state transition). */
function runSelfListenerEnable(){
/* auto-mute mic before we start playing the delayed stream */ /* auto-mute mic before we start playing the delayed stream */
if (micStream && !muted){ if (micStream && !muted){
muted = true; muted = true;
@ -4659,20 +4710,16 @@ async function enableSelfListenerMode(){
if (streamMode.has(pubHex)) continue; if (streamMode.has(pubHex)) continue;
streamMode.add(pubHex); streamMode.add(pubHex);
startStream(uuid, pubHex); startStream(uuid, pubHex);
/* mute the matched WebRTC remote so we don't hear both paths. /* mute the matched WebRTC remote so we don't hear both paths. */
* Self has no remoteAudio entry (we never subscribe to ourselves
* via SFU — see the self-echo skip in handleRemoteSfuTrack), so
* this is a no-op for the self row. */
const w = remoteAudio.get(uuid); const w = remoteAudio.get(uuid);
if (w) try { w.muted = true; } catch(_){} if (w) try { w.muted = true; } catch(_){}
added++; added++;
} }
roomMachines.selfListener.context.enrolledCount = added;
renderRoom(); renderRoom();
logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted'); logLine('', 'self-listener ON — '+added+' peers on buffered HTTP path (incl. self), mic muted');
} }
function disableSelfListenerMode(){ function runSelfListenerDisable(){
if (!selfListenerMode) return;
selfListenerMode = false;
for (const pubHex of [...streamMode]){ for (const pubHex of [...streamMode]){
let foundUuid = null; let foundUuid = null;
for (const [u, mm] of members){ for (const [u, mm] of members){
@ -4681,17 +4728,18 @@ function disableSelfListenerMode(){
streamMode.delete(pubHex); streamMode.delete(pubHex);
if (foundUuid) stopStream(foundUuid); if (foundUuid) stopStream(foundUuid);
} }
/* restore WebRTC playback for every remote — stopStream already /* restore WebRTC playback for every remote */
* unmutes the matched uuid; this catches any others we couldn't
* resolve (e.g. member dropped while we were in DJ mode). */
for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} } for (const [, a] of remoteAudio){ try { a.muted = false; } catch(_){} }
roomMachines.selfListener.context.enrolledCount = 0;
renderRoom(); renderRoom();
logLine('', 'self-listener OFF — back to live WebRTC mesh'); logLine('', 'self-listener OFF — back to live WebRTC mesh');
} }
function toggleSelfListenerMode(){ /* Observer: side effects fire on every off↔on transition. */
if (selfListenerMode) disableSelfListenerMode(); roomMachines.selfListener.observe(({ state, prev }) => {
else enableSelfListenerMode(); if (prev === null || state === prev) return;
} if (state === 'on') runSelfListenerEnable();
else runSelfListenerDisable();
});
/* Default-on DJ mode for listeners: skip the WebRTC mic playback path /* Default-on DJ mode for listeners: skip the WebRTC mic playback path
* for every audible peer and pull HTTP Ogg/Opus instead. Browser's * for every audible peer and pull HTTP Ogg/Opus instead. Browser's
@ -4818,7 +4866,7 @@ function renderRoom(){
streamEl.title = selfListenerMode streamEl.title = selfListenerMode
? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)' ? 'listening on the buffered HTTP stream — click to rejoin the live WebRTC mesh (also unmutes is via the mic button)'
: 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic'; : 'switch yourself to the listener stream (buffered, ~2s behind) — auto-mutes your mic';
streamEl.onclick = () => toggleSelfListenerMode(); streamEl.onclick = () => roomMachines.selfListener.send('TOGGLE');
} }
if (isMod(myRole) && m.uuid !== myUUID){ if (isMod(myRole) && m.uuid !== myUUID){
if (m.role === 'listener'){ if (m.role === 'listener'){
@ -5161,8 +5209,10 @@ $('btn-mute').addEventListener('click', () => {
sendMicState(); sendMicState();
/* Unmuting while self-listener-mode is on means "I want to talk /* Unmuting while self-listener-mode is on means "I want to talk
* again" — tear down the buffered HTTP streams and restore the live * again" — tear down the buffered HTTP streams and restore the live
* WebRTC mesh so the user is back in the now of the conversation. */ * WebRTC mesh so the user is back in the now of the conversation.
if (wasMuted && !muted && selfListenerMode) disableSelfListenerMode(); * The UNMUTE edge on the SelfListenerFSM encodes this — it's a
* no-op when already off, drops to off when on. */
if (wasMuted && !muted) roomMachines.selfListener.send('UNMUTE');
}); });
$('mic-select').addEventListener('change', async (e) => { $('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value; micDeviceId = e.target.value;
@ -5452,7 +5502,7 @@ $('btn-leave').addEventListener('click', async () => {
} }
streamAudio.clear(); streamAudio.clear();
streamMode.clear(); streamMode.clear();
selfListenerMode = false; roomMachines.selfListener.send('CLEAR');
for (const u of [...peers.keys()]) tearPeer(u); for (const u of [...peers.keys()]) tearPeer(u);
/* send 'bye' BEFORE closing the WS — server distinguishes a strong /* send 'bye' BEFORE closing the WS — server distinguishes a strong
* leave (user clicked leave / closed tab) from a hiccup disconnect * leave (user clicked leave / closed tab) from a hiccup disconnect
@ -5502,8 +5552,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace"> <footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br> <span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">0c3035d2de972b24edd97e8b6130f1f3</span><br> md5 <span class="stamp-md5">12a9280b3fcf75a793ad9b6a1b0ebcd1</span><br>
sha256 <span class="stamp-sha">7aa8d55df2a764566d1b1af276961c46109189f992b357a00b6201be94559339</span><br> sha256 <span class="stamp-sha">1562148740ec1d0ca0211574d03f5670757f86c663abd98aee47f951eddc3c48</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br> <span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span> <span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer> </footer>