Skip to content

A badge that would rather say nothing than say nought - #34

Merged
HarryCordewener merged 4 commits into
feat/mssp-scorecardfrom
feat/owner-badge
Aug 15, 2026
Merged

A badge that would rather say nothing than say nought#34
HarryCordewener merged 4 commits into
feat/mssp-scorecardfrom
feat/owner-badge

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

§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.

Stacked on #32 (feat/claim-wiring) only to avoid a three-way merge on Directory.Packages.props; nothing here depends on it. Rebase onto main freely.

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. 15 on somebody's front page is the incumbents' badge. mu*index │ 15 now · 4m ago is this project's claim, in nine more characters.

That required GameSummary to 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 — PlayersNowAt closes 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 as 0 would 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, nosniff and 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 which FieldRegistry calls 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 null for 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 Release clean, each suite run directly — 842 tests, 0 failed:

suite total
MUI.Catalog.Tests 211
MUI.Crawl.Tests 136
MUI.Crawler.Tests 77
MUI.Discovery.Tests 183
MUI.Web.Tests 235

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

🤖 Generated with Claude Code

§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>
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9bb60eb5-e226-4ddd-8373-1d70439d7ab6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@HarryCordewener

Copy link
Copy Markdown
Member Author

CI does not run on PRs whose base is not main, so the checks tab here is empty. Dispatched the same workflow against the branch instead — green on both legs: https://github.com/SharpMUSH/MUIndex/actions/runs/31869717721

This one has no real dependency on #32 — it is stacked only to avoid a three-way merge on Directory.Packages.props. Happy to rebase it onto main on request.

@HarryCordewener

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.
@HarryCordewener
HarryCordewener changed the base branch from feat/claim-wiring to feat/mssp-scorecard August 15, 2026 16:56
HarryCordewener pushed a commit that referenced this pull request Aug 15, 2026
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.
HarryCordewener and others added 2 commits August 15, 2026 14:30
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.
@HarryCordewener
HarryCordewener merged commit cd7de9a into main Aug 15, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/owner-badge branch August 16, 2026 23:52
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