Skip to content

Speak BluMint's 2.0.0 partner API: whole-set creates, close, progress, starting wins - #97

Merged
Confetti3 merged 76 commits into
Confetti3:nightlyfrom
BluMintInc:blumint/contract-pr1-upstream
Oct 17, 2026
Merged

Confetti3 merged 76 commits into
Confetti3:nightlyfrom
BluMintInc:blumint/contract-pr1-upstream

Conversation

@oconnorjoseph

Copy link
Copy Markdown

Draft for your review, please don't merge yet. Please read it and leave feedback whenever you can. Neither of us can test it end to end until BluMint's side (its partner API change) is live on BluMint's staging site; I'll post here when it is, test against it, and push any small fixes to this branch.

CI: GitHub holds workflow runs from a fork until you approve them, so the checks here need your "Approve and run". This PR also adds .github/workflows/bridge-tests.yml (caching, a concurrency group, and the C++ suites as their own job); the same runs pass on the fork, on a branch with this exact code (the same git tree): https://github.com/BluMintInc/SF4-Ember-Netplay/actions/runs/38996022353

Hi Katie,

This PR moves the bridge's BluMint connection onto version 2.0.0 of BluMint's game partner API. It changes how a match is created, refused, closed and reported, and it adds a way to restart a set from a score. Lookup stays the same. Ember's own routes, the signed results_url integrations and the lobby and tournament paths behave as before, apart from the two small exceptions under "Choices worth a look". The branch is cut from release at e505111 and merges release at cd4daa5 (v1.3.0), so it is current with 1.3.0, and it targets nightly like the other feature PRs (nightly carries release). Each commit stands on its own, so you can read it one commit at a time, and the merge commit explains its one conflict: both sides added migration 018 the same way.

What it does

1. Match creation reads the 2.0.0 body and refuses in BluMint's format. POST /v1/blumint/matches reads each player from identities[]: the one entry whose inGameId parses as an Ember ID. Team names, matchSettings and spectators are not read. Every refusal on the creation and close routes is now a top-level { "code": ... } body, plus players where the code names players, in place of the { "error": ... } envelope. BluMint reads only that top-level code. An answer it cannot read is final for BluMint, and BluMint then closes the match. The refusals:

Situation Answer
Not JSON, breaks the schema, too large or cut short, no Idempotency-Key 400 { "code": "invalid_request" }
Not two teams of one player 400 { "code": "match_unsupported" }
A player with zero or two Ember IDs, or the same player on both teams 400 { "code": "invalid_request" }
Seated players with no link on the connection 400 { "code": "player_unlinked", "players": [...] }, naming every unlinked id
A seated player in another unfinished match 423 { "code": "player_busy", "players": [...] }, naming every busy id
The key already names a different creation, or a close named it 409 { "code": "key_reused" }

A database failure still answers 503 in the bridge's own envelope. BluMint reads that as an outage and retries it. Auth failures keep the envelope too.

2. series.gamesToWin, with no default. The set length comes from series.gamesToWin and nothing else. If it is missing, 0 or not an integer, the answer is invalid_request. 1 to 10 is accepted. Above 10 is match_unsupported, and BluMint's host then runs that match. DEFAULT_GAMES_TO_WIN and the matchSettings read are gone.

3. Idempotency-Key. Every creation runs inside idempotent(). The key's owner is the connection, not the credential, since each blumint-register issues a new credential. The digest covers the two players, gamesToWin and startingWins. Asking again under the same key returns the same match for a day, even after that match has ended, and after that answers key_reused for as long as the bridge keeps the match (item 6). A renamed team or edited matchSettings under the key still replays. A changed roster or set length is key_reused. Refusals are never stored against the key. The old pair-based dedupe (active()) is gone.

4. A busy player is 423 player_busy. Before the insert, both players go through ledger::busy. BluMint holds the match and asks again under the same key every minute until the players are free. insert_match's own busy check is unchanged for every other connection.

5. Results, aborts and progress. submission() sends 2.0.0 bodies:

  • result: teams[].wins, with no placement and no score.
  • abort: for a cancelled or failed match, with reason and retryable: true. reports_disagree, report_late and report_missing map to disputed. no_reports and identity_unlinked map to insufficientPlayers. Anything else is serverFailure.
  • expired match: an abort with insufficientPlayers and retryable: false, so BluMint's host takes the match. Policy::silent_expiry is gone.

A new queue, delivery::progress (migration 019_blumint_progress.sql), sends a progress report after each game that raised the score without ending the set. Presence status reports have their own queue (item 13).

Each report body is built when it is sent. A report for a set that has ended by then is dropped. Once a progress report has been sent, an organizer can no longer void a game of the set (409 lease_conflict), since BluMint refuses any later report below the wins an earlier one gave a team, the result and an abort included.

6. The close route, and registration. POST /v1/blumint/matches/close takes { idempotencyKey, reason } only: BluMint names the match by the Idempotency-Key it created it under, and a body with a matchId and no key is 400 invalid_request. The bridge keeps each match's key as the match's own external_match_id, blumint:<key>, for as long as it keeps the match, because the stored answer under the key (idempotency_records) is removed after a day. So a close finds its match through the unique index every match already has, however old the match is, and the key makes no second match. Migration 024_blumint_keys.sql sets that id on each match whose answer is still stored; the random id the route made up before, which nothing read, is gone. A key whose every creation was refused stores no answer, so it is kept for a day in blumint_refused_keys. Any reason from 1 to 64 bytes closes the match, including one the bridge does not know, such as hostOverride. A match that is already over answers 200 and nothing changes. The close also marks the key closed for a day, so any later creation under that key gets key_reused and makes no match. This covers a creation that is still on its way. A key the bridge holds nothing under for the connection answers 404 { "code": "unknown_match" }, and is closed all the same. A repeat of that close answers 404 again, so a close whose first answer was lost gets the same answer when BluMint asks again, also when a creation the bridge refused arrived between the two. The close's own Idempotency-Key header is ignored, because closing gives the same result however often it is asked, and BluMint's close keys can be longer than 128 characters. A closed match sends nothing more.

blumint-register now registers lookup, create and close. The status route and its row are deleted, because BluMint never calls a status URL. The credential goes in authentication.secret, since BluMint's registration routes now refuse apiKey. Once all three calls succeed, the bridge revokes the callback credential any earlier registration handed over, because BluMint keeps one credential per route and has just overwritten it. A registration that fails partway revokes nothing, since BluMint may hold either credential on each route, and running it again finishes the change.

