Serve the real catalogue, and walk a referral graph in the wild - #9
Conversation
The site read a fixture. It now reads PostgreSQL when MUI_POSTGRES (or
ConnectionStrings:MUIndex) names one, applies migrations at startup, and falls
back to the fixture only when neither is set -- announced in the log and, more
importantly, on every page. A directory whose whole claim is that its data is
measured cannot show invented data silently; the demo banner is not dismissible
and sits above the mark, because a reader who cannot tell a measurement from a
fixture is being misled by the exact mechanism this project exists to replace.
Pointing it at sixteen live games immediately found three sentences the fixture
could not, and all three are the house rule read forward into prose:
- "Reachable 100.0% of the last 90 days" off a single probe. The arithmetic
was always right -- FractionReachable divides by observed time -- and the
sentence widened it to the window. It now names the days it measured, and
keeps the short phrasing only when all ninety were.
- "167 hours across the week could not be measured -- the game was not
reachable", about a game measured once and found perfectly reachable. A
failed probe writes no presence row, so an empty cell covers an hour we
could not reach and an hour we never probed alike. Every surface now says
"no measurement", never "not reachable"; the strip answers reachability,
from intervals that can tell the two apart.
- banner_hash, a digest we compute, rendered as sixty-four hex characters at
the top of "what the game says about itself". InternalFields is the list,
and it is a list rather than a convention because the old rule was a
connect_screen prefix inlined at the call site and banner_hash is not in the
field registry at all.
Then the referral crawl, which is its own answer. Of 141 live servers -- the
codebase survey plus every entry in TinTin's MSSP crawler list -- two publish
REFERRAL, and both name tbamud.com, one of them two ports of it. Walking that
found two more defects that no amount of reading would have:
- A NUL byte from the wire aborted a game's whole ingestion. PostgreSQL text
cannot hold 0x00, so the first field carrying one raised 22021 mid-write:
the game was left half-created, its endpoint never scheduled, and its
REFERRAL list never read. RFC 854 makes CR NUL the way to send a bare CR, so
the byte is telnet framing rather than content, and it is stripped where
text enters the crawler -- both doors, the line reader and the
subnegotiation. It is also the cheapest denial of service a hostile server
had.
- tbaMUD sends "Attempting to Detect Client, Please Wait...", pauses, and then
paints. Settling on a gap between lines stored that placeholder as the
banner and its hash as the identity, so two unrelated tbaMUDs fingerprinted
alike and the second went into a duplicate review instead of onto the site.
That is NAME "PennMUSH" again one field over. The connect-screen phase now
waits longer when what it has is slight and has not reached a prompt --
conditional, so a server that has already painted pays nothing.
Both halves of the listing gate are pinned end to end now: a referred host that
names itself becomes a second listed game without being seeded, and one that
answers with only its codebase's name does not. tbamud.com is the second case,
live -- discovered, probed, kept, and correctly unlisted.
Screenshots are retaken against the real catalogue. The old ones were honest
about being a fixture; these are the site's first day, which is the state every
real game enters in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WalkthroughThe PR adds PostgreSQL catalogue wiring, demo-source reporting, shared crawler text normalization, delayed banner handling, internal field filtering, referral validation, and clearer measured versus unmeasured activity reporting. ChangesCatalogue and reporting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Program
participant PostgresData
participant MigrationRunner
participant MainLayout
Program->>PostgresData: resolve configured PostgreSQL connection
Program->>PostgresData: register measured catalogue services
PostgresData->>MigrationRunner: apply database migrations
MainLayout->>MainLayout: display demo banner when fixture data is active
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/screenshots/README.md`:
- Around line 3-16: Remove the committed live-crawl provenance and generated
screenshot assets referenced by docs/screenshots/README.md, while preserving
only an operator-local reproduction procedure. In README.md lines 9-12, remove
the link and claims publishing the live-crawl screenshot set; do not replace
them with generated catalogue data or screenshot references.
- Around line 51-55: Specify the fenced Markdown block language by changing the
opening delimiter in the documentation around the counted/uncounted/no data
example to use text, while leaving the block contents unchanged.
In `@src/MUI.Catalog/Views.cs`:
- Around line 64-76: Replace the forbidden “uptime” term in the XML
documentation around the presence-row behavior with “reachable time” or
“reachability,” while preserving the existing explanation and meaning.
In `@src/MUI.Crawl/BannerText.cs`:
- Around line 64-108: Update SkipEscape for non-CSI, non-OSC escape sequences so
it consumes 0x20–0x2F intermediate bytes and the following final byte,
preventing characters such as B or 0 from reaching BannerText.Flatten. Add SCS
coverage for ESC ( B and ESC ( 0, including the resulting BannerFingerprint and
LooksUnfinished behavior.
In `@src/MUI.Web/Components/ActivitySummary.cs`:
- Around line 71-73: Update the XML remarks for ActivitySummary.CellValue and
ActivityCell.IsGap to describe the IsGap state as “not measured” instead of “not
reached,” matching the current CellValue implementation while preserving the
existing documentation for other states.
In `@src/MUI.Web/Components/ReachSeries.cs`:
- Around line 84-103: The ReachSummary.Sentence percentage currently uses
observed elapsed time while its wording uses measured calendar days, creating
mismatched denominators. Update the Reachability summary flow around
ReachableFraction and Sentence to use observed-time wording consistent with the
fraction (or change the fraction to measured-day calculation), preserving the
full-window wording behavior; add coverage for a window containing reachable,
unreachable, and unmeasured days.
In `@src/MUI.Web/Data/PostgresData.cs`:
- Around line 63-71: Update MigrationRunner.ApplyAsync to acquire a PostgreSQL
database-wide advisory lock before reading pending migrations and hold it
through all migration DDL and ledger writes, releasing it only after the
complete run finishes. Ensure ApplyMigrationsAsync continues using this
serialized migration path and preserves the existing applied-migration logging.
In `@tests/MUI.Crawl.Tests/ProbeSessionTests.cs`:
- Around line 184-194: Move the “A MU* server...” XML summary from its current
position to immediately above the private sealed FakeGame class declaration,
leaving the readiness-test summary attached to
AScreenThatSaidItWasNotReadyIsWaitedFor and preserving both descriptions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: db48d66e-9bd7-4848-9ad1-21ecc68f157d
⛔ Files ignored due to path filters (6)
docs/screenshots/01-home-feeds.pngis excluded by!**/*.pngdocs/screenshots/02-games-listing.pngis excluded by!**/*.pngdocs/screenshots/03-game-page.pngis excluded by!**/*.pngdocs/screenshots/04-plain-mode.pngis excluded by!**/*.pngdocs/screenshots/05-archive.pngis excluded by!**/*.pngdocs/screenshots/06-mobile-game.pngis excluded by!**/*.png
📒 Files selected for processing (29)
.gitignoreCLAUDE.mdREADME.mddocs/screenshots/README.mddocs/specs/2026-07-30-mu-directory-design.mdsrc/MUI.Catalog/Persistence/FieldRegistry.cssrc/MUI.Catalog/Persistence/NpgsqlGameQueries.cssrc/MUI.Catalog/Views.cssrc/MUI.Crawl/BannerText.cssrc/MUI.Crawl/MsspReport.cssrc/MUI.Crawl/ProbeOptions.cssrc/MUI.Crawl/TelnetProbe.cssrc/MUI.Crawl/WireText.cssrc/MUI.Discovery/BannerFingerprint.cssrc/MUI.Discovery/Identity.cssrc/MUI.Web/Components/ActivityHeatmap.razorsrc/MUI.Web/Components/ActivitySummary.cssrc/MUI.Web/Components/Layout/MainLayout.razorsrc/MUI.Web/Components/PlainText.cssrc/MUI.Web/Components/ReachSeries.cssrc/MUI.Web/Data/PostgresData.cssrc/MUI.Web/Program.cssrc/MUI.Web/wwwroot/app.csstests/MUI.Crawl.Tests/ProbeSessionTests.cstests/MUI.Crawl.Tests/WireTextTests.cstests/MUI.Crawler.Tests/CrawlCyclePostgresTests.cstests/MUI.Web.Tests/PlainParityTests.cstests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cstests/MUI.Web.Tests/ThreeStatesTests.cs
| Captured from the running site against **a real PostgreSQL catalogue**, populated by `mui-crawl` | ||
| against live servers: sixteen games, one probe each. Nothing here is a fixture. The site prefers a | ||
| database and falls back to the demo fixture only when none is configured — and when it does, every | ||
| page carries a banner saying so, because a reader who cannot tell a measurement from a fixture is | ||
| being misled by exactly the mechanism this project exists to replace. | ||
|
|
||
| | File | What it shows | | ||
| |---|---| | ||
| | `01-home-feeds.png` | The three liveness feeds. The *came back* card is the one place the site raises its voice. | | ||
| | `02-games-listing.png` | The listing, with a measured zero, an unknown count and an archived game distinguishable at a glance. | | ||
| | `03-game-page.png` | The whole game page: ANSI frame, heatmap, reachable strip, capability matrix, provenance. | | ||
| | `01-home-feeds.png` | The three liveness feeds, off a first crawl: sixteen games *newly discovered*. | | ||
| | `02-games-listing.png` | The listing. Measured counts, measured zeroes, and games whose handshake offered nothing. | | ||
| | `03-game-page.png` | Virtustan MUD — the whole game page, and the game that led the crawler to two more endpoints. | | ||
| | `04-plain-mode.png` | `?plain=1` — the same facts as words. | | ||
| | `05-archive.png` | The archive as a section rather than a bin. | | ||
| | `05-archive.png` | The archive. Empty, and saying so: nothing has been dark long enough. | | ||
| | `06-mobile-game.png` | 390px. Single column, heatmap keeps all 168 cells. | |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Remove the committed live-crawl catalogue snapshot.
The screenshots and their README contain live measurements. They are not hand-written test fixtures. Keep an operator-local reproduction procedure, but do not retain or link generated live-catalogue output.
docs/screenshots/README.md#L3-L16: Remove the live-crawl screenshot provenance and remove the generated screenshot assets from version control.README.md#L9-L12: Remove the link and claims that publish the committed live-crawl screenshot set.
As per coding guidelines, “Do not commit harvested datasets, mirrored listings, scraped seed lists, or catalogue snapshots.”
📍 Affects 2 files
docs/screenshots/README.md#L3-L16(this comment)README.md#L9-L12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/screenshots/README.md` around lines 3 - 16, Remove the committed
live-crawl provenance and generated screenshot assets referenced by
docs/screenshots/README.md, while preserving only an operator-local reproduction
procedure. In README.md lines 9-12, remove the link and claims publishing the
live-crawl screenshot set; do not replace them with generated catalogue data or
screenshot references.
Source: Coding guidelines
| ``` | ||
| Wed — peak 13 at 20:00, 12 hours not reachable | ||
| Fri — peak 16 at 20:00, nobody on 05:00-11:59, 1 hour probed but uncountable | ||
|
|
||
| counted = we got in and read a number, including a measured zero | ||
| uncounted = we got in and no number could be read | ||
| no data = we could not reach the game in that hour at all | ||
| no data = we have no measurement for that hour | ||
| ``` |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Specify the fenced-block language.
Markdownlint reports MD040 for this fence. Add text to the opening delimiter.
Proposed fix
-```
+```text
counted = we got in and read a number, including a measured zero
uncounted = we got in and no number could be read
no data = we have no measurement for that hour📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| Wed — peak 13 at 20:00, 12 hours not reachable | |
| Fri — peak 16 at 20:00, nobody on 05:00-11:59, 1 hour probed but uncountable | |
| counted = we got in and read a number, including a measured zero | |
| uncounted = we got in and no number could be read | |
| no data = we could not reach the game in that hour at all | |
| no data = we have no measurement for that hour | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)
[warning] 51-51: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/screenshots/README.md` around lines 51 - 55, Specify the fenced Markdown
block language by changing the opening delimiter in the documentation around the
counted/uncounted/no data example to use text, while leaving the block contents
unchanged.
Source: Linters/SAST tools
| /// <summary> | ||
| /// No measurement for that hour. Empty — and emphatically not a zero. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <b>Not measured, not "not reachable".</b> A presence row exists only when a probe got far | ||
| /// enough to try counting, and a probe that failed writes no presence row at all — it goes to | ||
| /// the availability writer instead (see <c>PresenceWriter</c>'s own remarks). So silence here | ||
| /// covers both an hour we could not reach and an hour we never probed, and those are different | ||
| /// facts about a game. Rendering silence as unreachability states our own gap as their outage, | ||
| /// which is the one thing this site may never do: a game found an hour ago would have 167 hours | ||
| /// of a perfect week's uptime described as downtime. Reachability has its own strip, measured | ||
| /// from intervals that can tell the difference. | ||
| /// </remarks> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Replace the forbidden uptime term.
Line 74 uses uptime. Replace it with reachable time or reachability.
As per coding guidelines, use the term reachable, never uptime, in schema, API, code, and copy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MUI.Catalog/Views.cs` around lines 64 - 76, Replace the forbidden
“uptime” term in the XML documentation around the presence-row behavior with
“reachable time” or “reachability,” while preserving the existing explanation
and meaning.
Source: Coding guidelines
| /// <summary>The index of the last character of the escape sequence starting at <paramref name="start"/>.</summary> | ||
| private static int SkipEscape(string banner, int start) | ||
| { | ||
| var i = start + 1; | ||
| if (i >= banner.Length) | ||
| { | ||
| return start; | ||
| } | ||
|
|
||
| // CSI: ESC [ … final byte in 0x40–0x7E. | ||
| if (banner[i] == '[') | ||
| { | ||
| for (i++; i < banner.Length; i++) | ||
| { | ||
| if (banner[i] is >= '@' and <= '~') | ||
| { | ||
| return i; | ||
| } | ||
| } | ||
|
|
||
| return banner.Length - 1; | ||
| } | ||
|
|
||
| // OSC: ESC ] … BEL, or ESC ] … ESC \. | ||
| if (banner[i] == ']') | ||
| { | ||
| for (i++; i < banner.Length; i++) | ||
| { | ||
| if (banner[i] == '\a') | ||
| { | ||
| return i; | ||
| } | ||
|
|
||
| if (banner[i] == '\e' && i + 1 < banner.Length && banner[i + 1] == '\\') | ||
| { | ||
| return i + 1; | ||
| } | ||
| } | ||
|
|
||
| return banner.Length - 1; | ||
| } | ||
|
|
||
| // Anything else two-byte: ESC 7, ESC =, ESC ( B and friends. | ||
| return i; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- required design and survey files ---'
for f in docs/specs/2026-07-30-mu-directory-design.md docs/codebase-survey-2026-07-30.md; do
if [ -f "$f" ]; then
wc -l "$f"
sed -n '1,220p' "$f"
else
printf 'MISSING %s\n' "$f"
fi
done
printf '%s\n' '--- candidate file map ---'
ast-grep outline src/MUI.Crawl/BannerText.cs --lang csharp
printf '%s\n' '--- BannerText.cs ---'
cat -n src/MUI.Crawl/BannerText.cs
printf '%s\n' '--- related symbols and tests ---'
rg -n --glob '!bin/**' --glob '!obj/**' 'SkipEscape|Flatten|BannerFingerprint|LooksUnfinished|BannerPatience' .Repository: SharpMUSH/MUIndex
Length of output: 42661
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- probe heuristic and fingerprint implementation ---'
cat -n src/MUI.Crawl/TelnetProbe.cs | sed -n ' eighty,225p' | sed 's/^/ /' || true
cat -n src/MUI.Discovery/BannerFingerprint.cs
printf '%s\n' '--- focused tests ---'
cat -n tests/MUI.Discovery.Tests/BannerFingerprintTests.cs
printf '%s\n' '--- design references to banner normalization and patience ---'
rg -n -C 4 'BannerFingerprint|BannerPatience|connect screen|escape|ANSI|SCS|character.?set' \
docs/specs/2026-07-30-mu-directory-design.md \
docs/superpowers/plans/2026-07-30-03-discovery-scheduling-identity.md
printf '%s\n' '--- behavioral probe: current SkipEscape/Flatten semantics versus SCS-aware semantics ---'
python3 - <<'PY'
import hashlib
import unicodedata
def skip_current(s, start):
i = start + 1
if i >= len(s):
return start
if s[i] == '[':
i += 1
while i < len(s):
if '@' <= s[i] <= '~':
return i
i += 1
return len(s) - 1
if s[i] == ']':
i += 1
while i < len(s):
if s[i] == '\a':
return i
if s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\':
return i + 1
i += 1
return len(s) - 1
return i
def skip_proposed(s, start):
i = start + 1
if i >= len(s):
return start
if s[i] == '[':
i += 1
while i < len(s):
if '@' <= s[i] <= '~':
return i
i += 1
return len(s) - 1
if s[i] == ']':
i += 1
while i < len(s):
if s[i] == '\a':
return i
if s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\':
return i + 1
i += 1
return len(s) - 1
while i < len(s):
if not (' ' <= s[i] <= '/'):
return i
i += 1
return len(s) - 1
def flatten(s, skipper):
out, pending = [], False
i = 0
while i < len(s):
ch = s[i]
if ch == '\x1b':
i = skipper(s, i)
elif ch.isspace():
pending = bool(out)
elif unicodedata.category(ch)[0] == 'C':
pass
else:
if pending:
out.append(' ')
pending = False
out.append(ch)
i += 1
return ''.join(out)
cases = {
'SCS ASCII': '\x1b(BWelcome',
'SCS line drawing': '\x1b(0Welcome',
'SCS with multiple intermediates': '\x1b 0AWelcome',
'two-byte ESC 7': '\x1b7Welcome',
'two-byte ESC =': '\x1b=Welcome',
'CSI': '\x1b[31mWelcome\x1b[0m',
'OSC BEL': '\x1b]0;title\aWelcome',
}
for name, value in cases.items():
old = flatten(value, skip_current)
new = flatten(value, skip_proposed)
print(f'{name}: current={old!r}, proposed={new!r}, changed={old != new}')
print('SCS fingerprint:', hashlib.sha256(flatten(cases['SCS ASCII'], skip_current).encode()).hexdigest(),
hashlib.sha256(flatten(cases['SCS ASCII'], skip_proposed).encode()).hexdigest())
PYRepository: SharpMUSH/MUIndex
Length of output: 50375
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- TelnetProbe heuristic ---'
sed -n '80,220p' src/MUI.Crawl/TelnetProbe.cs
printf '%s\n' '--- compact design implementation excerpt ---'
sed -n '2088,2135p' docs/superpowers/plans/2026-07-30-03-discovery-scheduling-identity.md
printf '%s\n' '--- behavioral probe ---'
python3 - <<'PY'
import hashlib
import unicodedata
def skip(s, start, scs):
i = start + 1
if i >= len(s):
return start
if s[i] == '[':
i += 1
while i < len(s):
if '@' <= s[i] <= '~':
return i
i += 1
return len(s) - 1
if s[i] == ']':
i += 1
while i < len(s):
if s[i] == '\a' or (s[i] == '\x1b' and i + 1 < len(s) and s[i+1] == '\\'):
return i + (1 if s[i] == '\x1b' else 0)
i += 1
return len(s) - 1
if not scs:
return i
while i < len(s) and ' ' <= s[i] <= '/':
i += 1
return min(i, len(s) - 1)
def flatten(s, scs):
out = []
pending = False
i = 0
while i < len(s):
ch = s[i]
if ch == '\x1b':
i = skip(s, i, scs)
elif ch.isspace():
pending = bool(out)
elif unicodedata.category(ch)[0] == 'C':
pass
else:
if pending:
out.append(' ')
pending = False
out.append(ch)
i += 1
return ''.join(out)
cases = [
('SCS ASCII', '\x1b(BWelcome'),
('SCS line drawing', '\x1b(0Welcome'),
('SCS multiple intermediates', '\x1b 0AWelcome'),
('ESC 7', '\x1b7Welcome'),
('ESC =', '\x1b=Welcome'),
('CSI', '\x1b[31mWelcome\x1b[0m'),
('OSC BEL', '\x1b]0;title\aWelcome'),
]
for name, value in cases:
old = flatten(value, False)
new = flatten(value, True)
print(f'{name}: current={old!r}; SCS-aware={new!r}; changed={old != new}')
value = '\x1b(BWelcome'
print('hashes:', hashlib.sha256(flatten(value, False).encode()).hexdigest(),
hashlib.sha256(flatten(value, True).encode()).hexdigest())
PYRepository: SharpMUSH/MUIndex
Length of output: 8844
Consume intermediate bytes in SkipEscape. ESC ( B and ESC ( 0 currently leave B or 0 in BannerText.Flatten, changing BannerFingerprint and LooksUnfinished results. Skip 0x20–0x2F intermediate bytes before the final byte, and add SCS coverage.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MUI.Crawl/BannerText.cs` around lines 64 - 108, Update SkipEscape for
non-CSI, non-OSC escape sequences so it consumes 0x20–0x2F intermediate bytes
and the following final byte, preventing characters such as B or 0 from reaching
BannerText.Flatten. Add SCS coverage for ESC ( B and ESC ( 0, including the
resulting BannerFingerprint and LooksUnfinished behavior.
| { IsGap: true } => "not measured", | ||
| { IsUnmeasurable: true } => "not counted", | ||
| _ => cell.Count!.Value.ToString(), |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the CellValue documentation with the new state.
Line 71 now returns "not measured" for IsGap, but the XML remarks immediately above still describe the state as "not reached" on Lines 66-67. Update the remarks so ActivitySummary.CellValue and ActivityCell.IsGap document the same contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MUI.Web/Components/ActivitySummary.cs` around lines 71 - 73, Update the
XML remarks for ActivitySummary.CellValue and ActivityCell.IsGap to describe the
IsGap state as “not measured” instead of “not reached,” matching the current
CellValue implementation while preserving the existing documentation for other
states.
| var bad = Days.Count(d => d.State is ReachState.Unreachable); | ||
| var degraded = Days.Count(d => d.State is ReachState.Degraded); | ||
| var unmeasured = Days.Count(d => d.State is ReachState.Unmeasured); | ||
| var measured = Window - unmeasured; | ||
|
|
||
| // The percentage's denominator is *observed* time, not the window (Reachability | ||
| // .FractionReachable). The sentence has to say so, or a game found an hour ago reads | ||
| // "Reachable 100.0% of the last 90 days" off a single successful probe — a true number | ||
| // wearing a claim eighty-nine days wider than the evidence. Only when the window is | ||
| // fully measured do the two denominators coincide and the shorter phrasing become true. | ||
| parts.Add(ReachableFraction is { } f | ||
| ? unmeasured == 0 | ||
| ? $"Reachable {Wording.Percent(f)} of the last {Window} days." | ||
| : $"Reachable {Wording.Percent(f)} of the {DayCount(measured)} we have measured." | ||
| : $"Reachability over the last {Window} days is not yet measured."); | ||
|
|
||
| parts.Add(bad == 0 | ||
| ? "No day in the window was unreachable." | ||
| ? unmeasured == 0 | ||
| ? "No day in the window was unreachable." | ||
| : "No day we measured was unreachable." |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
rg -n -C 12 \
'FractionReachable|ReachSeries\.Build|new ReachSummary|ReachSummary\(' \
src tests --glob '*.cs'Repository: SharpMUSH/MUIndex
Length of output: 36025
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- authoritative design ---'
sed -n '1,220p' docs/specs/2026-07-30-mu-directory-design.md
printf '%s\n' '--- codebase survey ---'
sed -n '1,220p' docs/codebase-survey-2026-07-30.md
printf '%s\n' '--- reachability arithmetic ---'
sed -n '68,145p' src/MUI.Catalog/Availability.cs
printf '%s\n' '--- reach series build ---'
sed -n '160,240p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- related tests ---'
sed -n '24,75p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '24,48p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.csRepository: SharpMUSH/MUIndex
Length of output: 35556
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- sentence and day-state logic ---'
sed -n '61,160p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- mixed-window test helpers ---'
sed -n '1,30p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '1,45p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs
printf '%s\n' '--- deterministic denominator probe ---'
python3 - <<'PY'
from datetime import datetime, timedelta
now = datetime(2026, 7, 31, 12)
window_start = now - timedelta(days=90)
# Each tuple is (state, start, end), with end=None meaning now.
intervals = [
("unreachable", now - timedelta(days=3, hours=23), now - timedelta(days=3, hours=22)),
("reachable", now - timedelta(days=1), now),
]
observed = timedelta()
reachable = timedelta()
for state, start, end in intervals:
end = end or now
start = max(start, window_start)
end = min(end, now)
if end > start:
observed += end - start
if state == "reachable":
reachable += end - start
# ReachSeries.Build marks a calendar day measured if any clipped interval overlaps it.
measured_days = set()
for state, start, end in intervals:
end = end or now
cursor = max(start, window_start).date()
last = min(end, now).date()
while cursor <= last:
day_start = datetime.combine(cursor, datetime.min.time())
day_end = min(day_start + timedelta(days=1), now)
if min(end, day_end) > max(start, day_start):
measured_days.add(cursor)
cursor += timedelta(days=1)
print(f"observed_hours={observed.total_seconds()/3600:g}")
print(f"reachable_hours={reachable.total_seconds()/3600:g}")
print(f"fraction_reachable={reachable.total_seconds()/observed.total_seconds():.6f}")
print(f"measured_days={len(measured_days)}")
print(f"sentence_denominator_days={len(measured_days)}")
PYRepository: SharpMUSH/MUIndex
Length of output: 7239
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- sentence and day-state logic ---'
sed -n '61,160p' src/MUI.Web/Components/ReachSeries.cs
printf '%s\n' '--- mixed-window test helpers ---'
sed -n '1,30p' tests/MUI.Catalog.Tests/AvailabilityContractTests.cs
sed -n '1,45p' tests/MUI.Web.Tests/SilenceIsNotEvidenceTests.cs
printf '%s\n' '--- deterministic denominator probe ---'
python3 - <<'PY'
from datetime import datetime, timedelta
now = datetime(2026, 7, 31, 12)
window_start = now - timedelta(days=90)
intervals = [
("unreachable", now - timedelta(days=3, hours=23), now - timedelta(days=3, hours=22)),
("reachable", now - timedelta(days=1), now),
]
observed = timedelta()
reachable = timedelta()
for state, start, end in intervals:
end = end or now
start = max(start, window_start)
end = min(end, now)
if end > start:
observed += end - start
if state == "reachable":
reachable += end - start
measured_days = set()
for state, start, end in intervals:
end = end or now
cursor = max(start, window_start).date()
last = min(end, now).date()
while cursor <= last:
day_start = datetime.combine(cursor, datetime.min.time())
day_end = min(day_start + timedelta(days=1), now)
if min(end, day_end) > max(start, day_start):
measured_days.add(cursor)
cursor += timedelta(days=1)
print(f"observed_hours={observed.total_seconds()/3600:g}")
print(f"reachable_hours={reachable.total_seconds()/3600:g}")
print(f"fraction_reachable={reachable.total_seconds()/observed.total_seconds():.6f}")
print(f"measured_days={len(measured_days)}")
print(f"sentence_denominator_days={len(measured_days)}")
PYRepository: SharpMUSH/MUIndex
Length of output: 7239
Use the same denominator for the percentage and wording
Reachability.FractionReachable uses observed elapsed time, but ReachSummary.Sentence names measured calendar days. A mixed window can therefore report, for example, 96% of 25 observed hours as “of the 3 days we have measured.” Use observed-time wording or calculate the fraction by measured days. Add a test with reachable, unreachable, and unmeasured days.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MUI.Web/Components/ReachSeries.cs` around lines 84 - 103, The
ReachSummary.Sentence percentage currently uses observed elapsed time while its
wording uses measured calendar days, creating mismatched denominators. Update
the Reachability summary flow around ReachableFraction and Sentence to use
observed-time wording consistent with the fraction (or change the fraction to
measured-day calculation), preserving the full-window wording behavior; add
coverage for a window containing reachable, unreachable, and unmeasured days.
| public static async Task ApplyMigrationsAsync(IServiceProvider services, ILogger logger) | ||
| { | ||
| var applied = await new MigrationRunner( | ||
| services.GetRequiredService<NpgsqlDataSource>(), logger).ApplyAsync(); | ||
|
|
||
| if (applied.Count > 0) | ||
| { | ||
| logger.LogInformation("Applied {Count} migration(s): {Names}", applied.Count, string.Join(", ", applied)); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Serialize migrations across application instances.
MigrationRunner.ApplyAsync reads the ledger before it starts each migration transaction. If two instances start against the same database, both can select the same pending migration. One instance can then fail on migration DDL or the unique ledger insert.
Acquire a database-wide advisory lock for the complete migration run. Keep the pending check, migration DDL, and ledger writes under that lock. This prevents a rolling deployment from leaving one web instance unable to start.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/MUI.Web/Data/PostgresData.cs` around lines 63 - 71, Update
MigrationRunner.ApplyAsync to acquire a PostgreSQL database-wide advisory lock
before reading pending migrations and hold it through all migration DDL and
ledger writes, releasing it only after the complete run finishes. Ensure
ApplyMigrationsAsync continues using this serialized migration path and
preserves the existing applied-migration logging.
| /// <summary>A MU* server, to the extent the probe can tell.</summary> | ||
| /// <summary> | ||
| /// A server that says it is not ready, then pauses for longer than a gap between lines. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// tbaMUD's shape, measured on <c>tbamud.com:4000</c>: <c>Attempting to Detect Client, Please | ||
| /// Wait...</c>, then about a second and a half of silence, then the real screen. Under a plain | ||
| /// quiet period the placeholder <em>was</em> the connect screen — it became the stored banner and | ||
| /// its hash became the game's identity, so two unrelated tbaMUDs fingerprinted alike and the | ||
| /// second went into a duplicate review instead of onto the site. | ||
| /// </remarks> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Move the FakeGame class summary back above the class.
Line 184 (/// <summary>A MU* server, to the extent the probe can tell.</summary>) is the doc comment for the FakeGame class. The two new tests were inserted directly after it, so this summary is now immediately followed by another <summary> block (Line 185-194) with no code element in between. That merged comment block attaches to AScreenThatSaidItWasNotReadyIsWaitedFor, not to FakeGame. Move Line 184 to sit directly above private sealed class FakeGame : IAsyncDisposable (Line 239) so each doc comment documents its intended element.
♻️ Proposed fix
- /// <summary>A MU* server, to the extent the probe can tell.</summary>
/// <summary>
/// A server that says it is not ready, then pauses for longer than a gap between lines.
/// </summary>
@@
private sealed class FakeGame : IAsyncDisposable
+ /// <summary>A MU* server, to the extent the probe can tell.</summary>
{🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/MUI.Crawl.Tests/ProbeSessionTests.cs` around lines 184 - 194, Move the
“A MU* server...” XML summary from its current position to immediately above the
private sealed FakeGame class declaration, leaving the readiness-test summary
attached to AScreenThatSaidItWasNotReadyIsWaitedFor and preserving both
descriptions.
The site read a fixture; it now reads PostgreSQL when
MUI_POSTGRES(orConnectionStrings:MUIndex) names one, applies migrations at startup, and falls back to the fixture only when neither is set — announced in the log and, more importantly, on every page. A directory whose whole claim is that its data is measured cannot show invented data silently.What a real crawl found that the fixture could not
Sixteen live games, one probe each. Three sentences were lying, and all three are the house rule read forward into prose:
Reachable 100.0% of the last 90 daysReachable 100.0% of the 1 day we have measured167 hours … could not be measured — the game was not reachable167 hours … have no measurement yetBANNER_HASH 301233d35aed…under what the game says about itselfReferral discovery, walked in the wild
Of 141 live servers — the codebase survey plus every entry in TinTin's MSSP crawler list — two publish
REFERRAL, and both nametbamud.com, one of them two ports of it. Walking it found two more defects:textcannot hold0x00, so the first field carrying one raised22021mid-write: game half-created, endpoint never scheduled,REFERRALlist never read. RFC 854 makesCR NULa bare CR, so the byte is telnet framing; it is stripped at both doors into the crawler. It was also the cheapest DoS a hostile server had.NAME "PennMUSH"again, one field over. The phase now waits longer only when what it has is slight and has not reached a prompt.Both halves of §7.2's gate are pinned end to end: a referred host that names itself becomes a second listed game without being seeded; one that answers with only its codebase's name does not.
tbamud.comis the second case, live — discovered, probed, kept, correctly unlisted.Also
CLAUDE.md). The importer is code; a harvested catalogue is not, and the import is one command an operator runs once against the one deployment.721 tests, six suites, zero warnings.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk
Summary by CodeRabbit
New Features
Bug Fixes
Documentation