Skip to content

Every slug a game has ever had, beside the games - #30

Merged
HarryCordewener merged 5 commits into
feat/presence-retentionfrom
feat/slug-history
Aug 15, 2026
Merged

Every slug a game has ever had, beside the games#30
HarryCordewener merged 5 commits into
feat/presence-retentionfrom
feat/slug-history

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Aug 15, 2026

Copy link
Copy Markdown
Member

§5.7 promises a forever-redirect from any slug a game has ever had. There was no former-slug table — aliases lived in an operator's configuration, which SlugHistory.cs itself called "a seam and not a store", good for "a handful of URLs and no more". Nothing re-minted a slug either, so the promise had nothing to keep and nothing that could break it.

What changed

migrations/0009_slug_history.sql — the alias table. A row names a game, not another slug. That is the whole design: a game renamed twice has two rows, both pointing at it, so its oldest URL resolves to its current one in a single join rather than by walking a chain — and a cycle is not expressible, because there is no edge between two slugs for one to be made of. slug is the primary key, so one URL can only ever have belonged to one game; the foreign key has no ON DELETE, because nothing is ever deleted and a cascade would only ever fire for a bug.

SlugMinter — the writer, and the only thing that re-mints. A rename is not a re-mint. The URL moves only once the winning NAME has held for a grace period (twice ProbeSchedule.LongestInterval, so a name has survived at least one more probe than the one that introduced it), because §5.7's own reasoning is that a game flipping its name daily would otherwise churn its URL. Stability is read off the change feed, which is the only record of when a value became what it is — GameField.FirstSeenAt is the age of the row and survives a change on purpose. It mints from MsspDefaults.MeaningfulName, the same rule CatalogueBinder lists a game under, so a server whose NAME is its codebase's has not renamed itself.

It runs from ProbeIngestor, after the reconciliation whose NAME it reads and last of everything a probe does — it is the only step there that can fail on a precondition, and a URL postponed to the next probe must never cost a measurement already made. A failed probe renames nothing.

IGameStore.RenameAsync — one statement. The retirement and the re-mint are one write, so there is no instant in which a slug somebody is holding is neither the old URL nor the new one.

Minting asks the history table as well as game.slug. A URL a game gave up is still one a stranger's bookmark points at. A game's own former slug is not taken from it — a game that renames back gets its old URL rather than corvid-2.

The redirect is on the page as well as the API. The URL a stranger holds is /g/{slug}, so an alias table that only redirected /api/games/{key} would leave the half nobody types. Middleware, because a component has no honest way to send a 301 (NavigationManager.NavigateTo under static SSR is temporary). A slug some game still wears is never treated as a former slug — checked, not assumed of the record. The query string travels with the redirect; HEAD is treated like GET; one path segment only.

Loops. The store's query excludes g.slug = h.slug — the one case a row can name a slug that is current again — so a game that took back its own name resolves to itself. The configured path refuses a self-pointing alias.

Archive. Nothing filters on lifecycle state: an archived game's former URLs work exactly as its page does, asserted at the store, the API and the page.

Fixture mode kept. ISlugHistory resolves to the table where there is a catalogue and to ConfiguredSlugHistory where there is not; where both exist the table answers first.

Docs. §10.1's third gap now records the table; the other two are untouched (they are being closed on feat/api-provenance).