7. Answer mapping. The mapping on release was already right for BluMint, with one change. BluMint's 401 and 403 (a rotated key, or one bound to no game) are retried for the usual day instead of refused, so reports sent while the key is wrong still go out once it is fixed. This matches how the Twitch notifier treats a 401 or 403. It applies to BluMint only: a signed results_url platform's 401 or 403 still refuses its result. The change is the commit "Retry a report BluMint answers 401 or 403" plus the 401/403 row in BLUMINT_QUICKSTART.md, if you'd rather drop it.

8. Lookup. No change.

9. Tests. tests/blumint.rs covers lookup, creation and its refusals, busy players, the close (including a match that has already ended, a match whose stored answer has been removed, a key whose creations were all refused and another connection's key) and registration (including the credential it supersedes). A new tests/blumint_reports.rs covers what the bridge reports: presence statuses and their revisions, each player's Ready from their claims (an older Ember's included), a player going absent, a build refusal reading as present, progress, results, aborts, BluMint's answers and starting wins. It also shows that a Discord account moved to another Ember ID mid-set ends that set with a retryable abort carrying the games already won, that a BluMint match lives 24 hours on a bridge set to 48, while another connection's match keeps the 48, that a game open at that cap does not hold the players, and that a voided game cannot take back wins BluMint has been sent. A shared stand-in BluMint, with an answer queue, lives in tests/common/blumint.rs. The protocol crate has binding tests, and src/tests/room_tournament_test.cxx has a starting-wins case, a case where a fighter drops at 1-1 and rejoins, a binding that waits for a casual game to end, and a fighter rejoining a locked match room. src/tests/tournament_play_test.cxx checks that each claim carries the fighter's own Ready and that a change is claimed at once, and rust/sf4-net checks that the helper reads ready from the game and sends it only to a bridge that lists the feature.

10. Docs. BLUMINT_QUICKSTART.md is rewritten for 2.0.0: the creation body, refusals, close, the reports and how the bridge reads each answer, the data-use terms, and BluMint's answers to your four questions. INTEGRATIONS.md, INTEGRATION_PATHS.md and the server traffic diagram follow. INTEGRATIONS.md also says that match.completed's starting_wins is not in EMBER-TB-001's published schema, which stays as published, so a subscriber validating events against it must allow the field.

11. Questions. They are at the end of this description.

12. Starting wins. When BluMint asks for a set again after a retryable abort, it sends startingWins on every team. After a host reset it sends 0 on every team. The bridge counts those wins in at every layer:

  • Creation: startingWins is on both teams or neither, each value a whole number below gamesToWin.
  • Store: migration 020_match_starting_wins.sql adds starting_wins_p1 and starting_wins_p2.
  • Score: ledger::scores() adds the base, so every report, the snapshot, the assignments and each player's record count it.
  • Binding: every binding stays version 1. A set from 0-0 is the binding byte for byte as before, since starting_wins is left out at 0-0; a set from a score adds starting_wins.
  • Helper and game: the helper view and DecodeBinding carry it, and the room's first binding seats the table's score from it.

13. Presence status reports. BluMint's contract requires every partner to tell it who has shown up, so a later BluMint release can disqualify a player who never joins. The bridge keeps one status per match in its own outbox, blumint_status (migration 021). Whenever a player's presence changes, it sends that status again with a higher revision and an observedAt time:

  • when each player opens the match;
  • when a player presses Ready or takes it back;
  • when each game starts, as running;
  • after each game that leaves another to play, with both players not ready unless one has already pressed Ready for the next game;
  • when a player's Ember stops claiming the match for 136 seconds (routes::blumint::liveness::LEASE_SECS). They then read absent. The lease is the longest a connected helper takes to commit its next claim when one fails: the answer's own request, the 10-second wait, five requests on the attempt that fails (a session renewal's two, the capabilities read, the challenge and the claim) at the helper's 15-second timeout, the 5-second retry wait and the retry's two requests, plus a second for the claim's whole-second stamp. The existing 10-second maintenance pass checks only the claims that lapsed since the previous pass, reading the open matches through migration 023's matches_open index (delivery::status::observe_lapsed), and with no match open it writes nothing. Time the bridge was down does not count against a claim that was live when it stopped. On a restart the bridge first runs the pass it missed at the last moment it is known to have run (its last pass, claim or observed status), so a claim that lapsed before the stop stays lapsed, and then starts the lease again from the restart for each claim on an open match still live at that moment. So match_claims.claimed_at means when a claim's current lease began, which is when the claim was taken except across a restart; nothing else reads it. A status observed far ahead of the clock (a wall clock stepped back) is not followed, so connected players do not read absent until the clock catches up.

A player is absent until their Ember claims the match, present-ready once they press Ready at the match's table or while a game is running or waiting for its reports, and present-not-ready otherwise. Each claim now carries the fighter's own Ready (Claim::ready), read from their seat at the bound table in the room snapshot (TournamentPlay::SeatReady), and Ember claims again as soon as it changes, at most every 2 seconds, instead of waiting out the 10-second interval. Taking Ready back or leaving the seat reads not ready (match_claims.ready, migration 025_claim_ready.sql). A decided game takes back, as it settles, the Ready of every claim from no later than its first report (ledger::unready_before), since the room clears both seats when it ends a played game, before each Ember's claim says so; a later claim, from a fighter who pressed Ready for the next game before this one was decided, stays. A game called off because one fighter took Ready back leaves the other Ready, and they keep reading present-ready. So BluMint can tell the player who stalls from the one waiting on them. A claim without the field, from an older Ember, reads not ready. The bridge lists matches.claim_ready in /v1/capabilities, and the helper sends ready: true only to a bridge that lists it, since an older bridge's Claim refuses an unknown field; a not-ready claim leaves the field out. A bridge that lists it is remembered for the match until a claim carrying it fails; one that does not is asked again on the next Ready claim. A capabilities read that fails fails the claim, which the game retries in 5 seconds, rather than sending a Ready fighter's claim that reads not ready. A player refused for a mismatched build is recorded (match_claim_refusals, migration 022) and reads as present, not absent, so BluMint never removes the player who tried to join, until a claim of theirs on the opponent's build is taken; from then on they follow the lease like any other player. The refusal message now reads "Your Ember version differs from your opponent's. Update Ember, and ask your opponent to update too." in all 14 catalogs, because the bridge compares the two players' builds and no single build is required. This replaces the earlier one-time first-game status.

14. The Discord pages speak to the player. The bridge's Discord connect, move and result pages, and the connect page at embernetplay.link/start, are written for the player in a tournament and name the site by its own name: the bridge's pages name each tenant that finds players by Discord account, from its configured name, and the connect page shows a name the site adds after the bridge ID (/start#<bridge id>/BluMint) as text, keeping only the bridge ID in its address and in the link it copies, which is all an older Ember's Paste link reads; Paste link now also reads past the name, so a link the site sent can be pasted as it is. The move page says that moving stops the old Ember ID's live matches and what each site then does: a site that restarts, such as BluMint, ends the match without the game in progress and starts it again, and a site that reviews disputes may hold it for its organizer, who decides that game, and both pages name the Discord account the Ember ID has now, which connecting disconnects.

15. What it builds on from 1.3.0.

  • Migration 018 (018_room_own_delay.sql). The BluMint migrations are numbered 019 to 025 after it, and 018 and its test are yours, unchanged.
  • The SDK gate (cmake/RunSdkTests.cmake, the BridgeSdk ctest that build-current.ps1 requires). The new CI job runs the same steps on Linux against the bridge this branch builds, and the SDK suite passes unchanged. The SDK's Binding type in sdk/typescript/src/testing.ts gains the optional starting_wins (item 12).

How to test

From server/ember:

cargo fmt --all -- --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked

The SDK suite, against the bridge this branch builds, the same steps as the BridgeSdk ctest. Build it with cargo build --locked -p ember-bridge from server/ember, then from sdk/typescript:

npm ci --ignore-scripts
npm run build
EMBER_BRIDGE_TEST_BINARY="$PWD/../../server/ember/target/debug/ember-bridge" npm test

From rust/sf4-net: cargo test --locked --lib tournament. On Linux, tests/tournament_bridge.rs is Windows-only and runs no tests.

The three C++ suites build with g++ on Linux. Each also needs RoomModel.cxx, RoomModelActions.cxx, RoomModelJson.cxx, RoomModelTournament.cxx, RoomModelServerOwned.cxx and RoomDigest.cxx from src/session/:

g++ -std=c++17 -O1 -pthread -o room_tournament_test src/tests/room_tournament_test.cxx <the RoomModel sources>
g++ -std=c++17 -O1 -pthread -o tournament_fuzz_test src/tests/tournament_fuzz_test.cxx src/session/TournamentAnswers.cxx src/netplay/TournamentPlay.cxx <the RoomModel sources>
g++ -std=c++17 -O1 -pthread -o tournament_play_test src/tests/tournament_play_test.cxx src/netplay/TournamentPlay.cxx <the RoomModel sources>

All of the above pass in the fork's CI on this exact code.

Older clients

  • A set from 0-0, a reset set included, produces the version-1 binding byte for byte. A helper and game from before this PR keep playing every such set.
  • A set restarted from a score produces a binding that carries starting_wins, still version 1. An older helper refuses it as an unknown field (deny_unknown_fields on the binding) rather than seat it at 0-0, before it reads the version. Its claim then stalls without saying why until the match expires, which BluMint hears as a final abort, and BluMint's host takes the match. I accepted that limitation rather than have new helpers send a capability header so the bridge could refuse an old one's claim with incompatible_build: the bridge cannot tell an older helper from a claim's build id, the case needs a set restarted from a score, and the host takes the match either way.
  • A claim gains an optional ready. A helper sends it, as true, only to a bridge whose /v1/capabilities lists matches.claim_ready, since an older bridge refuses a claim with a field it does not know, and a claim without it, from an older helper, reads not ready. The game sends the helper ready with every match_claim; a request without it reads not ready.
  • match.completed gains an optional starting_wins. It is omitted at 0-0, so an existing event's bytes are unchanged.
  • Migrations 019 to 025 are new files, and no applied migration is edited. 020 also lowers the deadline of any BluMint match still open from before the cap to at most a day after its creation.
  • 018 is 018_room_own_delay.sql exactly as nightly and upstream release ship it, with its test, so the BluMint migrations follow it and a bridge that already recorded that 18 runs them as new versions; migrate() skips a version already recorded.

What I could not build here

I worked on Linux, so the Windows client (CMake/vcpkg, the game DLL) was not compiled, and rust/sf4-net/tests/tournament_bridge.rs did not run. The C++ changes are in src/session/ (TournamentAnswers.cxx, TournamentBinding.hxx, RoomModelTournament.cxx, RoomModel.cxx), src/netplay/TournamentPlay.cxx (the claim's ready, and holding the first binding back while a casual game is under way), the one-line ready in src/sf4e/sf4e__NetplayRuntime__Tournament.cxx's claim request, src/common/TournamentLink.hxx (Paste link reading past the site's name, tested in src/tests/join_link_test.cxx, which the core-tests job runs), and three test files, src/tests/room_tournament_test.cxx, src/tests/tournament_fuzz_test.cxx and src/tests/tournament_play_test.cxx. Their room logic is covered by the three suites above, which do build with g++. Please build the client before merging. scripts/build-current.ps1 runs RoomTournament, TournamentPlay, TournamentFuzz, BridgeSdk and HelperRust (which runs rust/sf4-net/tests/tournament_bridge.rs) in its ctest pass, so that build covers the Windows-only tests. No CI runs them, since the core-tests job stops at SF4E_CORE_TESTS_ONLY. Once that build is on staging, BluMint's testers play a restarted set at 1-0 on two Windows PCs, so you don't need to find a tester.

Choices worth a look

  • matches::idempotent is generic over the route's error type (E: From<ApiFailure>). This lets a BluMint refusal travel the Err path and never be stored against the key. Every existing caller infers ApiFailure and is unaffected. It also moved, unchanged otherwise, to routes/idempotency.rs, with matches.rs re-exporting it, because the change pushed matches.rs past CONTRIBUTING's 1,000 lines.

  • BluMintRefusal has UnknownMatch (the close's 404) and Bridge(ApiFailure). Bridge carries the bridge's own failures in its own envelope. From<ApiFailure> maps InvalidRequest to invalid_request and IdempotencyConflict to key_reused, and keeps everything else as Bridge. A refusal commits a write that wrote nothing. A Bridge failure rolls the write back.

  • An oversized or truncated body. The two BluMint routes take the body as Result<Body<GENERAL_BODY>, ApiFailure>. A body over the cap or cut short is refused as invalid_request after authentication, not by the extractor in the envelope. BluMint passes the host's free-form match settings through, so a creation can outgrow 64 KiB.

  • Ending progress reports. I kept BluMint-specific code out of the shared match-ending flow (bump() and friends). Two checks do the work instead. delivery::progress::lease first marks unsent rows of ended matches not_required, looking each row's match up by id, so a pass reads only the matches a report waits on. attempt() then rechecks the match when it sends. The close relies on the same two checks. The visible result is the same: nothing goes out after a match ends. For a moment after a match ends, its rows still read queued.

  • Progress lives in delivery::progress. play::issue and ledger::settle each make one call (game_started, game_settled, keep_reported_wins), and each reads the connection's policy itself, so shared match code carries no BluMint conditions.

  • A game BluMint has been told of cannot be voided. BluMint keeps the wins a progress report gave a team and answers any later report below them with invalid_wins, so a void after one was sent would leave the set unable to report its result or abort. settle() refuses any change that lowers a team's wins once a progress report of the match has been attempted, mid-set or in a correction. A void before anything was sent is still allowed. A progress report is queued only when a game raised the score, keyed by the games won so far; when a void brings a count back, its row is queued again and sent with the score as it then stands.

  • A BluMint match lives at most 24 hours. BluMint stops excusing a player held by a match it could not close 24 hours after that match was made. Without a cap, a set played at its third hour and then left open kept its players busy until hour 27, and BluMint would have disqualified them in that gap. So a connection's policy can cap a match's lifetime (Policy::max_lifetime_secs, 24 hours for BluMint, BLUMINT_MATCH_LIFETIME_SECS), and a match on it stores the end of that cap as matches.ends_by when it is created, as it stores expiry_secs. Its lifetime is the lesser of match_expiry_hours and the cap, and a game started or decided moves expires_at as on any connection, but never past ends_by; expiry reads no connection kind. At that cap the match expires even with a game open or a room lease live, and its open game is aborted. Every other connection's matches keep match_expiry_hours as it is and still wait for an open game.

  • Player records count the starting score. GET /v1/players/{id}/record counts startingWins in a set's won or lost result and in the recent entry's score. Games won and lost still count only games played in the match. Lobby standings are unaffected, since lobby sets start at 0-0.

  • The room's score when a fighter leaves. At a bound table, a fighter leaving keeps the score played, so a fighter who drops at 1-1 rejoins at 1-1. On release the room went back to 0-0, and after the next win the bridge ended the set while the room still waited for a game. A bound room plays one match and its table cannot be re-seated by hand, so nothing there needs the casual reset. The bridge's ledger stays the set's score of record, since spec §16.5 makes the room scoreboard presentation, not the accounting source. A casual table still starts over at 0-0.

  • match.completed's check counts accepted games above the base. Without this, every set with a base would fail the check and answer 503.

  • ledger::Match and NewMatch carry starting_wins. The lobby, tournament and generic paths pass [0, 0].

  • Seeding the table's score. BindTournament seeds the score from startingWins on the room's first binding, 0-0 included, so a casual game scored at the table before the binding does not carry into the set. A first binding that arrives while a casual game at table 0 is Ready, Playing or Paused is refused with WrongPhase, so the casual game's result is never added to the set's score. The state machine (FollowClaim) does not offer the binding while that game is under way, so the room builds no recovery checkpoint for a binding it would refuse, and offers it on the first tick after the game ends. A newer binding for the same set keeps the score played.

  • A locked match room still admits its fighters. The host of a tournament room can lock admission from the room settings. A bound fighter now joins whatever the lock, so one who drops from a locked room can rejoin; a bound room admits nobody else anyway.

  • The close's answer. It answers 200 { "closed": true }. A key-form idempotencyKey is checked for length only (1 to 128).

  • No idempotencyKey metadata. I left { idempotencyKey } out of the match metadata, since bridge metadata keys are lowercase snake_case and nothing reads it.

  • Docs in one commit. The docs change is one commit near the end. I know you usually change docs in each code commit. I kept the code commits to code and tests so each is easier to review. The fixes after it update their own docs.

  • Cleanup commits. Three commits only tidy up, for CONTRIBUTING's size bar:

    • the adapter moved to routes/blumint/.
    • tests/blumint.rs was split into tests/blumint.rs, tests/blumint_reports.rs and tests/common/blumint.rs.
    • the play test helpers moved to tests/common/play.rs behind a small Table trait.

    Commits before the cleanup still have matches.rs just over 1,000 lines.

  • Busy-player cases have their own test. They are in a_busy_player_is_refused_until_their_match_ends, which needed a third linked player.

  • The expiry test uses a one-hour expiry. It shows that a key still replays its expired match. Idempotency records are pruned after 24 hours, the same as the default expiry. At the default, the key's record is gone by the time the match expires, which is still past the 3 hours BluMint needs.

  • tests/discord.rs. The second test's refusal names both players, because the second player there was never looked up, so is also unlinked.

  • A CI workflow for the bridge and room tests. .github/workflows/bridge-tests.yml runs the "How to test" commands above on ubuntu-latest on every PR and on pushes to release: the bridge, SDK and helper tests in one job, and the three C++ suites in another. It caches cargo's downloads and build output, and a newer push to the same PR cancels the run still going. No existing workflow runs them: degraded-connection-experiment.yml runs the core tests and the degraded-connection policy tests only. It is three commits ("Run the bridge and room tests on every push and pull request", "Run the bridge tests once per push, and cache by toolchain" and "Run the SDK tests against the bridge in CI"), so you can keep it or drop it.

Questions for you

  1. Two bridges, and registering BluMint on each. Could BluMint's production run on a bridge of its own, separate from staging? Your docs call bridge.embernetplay.link a staging service whose data may be reset and which carries no uptime promise, and prize matches shouldn't run on a service that may be reset. Here is what we'd suggest, in this order:

    1. Stand up a staging bridge on this PR's build, such as bridge-staging.embernetplay.link, for BluMint staging and our testers. Staging deploys of PR builds like this one, and BluMint's QA, then never touch production. Please send its origin and bridge id, and tell us how a tester's client comes to trust it. BLUMINT_QUICKSTART.md's "What you get" table already describes the two bridges and leaves the staging bridge id as brg_..., so please fill it in there too. The client adds a bridge only when the player approves it in the native UI, so we'd like to know where testers do that, or whether they need a build pointed at it.
    2. Register BluMint staging there. Run ember-bridge blumint-register against BluMint staging's connection on the new bridge. BluMint's close route is live on staging now. It has to be this PR's build: BluMint's registration routes refuse the apiKey that release sends and take authentication.secret. The registration points BluMint staging's lookup, create and close URLs at the new bridge and registers no status URL. Your current staging registrations keep working until it succeeds.
    3. Retire blumint-partner-staging on bridge.embernetplay.link once step 2 succeeds, so that bridge carries production's connection only.
    4. Make bridge.embernetplay.link production, keeping brg_0dbc0598-2312-4ce3-9df8-e160330565e6, with its own BluMint connection and credentials. It is the client's default bridge, so players need no client change and keep the connect link they have today. Nobody but you has linked Discord there, so there are no player links to carry over, and you're free to reset its data before it goes live.
    5. Register BluMint production with ember-bridge blumint-register against the production connection, once BluMint tells you its release has deployed to production. BluMint's close route is not on production until then.

    Once all three of its calls succeed, each registration revokes the callback credential an earlier one on the same bridge handed over, so only the newest works. If the staging bridge will take a while, tell us, and you can run step 2 against blumint-partner-staging on today's bridge in the meantime.

  2. Data-use terms. Will you accept BluMint's published terms in writing in this thread? Ember uses and keeps the ids BluMint sends, and the ids its lookup returns, only to seat players in BluMint matches, report those matches and let listed staff watch. It shares them with no one else and keeps them secure. BluMint issues the production key only once you accept them.

Thanks for looking at this.

The adapter is about to grow a refusal format, a close route and reports
after each game, which would push one file past the size CONTRIBUTING
asks for. This moves routes/blumint.rs to routes/blumint/mod.rs with no
change, so the next commits can add files beside it.
BluMint's partner API 2.0.0 sends each player as a list of identities,
and the Ember ID its lookup answered is one of them, tagged
"source": "partner". The set's length is series.gamesToWin, beside
matchSettings rather than inside it, and it has no default. BluMint reads
a refused creation by a code at the top level of the body only, and
files the bridge's error envelope as an answer it cannot read, which is
final for the match.

The creation now picks the one Ember ID among each player's identities
and reads the length from series. A missing or malformed length is
invalid_request, and one above first to 10 is match_unsupported, so
BluMint hands that match to its host. Anything but two teams of one is
match_unsupported. Every player without a link on the connection is
named in one player_unlinked answer, so BluMint can look them all up
again before it asks again. Refusals answer { "code": ... } through
BluMintRefusal; the bridge's own failures keep the envelope.

The creation moves to routes/blumint/create.rs.
BluMint sends an Idempotency-Key with every match creation and asks again
under the same key until it has an answer, and it sends a new key when it
wants a new match. The bridge matched a retry by its players and set
length instead, because BluMint used to send no key, so it could not tell
a lost answer from a new request.

A creation now goes through matches::idempotent under the key, scoped to
the connection rather than the credential, since each registration issues
a new credential. The same key answers the same match, even after the
match ends, and team names or BluMint's own settings can change under it.
Another set length or other players under the key answer 409 key_reused,
and a creation without a key is invalid_request. A refusal is never
stored against the key, so the same key works once the player is linked.
idempotent takes the route's own error type for that, and the pair
lookup is gone.
A creation naming a player who is still in another match was refused
with the bridge's 409 lease_conflict, which BluMint reads as final. BluMint
holds such a match instead and asks again under the same key while the
other match ends, but only when the answer is 423 with the code
player_busy and the busy players named.

Once both players are linked, the creation now checks each of them for
another unfinished match on the connection and names every busy one, in
the order BluMint sent them. Nothing is stored against the key, so the
same key creates the match once the players are free.
…l abort

BluMint's partner API 2.0.0 takes a report with a mode. A finished set is
a result whose teams carry wins, not score. A match that ended without a
result is an abort with a reason and whether BluMint may ask for it again,
and its teams carry the wins so far, so a game the abort overtook is not
lost when BluMint asks for the set again. mustRestart is gone.

Results now go as mode "result". A cancelled match goes as a retryable
abort: disputed when the fighters' reports disagreed or one never came,
insufficientPlayers when nobody reported or a link ended, serverFailure
for anything else, such as a provider calling it off. An expired match,
which nothing could be told before, now goes as an abort BluMint must not
retry, insufficientPlayers, so its host takes the match. That ends the
silent expiry, so Policy::silent_expiry is gone.

The reports move to routes/blumint/report.rs.
The BluMint tests need a set played for real, through claims, the room,
permits and reports, to see what the bridge tells BluMint when a set's
first game starts. Those helpers were local to tests/play.rs. They move
to tests/common/play.rs unchanged, over a small Table trait for the two
fighters and their bridge, and tests/play.rs uses them from there.
BluMint shows a set's score while it is played and counts the set as
started once it hears so, which closes the window in which its host can
still undo the previous match. With only the result, BluMint heard
nothing until the set was over.

The bridge now queues a status report when a set's first game is
permitted, with each player's presence, and a progress report after each
game won that does not end the set. A draw, an agreed abort or a voided
game changes no score and queues nothing, and the game that ends the set
is the result. The reports go out from their own delivery queue
(delivery::progress, table blumint_progress in migration 018), on the
same schedule and with the same reading of BluMint's answers as results,
except that a status BluMint answers 404 to is tried again, as BluMint
asks. Each body is read from the ledger when it is sent, and nothing is
sent once the match has ended. Policy::reports_progress says which
platforms hear this.

The BluMint tests now script BluMint's answers and play a set through
the shared play helpers, and pin how each answer is read.
BluMint's registration routes take the credential to call a partner back
with as authentication.secret, and refuse a body that sends it as apiKey
with 400, so blumint-register fails at its first route against BluMint
today. Registration now sends secret, and the test reads the credential
from there.
BluMint closes a match it no longer needs, because the bracket match was
decided, replaced or removed without it or its host took it over, and it
waits for the close before it creates the players' next match. Ember had
no close, so such a match stayed open and kept its players busy until it
expired. BluMint also never calls the status route, which it no longer
documents.

POST /v1/blumint/matches/close now ends the match it names, or, when
BluMint never had the creation's answer, the match made under the
creation's Idempotency-Key. A closed match frees its players and is sent
nothing more, no result, abort or progress. A closed key answers
key_reused to every later creation, including one still on its way, so
it can never make a match nobody plays. Every reason closes the match, a
second close answers 200 too, and a match the bridge did not make for
the connection is 404 unknown_match. The close's own Idempotency-Key is
not read, since closing is the same however often it is asked and
BluMint's close keys can be longer than 128 characters.

Registration now tells BluMint the lookup, create and close URLs. The
status route and its match_status are gone; presence moves to the
reports, where the first game's status uses it.
BluMint answers 401 to a key it does not know and 403 to a key bound to
no game. Both come from a key that was rotated or set up wrong, which its
operator fixes, and every result and progress report sent meanwhile was
refused for good. BluMint's answers of 401 and 403 are now retried on the
usual schedule for a day, like a 429, so the reports go out once the key
is right. Every other 4xx is still refused, and a results_url platform's
401 or 403 still refuses its result, as before.

This is its own commit so it can be dropped on its own.
When BluMint asks for a set again after its match ended without a
result, it sends startingWins on every team, the games each had won, and
counts them in every report it takes. The bridge started every set at
0-0, so a set cut short at 1-0 began again from nothing, and a report
from it would be refused as lower than the score BluMint already had.

The creation now reads startingWins, on every team or on none, each a
whole number below the set's length, and makes them part of what one
Idempotency-Key names. The match stores them (migration 019) and the
ledger counts them into its score, so the set ends when a team reaches
its length in all, and every report, the snapshot and the assignments
read the same score. match.completed names them when they are not 0-0,
and checks one accepted game per win past them.

The room binding carries them too, so the room starts from the same
score. A binding from 0-0 is the version-1 binding, byte for byte, so a
helper from before this keeps playing every such set, a reset set
included. One that starts from a score is version 2, which an older
helper refuses rather than seat the set at 0-0. The helper passes them
to the game, and the SDK's Binding type names them.
A tournament binding can now carry the score its set starts from, when
the platform asked for the set again after its match was cut short. The
game read no such thing, so the room's table started the set at 0-0 and
would play it to its full length while the bridge counted the earlier
games.

The game now reads starting_wins from the helper's binding, 0-0 when a
helper sends none, and refuses a slot at or above the set's length. The
room's binding keeps them, in the snapshot only when they are not 0-0,
so a set from the start keeps the bytes it always had. The first binding
of a room seats the table's score from them. A newer binding for the
same set, after a fighter's endpoint changed, keeps the score the table
has played to, so a mid-set change does not drop 1-1 back to 1-0.
BLUMINT_QUICKSTART.md described the 1.3.0 contract: inGameId at the top
of each player, gamesToWin in matchSettings with a default, a busy 409,
the status route, mustRestart and the silent expiry, plus questions for
BluMint that BluMint has since answered, and links that now work.

It now gives the 2.0.0 creation body with its Idempotency-Key, series and
startingWins, the refusals and their codes, the close route, the status,
progress, result and abort reports with the abort reasons, how the bridge
reads each of BluMint's answers, the data-use terms, and BluMint's
answers to the four questions. INTEGRATIONS.md, INTEGRATION_PATHS.md and
the server traffic diagram follow.
matches::idempotent now serves BluMint's creation as well as the match,
lobby and tournament routes, and its change for that pushed matches.rs
past the 1,000 lines CONTRIBUTING asks for. It moves unchanged to
routes/idempotency.rs, and matches.rs re-exports it, so its callers
are as they were.
tests/blumint.rs had grown to about 1,500 lines with the reports, the
close and starting wins. The stand-in BluMint and the helpers move to
tests/common/blumint.rs. tests/blumint.rs keeps lookup, creation and its
refusals, the close and registration, and tests/blumint_reports.rs takes
what the bridge reports: the first game's status, progress, results,
aborts, BluMint's answers and starting wins. No test changes.
A creation or close body over the bridge's 64 KiB cap, or cut short, was
refused by the body extractor before the handler ran, so it answered in
the bridge's { "error": ... } envelope. BluMint reads only a top-level
code, so it filed that answer as one it cannot read, which is final, and
closed the match by key. The host's match settings are free-form, so a
creation can outgrow the cap.

Both routes now take the body as a result and refuse a failed one as
400 { "code": "invalid_request" } after authentication, which BluMint
hands to its host without a close.
A player's record decided a set from the games accepted in its match
alone, so a set BluMint asked for again at 1-0 and won 2-0 read as
neither won nor lost: sets.won missed it, the recent entry said "lost"
with 1-0, and the opponent's record never counted the loss.

The record now reads the match's starting wins and counts them in the
set's result and in the recent entry's score. Games won and lost still
count only the games played in the match. Lobby standings are unchanged,
since a lobby set always starts at 0-0.
Each progress pass first marks not_required the unsent rows whose match
has ended. It found those matches with match_id IN (SELECT id FROM
matches WHERE state IN ...), and nothing indexes matches by state, so
while any row waited (a status retrying a 404, or a row on a connection
with no key) every pass scanned every match inside the write lock: about
80 ms a pass at 200,000 matches, at least once a second.

The sweep now checks each unsent row's own match by its primary key,
which costs the same whatever the match history.
BluMint frees a player held by a match it could not close only for 24
hours after the match was made: past that, it reads the player as busy
for real, holds their next match and in the end disqualifies them. The
bridge moved a match's deadline a whole lifetime past each game, so a
BluMint set played at its third hour, then left open by a close that
failed, kept its players busy until its 27th, three hours after BluMint
stopped excusing them.

A connection's policy now says whether its matches keep the deadline
they were made with, and a BluMint connection's do: a game started or
decided no longer moves it, and a set still open 24 hours after its
creation ends between games as the final abort an unplayed match gets.
Every other connection's sets move their deadline as before.
A seated fighter leaving set the table's score to 0-0, which was
harmless while every bound set started there. A set started from 1-0
now dropped to 0-0 when the opponent's connection fell before the first
game. After one more win the bridge had ended the set at 2-0 while the
room showed 1-0 and waited for another game.

A fighter leaving a bound table now resets its score to the binding's
starting wins, so a fighter who drops before the first game comes back
to them. A newer binding that moves a fighter to another endpoint sends
the old one away, and that no longer resets the set: the table keeps the
score it had played to, as the binding comment always said.
A BluMint match keeps the deadline it was made with, and that deadline
was match_expiry_hours after its creation, a value the bridge's operator
may raise to 14 days. BluMint frees a player held by a match it could
not close only for 24 hours after the match was made, so on a bridge
set to 48 hours a match left open by a failed close kept its players
busy for a day after BluMint stopped excusing them, and BluMint could
disqualify them for it.

A match on a connection whose matches keep their first deadline now
lives the lesser of match_expiry_hours and 24 hours. Every other
connection still takes match_expiry_hours as it is.
A fighter leaving a bound table set its score back to the binding's
starting wins. In a set at 1-1 from 0-0, a fighter whose connection
dropped came back to a room showing 0-0. After the next win the bridge
ended the set at 2-1 while the room showed 1-0 and waited for a game
that could no longer start.

A bound table plays one set, so a fighter leaving it now keeps the
score it has played to, and only a casual table starts over. The first
binding always seeds the score from its starting wins, 0-0 included, so
a casual game scored at the table before the binding does not carry
into the set. A newer binding that moves a fighter no longer needs to
save and restore the score around sending the old endpoint away.
The quickstart said the bridge ends a match nobody plays 24 hours after
its creation. A BluMint match lives the lesser of match_expiry_hours and
24 hours, so on a bridge set below a day it ends sooner. The quickstart
now gives 24 hours as the latest it ends, as INTEGRATIONS.md does.
The played-score test was added between the casual-room test and its
comment, so the comment read as the first line of the new test's.
No workflow runs the bridge workspace, the helper's tournament module or
the room tournament suites: the core-tests job stops at
SF4E_CORE_TESTS_ONLY. This workflow runs the server workspace's fmt,
clippy and tests, the helper's tournament unit tests, and the
RoomTournament, TournamentFuzz and TournamentPlay suites built with g++,
the same commands the PR's test steps give.
An organizer could void an accepted game of a BluMint set after a
progress report counting it had reached BluMint. BluMint keeps the wins
a report gave a team and answers any later report below them with
invalid_wins, so the set's result or abort was refused for good. And
since progress rows are keyed by the games won so far, the next win
reused a key already queued or sent, and INSERT OR IGNORE dropped that
report.

settle now refuses, with lease_conflict, any change that lowers a team's
wins on a connection that reports progress once a progress report of the
match has been sent at least once, mid-set or in a correction. A row
that comes back after a void is queued again, so it is sent with the
score as it then stands; its attempt count is kept, so an older lease
cannot record on it. The progress checks now live in delivery::progress
(game_started, game_settled, keep_reported_wins) instead of at each call
site, and expiry::extend takes the match.
…nizer game

The status was queued only for a permit with seq 1. An organizer's game
recorded before the fighters' first takes seq 1, so their first permit
was seq 2 and BluMint never heard the set had begun. After an organizer
draw, nothing told BluMint the set was under way until a game raised the
score, and its host's window to undo the previous match stayed open that
long.

Every permit now queues the status, and the status row's key, games
played 0, keeps it to one per match.
…tion

On a BluMint connection a game no longer moved expires_at, so on a bridge
whose match_expiry_hours is under 24 a set still being played expired
between two games once its first deadline passed. BluMint was then sent
a final insufficientPlayers abort for a set both players were playing.
BluMint's window needs only the upper bound: no BluMint match outlives a
day after its creation.

extend now moves a BluMint match's deadline as on any connection, but
never past BLUMINT_MATCH_LIFETIME_SECS after its creation.
Policy::fixed_lifetime is Policy::capped_lifetime, and the docs say
games move the deadline up to that cap.
The sweep skipped a match with an open game or a live room lease, so a
BluMint match with a game permitted shortly before its 24-hour cap, or a
claim that kept renewing its lease, outlived the cap. Its players stayed
busy after BluMint stopped counting them as held, and BluMint could then
disqualify them.

A match on a capped-lifetime connection now expires once the cap has
passed, whatever is open in it, and its open game is aborted in the same
transaction, as a cancellation does. Every other match still waits for
its open game and its lease.
The 24-hour cap applied only to matches made after the deploy. A
BluMint match still open from before it kept the deadline the old
extend gave it, a whole lifetime after its last game, so it could hold
its players past the day BluMint counts them held.

Migration 019 now also lowers each open BluMint match's deadline to at
most a day after its creation, and its expiry_secs to at most a day, as
a new match gets. 019 has not shipped, so the clamp is a second
paragraph of it rather than a new version.
…when it is read

A Ready fighter could read present-not-ready, so the player who stalls
and the one waiting on them looked the same to BluMint.

- A game's end no longer clears both claims' Ready. The room does not
  always clear both seats: a fighter taking Ready back calls the game
  off for both and leaves the other Ready, and a fighter may press
  Ready for the next game before the bridge decides this one. Each
  Ember claims a change of its own seat at once, so the bridge needs no
  clear of its own.
- A capabilities read that fails fails a Ready claim, which the game
  retries in 5 seconds, instead of sending it without ready, which the
  bridge stored as not ready and the game took as already said.
- Only a Ready claim reads the bridge's capabilities; a not-ready claim
  leaves the field out, which reads the same on every bridge. A bridge
  that reads ready is remembered for the match until a claim carrying
  it fails, so a bridge put back to one that refuses the field is asked
  again rather than refusing every claim for the rest of the match. One
  that does not read it is asked again on the next Ready claim, so a
  bridge upgraded while the match is played is heard.
- The guide says how a game's end leaves each player's Ready.
The move page says the old Ember ID's live tournament matches end with
its links, without the game in progress, and start again from the games
already won. The move and connect pages name the Discord account the
Ember ID has now, which connecting disconnects. The quickstart says a
cancel that never reaches the bridge leaves the page usable until the
sign-in lapses, and INTEGRATIONS.md says the answer carries the page's
own secret, not the sign-in's state.
Each team's wins in a progress report, a result and an abort count the
starting wins and every game so far, not the one game's outcome. The
progress example is a first to 3 at 2 to 1.
A pull request's push no longer also runs the workflow as a push, and a
newer push cancels the run it supersedes. The cargo cache is keyed on the
pinned toolchains as well as the lock files, and CI builds without
incremental state or full debug info, which only grow it. The C++ suites
run as their own job beside the Rust one, build side by side, and skip the
package list refresh when the runner already has nlohmann-json.
…s them

The bridge's Discord pages and the connect page speak to the player in a
tournament, who uses the site rather than owns it, and name the site by
its own name in place of "your tournament site". The bridge's pages name
each tenant that finds players by Discord account, from its configured
name, and say which of them start an ended match again from the games
already won. The connect page shows a name the site adds after the bridge
ID (/start#BRIDGE/NAME) as text, and keeps only the bridge ID in its
address and in the link it copies, which is what Ember reads.
bridge.embernetplay.link, with its bridge ID, is BluMint's production
bridge, and a separate staging bridge serves BluMint staging and its
testers. The staging bridge's ID is left for its operator to fill in.
…t hears them

The delivery module and the delivery pass say the progress and status
queues serve a platform whose policy reports progress, as the policy flag
that gates them does, rather than naming BluMint as if it were the
mechanism.
A helper that predates starting wins refuses a binding carrying them as an
unknown field before it reads the version, so a second version guarded
nothing, and a new helper's version check only refused bindings the bridge
never signs. The binding names its starting wins under VERSION, as every
other signed object pins its one version.
A BluMint match's external_match_id is blumint:<key>, so a close finds the
match through the unique index every match already has, and migration 023
fills it from the stored answers rather than adding a second key column
and index. The id the route made up, which nothing read, is gone.
A match whose platform caps its lifetime (Policy::max_lifetime_secs,
BluMint's 24 hours) stores the end of that cap when it is created, as it
stores expiry_secs. extend is one statement on the match ID again, and the
sweep reads ends_by, so expiry reads no connection kind and names no
platform. Migration 019 sets ends_by on the BluMint matches it caps.
…a down span

A bridge that starts again moves each claim still live at its last run to
start its lease from now, so claimed_at is when the claim's current lease
began, and every read of a claim is its claimed_at plus the lease. The SQL
spliced in by a macro, the down_since and resumed_at columns and their
parameters are gone, and reading a status no longer reads bridge_liveness.
The room refuses a first binding while a casual game at the match's table
is Ready, Playing or Paused, and FollowClaim offered it again every second
meanwhile, each offer building and dropping a recovery checkpoint on the
game thread. FollowClaim now waits for that game to end, and offers the
binding on the first tick after it.
018 describes only the per-game progress rows it holds, 020 no longer
deletes status rows no database has, and the three write their foreign
keys as REFERENCES matches(id), as every earlier migration does.
- Name the matches' starting score starting_wins in its columns and
  migration, as everywhere else; correct the progress queue's comment on
  what indexes matches; list max_lifetime_secs in the documented Policy;
  map the BluMint tests in STATUS.md; document the new public items; and
  rewrap the quickstart's ragged paragraphs.
- Declare the composite-key tables of 018, 021 and 023 WITHOUT ROWID, and
  write the sweep's mark only while a match is open, so an idle bridge's
  maintenance writes nothing; a restart finds the last run from claims and
  statuses when no sweep has been marked.
- Count failed matches as ended in the report-settling test helper, end
  the room tests' games through one helper, and name two tests by what
  they show.
Paste link reads past the site's name after the bridge ID (/start#BRIDGE/NAME), as a site sends it in a message or email, so a player who copies that link rather than the page's own can paste it. The integration guide and the page's comments give the exact names the page shows, 1 to 40 letters, digits, spaces and . ' & -, and say the page drops any other.
The Discord move and connect pages said every site's match ends and its game in progress does not count. That holds only for a site that restarts a match (BluMint); a site that reviews disputes may hold the match for its organizer, who may count the game. Each site's sentence now says which.
A creation refused after the first close of a key the bridge held nothing under recorded the key as refused, so a repeated close answered 200 instead of the 404 the first gave. The close now reads its own mark first.
The quickstart presented bridge.embernetplay.link as BluMint's production bridge, though its only BluMint connection is staging's. Both columns are now planned, with Not built yet saying what is missing. Testing on staging trusts the staging bridge under Linked accounts and connects Discord from its connect page, not the Discord row, which uses the production bridge. It also gives the names the connect page shows and that Paste link takes the link with the name, and says a creation refused after a close still answers its 400.
Upstream release ships 018_room_own_delay.sql as version 18, and migrate() keys applied migrations by version alone, so the BluMint migrations, none of them shipped, move to 019 to 025, and 018 is upstream's file as it ships. A bridge that has run upstream's 18 then runs these as new versions, and the fork takes upstream without a clash in its numbers.
The room clears both seats when it ends a decided game, but the bridge kept each claim's Ready until that fighter's next claim, so BluMint read both fighters Ready between games for up to a few seconds. Settling a decided game now clears the claims from no later than its first report. A later claim, a fighter pressing Ready for the next game before this one is decided, stays, and so does a called-off game's.
db.rs then matches upstream release's at 18, its test included, so taking upstream keeps this side of db.rs.
claim_match sends through send_claim, which the test drives with a failing send, so the step that forgets a bridge reads ready once a claim carrying it fails is covered rather than called by hand.
Brings the branch up to release at cd4daa5.

Conflicts:
- server/ember/bridge/src/db.rs: both sides added migration 018
  (room_own_delay) and its test identically; this branch also adds
  migrations 019 to 025 and two tests after them. Resolved to this
  branch's side, which keeps every line of release's file and numbers
  019 to 025 after release's 018. No renumbering was needed: release
  has no migration past 018.

Auto-merged without conflict: locales/*.po, rust/sf4-net/src/tournament/mod.rs,
src/session/RoomModel.cxx, src/session/TournamentAnswers.cxx and
src/tests/tournament_fuzz_test.cxx.
release's BridgeSdk ctest (cmake/RunSdkTests.cmake) runs the
TypeScript SDK suite against the ember-bridge its tree builds, and
build-current.ps1 refuses a run without it. The bridge job now runs the
same gate on every push and pull request: build ember-bridge, then
npm ci --ignore-scripts, npm run build and npm test with
EMBER_BRIDGE_TEST_BINARY pointing at it.
The Binding type's comment named a version 2 that no binding carries:
a set from a score adds starting_wins to a version 1 binding.
Confetti3 added a commit that referenced this pull request Oct 17, 2026
Joseph O'Connor's branch (BluMintInc/SF4-Ember-Netplay
blumint/contract-pr1-upstream at e36a625) plus seven review fixes: the room
model's starting wins read builds as C++14 for MSVC, one GameUnderWay check,
the Discord pages in their own file, matches an older bridge left open ended
as the bridge starts, a status row that does not parse failing on its own, a
256 KiB cap for BluMint match creation, a second BluMint connection in the
cross-connection close test, and docs that match the bridge.
@Confetti3
Confetti3 merged commit e36a625 into Confetti3:nightly Oct 17, 2026
@Confetti3

Copy link
Copy Markdown
Owner

I accept BluMint's published data-use terms for Ember's integration. We'll use and keep the IDs BluMint sends and those returned by its lookup only to seat players in BluMint matches, report those matches, and let listed staff watch. We won't share those IDs with anyone else, and we'll keep them secure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants