Every slug a game has ever had, beside the games - #30
Conversation
…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>
|
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 |
…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>
|
@coderabbitai review |
|
…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>
#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.
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.
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.
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.
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.
* 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>
§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.csitself 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.slugis the primary key, so one URL can only ever have belonged to one game; the foreign key has noON 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 winningNAMEhas held for a grace period (twiceProbeSchedule.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.FirstSeenAtis the age of the row and survives a change on purpose. It mints fromMsspDefaults.MeaningfulName, the same ruleCatalogueBinderlists a game under, so a server whoseNAMEis its codebase's has not renamed itself.It runs from
ProbeIngestor, after the reconciliation whoseNAMEit 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 thancorvid-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.NavigateTounder 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;HEADis treated likeGET; 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.
ISlugHistoryresolves to the table where there is a catalogue and toConfiguredSlugHistorywhere 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
UseStatusCodePagesWithReExecutewas global, so it answered for all three surfaces this one process serves. Measured onPOST /account/passkey/sign-in, whichPasskeys.csanswers with a bodiless 401 — without the middleware401, no content-type, empty body; with it400, 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.jsdoesthrow new Error(await response.text()), so a mistyped passkey reported "Bad Request".GET /api/<unmatched>came back as a whole HTML document. NowNotFoundPage.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 viaIStatusCodePagesFeaturerather than wrapped inUseWhen, because inside a branch the re-execution reaches no endpoint and every unknown page comes back empty.SiteHostnow maps the API and stands in for the account endpoints, and calls the same helperProgramdoes instead of reassembling the pipeline.ISlugHistoryStore's query knows which slugs are live;ConfiguredSlugHistorycannot, so one staleSlugAliasesentry 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).MarkReachableAsyncandarchive.RestoreAsync, andRenameAsyncraises 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.SELECT slug FROM retiredyielded nothing whenON CONFLICT DO NOTHINGsuppressed the insert, soA→B→A→Breported "the slug did not move". It readsprevious— 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.$"{stem}-{Guid:N}"[..MaxLength]threw for any stem under 31 characters while the comment above claimed it always terminates.Game.razor's cascadingHttpContextstopped 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 inRoutes.razorlooking like the site's answer. There is now a routable/not-foundpage carrying it, named by the router'sNotFoundPage, 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 Releaseclean; each suite run directly (</dev/null), Catalog and Crawler against real PostgreSQL through Testcontainers on Podman:MUI.Catalog.Tests— 222 passed, 0 failedMUI.Crawl.Tests— 136 passed, 0 failedMUI.Crawler.Tests— 96 passed, 0 failedMUI.Discovery.Tests— 183 passed, 0 failedMUI.Web.Tests— 236 passed, 0 failedSmoke-tested against the running app, with a stale alias configured for a live slug (
m-u-s-h = gaslight-row) alongside a real one:Three defects in this PR were found by tests rather than by reasoning:
QuerySingleOrDefaultAsync<DateTimeOffset?>overmax(at)throwingInvalidCastException(Npgsql returnstimestamptzas a UTCDateTime; 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
0009may need renumbering; PR A stranger may hand us an address, and an address is all we take #21 carries0010.SiteHoststill composes its own service graph. When it lands, that composition should become oneSiteComposition.AddMuiSitecall — the pipeline half already goes through the sharedUseMuiNotFoundPage/UseFormerSlugRedirects.POSTto a minimal-API endpoint mapped afterUseAntiforgery()is refused with 400 in a slim host. If that reproduces in the real accounts host, passkey sign-in is broken independently of this PR — worth a look from whoever owns Claiming was complete, tested, and wired to nothing #32.🤖 Generated with Claude Code