Two people can run one game, and a game can change hands - #35
Conversation
§8.5 allows a game several owners, each having verified a token of their own. §8.4 makes a counter-claim — a different account proving control now — the way a game changes hands. Both were in the spec, the audit log's vocabulary already had counter_claimed in it, and nothing could reach either. The whole design turns on one fact: A CO-FOUNDER JOINING AND A NEW OPERATOR TAKING OVER PUBLISH THE IDENTICAL LINE IN THE IDENTICAL CONFIG FILE. The crawler reads the same string either way, so the difference cannot be measured. It is declared — by the claimant, on a page that explains both, before the token is minted, and stored on the claim the token belongs to. Guessing was the alternative and it is wrong in both directions. "A claim on an already-claimed game is a takeover" unclaims somebody's partner the first time two people run one game; the opposite leaves a departed operator holding a listing they no longer run. Neither intent is more trusted. Both publish a token on the server, which is the whole test, so choosing 'assume' proves exactly what 'join' proves — the choice decides what happens to the OTHER claims, not how hard this one was to make. A counter-claim revokes the displaced owners, and it is the one revocation nobody typed. Their claim records the reason and their OWN history gets the counter_claimed event, because that is whose record changed: an owner who finds their claim gone must be able to read why without inferring it from somebody else's log. The game stays claimed throughout, so a handover never flickers the listing badge off and on. Their token, still sitting in the config file where every probe reads it, settles nothing — asserted, because that is the normal state of a handover. Resigning is explicit and scoped to the account. RevokeAsync takes a claim id, and a claim id is not a credential — it travels in URLs and logs — so ResignAsync checks the claim belongs to the caller. The form asks for the word typed out, because a bare button beside a game's name is one misclick from a listing an operator no longer owns. The dashboard names co-owners and shows each claim's audit log in words. BeaconMissing reads as an observation, not a warning: absence never revokes, and alarming somebody about it would be the interface arguing for a rule the system does not have. Migration 0012 — expect to renumber; 0009 through 0011 are taken on open branches. The column defaults to 'join', which is the honest backfill: no claim written before it existed displaced anybody. 843 tests over five suites, Postgres exercised. 13 new, including both directions of the intent vocabulary against a real CHECK constraint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
CI does not run on PRs whose base is not Re-target to |
|
@coderabbitai review |
|
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.
Five features' worth of markup landed in Account.razor across the merge chain — the owner panel, the MSSP scorecard link, the badge snippet, the co-owner line, the history block, the resign form and the status banner — and nothing had ever rendered it. The first render found a bug in the first line it produced: <h3>What only you can tell us about game.Name</h3> A component parameter of type string written as Name="game.Name" is a LITERAL, not an expression. GameId="game.Id" beside it is parsed as an expression because Guid is not a string, which is exactly what hid it: the two lines look identical and only one of them was wrong. Every owner would have been greeted by the name of a variable. The states an operator can be in are covered now: no database, signed out, no claims, a pending claim, a verified claim with everything a claim grants, several games at once, and a co-owned game where the resign copy differs. The status banner is driven through every arm of its else-if chain by the querystring the redirect actually carries — it has already reported the wrong action once, and a chain is the shape that does that silently. Two things pinned that had gone wrong before. Every form action on the page is checked against the routes the deployable actually maps, because a form posting to an unmapped route fails at the browser rather than at build: proved by typo-ing an action and watching the test name it. And the badge snippet is checked as entity-encoded bytes rather than through an assertion message — the failure output HTML-decodes what it prints, which makes correctly escaped markup look like markup that got away. The signed-in states render at component level with an HttpContext cascaded in, because §8.2 makes passkeys the only way in and a loopback host cannot produce a session without an authenticator. The page's own guard on Identity.IsAuthenticated still runs and the claims it filters are real records in a real ClaimService; what stands in is the user store and the ceremony. The signed-out and no-database states go through SiteHost end to end. 1,192 tests over five suites, Postgres exercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carries the shared measured/declared predicate and /submit's opt-out refusal to the tip of the chain.
§8.5 allows a game several owners, each having verified a token of their own. §8.4 makes a counter-claim — a different account proving control now — the way a game changes hands. Both were in the spec,
claim_event's vocabulary already containedcounter_claimed, and nothing could reach either.The fact the whole design turns on
A co-founder joining and a new operator taking over publish the identical line in the identical config file. The crawler reads the same string either way. The difference cannot be measured, so it is declared — by the claimant, on a page that explains both, before the token is minted, and stored on the claim that token belongs to.
Guessing was the alternative, and it is wrong in both directions:
Neither intent is more trusted. Both publish a token on the server, which is the whole test (§8.1) — so an account choosing
assumehas proved exactly what one choosingjoinproved. The choice decides what happens to the other claims, not how hard this one was to make. That is why storing it at issue time matters: a token published as a co-owner must not be settleable as a takeover.What a counter-claim does
It revokes the displaced owners, and it is the one revocation nobody typed. Three things follow, all asserted:
Resigning
Explicit, as §8.4 requires of every revocation that is not a counter-claim: a claim never lapses on its own.
RevokeAsynctakes a claim id and nothing else, which is right for the service and wrong for a caller reached from a form — a claim id is not a credential and travels in URLs and logs.ResignAsyncchecks the claim belongs to the account. The form asks for the word typed out, because a bare button beside a game's name is one misclick from a listing an operator no longer owns.The last owner leaving unclaims the game; anyone else leaving does not. Nothing is deleted: the claim survives revoked, with its reason and its history.
The audit log, in words
The dashboard names co-owners and renders each claim's history.
BeaconMissingreads as an observation rather than a warning — a probe not reading the token happens for reasons nothing to do with the owner, and alarming them about it would be the interface arguing for a rule the system does not have.Migration
0012_claim_intent.sql— expect to renumber; 0009 is taken twice (#31, #30), 0010 by #21 and 0011 by #28. The column defaults tojoin, which is the honest backfill: no claim written before it existed displaced anybody, so recording them as joins states what happened rather than inventing an intent nobody expressed.The dashboard is rendered now, and it was greeting owners with a variable name
Account.razorhad no render harness on any branch while five features' worth of markup landed in it across this chain — the owner panel, the scorecard link, the badge snippet, the co-owner line, the history block, the resign form and the status banner. The first render found a bug in the first line it produced:A component parameter of type
stringwritten asName="game.Name"is a literal, not an expression.GameId="game.Id"immediately above it is parsed as an expression, becauseGuidis not a string — which is exactly what hid it. The two lines look identical and only one of them was wrong. Every owner would have been greeted by the name of a variable.States covered
No database · signed out · signed in with nothing claimed · a pending claim · a verified claim with everything a claim grants (§8.5 enrichment, §11 suppression, the scorecard link, the badge snippet, the audit log, §8.4 resignation) · several games at once · a co-owned game, where the resign copy differs.
The status banner
Driven through every arm of its else-if chain by the querystring the redirect actually carries, plus a crafted URL carrying three outcomes at once to pin which arm wins. This page has already told an operator the wrong thing once — hiding a connect screen reported the enrichment sentence — and #35 gave the chain a third arm. A chain is the shape that silently reports the wrong branch.
Two things pinned that had gone wrong before
Every form action is checked against the routes the deployable actually maps. A form posting to an unmapped route fails at the browser, not at build: no compiler, renderer or unit test says a word. Verified by typo-ing an action and watching the test name it. (This needed reading the routes off the builder — the composite
EndpointDataSourcein DI is empty until the app starts, so resolving it there would have compared the page against no routes and passed for the wrong reason.)The badge snippet is checked as entity-encoded bytes. It is correct —
<img src="…"— but the assertion output HTML-decodes what it prints, which made correctly escaped markup look like markup that had got away. Read off the rendered bytes instead.On authentication, since it is the thing being tested
The signed-out and no-database states go through
SiteHostend to end. The signed-in states cannot: §8.2 makes passkeys the only way in, so a loopback host has no way to produce an authenticated session without an authenticator. Those render at component level with anHttpContextcascaded in — which is the object the framework would have supplied, so the page's own guard onIdentity.IsAuthenticatedstill runs and the claims it filters are real records in a realClaimService. What stands in is the user store and the credential ceremony, neither of which is the authorisation under test. Saying so rather than quietly stubbing it.One incidental finding:
[SupplyParameterFromQuery]does not bind under a bareHtmlRendererunlessAddSupplyValueFromQueryProvider()is registered. Without it every one of those properties is null and the banner silently never renders — indistinguishable from a banner that decided not to.Verified
dotnet build MUIndex.slnx -c Releaseclean, each suite run directly — 1,192 tests, 0 failed (the chain, not this branch alone):Catalog and Crawler against real PostgreSQL via Testcontainers/Podman. 13 new tests, including both directions of the intent vocabulary against the real
CHECKconstraint — a constraint that refuses a value the code produces does not fail in a test, it fails the first time somebody tries to take over a game.Left undone
Account.razorandClaim.razorwill conflict with Owning a game: add what MSSP has no field for, and take your screen back #29, Read an operator their own MSSP back, and never call it a fault #33 and A badge that would rather say nothing than say nought #34, all of which touch these files.🤖 Generated with Claude Code