Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ our host is unreachable and perfectly alive.
captured `ProbeResult` fixture with no network involved.
- Credit MSSP `CREATED` toward archive grace. It is one hand-typed line of `mush.cnf` and crediting
it would make the threshold trivially gameable.
- **Let a refusal reach a game's record, or leave one with no record at all.** Both halves were
wrong at once. A scope refusal and an opt-out are decisions of ours, so they stay out of
`availability_interval` — where `refused` already means an RST from a real host, a measurement of
*them*. But `RefuseAsync` writing `succeeded: true` and nothing else left a target that reads as
flawless: no failures, a recent attempt, no availability row, and a log line with thirty minutes of
retention. Two production targets sat like that and were findable only by noticing that
`next_probe_at` happened to be exactly seven days after `last_probed_at`. `crawl_refusal`
(migration 0039) is the answer and is **our note about our own decision**, on the `icon_attempt`
precedent: droppable, never rendered on a game page, and cleared the moment the address is dialled
again so it lists what stands rather than everything that ever happened.
- **Assume an opt-out unlists a game.** They are two acts. `crawl_opt_out_record` stops the dial and
most asks are only that; `game_unlist` answers "and take us off the site". Convergence MUSH asked
for the second on 2026-08-16, got the first, and stayed listed — indexable, with `telnet://` in its
structured data under a page titled "how to connect" — for a month, because the state could only be
set by a verified owner and the person who asked had no account. Attribution is now **a person or
an explanation**, never neither (migration 0040, `UnlistedBy`).
- **Add a `Refused` member to `ProbeOutcome`, or dress a scope refusal as `ProbeResult.Failed(…)`.**
A refusal happens *before* a probe exists, and `FailureCause.Refused` already means the far end
sent an RST — a real measurement of a real host. Conflating them is unrecoverable downstream. The
Expand Down Expand Up @@ -276,9 +292,12 @@ mounted inside `MUI.Web` itself — `src/MUI.Web/Mcp/` — that reuses the same
uses (`OptOutGate`, `ICrawlTargetRepository`, `NpgsqlGameFieldStore`, the deployment's own singleton
`CrawlCycle`) rather than reviving the excluded CLI image. It is gated behind `MUI_MCP_TOKEN`, a
shared bearer secret checked in constant time; unset, every request fails authentication (fail
closed — see `docs/deploy.md`'s "Administering the site over MCP"). Ten tools, mirroring the CLI:
closed — see `docs/deploy.md`'s "Administering the site over MCP"). Thirteen tools, mirroring the CLI:
`crawl_seed_add`, `crawl_opt_out_record`, `crawl_opt_out_check`, `crawl_due_targets`,
`crawl_run_cycle`, `crawl_summary`, plus four capabilities of its own — `game_field_set`, a staff override
`crawl_run_cycle`, `crawl_summary`, plus seven capabilities of its own — `crawl_refusals`, which
lists the addresses we are declining to dial and why (issue #185); `game_unlist` and `game_relist`,
staff's route to §11's "and take us off the site" for an ask from somebody with no account, which is
how most of them arrive (issue #187, and see **Never** below); `game_field_set`, a staff override
(`FieldSource.Staff`) of one `GameField` row, for fixing a mis-parsed value by hand without raw SQL;
`game_rename` (also `mui-crawl --rename`), which writes `NAME` through that same staff override
and then takes `SlugMinter`'s immediate, no-grace mint-and-rename path — the one a verified owner's
Expand Down
24 changes: 22 additions & 2 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,10 +791,30 @@ Set `MUI_MCP_TOKEN` (`openssl rand -hex 32`, never committed) and point a client
`https://<site>/mcp` with `Authorization: Bearer <token>`. Unset, every request gets a 401 and MUI.Web
says so once at startup — this endpoint fails closed, never open.

The ten tools (`src/MUI.Web/Mcp/CrawlAdminTools.cs`, `src/MUI.Web/Mcp/GameAdminTools.cs`) mirror
The thirteen tools (`src/MUI.Web/Mcp/CrawlAdminTools.cs`, `src/MUI.Web/Mcp/GameAdminTools.cs`) mirror
`mui-crawl`'s CLI surface — `crawl_seed_add`,
`crawl_opt_out_record`, `crawl_opt_out_check`, `crawl_due_targets`, `crawl_run_cycle`,
`crawl_summary` — plus four capabilities of its own. `game_field_set` is a staff override of a single
`crawl_summary` — plus seven capabilities of its own.

`crawl_refusals` lists the addresses the crawler is declining to dial, longest-standing first, with
the reason in the guard's or the opt-out register's own words (issue #185). There is no other way to
ask: a refusal happens before a probe exists, so it is recorded as a successful attempt — the far end
did not fail — and the target it leaves behind is indistinguishable from a healthy one. The only
other trace was a log line with about thirty minutes of retention. A row disappears the moment the
address is dialled again, so the list is what stands now rather than everything ever refused;
`crawl_summary`'s `refusals standing` total counts the same rows.

`game_unlist` takes a game out of the listing, the rankings and the daily figure because the people
who run it asked (§11) — staff's route to the state the owner dashboard reaches with a verified
claim, and the only route when the ask came from somebody without one, which is how most of them
arrive. **Recording an opt-out and unlisting a game are two acts**: `crawl_opt_out_record` stops the
dial and most asks are only that; this one answers "and take us off the site". `because` is required,
as on `--merge` and `--distinct`. §7.5 is untouched — the page, the URL, the history and the change
feed all go on answering, and the game keeps being probed unless an opt-out also stands. What stops
is the promotion: the listing, the rankings, and being offered to search engines with a connect
address attached (issue #187). `game_relist` undoes it, and is needed because nothing else can for a
game staff unlisted — an opted-out address is refused before the dial, so the probe that would
otherwise relist it never happens. `game_field_set` is a staff override of a single
`GameField` row (`FieldSource.Staff`, spec §5.1) for fixing a mis-parsed value by hand without raw
SQL, and explicitly declines to re-mint a game's slug when the field is `NAME`. `game_rename` (also
`mui-crawl --rename`) is that missing half: it writes `NAME` through the same staff override and then
Expand Down
64 changes: 64 additions & 0 deletions migrations/0039_crawl_refusal.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- crawl_refusal: the addresses we are declining to dial, and why (issue #185).
--
-- A refusal happens before a probe exists, so `CrawlCycle.RefuseAsync` records it through
-- `RecordAttemptAsync(succeeded: true)` — right, because the far end did not fail — and the target
-- then reads as flawless: consecutive_failures = 0, a recent last_probed_at, no availability row.
-- The only trace was a log line with about thirty minutes of retention. On 2026-09-17 two of 1,677
-- production targets were in that state (one scope refusal, one standing opt-out) 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.
--
-- THIS IS OUR NOTE ABOUT OUR OWN DECISION, and that is the whole of why it may exist. Rule 5 keeps
-- it out of availability_interval, where `refused` already means an RST from a real host — a
-- measurement of them, not of us. Nothing here reaches a game page, the API, or the change feed;
-- the game_id column exists so an operator can join, never so a surface can render.
--
-- Droppable and refillable, like game_icon and icon_attempt, and for the same reason: §7.5's
-- "nothing is ever deleted" is about what a game said and when it was reachable. This is neither.
--
-- STANDING REFUSALS, NOT A LOG. A row is removed the moment we dial the address again, so the table
-- answers "what are we not dialling, right now" rather than "what has ever been refused". The
-- opposite was tried in icon_attempt's ancestor and produced a queue that could not move. The
-- history of an ask lives in crawl_opt_out, which never deletes; the history of a scope refusal is
-- the DNS answer, which is not ours to keep.
--
-- No BEGIN/COMMIT: MigrationRunner opens its own transaction per script and writes the ledger entry
-- inside it.

CREATE TABLE crawl_refusal (
host text NOT NULL,
port integer NOT NULL,

-- NULL while the address has never been listed, which is the common case for a scope refusal:
-- an address we will not dial rarely got far enough to become a game.
game_id uuid REFERENCES game (id) ON DELETE CASCADE,

reason text NOT NULL,

-- What the guard or the opt-out register actually said, in its own words. Evidence for a person
-- reading this later, never compared and never parsed.
detail text NOT NULL,

first_refused_at timestamptz NOT NULL,
last_refused_at timestamptz NOT NULL,

-- How many cycles have met it. A refusal standing for months and one taken this morning are
-- different situations and the timestamps alone do not separate them from a target that is
-- simply rarely due.
times integer NOT NULL DEFAULT 1,

PRIMARY KEY (host, port),

-- The two ways a dial is declined, matching DialRefusal. `refused` is deliberately not among
-- them: that word belongs to availability_interval and means the far end sent an RST.
CONSTRAINT crawl_refusal_reason_vocabulary CHECK (reason IN ('out_of_scope', 'opted_out')),
CONSTRAINT crawl_refusal_detail_says_something CHECK (btrim(detail) <> ''),
CONSTRAINT crawl_refusal_times_are_positive CHECK (times > 0),
CONSTRAINT crawl_refusal_last_is_not_before_first CHECK (last_refused_at >= first_refused_at),
CONSTRAINT crawl_refusal_port_is_a_port CHECK (port BETWEEN 1 AND 65535),
CONSTRAINT crawl_refusal_host_is_canonical CHECK (
host = lower(host) AND host = btrim(host) AND host NOT LIKE '%.')
);

-- The operator's question: what have we been declining, longest first.
CREATE INDEX crawl_refusal_first_refused_idx ON crawl_refusal (first_refused_at);
38 changes: 38 additions & 0 deletions migrations/0040_unlisted_reason.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Lets staff honour "please unlist us" from somebody who has no account (issue #187).
--
-- Migration 0025 required every unlisting to name an app_user, which reads as the right rule and is
-- only the right rule for the dashboard. Measured on production: Convergence MUSH's admin asked to
-- be unlisted in a chat message on 2026-08-16. The opt-out was recorded, honoured at the dial, and
-- the game stayed in the listing for a month — because the person who asked held no verified claim,
-- so `unlisted_by` could not be filled and the state could not be reached at all. The request was
-- granted and its point was not.
--
-- Attribution becomes A PERSON OR AN EXPLANATION, never neither. That is the shape this codebase
-- already uses everywhere the actor is staff rather than an account: excluded_reason,
-- crawl_opt_out.detail, and --because on merge and distinct. An unlisting nobody is accountable for
-- is one nobody can review, which is why the check stays in the schema rather than moving into the
-- tool where raw SQL could walk around it.
--
-- Nothing is backfilled: every existing unlisting came through the dashboard and already names the
-- account that asked, which is a better record than a reason anybody could write now.
--
-- No BEGIN/COMMIT: MigrationRunner opens its own transaction per script and writes the ledger entry
-- inside it.

ALTER TABLE game
ADD COLUMN unlisted_reason text,

DROP CONSTRAINT game_unlisting_is_attributed,

ADD CONSTRAINT game_unlisting_is_attributed CHECK (
(state = 'unlisted') = (unlisted_at IS NOT NULL)
AND (unlisted_at IS NULL) = (unlisted_by IS NULL AND unlisted_reason IS NULL)
AND NOT (unlisted_by IS NOT NULL AND unlisted_reason IS NOT NULL)),

ADD CONSTRAINT game_unlisting_reason_is_not_blank CHECK (
unlisted_reason IS NULL OR length(btrim(unlisted_reason)) > 0);

COMMENT ON COLUMN game.unlisted_reason IS
'Why staff unlisted this game, when the ask came from somebody with no account. Exactly one of '
'this and unlisted_by is set: an unlisting is attributable to a person or explained in words, '
'never both and never neither.';
14 changes: 9 additions & 5 deletions src/MUI.Catalog/Persistence/Games/NpgsqlGameStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,12 @@ UPDATE game

public async Task UnlistAsync(
Guid id,
Guid byUserId,
UnlistedBy by,
DateTimeOffset at,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(by);

await using var connection = await source.OpenConnectionAsync(cancellationToken);

// archived_at is cleared because the schema holds the date and the state in step; the history
Expand All @@ -163,10 +165,10 @@ await connection.ExecuteAsync(new CommandDefinition(
UPDATE game
SET state = 'unlisted', archived_at = NULL,
excluded_at = NULL, excluded_reason = NULL,
unlisted_at = @at, unlisted_by = @byUserId
unlisted_at = @at, unlisted_by = @byUserId, unlisted_reason = @reason
WHERE id = @id AND state <> 'excluded'
""",
new { id, at = at.ToUniversalTime(), byUserId },
new { id, at = at.ToUniversalTime(), byUserId = by.UserId, reason = by.Reason },
cancellationToken: cancellationToken));
}

Expand All @@ -179,7 +181,8 @@ public async Task RelistAsync(Guid id, DateTimeOffset at, CancellationToken canc
await connection.ExecuteAsync(new CommandDefinition(
"""
UPDATE game
SET state = 'active', unlisted_at = NULL, unlisted_by = NULL
SET state = 'active', unlisted_at = NULL, unlisted_by = NULL,
unlisted_reason = NULL
WHERE id = @id AND state = 'unlisted'
""",
new { id },
Expand Down Expand Up @@ -213,7 +216,8 @@ UPDATE game
excluded_at = NULL,
excluded_reason = NULL,
unlisted_at = NULL,
unlisted_by = NULL
unlisted_by = NULL,
unlisted_reason = NULL
WHERE id = @id AND state NOT IN ('excluded', 'unlisted')
""",
new { id, state = SqlEnums.ToDb(state), at = at.ToUniversalTime() },
Expand Down
50 changes: 42 additions & 8 deletions src/MUI.Catalog/Persistence/Games/Stores.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
namespace MUI.Catalog.Persistence;

/// <summary>
/// Who an unlisting is answerable to — an account, or staff with a reason (migration 0040).
/// </summary>
/// <remarks>
/// <para>
/// A type rather than a nullable pair, because the thing that must be impossible is "neither". The
/// schema refuses that too (<c>game_unlisting_is_attributed</c>), and this is what stops a caller
/// having to remember.
/// </para>
/// <para>
/// <see cref="Staff"/> exists because the dashboard is not the only way an ask arrives. Convergence
/// MUSH's admin asked in a chat message; the opt-out was honoured at the dial and the game stayed
/// listed for a month, because they held no verified claim and there was no other route. §11 already
/// anticipated one ("the recorded-request route reaches the listing by hand"); this is it.
/// </para>
/// </remarks>
/// <param name="UserId">The account that asked, claim-verified through the dashboard.</param>
/// <param name="Reason">Why staff did it, when nobody with an account could.</param>
public sealed record UnlistedBy(Guid? UserId, string? Reason)
{
/// <summary>A verified owner, through their own dashboard.</summary>
public static UnlistedBy Owner(Guid userId) => new(userId, null);

/// <summary>
/// Staff, on somebody's behalf, saying who asked and how.
/// </summary>
/// <remarks>
/// The reason is required and not merely non-null: a blank one is an unlisting nobody can
/// review, which is what the account was there to prevent.
/// </remarks>
public static UnlistedBy Staff(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);

return new UnlistedBy(null, reason);
}
}

/// <summary>
/// The <c>game</c> table (spec §5, §7.5). <see cref="IGameFieldStore"/>, <see cref="IPresenceStore"/>
/// and <see cref="IAvailabilityStore"/> all hang off it.
Expand Down Expand Up @@ -27,17 +65,13 @@ public interface IGameStore
/// Takes a game out of the listing because the people who run it asked (spec §11, migration 0025).
/// </summary>
/// <remarks>
/// Separate from <see cref="ExcludeAsync"/>: this carries an account, not an argument. The reason
/// is that they asked; <paramref name="byUserId"/> is the account that held a verified claim, and
/// <c>crawl_opt_out</c> holds how the ask arrived.
/// Separate from <see cref="ExcludeAsync"/>: the reason here is that they asked, and
/// <c>crawl_opt_out</c> holds how the ask arrived. What varies is who can be held to it — see
/// <see cref="UnlistedBy"/>, which is never inferred and never defaulted.
/// </remarks>
/// <param name="byUserId">
/// The account that asked — claim-verified through the dashboard, or the operator's own where
/// §11's recorded-request route reaches the listing by hand. Never inferred, never defaulted.
/// </param>
Task UnlistAsync(
Guid id,
Guid byUserId,
UnlistedBy by,
DateTimeOffset at,
CancellationToken cancellationToken = default);

Expand Down
3 changes: 3 additions & 0 deletions src/MUI.Crawler.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,9 @@ await fields.RecordChangeAsync(
new ClaimService(new NpgsqlClaimStore(source), games, time),
// §11 — a CLI crawl fills the same replay window an in-process one does.
new NpgsqlProbePayloads(source),
// Issue #185 — and it writes down what it declined to dial, for the same reason: an operator
// running a cycle by hand is the reader most likely to want to know.
new NpgsqlCrawlRefusalStore(source),
loggerFactory.CreateLogger<CrawlCycle>());

if (arguments.DryRun)
Expand Down
Loading