Review round: six findings, all verified and fixed

  1. BLOCKER, mine, fixed. UseStatusCodePagesWithReExecute was global, so it answered for all three surfaces this one process serves. Measured on POST /account/passkey/sign-in, which Passkeys.cs answers with a bodiless 401 — without the middleware 401, no content-type, empty body; with it 400, text/html, empty body. Worse than a wrong body: the status itself is rewritten, because the re-execution runs the same method against a page that does not handle it. passkey.js does throw new Error(await response.text()), so a mistyped passkey reported "Bad Request". GET /api/<unmatched> came back as a whole HTML document. Now NotFoundPage.UseMuiNotFoundPage: GET or HEAD, and never under /api — a page is something a reader navigated to, a POST is an endpoint answering for itself (including §8.1's claim check, mapped under /g/, which a path rule would have caught). Switched off per request via IStatusCodePagesFeature rather than wrapped in UseWhen, because inside a branch the re-execution reaches no endpoint and every unknown page comes back empty. SiteHost now maps the API and stands in for the account endpoints, and calls the same helper Program does instead of reassembling the pipeline.
  2. Holds, fixed. The redirect asked the history before checking whether a game still wears the slug. ISlugHistoryStore's query knows which slugs are live; ConfiguredSlugHistory cannot, so one stale SlugAliases entry naming a live slug permanently redirected readers off a working page. Now checked explicitly. It cannot be done the other way round — letting the route answer and rewriting its 404 — because the response has already started when a Razor Components endpoint returns one (measured; HasStarted == true).
  3. Holds, fixed. The re-mint ran before MarkReachableAsync and archive.RestoreAsync, and RenameAsync raises when two games settle on one name in the same cycle. It now runs last and cannot escape: a rename that loses the race is logged and retried next probe.
  4. Holds, fixed. SELECT slug FROM retired yielded nothing when ON CONFLICT DO NOTHING suppressed the insert, so A→B→A→B reported "the slug did not move". It reads previous — what the game was at — and the earliest retirement is still the one kept. The fakes were right and the real one was not; both are now pinned by the same sequence, in Postgres and in memory.
  5. Holds, fixed. $"{stem}-{Guid:N}"[..MaxLength] threw for any stem under 31 characters while the comment above claimed it always terminates.
  6. Holds, fixed. Game.razor's cascading HttpContext stopped being read when the page began signalling not-found; removed.

Also found by the new page tests, and fixed

/g/{unknown} answered 200 with a page reading "No game here" — which tells a crawler to index a game that does not exist. Fixing it turned up why: <NotFound> inside <Router> renders nothing under static server rendering, so every mistyped URL was already answering 404 with an empty body while that paragraph sat in Routes.razor looking like the site's answer. There is now a routable /not-found page carrying it, named by the router's NotFoundPage, and the game page signals not-found rather than duplicating the sentence.

Unrelated pickup

Testcontainers 4.13.0 pulls SSH.NET 2025.1.0, now under a high-severity advisory (NU1903), which fails restore under TreatWarningsAsErrors. Bumped to 4.14.0 → SSH.NET 2026.0.0 — the same one-line change as #26.

Verified

dotnet build MUIndex.slnx -c Release clean; each suite run directly (</dev/null), Catalog and Crawler against real PostgreSQL through Testcontainers on Podman:

  • MUI.Catalog.Tests222 passed, 0 failed
  • MUI.Crawl.Tests136 passed, 0 failed
  • MUI.Crawler.Tests96 passed, 0 failed
  • MUI.Discovery.Tests183 passed, 0 failed
  • MUI.Web.Tests236 passed, 0 failed

Smoke-tested against the running app, with a stale alias configured for a live slug (m-u-s-h = gaslight-row) alongside a real one:

/g/tidewater-nights          301  loc=/g/m-u-s-h
/g/tidewater-nights?plain=1  301  loc=/g/m-u-s-h?plain=1
/g/m-u-s-h                   200  (the live slug keeps its own page)
/g/nope                      404  text/html, "No game here"
/nothing-here                404  text/html, "No game here"
/api/nothing-here            404  no content-type, no HTML
/api/games/never-existed     404  application/problem+json

Three defects in this PR were found by tests rather than by reasoning: QuerySingleOrDefaultAsync<DateTimeOffset?> over max(at) throwing InvalidCastException (Npgsql returns timestamptz as a UTC DateTime; the crawl loop swallowed it, so it presented as "the rename simply did not happen"), the 200-for-a-missing-game above, and the status-code-page scope.

Notes for the merge

🤖 Generated with Claude Code

…sting

§5.7 promises that every slug a game has ever had redirects to it, for ever.
There was no former-slug table: aliases lived in an operator's configuration,
which SlugHistory.cs itself called "a seam and not a store", good for a handful
of URLs and no more. Nothing re-minted a slug either, so the promise had nothing
to keep and nothing that could break it.

game_slug_history sits beside the games, and a row names a GAME rather than
another slug. That is the whole design: a game renamed twice has two rows, both
pointing at it, so its oldest URL resolves to its current one in a single join
rather than by walking a chain — and a cycle is not expressible, because there is
no edge between two slugs for one to be made of. The read excludes the case where
a game has taken back a name it used to have, which is the only way a row can end
up naming a slug that is current again.

SlugMinter is what writes it, and it is the only thing that re-mints. A rename is
not a re-mint: the URL moves only once the winning NAME has held for a grace
period — twice the longest probe interval, so a name has survived at least one
more probe than the one that introduced it — because a game that flips its name
daily would otherwise churn its URL. It mints from MsspDefaults.MeaningfulName,
the same rule CatalogueBinder lists a game under, so a server whose NAME is its
codebase's has not renamed itself. The retirement and the re-mint are one
statement: there is no instant in which a slug somebody is holding is neither.

Minting now asks the history table as well as game.slug. A URL a game gave up is
still one a stranger's bookmark points at, and handing it to a new listing would
silently redirect that reader to a game that never wore it.

Nothing is deleted and nothing is filtered on state, so an archived game's former
URLs work exactly as its page does.

MUI.Web reads the table where there is a database and the configuration where
there is not — the site starts on the demo fixture with no Postgres at all, and
an operator carrying a rename no probe can know about is still doing something
legitimate. The configured path also stops answering an alias that points at
itself, which is a 301 no reader can escape.

Unrelated pickup: Testcontainers 4.13.0 pulls SSH.NET 2025.1.0, now under a high
severity advisory, which fails restore under TreatWarningsAsErrors. Bumped to
4.14.0, which pulls SSH.NET 2026.0.0.

Verified: build clean, and all five suites green — Catalog 221, Crawl 136,
Crawler 92, Discovery 183, Web 221.

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: c03cf527-5c77-4650-ac78-aa4600685903

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.

…g it

§5.7's promise is about URLs people keep — a bookmark, a link in a channel
topic, a search-engine result. All of them point at /g/{slug}, and all of them
still 404'd: the former-slug table redirected /api/games/{key} and nothing else,
which is the half nobody types.

The redirect is middleware ahead of the Blazor route rather than a branch inside
the page. A redirect is a fact about a URL rather than about a game, and the page
is a component with no honest way to send a 301 — NavigationManager.NavigateTo
under static SSR produces a temporary one, and this promise is permanent. It asks
the history and only the history: a hit means the slug asked for is not current
for the game that wore it, because the store's own query refuses to answer with
the slug it was given. A game that took back a name it used to have therefore
resolves to itself instead of bouncing. The query string travels with the
redirect, because plain mode is a real second surface and a reader who asked for
?plain=1 must not be answered with the graphical page. HEAD as well as GET: a
link checker asking whether a URL still works is exactly the reader this is for.

Then the tests, which is where the rest of this came from. MUI.Web.Tests renders
components headlessly, so nothing here had ever looked at a status line. SiteHost
is ApiHost's counterpart — the routed site on a loopback port, composed the way
Program composes it — and the first thing it measured was that /g/never-existed
answers 200. A page that says "no game here" under a 200 tells a crawler to index
a game that does not exist.

Fixing that turned up the reason: <NotFound> inside <Router> renders nothing at
all under static server rendering, so EVERY mistyped URL on this site was already
answering 404 with an empty body, and the paragraph in Routes.razor was dead copy
that looked like the site's answer. So: a routable /not-found page carrying that
paragraph, named by the router's NotFoundPage, with UseStatusCodePagesWithReExecute
in front of it — and the game page signalling not-found rather than rendering its
own duplicate of the same sentence. One answer, whether the route did not match or
a page decided there was nothing at it, with the layout on both paths.

Verified against the running app as well as the suites: /g/{former} 301s with its
query string intact, /g/{unknown} is a 404 that says so, and an archived game's
former URL behaves exactly as a live one's.

Web suite 221 -> 231, and the other four unchanged: Catalog 221, Crawl 136,
Crawler 92, Discovery 183.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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.

…g page

Review of #30 found six, and the first was mine: UseStatusCodePagesWithReExecute
went on the pipeline as a whole, so it answered for all three surfaces this one
process serves. Measured rather than argued — POST /account/passkey/sign-in,
which Passkeys.cs answers with a bodiless 401:

  without the middleware   401, no content-type, empty body
  with it                  400, text/html, empty body

Worse than a wrong body: the status itself is rewritten, because re-execution
runs the same method against a page that does not handle it and its own failure
becomes the answer. passkey.js does `throw new Error(await response.text())`,
so a mistyped passkey reported "Bad Request". GET /api/<unmatched> came back as
a whole HTML document. Now scoped: GET or HEAD, and never under /api. A page is
something a reader navigated to; a POST is an endpoint being asked to do
something and it answers for itself — including §8.1's claim check, which is
mapped under /g/ and a path rule would have caught. Switched off per request
through IStatusCodePagesFeature rather than wrapped in UseWhen, because inside a
branch the re-execution reaches no endpoint and every unknown page comes back
empty.

The harness knew only about pages, which is why nothing caught it. SiteHost now
maps the API and stands in for the account endpoints, and calls the same
UseMuiNotFoundPage Program does rather than reassembling the pipeline by hand.

Then: the redirect asked the slug history before finding out whether some game
still wears that slug. The table's query knows (g.slug <> h.slug); configuration
cannot, so one stale SlugAliases entry naming a live slug permanently redirected
readers off that game's working page. It is checked here now rather than assumed
of the record. It cannot be done by letting the route answer and rewriting its
404 — the response has already started when a Razor Components endpoint returns
one, measured.

The re-mint ran before last_reachable_at and before un-archiving, and RenameAsync
raises when two games settle on one name in the same cycle. The measurement went
down with the URL, the precondition persisted, and §7.5 would archive a game that
answers every probe. It now runs last and cannot escape: a URL postponed to the
next probe is cosmetic, a measurement dropped is not.

RenameAsync read its answer off the insert, so A->B->A->B reported "the slug did
not move" when ON CONFLICT suppressed a retirement that already existed. It reads
what the game was at instead. The fakes were right and the real one was not; both
are now pinned by the same sequence.

And two smaller ones: the last-resort slug appended a 32-character GUID and then
sliced to a fixed length, throwing for any stem under 31 characters while the
comment above it claimed it always terminates; and Game.razor's cascading
HttpContext stopped being read when the page started signalling not-found.

Catalog 222, Crawl 136, Crawler 96, Discovery 183, Web 236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HarryCordewener added a commit that referenced this pull request Aug 15, 2026
#30 reported that a JSON POST to a minimal API mapped after UseAntiforgery() is
refused with 400 in a slim host, even with .DisableAntiforgery(), which would
mean passkey sign-in is broken and §8.2 leaves no other way in.

IT DOES NOT REPRODUCE. Measured against the real host — accounts registered,
Postgres behind them, migrations applied, ASPNETCORE_ENVIRONMENT=Production:

  POST /account/passkey/assertion-options     -> 200
  POST /account/passkey/sign-in  (as sent)    -> 401
  POST /account/passkey/registration-options  -> 200
  POST /account  (Razor route, no token)      -> 400   <- control

The control is the part that makes the rest mean anything: anti-forgery IS live
in that pipeline and refuses an untokened POST one route over. The 401 is the
handler answering, not the middleware — the first attempt without the ceremony's
cookie got a 500 from SignInManager saying no assertion was underway, which is
the handler too.

The mechanism: a minimal API is given anti-forgery metadata only when it binds
FORM data. These bind JSON or a query string, so they carry none and the
middleware passes them through; MapRazorComponents puts metadata on component
routes, which is why the control is refused. And .DisableAntiforgery() sets
RequiresValidation false — a 400 surviving it was never anti-forgery's.

Adding the test anyway. Sign-in is the only door in the building and no suite
opened it: the ones that exercise sign-in are the ones that stub it, and the
composition tests stop at the graph. These drive the real routes through
AddMuiSite/UseMuiSite, with a control that fails if anti-forgery ever stops
being live and would catch the reported failure if it ever became real.

841 tests over five suites, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixth link, and the first of round two: the chain now runs main → #27#32#31#21#28#30.

MIGRATION RENUMBERED. 0009_slug_history.sql → 0012_slug_history.sql: #31
took 0009, #21 took 0010, #28 took 0011. MigrationRunner sorts embedded
resource names ordinally and MUI.Catalog.csproj globs migrations/*.sql into
LogicalName MUI.Catalog.Migrations.%(Filename)%(Extension), so a rename is
the whole change — the file is still embedded and now sorts after 0011.
Nothing in the tree referred to it by number.

Six conflicts.

PostgresData — #30 added the former-slug store with AddSingleton, into a
method #32 had converted to TryAdd throughout precisely because
AddMuiCrawler registers the same objects and one deployable calls both. The
store is registered TryAdd for the same reason the availability store above
it is: two of them would be two pools answering one question.

Program.cs and SiteComposition — the same shape as #21's merge. #30 added
UseMuiNotFoundPage and UseFormerSlugRedirects to a pipeline #32 had moved
into UseMuiSite; they move with it, at the positions #30 put them in — the
404 page before the static files, the redirect after the account routes and
before the route that would answer "not found".

SiteHost now calls AddMuiSite/UseMuiSite instead of restating them, which
its author flagged as a one-call follow-up. Verified it binds rather than
merely compiling: commenting out either middleware inside UseMuiSite fails
seven of #30's tests by name.

ApiHost — #27 added an IGameQueries parameter and #30 added an
Action<IServiceCollection> one, to the same optional slot. Both are kept
and SlugRedirectTests names its argument.

CrawlCyclePostgresTests took both new optional parameters, the spec's §10.1
took #27's prose with #30's paragraph in place of the "one gap remains"
sentence that is no longer true — after this merge all three of the gaps
that section lists are closed.

And the compile breaks a restack earns: #30 gave ProbeIngestor a SlugMinter
and CatalogueBinder an ISlugHistoryStore, which #31's and #21's Postgres
tests construct by hand; Render's StubGameStore grew RenameAsync, throwing,
because it throws for every other writer and a surface reaching for one is
a surface in the wrong layer.
@HarryCordewener
HarryCordewener changed the base branch from main to feat/presence-retention August 15, 2026 16:35
HarryCordewener pushed a commit that referenced this pull request Aug 15, 2026
Seventh link: main → #27#32#31#21#28#30#26.

Two conflicts.

Arguments.cs — this branch moved seed parsing out to CrawlSeed.Parse so
the CLI, the environment variable and compose.yaml all read an address the
same way; #31 added ParseAddress beside the ParseSeed that moved. Kept
ParseAddress where it is and let ParseSeed stay gone: an opt-out address
comes only from the CLI, and it takes an optional port, so it is not the
same parse.

Program.cs — the same shape as the two merges before it. This branch added
configure.Apply(builder.Configuration) to the AddMuiCrawler call, which #32
had moved into SiteComposition.AddMuiSite; the line moved with it.

That move earned a test. CrawlerSettings.Apply has thorough tests and every
one of them calls it on a builder it constructed itself, so all of them
pass on a site that never calls it — and the call has now moved between
files during a restack, which is exactly how it would be lost. The new
CompositionTests case resolves CrawlerOptions out of the site's own graph
with Crawler:Enabled=false and a configured seed, and fails when the line
is commented out. It also pins the half configuration may not do: a seed
that arrives this way is never an operator seed, because §7.2's exemption
is a claim a human makes about one address and an environment variable
copied between deployments is not that human.

Verified after the migration rename in the link below this one: all twelve
migrations are still embedded in MUI.Catalog.dll and 0012_slug_history
sorts last. This branch's new EnsureTheMigrationsWereFound target and the
runner's empty-set refusal both still hold.
HarryCordewener pushed a commit that referenced this pull request Aug 15, 2026
Eighth link: main → #27#32#31#21#28#30#26#29.

THE ANTI-FORGERY REORDER, WHICH WOULD HAVE VANISHED. This branch moves
UseAntiforgery after UseAuthentication, and it was written against the
Program.cs #32 deleted — so it merged clean and applied to nothing. The
rule is reapplied inside the composition, and it is now a named method
rather than three lines: UseMuiAntiforgeryAfterAuthentication.

A method because the test that proves the rule matters built its own copy
of the three lines. OwnerEndpointTests' harness said "Program's order" in a
comment and restated it in code, so it asserted its own ordering and would
have gone on passing with the site's reversed — which is the failure it
exists to catch, one level up. The harness now calls the site's own method
for the correct order and hand-builds only the wrong one, because there is
no other way to build a thing that is not supposed to exist. Verified:
swapping the two lines inside the method fails five of this branch's tests
by name, and it failed none of them before.

The ordering is worth that trouble. An anti-forgery token issued to a
signed-in operator carries their identity; validating before authentication
compares it against nobody and every owner's form post is refused as
forged, while every public page — all GET — goes on working perfectly.

PlainText — #27 factored the plain label into PlainText.Label, this branch
added a third word to it at one of the four call sites. The three-way
choice moved into Label: an owner's answer is owner-declared on the listing,
on the game page and in the archive alike, and "declared" alone would put
what an operator typed into our form and what their config file emits under
one word. That was this branch's own argument for making the distinction at
all; it just has one place to live now.

app.css is two separate blocks, both kept. InMemoryFieldStore grew #30's
LastChangedAtAsync, answering null — which is true of a store whose
RecordChangeAsync already discards what it is handed.

DeclaredOf's empty-value filter survived intact, and the ladder conflict
its author expected with #28 did not happen: #28 never touched
NpgsqlGameQueries. A cleared owner row is still filtered before the ladder
rather than after it, so it cannot win its group and silence the MSSP value
underneath.
HarryCordewener pushed a commit that referenced this pull request Aug 15, 2026
Ninth link, and the first of the three that were based on feat/claim-wiring
rather than on main: main → #27#32#31#21#28#30#26#29#33.

Two conflicts, and one of them is the reason these three were flagged.

Account.razor — this branch put the scorecard link in the <li> a claimed
game used to be, and #29 has since turned each claimed game into a
<details> with an owner panel inside it. Textually the two edits are the
same lines; what the branch meant was "one link per verified claim, on the
dashboard and nowhere else", so the link moved into the <summary> beside
the game's name and the verified date. Taking either side wholesale would
have lost the owner panel or lost the only route to the scorecard.

app.css is two independent blocks, both kept.

Read the branch's tests rather than trusting them: MsspLintTests and
MsspScorecardSurfaceTests assert MsspLint's judgements and that the page is
CLOSED to a stranger, neither of which touches the dashboard's markup, so
nothing here went stale. They also do not cover the dashboard link itself —
noted rather than fixed, because Account.razor has no render harness and
building one for one anchor is a bigger change than this merge.
HarryCordewener pushed a commit that referenced this pull request Aug 15, 2026
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 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.
Carries the shared measured/declared predicate and /submit's opt-out
refusal down the chain.
@HarryCordewener
HarryCordewener merged commit fffa758 into main Aug 15, 2026
3 checks passed
HarryCordewener added a commit that referenced this pull request Aug 15, 2026
* Claiming was complete, tested, and wired to nothing

§8.5 says nothing sets game.is_claimed, so the listing badge and §7.5's ceiling
grace have never been exercised. Claiming shipped in #16 and the note still read
true, so I went looking for why. The claim logic was not the problem — it is
complete and it has tests. The composition was.

The site has two compositions of the same objects. mui-crawl builds the crawl
loop by hand and passes every collaborator; the deployed site assembles it
through DI. Three things were wrong with the second, and none of them could fail
loudly:

CrawlCycle takes its ClaimService as an OPTIONAL parameter, deliberately — a
crawl with no database should do slightly less rather than refuse to run. The
crawler graph registered no ClaimService, so a crawler-only deployment settled
no beacons and said nothing about it.

ClaimService was registered scoped, and the crawl loop that needs it is a
singleton BackgroundService. A scoped dependency is one CrawlCycle can never
legally be given: with scope validation on, which is what `dotnet run` does, the
container refuses to build and THE SITE DOES NOT START with a connection string
set. Production leaves validation off, so there it worked — by accident, and
only there. That is also the answer to §8.5's note: is_claimed was being set in
production and nowhere else.

IClaimStore was registered nowhere at all. Account.razor service-locates it and
reads a null as "this site has no database", so every operator's dashboard was
empty on a site that had their claims, and /g/{slug}/claim/check threw on
request. A service-located dependency fails silently by construction.

Both services are stateless over a pooled NpgsqlDataSource, so both are now
TryAddSingleton, registered by the crawler graph and the accounts graph alike —
the one deployment that runs both gets one of each.

CompositionTests resolves the graph Program builds, under scope validation, in
both environments, and asserts the claim path is really joined. It fails on the
parent commit in seven ways and is the only kind of test that could have caught
any of this: every part was correct and the wiring between them was not.

§8.5's closing note is rewritten to say what is actually true, and to name the
general hazard — an optional dependency and a service-located one both fail
silently when the composition is wrong, and the claim path has one of each.

830 tests over five suites, Postgres exercised. Testcontainers 4.13.0 -> 4.14.0
is the same one-line pickup as #26: SSH.NET 2025.1.0 is now advised against and
NU1903 fails restore on main without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Compose the test from Program's own graph, and make "look sooner" look

Review findings on the composition fix. All four held; each is verified rather
than taken.

CompositionTests restated Program's registrations instead of running them, so a
future divergence — a scoped service consumed by a singleton, AddMuiAccounts
moving — would break the site while all the tests passed. That is exactly the
failure this file exists to catch. The graph moves out of Program's top-level
statements into SiteComposition.AddMuiSite/UseMuiSite, which the deployable and
the test now both call, so there is one copy and nothing to diverge from.

The on-demand check moved nothing. RequestCheckAsync wrote last_checked_at and a
check_requested event; due-ness comes only from crawl_target.next_probe_at, so
no probe ever came of it while the page said the button dialled a real server. A
rate limiter on an action that does not happen is the most convincing possible
no-op. IOnDemandProbes brings the game's targets forward with LEAST — an ask can
only make a probe sooner — and the crawl loop still does the dialling under
CRAWL DELAY and §7.2's address gate. Five Postgres tests assert on the schedule
rather than on the audit log. The button now says what it does: "Look sooner",
brings your game to the front of the queue, we dial on our own schedule.

The remark on TheHostedCrawlerCanBeResolvedInProductionToo described a failure
that never happened — it passes on the parent commit, because Production leaves
scope validation off and resolved the scoped ClaimService from the root. It is a
control, not a finding, and now says so. Measured: the original seven tests fail
five ways on the parent, not seven. With the four added here, ten fail seven.

"A crawler-only deployment" justified the new registrations in three places and
describes nothing: AddMuiCrawler has exactly one caller, MUI.Web's Program, and
mui-crawl builds its graph by hand. §4.11 has one deployable. The real consumer
is the web tier's in-process CrawlCycle, and all three now say so.

Two more while here. The demo composition had no test at all; it has one, and it
asserts the claim surfaces are absent rather than broken and that the page still
admits nothing on it was measured. And the web tier registered its own
NpgsqlAvailabilityStore with AddSingleton, so the crawler's TryAdd was skipped
and the concrete type and IReachableHistory pointed at a second instance —
harmless on one pool and a direct contradiction of the crawler's own comment
that two would be two connection paths answering one question. AddPostgresCatalogue
is TryAdd throughout now, and one object answers to all three names.

838 tests over five suites, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drive the passkey ceremony for real, because nothing ever had

#30 reported that a JSON POST to a minimal API mapped after UseAntiforgery() is
refused with 400 in a slim host, even with .DisableAntiforgery(), which would
mean passkey sign-in is broken and §8.2 leaves no other way in.

IT DOES NOT REPRODUCE. Measured against the real host — accounts registered,
Postgres behind them, migrations applied, ASPNETCORE_ENVIRONMENT=Production:

  POST /account/passkey/assertion-options     -> 200
  POST /account/passkey/sign-in  (as sent)    -> 401
  POST /account/passkey/registration-options  -> 200
  POST /account  (Razor route, no token)      -> 400   <- control

The control is the part that makes the rest mean anything: anti-forgery IS live
in that pipeline and refuses an untokened POST one route over. The 401 is the
handler answering, not the middleware — the first attempt without the ceremony's
cookie got a 500 from SignInManager saying no assertion was underway, which is
the handler too.

The mechanism: a minimal API is given anti-forgery metadata only when it binds
FORM data. These bind JSON or a query string, so they carry none and the
middleware passes them through; MapRazorComponents puts metadata on component
routes, which is why the control is refused. And .DisableAntiforgery() sets
RequiresValidation false — a 400 surviving it was never anti-forgery's.

Adding the test anyway. Sign-in is the only door in the building and no suite
opened it: the ones that exercise sign-in are the ones that stub it, and the
composition tests stop at the graph. These drive the real routes through
AddMuiSite/UseMuiSite, with a control that fails if anti-forgery ever stops
being live and would catch the reported failure if it ever became real.

841 tests over five suites, Postgres exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@HarryCordewener
HarryCordewener deleted the feat/slug-history 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