A badge that would rather say nothing than say nought - #34
Conversation
§8.5's owner-published outputs: /g/{slug}/badge.svg and /g/{slug}/badge.json,
public and CORS-open because the whole point is a page we do not control.
This is the surface with the least room and the most exposure, and none of the
rules bend for it.
The count carries its own age. "15" on somebody's front page is the incumbents'
badge; "15 now · 4m ago" is ours, in nine more characters. That needed
GameSummary to know WHEN the count was measured, which it did not — §10.1 names
that gap as the one place this project contradicts itself, and PlayersNowAt
closes it for the field a third party is most likely to republish. A count with
no age reads as unknown rather than as a bare figure.
An unknown count is never a zero. A game whose WHO we cannot parse renders
"players unknown" in grey; a measured zero renders "0 now" in the accent,
because we got in and nobody was there and that is a real fact. The first would
be our parser's limit printed as a fact about their game, on their own website,
where we could not correct it — rule 4 where it does the most damage.
An archived game says archived rather than showing the last number it had. The
state is decided before the count, so a game that stopped answering in 2023
cannot wear a live-sounding figure.
The accent means measured everywhere on this site and is spent nowhere else, so
an unknown badge is grey.
A game's name is MSSP text and therefore attacker-controlled. It reaches the
accessible title and nowhere else, escaped: an SVG is a document, and a game
named </text><script> would otherwise be a stored injection with a distribution
mechanism. No external font, no remote reference, nothing fetched.
Caching: strong ETag over the exact bytes and If-None-Match through the same
ApiResponse the rest of §10 uses, with max-age=300 — long enough that a popular
front page does not turn its readers into our traffic, and well inside the two
hours after which a count is stale, so a cache entry cannot outlive the
freshness of what it holds. A former slug redirects permanently, which matters
more here than anywhere: a badge is pasted into a template once and left for
years.
Found while testing: the resolver returned a bare null for both "no such game"
and "redirected", so the caller wrote a 404 over the 301 and the forever-redirect
worked on neither route. Two outcomes, two values.
842 tests over five suites, Postgres exercised. 12 new, all of them about what
the badge declines to claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
CI does not run on PRs whose base is not This one has no real dependency on #32 — it is stacked only to avoid a three-way merge on |
|
@coderabbitai review |
|
Tenth link: main → #27 → #32 → #31 → #21 → #28 → #30 → #26 → #29 → #33 → #34. THE BADGE WAS PUBLISHING A GAME'S OWN ASSERTION AS OUR MEASUREMENT, and this merge is where that became visible. This branch added GameSummary.PlayersNowAt — "when the count was measured" — and said so in its own doc comment: "not the whole of §10.1's fix, the codebase still has no chip". #27 landed the whole fix while this branch sat on feat/claim-wiring, so the summary now carries PlayersNowProvenance, a chip with the count's source on it. PlayersNowAt is dropped and the badge reads the instant off the chip; keeping both would have been two answers to one question, which is the thing #27 exists to stop. But an instant is not the interesting half. PlayersNow can come from MSSP PLAYERS — PresenceChoice ranks WHO, then MSSP, then the connect screen — and this badge writes "N players measured 4m ago", returns state "measured" in its JSON, and paints the accent that means measured on every other surface here. On somebody else's front page, where we cannot correct it. That is rule 5 broken by a format string, which is very nearly the sentence §10.1 uses about the unlabelled listing. So Counted now requires ProvenanceChip.IsMeasured — the same predicate ApiMapper.Counted already uses to decide playersNowState, so the badge and /api/games/{slug} cannot disagree about one game. A declared count reads as unknown: three states, no new vocabulary, and the badge says only what we measured. What the game says about itself is on its page, attributed. Nothing covered this: BadgeApiTests used ashen-court only to assert two URLs, and ashen-court is the fixture's MSSP-declared row — put there by #27 precisely as the argument for labelling. ADeclaredCountIsNotPublishedAsA- MeasuredOne now pins it, with m-u-s-h as the measured control, and it fails if the IsMeasured guard is relaxed. Account.razor for the third time: the badge snippet was written against the <li> a claimed game used to be. It is now in the <details> body beside the owner panel rather than in the <summary>, because a <details> nested inside a <summary> is interactive content inside a control. NEEDS A HUMAN: ProvenanceChip.IsMeasured is Handshake-or-Who, so a count read off a connect screen is "declared" — while migration 0003 says in terms that a banner count "is still a measurement of ours". The two disagree, the disagreement predates this chain, and it now decides whether Aardwolf's badge shows a number. I have kept the badge consistent with the API rather than picking a side.
Eleventh and last link of the restack: main → #27 → #32 → #31 → #21 → #28 → #30 → #26 → #29 → #33 → #34 → #35. MIGRATION RENUMBERED. 0012_claim_intent.sql → 0013_claim_intent.sql, since #30's slug history now holds 0012. Thirteen migrations are embedded in MUI.Catalog.dll, 0013 sorts last, and nothing referred to it by number. Three conflicts and one clean merge that did not compile. Account.razor, for the third merge running, and this one had the most in it. The dashboard's status banner is now one else-if chain — resigned, then saved, then refused — because a redirect carries exactly one outcome and two banners at once would be two answers to one action. A claimed game's block holds, in the order an owner would want them: who else owns it, the enrichment panel, the badge snippet, the history, and giving up the claim last. #35 was written when a claimed game was an <li>, so all of it had to move inside the <details> #29 introduced; the co-owner line in particular was inside the <summary>, which is not somewhere a <p> may live. Passkeys.cs maps both write surfaces rather than choosing: MapMuiOwnerWrites is §8.5's enrichment and §11's suppression, MapMuiOwnership is §8.4's counter-claim and resignation. They are different routes. Claim.razor merged clean and broke the build, which is the useful kind of failure. #21 changed this page from IGameQueries to IGameStore on purpose — a submitted game is hidden from the public read until somebody claims it, so looking it up through the listing's own query made claiming the one thing a hidden game could never do — and #35 added three uses of the old Page.Summary against the view model that is no longer loaded. They now read the row, which is what the rest of the page already did. Read #35's tests rather than trusting them, as asked: OwnershipPostgresTests and OwnershipSchemaTests assert ClaimService and the claim_intent schema against a real database, neither of which touches the dashboard markup or the page's lookup, so nothing in them went stale. The dashboard markup itself has no render harness on any of these three branches.
Carries the shared measured/declared predicate and /submit's opt-out refusal down the chain.
#27's FieldSources.IsMeasured now includes banner, and the badge picked that up without a line changing: PlayerBadge.Read and BadgeEndpoints both guard on ProvenanceChip.IsMeasured, which delegates to it. There was no third spelling to fold in — checked, and the only other IsMeasured in the tree are CapabilityFields' (measured-vs-declared FIELD NAMES) and CatalogueSource's (fixture-vs-database), neither of which is this line. But nothing asserted the case the decision turned on. This test had two rows, one on each side of the old line, and Aardwolf — whose count exists only because its connect screen states it — was in neither. Added as the second control: dropping banner from the shared predicate fails this test along with three of #27's own, and passing it back leaves all four green. The MSSP row is untouched and still refuses: a game filling in a structured self-description is reporting rather than being read, and its PLAYERS may be whatever the codebase last cached.
§8.5's owner-published outputs:
/g/{slug}/badge.svgand/g/{slug}/badge.json. Public and CORS-open, because the whole point is a page we do not control.The surface with the least room and the most exposure
A badge is embedded where there is no footnote, no provenance chip and no second sentence. If a number can be published unlabelled, or a silence published as a zero, it happens here first and we never see it. So none of the rules bend:
The count carries its own age.
15on somebody's front page is the incumbents' badge.mu*index │ 15 now · 4m agois this project's claim, in nine more characters.That required
GameSummaryto know when the count was measured, which it did not. §10.1 names exactly this as the one place the API contradicts the rule the project exists to serve —PlayersNowAtcloses it for the field a third party is most likely to republish. It is not the whole of §10.1's fix (there is still no chip on the summary), and the two halves never travel apart: a count with no measurement time reads as unknown rather than as a bare figure.An unknown count is never a zero. Midnight Sun answers and cannot be counted →
players unknown, in grey. Eldertale was probed and nobody was there →0 now, in the accent, because that is a real measurement. The first rendered as0would be our parser's limit printed as a fact about their game, on their own website, where we cannot correct it — rule 4 where it does the most damage.An archived game says
archived. The state is decided before the count, so a game that stopped answering in 2023 cannot wear a live-sounding figure it happens to still have on file.Grey, not green. The accent means measured everywhere on this site and is spent nowhere else, so an unmeasured badge does not wear it.
Safety
A game's name is MSSP text and therefore attacker-controlled. It reaches the accessible
<title>and nowhere else, HTML-escaped — an SVG is a document, and a game named</text><script>would otherwise be a stored injection with a distribution mechanism. No external font, no remote reference, nothing fetched: a badge that pulled an asset would put a third-party request on every page embedding it.Caching
Both routes go through the existing
ApiResponse, so both get a strong ETag over the exact bytes,If-None-Match→ 304,nosniffand the open CORS header.max-age=300, overriding the API's 60: long enough that a game on a popular front page does not turn its readers into our traffic, and deliberately well inside the two hours after whichFieldRegistrycalls a count stale — a cache entry that outlived the freshness of what it holds would serve a stale number under a live label.A former slug redirects permanently, which matters more here than on any other route: a badge is pasted into a template once and left for years.
A bug found while testing
The slug resolver returned a bare
nullfor both "no such game" and "redirected", so the caller wrote a 404 over the 301 it had just set and §5.7's forever-redirect worked on neither badge route. Two outcomes, two values.Verified
dotnet build MUIndex.slnx -c Releaseclean, each suite run directly — 842 tests, 0 failed:Catalog and Crawler against real PostgreSQL via Testcontainers/Podman. 12 new tests, nearly all of them about what the badge declines to claim.
Left undone
GameViewand the surface-guard test for a convenience; the dashboard shows the operator the snippet, which is where they are.Account.razorsnippet block will conflict with Owning a game: add what MSSP has no field for, and take your screen back #29 and Read an operator their own MSSP back, and never call it a fault #33, both of which touch that loop. Trivially.🤖 Generated with Claude Code