Skip to content

Write down the dials we refuse, and let staff honour "unlist us" - #189

Merged
HarryCordewener merged 1 commit into
mainfrom
claude/refusal-record-and-unlisting
Sep 18, 2026
Merged

HarryCordewener merged 1 commit into
mainfrom
claude/refusal-record-and-unlisting

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

Closes #185. Closes #187.

Two defects found auditing the registry after #184. Both had been true since the code was written, and neither was visible from anywhere.

A refused dial left no record (#185)

CrawlCycle.RefuseAsync records a refusal through RecordAttemptAsync(succeeded: true) — correct, the far end did not fail — so the target it leaves behind reads as flawless: no failures, a recent attempt, no availability row. The only trace was a log line with about thirty minutes of retention.

Two of 1,677 production targets were in that state (HellMOO, refused by the address gate; Convergence MUSH, opted out) and neither was findable except by noticing that next_probe_at happened to be exactly seven days after last_probed_at — an accident of the backoff rather than a record.

crawl_refusal (migration 0039) is our note about our own decision, on the icon_attempt precedent: nothing in it reaches a game page, the API or the change feed, and rule 5 is what keeps it out of availability_interval, where refused already means an RST from a real host — a measurement of them.

It lists standing refusals, not history: a row goes the moment the address is dialled again, so it cannot become the queue nobody reads that icon_attempt's ancestor was. Read it with the new crawl_refusals tool; crawl_summary gained a refusals standing total over the same rows.

An opt-out could not actually unlist a game (#187)

Recording an opt-out and unlisting a game are two acts, deliberately — the first stops the dial, and most asks are only that. Convergence MUSH asked for the second on 2026-08-16, got the first, and stayed listed for a month: indexable, publishing telnet://game.convergencemush.org:10000 as schema.org GameServer under a page titled "how to connect".

Nothing could have fixed it. game_unlisting_is_attributed demanded an app_user, UnlistAsync was reachable only from the owner dashboard, and the person who asked had no account. The request was granted and its point was not.

Attribution becomes a person or an explanation, never neither (migration 0040, UnlistedBy) — the shape excluded_reason, crawl_opt_out.detail and --because already use here. game_unlist and game_relist are staff's route to it; the second exists so this is not a one-way door, because an opted-out address is refused before the dial and the probe that would otherwise relist a game never happens.

And the page stops being promoted while it is withheld — noindex, nofollow, and no structured data at all, for unlisted and excluded alike. §7.5 is untouched: the page, the URL, the history and the change feed all go on answering. Keeping a record and advertising a game are different acts, and this is where they part.

Archived is deliberately not included. An archived game is a real game one successful probe away from being active again, and staying findable is the whole difference between going dark and being taken out.

Notes

  • SitePreview now treats an empty graph as an absent one. An empty ld+json block is worse than none: a machine reading it gets a parse error where it would otherwise have read an absence.
  • NpgsqlCrawlRefusalStore.Row is a class with init properties rather than a positional record, following every other store here. Dapper matches a record's constructor by parameter type and Npgsql hands it a DateTime for a timestamptz, so a positional DateTimeOffset fails to materialise at run time with nothing to see at compile time — which is exactly how it failed while being written.

Testing

TDD throughout: eleven new tests, each watched fail first. 3,206 pass across all six suites, Postgres exercised rather than skipped.

Not in this PR

🤖 Generated with Claude Code

#187)

Two defects found auditing the registry, both of which had been true since the
code was written and neither of which anything could see.

**A refused dial left no record (#185).** `RefuseAsync` records it through
`RecordAttemptAsync(succeeded: true)` — right, because the far end did not fail
— so the target reads as flawless: no failures, a recent attempt, no
availability row. The only trace was a log line with about thirty minutes of
retention. Two of 1,677 production targets were in that state and neither was
findable except by noticing that `next_probe_at` happened to be exactly seven
days after `last_probed_at`, which is an accident of the backoff rather than a
record.

`crawl_refusal` (migration 0039) is our note about our own decision, on the
`icon_attempt` precedent: nothing in it reaches a game page, the API or the
change feed, and rule 5 is what keeps it out of `availability_interval`, where
`refused` already means an RST from a real host. It lists standing refusals
rather than history — a row goes the moment the address is dialled again — so it
cannot become the queue nobody reads that `icon_attempt`'s ancestor was. Read it
with the new `crawl_refusals` tool; `crawl_summary` counts the same rows.

**An opt-out could not actually unlist a game (#187).** Recording one and
unlisting are two acts, deliberately — the first stops the dial, and most asks
are only that. Convergence MUSH asked for the second on 2026-08-16, got the
first, and stayed listed for a month: indexable, publishing `telnet://` as
schema.org `GameServer` under a page titled "how to connect". Nothing could fix
it, because `game_unlisting_is_attributed` demanded an `app_user` and the person
who asked had no account.

Attribution becomes a person *or* an explanation, never neither (migration 0040,
`UnlistedBy`) — the shape `excluded_reason`, `crawl_opt_out.detail` and
`--because` already use. `game_unlist` and `game_relist` are staff's route to
it, the second so it is not a one-way door: an opted-out address is refused
before the dial, so the probe that would otherwise relist a game never happens.

And the page stops being promoted while it is withheld: `noindex, nofollow`, and
no structured data at all, for `unlisted` and `excluded` alike. §7.5 is
untouched — the page, the URL, the history and the change feed all go on
answering. **Archived is deliberately not included**: an archived game is a real
game one successful probe away from being active again, and staying findable is
the whole difference between going dark and being taken out.

3,206 tests pass across all six suites.

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

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 22 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 22 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 16 seconds for your next included review.

Check out review usage here.

Limit details: You’ve used all 2 included reviews currently available. Your 52 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 024bdfac-7cd2-45db-b0f4-d8a9a11138b8

📥 Commits

Reviewing files that changed from the base of the PR and between ba2964e and b5ca43f.

📒 Files selected for processing (32)
  • CLAUDE.md
  • docs/deploy.md
  • migrations/0039_crawl_refusal.sql
  • migrations/0040_unlisted_reason.sql
  • src/MUI.Catalog/Persistence/Games/NpgsqlGameStore.cs
  • src/MUI.Catalog/Persistence/Games/Stores.cs
  • src/MUI.Crawler.Cli/Program.cs
  • src/MUI.Crawler/Crawl/CrawlCycle.cs
  • src/MUI.Crawler/CrawlerServiceCollectionExtensions.cs
  • src/MUI.Crawler/Persistence/NpgsqlCrawlRefusalStore.cs
  • src/MUI.Crawler/Scheduling/CrawlSummary.cs
  • src/MUI.Web/Accounts/OwnerListing.cs
  • src/MUI.Web/Components/Pages/Game.razor
  • src/MUI.Web/Components/Pages/GameStructuredData.cs
  • src/MUI.Web/Components/SitePreview.razor
  • src/MUI.Web/Mcp/CrawlAdminTools.cs
  • src/MUI.Web/Mcp/GameAdminTools.cs
  • src/MUI.Web/Mcp/McpToolModels.cs
  • tests/MUI.Catalog.Tests/Persistence/Games/UnlistedGamePostgresTests.cs
  • tests/MUI.Catalog.Tests/Persistence/Queries/GameQueriesPostgresTests.cs
  • tests/MUI.Catalog.Tests/Persistence/Support/InMemoryPersistence.cs
  • tests/MUI.Crawler.Tests/Crawl/RefusalRecordPostgresTests.cs
  • tests/MUI.Crawler.Tests/Support/CrawlCycles.cs
  • tests/MUI.Crawler.Tests/Support/InMemoryCatalogue.cs
  • tests/MUI.Web.Tests/AccountSurfaceTests.cs
  • tests/MUI.Web.Tests/ClaimSurfaceTests.cs
  • tests/MUI.Web.Tests/Mcp/McpToolsTests.cs
  • tests/MUI.Web.Tests/Mcp/StaffUnlistTests.cs
  • tests/MUI.Web.Tests/NullStores.cs
  • tests/MUI.Web.Tests/OwnerEndpointTests.cs
  • tests/MUI.Web.Tests/Pages/GameStructuredDataTests.cs
  • tests/MUI.Web.Tests/Render.cs

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

@HarryCordewener
HarryCordewener merged commit 1d62f79 into main Sep 18, 2026
3 checks passed
@HarryCordewener
HarryCordewener deleted the claude/refusal-record-and-unlisting branch September 18, 2026 15:26
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.

An opted-out game keeps an indexable page that tells search engines how to connect A dial we refuse is indistinguishable from a healthy target

1 participant