The board on Pete shows flat rows; this hands it two more channels so a name
can become a page. Public stats + equipped gear ride the roster snapshot
(RosterDetail on each entry, keyed by the anonymous token, no handle). The
private self-view — inventory, vault, house, pets — rides its own push keyed
by localpart, so Pete only ever serves it back to the one signed-in owner it
belongs to; the board token rides along so the ownership check is a join, never
a reversal of the one-way token. The private set skips no one for opt-out (that
governs the public board only) and skips the dead (no live page to own).
The game-side half of Mischief Makers M3. gogobee polls Pete for storefront
orders and opens the contract itself — the money, the eligibility, the fight
are all its own, exactly as a Matrix !mischief buy.
- roster push now carries each buyer's advisory euro balance (keyed by localpart,
a separate keyspace from the anonymous board token) and the live tier catalog,
so the storefront renders gogobee's current prices and never hardcodes one
- placeWebMischief: the fulfilment path, debit-first-then-refund so the money
state is a pure function of the order guid. DebitIdem + an order_guid stamp on
the contract make a retried claim neither double-charge nor double-open
- resolveRosterToken recomputes the one-way board token per live player to name
a mark; the buyer is @<username>:<homeserver> from Authentik's preferred_username
- a 30s poll loop drives it; a lost verdict just leaves the order pending to
re-run, so the loop is its own retry and needs no durable queue
- euro.HasExternalTx lets the affordability gate tell a first attempt (no debt
for a mischief buy, like Matrix) from a retry of one already paid
order_guid column added to mischief_contracts (schema + idempotent ALTER).
Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.
It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.
The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.
First GET gogobee has ever made to Pete.
We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.
Two halves:
A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.
And a "departure" bulletin when a bored adventurer lets itself out.
The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.
The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.
A. Privacy — the public GUID no longer leaks a stable per-player id.
Replaced userHash(userID)=sha256(userID)[:6] with
eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
The salt is 32 random bytes, auto-generated once and persisted in the
durable news_config table (cached via sync.Once). Because each event
uses a distinct HMAC message, tokens are a PRF output and are BOTH
uncomputable from a Matrix handle (no enumeration of a player's
events, incl. ones anonymized after !news optout) AND mutually
unlinkable (a named event can't be walked back to a player's other,
anonymized events). Updated all emit sites: pete.go zone, dnd_combat
death, adventure_duel rival, dnd_setup arrival, achievements
milestone, bootstrap x3.
B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
the invariant GUID-prefix == event_type, which also fixes latent
permalink mislabels (achv->milestone, rival->rival_result rendered a
neutral "Dispatch" on their permalink pages).
Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.
Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.
Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.