Skip to content

Two people can run one game, and a game can change hands - #35

Merged
HarryCordewener merged 4 commits into
feat/owner-badgefrom
feat/multi-owner
Aug 15, 2026
Merged

Two people can run one game, and a game can change hands#35
HarryCordewener merged 4 commits into
feat/owner-badgefrom
feat/multi-owner

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Aug 15, 2026

Copy link
Copy Markdown
Member

§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 contained counter_claimed, and nothing could reach either.

Stacked on #32 (feat/claim-wiring), which registers IClaimStore and makes the dashboard able to read claims at all.

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:

  • "A claim on an already-claimed game is a takeover" silently 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 (§8.1) — so an account choosing assume has proved exactly what one choosing join proved. 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:

  • The event goes on the losing claim. That is whose record changed. An owner who finds their claim gone must be able to read why from their own history rather than inferring it from somebody else's.
  • The game never stops being claimed on the way through, so a handover does not flicker the listing badge off and on.
  • The displaced owner's token still settles nothing. It is very likely still in the config file — that is the normal state of a handover — and every probe reads it. Presence establishes and absence never revokes (§8.4), but a revoked claim is not a claim.

Resigning

Explicit, as §8.4 requires of every revocation that is not a counter-claim: a claim never lapses on its own.

RevokeAsync takes 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. ResignAsync checks 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. BeaconMissing reads 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.sqlexpect to renumber; 0009 is taken twice (#31, #30), 0010 by #21 and 0011 by #28. The column defaults to join, 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.razor had 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:

<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" immediately above 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.

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 EndpointDataSource in 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 — &lt;img src=&quot;…&quot; — 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 SiteHost end 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 an HttpContext cascaded in — which is the object the framework would have supplied, so 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 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 bare HtmlRenderer unless AddSupplyValueFromQueryProvider() 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 Release clean, each suite run directly — 1,192 tests, 0 failed (the chain, not this branch alone):

suite total
MUI.Catalog.Tests 297
MUI.Crawl.Tests 142
MUI.Crawler.Tests 154
MUI.Discovery.Tests 255
MUI.Web.Tests 344

Catalog and Crawler against real PostgreSQL via Testcontainers/Podman. 13 new tests, including both directions of the intent vocabulary against the real CHECK constraint — 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

🤖 Generated with Claude Code

§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>
@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: 4a52579e-ffd4-4d99-ad90-62275f609cfd

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@HarryCordewener

Copy link
Copy Markdown
Member Author

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

Re-target to main once #32 lands and the checks will attach normally.

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

Eleventh and last link of the restack: main → #27#32#31#21#28#30#26#29#33#34#35.

MIGRATION RENUMBERED. 0012_claim_intent.sql → 0013_claim_intent.sql, since
#30's slug history now holds 0012. Thirteen migrations are embedded in
MUI.Catalog.dll, 0013 sorts last, and nothing referred to it by number.

Three conflicts and one clean merge that did not compile.

Account.razor, for the third merge running, and this one had the most in
it. The dashboard's status banner is now one else-if chain — resigned, then
saved, then refused — because a redirect carries exactly one outcome and
two banners at once would be two answers to one action. A claimed game's
block holds, in the order an owner would want them: who else owns it, the
enrichment panel, the badge snippet, the history, and giving up the claim
last. #35 was written when a claimed game was an <li>, so all of it had to
move inside the <details> #29 introduced; the co-owner line in particular
was inside the <summary>, which is not somewhere a <p> may live.

Passkeys.cs maps both write surfaces rather than choosing: MapMuiOwnerWrites
is §8.5's enrichment and §11's suppression, MapMuiOwnership is §8.4's
counter-claim and resignation. They are different routes.

Claim.razor merged clean and broke the build, which is the useful kind of
failure. #21 changed this page from IGameQueries to IGameStore on purpose —
a submitted game is hidden from the public read until somebody claims it,
so looking it up through the listing's own query made claiming the one
thing a hidden game could never do — and #35 added three uses of the old
Page.Summary against the view model that is no longer loaded. They now read
the row, which is what the rest of the page already did.

Read #35's tests rather than trusting them, as asked: OwnershipPostgresTests
and OwnershipSchemaTests assert ClaimService and the claim_intent schema
against a real database, neither of which touches the dashboard markup or
the page's lookup, so nothing in them went stale. The dashboard markup
itself has no render harness on any of these three branches.
@HarryCordewener
HarryCordewener changed the base branch from feat/claim-wiring to feat/owner-badge August 15, 2026 16:59
HarryCordewener and others added 2 commits August 15, 2026 14:23
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.
@HarryCordewener
HarryCordewener merged commit aca4583 into main Aug 15, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/multi-owner 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