USER_SAFETY.md step 4 (§4.3): the app half of report, block and hide.
- Long-press a Rust chat line for its menu: Report (the six reasons and an
optional detail), Hide for me, Block player where the line says canBlock,
and for admins and moderators Hide for everyone, or Show to everyone again
on a hidden line. Signed out, it says Sign in to report or block and opens
the login screen (S6).
- The feed is re-read after a hide, a block or a staff hide, never edited in
the app: the server filters it per reader (S11). A report changes nothing
and does not re-read.
- Staff see a line hidden for everyone dimmed and marked Hidden by staff (S10).
- Account -> Blocked lists account and Steam-ID blocks with Unblock, says when
a block no longer applies because its target is now staff (S8), and offers
Show them again for hidden items.
- Core's routes on a new SafetyApi; the module's two staff routes on RustApi.
RustEventDto gains canBlock and hidden.
Two fixes from the emulator walk: Back on the report form or the block
confirmation now steps back to the menu instead of closing the sheet (Back
to put the keyboard away was throwing a half-written report away), and the
sheet opens fully so Send report is on screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
ACCOUNT_DELETION.md §4.3 (step 4). My account gains a Delete my account
row, last and set apart, which opens its own screen: what goes, what stays,
the staff note, then the typed username, the password and, when two-factor is
on, an authenticator or recovery code. Delete stays off until the name matches.
MeApi.deleteAccount sends DELETE /auth/me/account and reads the raw response,
so each refusal's code becomes its own sentence (the website's words). A
wrong password is a 400 (AD12), so the authenticator never replays it.
On success AuthRepository.forgetDeletedAccount does what a sign-out does
without its server calls: the inbox snapshot, the trust token (which a
sign-out keeps, but the account is gone) and the session, whose teardown
also drops the push registration. It is not cancellable. The route holds the
view model, so it tells a deletion from any other sign-out, and opens sign-in
with "Your account has been deleted." on the shell's snackbar.
Tests: the refusal mapping per code, 429, unknown codes, no answer, the
request body; the confirm rule, the staff rule, each refusal's sentence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PushService was a dataSync foreground service. Android 15 stops dataSync
after six hours a day and expects the service to stop itself on
onTimeout; it never did, so with target 36 the app would crash. A held-open
push connection is not a transfer either, which is what Play reserves
dataSync for.
- The service is now specialUse, with the PROPERTY_SPECIAL_USE_FGS_SUBTYPE
reason Play reviews (ntfy does the same on Play). No daily limit.
- PushBootReceiver restarts it after BOOT_COMPLETED and MY_PACKAGE_REPLACED
when push was on and a registration is saved (a sign-out clears it). It
starts the service from the saved topic instead of re-registering, since
the network is often not up at boot.
PLAN.md M8, decided by the org lead 2026-10-29.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Step 4 of docs website/DEMO_MODE.md §10 (website#222).
- DemoLockInterceptor on the main client peeks every 403 body. On
code DEMO_LOCKED it reports the server's sentence to DemoNotices, and the
shell shows it as one snackbar, like the web's toast (DM19). A newer refusal
replaces the one showing. Screens added later get it without doing anything.
- ApiResult.HttpError carries the body's code (read only on a 403, peeked so
the body is not consumed); isDemoLocked(). ErrorKind.DEMO_LOCKED is mapped
ahead of the status in toUiState and toShardUiState, and is not retryable.
- Screens whose 403 copy blames the role now say the demo sentence instead:
the four admin view models, Characters' account creation, and the contact
form's line (the contact form is locked on the demo).
- Home draws the strip from GET /public/settings' demo block: the wipe time in
UTC, the published usernames, and the site's sign-in page for the passwords.
The app does not list the passwords (DM20). A real shard sends no block, so
it draws nothing.
Checked on an emulator against website main (69998ec) with DEMO_MODE=true and
seed:demo: the strip on Home; demo-player's username change refused with the
snackbar under the screen's own line; demo-admin's site-mode switch refused
with the demo sentence in place of "You don't have permission".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Step 1 of M8 (docs/android/PLAN.md item 9).
- compileSdk/targetSdk 36 (Play's floor since 2026-08-31). AGP 8.6.1
cannot build against 36, so AGP goes to 8.13.2 (the last 8.x) with the
Gradle 8.14.3 wrapper. Kotlin, KSP and Hilt are unchanged. All three
workflows install platforms;android-36; AGP 8.13 still defaults to
build-tools 35.0.0.
- release.yml also runs bundleRelease, signed with the same keystore,
and attaches the .aab to the Gitea release beside the APK (the first
bundle has to be uploaded by hand, and this is where it comes from).
- A last step uploads the bundle to Play's internal track through the
Play Developer API v3 edit flow. The OAuth token comes from a
service-account JWT signed with openssl; the key is the new
PLAY_SERVICE_ACCOUNT_JSON secret. While that secret is unset, the
step warns and skips. The step runs after the Gitea release, so a
Play failure never holds back the APK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md M20 step 4. Module-Rust#60 gives every event frame a weaponName: the
game's own name for a ranged weapon, or the prefab name as words. The feed
printed the prefab name as words itself, which read "with ak47u entity".
weaponName() in RustFormat.kt is the Kotlin half of module-rust's format.js
weapon(). It prints frame.weaponName. Against a module too old to send it,
it drops the .entity / .weapon / .deployed ending before prefabName(), as
the website does, so an older module's kill reads "with ak47u".
Tests: a kill with weaponName, the same kill from an older module, and the
fallback's edges (blank name, each ending, an ending only at the end, no
weapon).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Found by the M20 trace, and fixed in this PR by the org lead's decision.
Both rows call /admin/shard/*, module-uo's admin mount, but they carried no
capability. So a moderator on a Rust-only site saw "Moderation" in the drawer
and opened a UO form ("Serial (0x…)", kick or ban by UO account) where every
action failed.
Both rows now declare Capability.SHARD, as the player rows do, so they are UO
screens and their strings belong in strings_uo.xml, where step 1 put them. A
new MenuCapabilityGatingTest case covers it: a moderator on a Rust-only
backend sees neither row but keeps the Dashboard, and on a UO backend sees
both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md M20 step 3. StringVocabularyTest reads strings.xml, strings_uo.xml
and strings_rust.xml in every values* directory, so translations are checked
too. It fails, naming the key and the word, when the core file holds either
game's word, the UO file holds a Rust word, or the Rust file holds a UO word.
The word list is runicgateway.com's checkTerms.mjs list, word for word. The
app never shows Discord's "guild ID", so that allowlist entry isn't carried
over.
Three more tests keep the guard honest:
- a fourth strings file with no owner is refused, because Android would read
it and the guard would skip it;
- the three default files must exist and hold strings, so a wrong path can't
pass on nothing;
- the matcher is checked against known hits and misses.
Run against the core file as it was before the rewording, it names exactly
the 14 strings that commit changed.
build.gradle.kts declares src/main/res as an input of the Test tasks. A
reworded string changes no class, so without that input Gradle left the test
task UP-TO-DATE and the guard did not run again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md M20 step 2. The first screen a Rust community's player sees said
"Connect to your shard", and the next said "Sign in with your shard account".
The core strings now use core's words, as the website and runicgateway.com do:
- connect_*: "your community", "a Runic Gateway site"
- login_subtitle: "Sign in with your account on this site."
- error_shard_offline: "The game server is offline right now." It is the
shared 503 text that both games' screens show.
- error_feature_unavailable and notifications_*: "This site …"
- push_channel_* and push_service_text: "Notifications from your community"
Two comments in the file said "shard" too, and now say "site". Keys are
unchanged. The UO screens keep their words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
PLAN.md M20 step 1. strings.xml keeps the core strings; strings_uo.xml and
strings_rust.xml take the strings only that game's screens use. Android merges
the three into one R.string, so no key changes and no Kotlin changes.
Each string was classified by the screens that reference it, traced in code,
not by its key: a string any deployment's screen can show stays core.
error_shard_offline and error_feature_unavailable are the shared error-view
texts, so they stay core. All seven legacy push streams, server.status
included, are declared only by module-uo, so their titles are UO.
No text changes: all 578 elements are moved byte for byte (checked by
comparing name → element before and after).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Steam sign-in (docs/website/STEAM_SIGNIN.md) lets a person link more than one
Steam account. Unlink went by provider, so it would have removed all of them;
it now sends the identity's id (DELETE …/identities/:provider?id=). A Steam row
reads "Steam: <persona name>" with its SteamID64 beneath, since Steam sends no
email. The sign-in sheet already lists providers from /auth/providers, so Steam
appears there with no change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
A Backpack section under Coins: one card per server (GET /player/rust/servers),
an Open button per linked account that reads GET /player/rust/backpacks once
per press and never polls, and the slots as pages of four-wide rows named by
the game (D340), blueprints by what they make (D341), overflow marked with the
take-back sentence. A 409 says Backpacks is not installed; the section is left
out with Coins on that read's 404, since both ship in protocol 14.
backpackPages/stackLabel are pure (BackpackLayout.kt) and tested, with the DTO
decode and the view model's open/read-again/close. Local gradle lint test
assembleDebug passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
The app half of the module's Economics (protocol 14): a Coins section on the
Rust account screen, from GET /player/rust/economics — one card per server,
each linked account's balance, "last known" when the server did not answer,
and "Economics is not installed on this server" where it is absent (D199).
A third independent read beside the links and the entitlements; a link
re-reads it, and a 404 (a module older than protocol 14) leaves the section
out rather than showing an error the player can do nothing about. A balance
never reported shows a dash, not zero.
testDebugUnitTest, lintDebug and assembleDebug pass locally; four new tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
- The map (D302, D303), as the site draws it:
- an NPC's card is its name, or "(boss)" or "(a boss's add)";
- the card names its event, with the title from the run resolver's
existing calendar read;
- a boss is bigger and magenta;
- a boss outside any event is a world-layer marker with its name.
- The event screen renders the live occurrence's progress lines
(MODULE_API 1.12.0, D304): "Bandit: 2 of 3 left", "Walk Juggernaut:
59%". It re-reads quietly every 15 s while the event is live.
- DTOs gain the new fields with defaults, so an older site decodes as
before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Each leaderboard row carries `titles: [{ text, color }]` from a phase-17
module: the chat titles the player holds now. Drawn as chips under the
name, in the operator's colour with black or white ink chosen by WCAG
contrast (the website's rule). Absent from an older module, so the field
defaults to empty and nothing is drawn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
RustServerDto gains nextWipe { at, source }, null both when the operator
set no schedule and against a module older than phase 16. The server
list row and the server screen's header read "Next wipe Thu 1 Oct,
19:00 · in 6 days" in the phone's own zone, and "(rescheduled)" for a
one-off date the operator set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
A fifth tab on the Rust server screen, between Online and Wipes, shown
when the site's module declares `map` under the app's one capability
rule (D122). `?tab=map` opens it; on a site without a map it opens the
feed.
The phone draws the map itself (D121): Coil loads the picture once per
map as RGB_565, and a Compose Canvas draws the grid and the markers,
with pinch, pan and double tap. The frame is §30.3's transform, ported
from the web's mapGeometry.js with its test cases. Markers keep their
size on screen; a zone is drawn on the ground. Nothing here gates a
layer: map/live is projected per viewer on the server, and the app
draws what it is sent.
- The legend: a switch per layer the viewer was sent, and who can see
each one they were not (with the D113 line). The mates row offers
the Steam link to a signed-in, unlinked viewer.
- The tap card: what a marker is, its grid square, and what its layer
carried. A site-event marker whose run core's public calendar lists
(D125) has Open event, which opens the app's own event page on that
run (D123). A rehearsal's or unlisted event's marker says only
"Site event".
- The poll: map/live every pollMs (10 s) while RESUMED on the Map tab
(D124), and not at all for a viewer sent nothing that moves. A new
mapKey re-reads the map, so a new seed's picture follows in place.
- Per account: the map's state is dropped and re-asked on a change of
signed-in account, and an answer asked for the previous account is
discarded when it lands.
Found on the emulator walk and fixed here: the reader's zoom was lost
on the way back from the event page, and again whenever the status line
under the map changed length (a resize was treated as a rotation).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Three places the app still assumed its site was a UO shard (D69-D71):
- The linked-account check behind personal streams asked module-uo's
/player/shard/accounts. On a Rust site that failed, and the whole raid
alert row was disabled in every channel. It now asks the site's own
module by capability, and the link only holds back switching push ON;
switching anything off is never refused.
- A tickle whose ref names an inbox row is titled from that row, pulled
over the authenticated inbox API (wake-and-pull). The lock screen shows
only the per-stream title. Each tickle runs in its own job, because
collectLatest would cancel a pull on the next tickle.
- /player/rust and /rust/servers/<id>?tab=<tab> open natively; a tab the
app does not have (clans) still hands off to the browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Module-Rust now withholds who is online below an operator-chosen
audience - staff by default (org lead, 2026-09-22: nothing names who is
online by default). The Online route answers an empty list with hidden,
count and audience; the feed answers presenceHidden and
presenceAudience.
Before this the app would have rendered the withheld list as "the
island is empty" on a full server. Now:
* RustOnlineDto and RustEventListDto carry the flags; the repository
and view model keep the whole answer instead of its rows.
* the Online tab says "2 players online" and who can see the names;
the feed says, once above the rows, that joins, deaths and chat are
not shown.
* an older module without the flags decodes as visible, as before.
Walked on an emulator against a core with the module installed: the
withheld panel and the feed note as a signed-in player at the staff
default, and the names appearing on the next poll after the fleet was
widened to signed-in - which also proves the app's bearer session
reaches the module's viewer check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
Found by the emulator walk, not by the suite: the app's row carried the
name and the Steam id and stopped there, while the website's says "linked
just now on rust-oxide". Neither fact is part of the identity — a link is
fleet-wide — but which server minted the code is where a support
conversation starts, and the module's own schema comment says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
R10's leg B: identity, and the half of R2 a player is allowed to see. Every
route this calls existed and answered before a line of Kotlin was written,
except the entitlement read, which is this phase's one website change.
**One drawer row under the player group, shaped like `CharactersScreen` one
game along**: the code card first, then what the code got them. A link is
fleet-wide, so it is not a tab under one server — a Steam account is one
person wherever they play, while stats are per server and per wipe.
**Gated on `rust`, not on `identity`.** The module declares a surface word
per feature, and D16's rule is that a capability answers one question — *is
the module there*. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is
right here: `/player/rust/*` is `requireAuth` with no role above it, and
staff play the game too.
**Two reads, neither blocking the other.** An entitlement is authored
against the website account, so it exists before a Steam id does — the
person who has just been given something and has not linked yet is exactly
the one who needs both halves at once, and a failure on either leaves the
other standing.
**The four refusals stay four pieces of advice.** 400 is a spent code, 409
is a Steam account another account holds (`/unlink` in game releases it),
429 is the limiter, and 503 is a server that could not be reached — where
the code is still good, so it may not say "get a new one". A player told
otherwise goes back to the same unreachable server for another code.
The app does no scope arithmetic: `*` never reaches a screen. Each entry
arrives with its servers already resolved and each marked, because a second
implementation of `inScope` is a second thing to keep true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
Run 76 hung in `compileDebugKotlin` for thirteen minutes and was failed with no
error in its log, where the last good run finished that task in two. Nothing
about that reads as a compile error, and this repo's Gitea has no rerun
endpoint — so this empty commit is the re-run, to tell a transient runner
problem from a real one before bisecting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
**Unrelated to this PR's feature work**, and fixed here because it blocks
verifying it (org lead, 2026-09-17). PR #46 passed on this workflow yesterday;
every Android PR fails now.
`android-actions/setup-android@v3` is a floating tag and the action's `packages`
input defaults to `tools` — an obsolete package Google has since removed from the
SDK repository. So the step runs `sdkmanager tools`, gets `Warning: Failed to
find package 'tools'`, exits 1, and CI fails in **Set up Android SDK**, before a
line of this repo is compiled.
`packages: ''` turns that install off. It was always redundant here: the very
next step installs exactly what the build targets — `platform-tools`,
`platforms;android-35`, `build-tools;35.0.0` — precisely so the build never
depends on what some action decided to fetch.
Not addressed here, and worth its own decision: `@v3` is a floating major tag, so
the next upstream change can break CI the same way without warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Three things, none of which a unit test could have seen.
**The drawer's live count resolved once per process.** It was keyed on the
capability answer alone, so it was read at connect and never again — which is
not what "live" means on a row somebody opens the drawer to look at. It now
refreshes on resume, beside the inbox's unread badge and for the same reason:
coming back to the app is exactly when a stale number would be noticed. Still
never on a timer, still nothing at all on a site without the module.
**Every card's text sat flush against its edge.** `ShardCard` is the themed
`Card` and carries no padding of its own — each caller pads its own content, and
these four did not. On a phone the first glyph of each line read as clipped.
**A name touched its own kill count.** Five numeric columns beside an
equal-weight name column left "Brannock" and "50" reading as one field. The name
now takes a wider share and ellipsizes, and the ACTIVE SORT is marked on the
header rather than by tinting a column of numbers — the header is the control,
and tinting the values says "these are special" instead of "this is what the
table is ordered by".
Walked against the phase-4 rig: a core with the module installed, one live
server and one that has never reported. Both halves of the phase criterion hold
on a phone — the Rust site renders every panel with its server unreachable, and
the same app against the UO core shows its five shard rows and no Rust row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The app's half of module-rust's read path — the leg R10 says trails the website
surface it consumes by one phase, so it is built against routes that exist.
Two screens, mirroring what phase 4 shipped: `/rust` is the server list (D12),
and one server is a single screen with four tabs (D13) rather than four
destinations. Both render entirely from the website's own tables, so the phase
criterion — a fleet that is entirely off still shows its maps, seeds, wipe
dates, killfeeds, leaderboards and last known presence — holds here for the same
reason it holds on the web.
What is new to the app rather than copied:
- **A poll that is not a load.** `PollWhileResumed` + `refreshInto` (D17): a
refresh is invisible when it succeeds and KEEPS the rows when it fails. The
app had one shape for a read — blank, ask, replace — which is right for opening
a screen and would clear the killfeed three times a minute here. Gated on
RESUMED, so a backgrounded app makes no requests at all and returning to it
refreshes at once.
- **A second game module in the drawer.** `Capability.RUST`, gating one row. It
deliberately does not gate on `servers`/`killfeed`/`leaderboard`/`presence`/
`wipes`: those name surfaces, core flattens every module's capabilities into
one list, and another module declaring `servers` would reveal these screens on
a site with no Rust. Module-Rust#5 adds the identity string.
- **`/rust` in NavPaths**, so an admin's nav override or an added link opens
natively instead of handing off to a browser (D19).
- **A live player count on the drawer row** (D19) — the phone's answer to D15's
footer slot, in the same badge slot the inbox count uses, with the same
screen-reader treatment. Zero renders nothing; a failed read keeps the last
number; it never polls.
Two things carried across from the website's own page walk rather than
rediscovered: "last reported" reads `lastSeenAt` and never `updatedAt` (a failed
poll moves the second), and a feed row from another calendar day carries its
date, or a row from a past wipe reads as this afternoon.
The four navigation tests that moved did so because APP_MENU gained a row and
the website's nav number line gained an index; each now says which.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
The defect Phase 14b found in `MyEventsViewModel` and flagged next door: the
notifications surface has the identical shape, and it leaks the same way.
A drawer route's view model outlives a sign-out. `navigateTopLevel` uses
`popUpTo(HOME) { saveState = true }` with `restoreState = true`, so the
`NavBackStackEntry` keeps its `ViewModelStore` and a view model that loaded only
in `init` never runs again. Signing out and back in as somebody else showed the
second account the FIRST account's inbox — titles and body text written for
another person — with no request made at all, while the badge above the list
showed the new account's real unread count, because the shell refreshes that on
every session change.
`InboxCache` was never the hole: it is keyed by (base URL, user id) and a snapshot
has never crossed an account. The hole was the in-memory state, which nothing
invalidated.
Both view models now key on the signed-in account id, so a resume revalidation
that returns the same user does not refetch. The inbox resets its state *before*
loading rather than after, because `load()` paints the cache only when there is no
`Success` on screen — otherwise the previous account's rows stay up for the whole
round trip.
The settings screen behind the inbox's gear is fixed with it, and there the stale
render is worse than disclosure: those controls are written from, so a screen
still showing the previous account's preferences would send this account's PUT
built out of them.
Walked on the emulator against a local website, before and after: two accounts
with deliberately different inboxes, signed out and in within one process. Before,
the second account saw the first's rows and the server logged no inbox fetch;
after, it logs the fetch and shows its own.
572 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md.
Four screens on the four routes Phase 14a shipped: the public calendar, an event
page carrying `?run=`, an arc, and participation history. One drawer row for the
history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and
self-scoped, and the website needed two mounts for it only because `RequirePlayer`
guards `/account` there.
The prerequisite fix is the larger half. The app read `/public/modules` nowhere
and mapped every `/public/shard/features` failure to "unknown", which `canSee`
treats as visible — so on a site with no `uo` module every shard row rendered and
every one of them 404'd. Absence of an answer is not an answer of absence: a
successful module list that omits `shard` hides the rows, a failed read keeps the
last answer the host gave, and a host that has never answered leaves the gate
open. Capability and feature compose as two gates and answer different questions:
whether the module is installed (per host) and whether this shard publishes the
surface to this viewer (per viewer).
Also corrects the website path → route table, wrong since the module-system
cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard
rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link
to one handed off to a browser, and the sort-key line was wrong. Two existing
tests had been passing vacuously since that day.
An inbox link to an event now opens the app rather than a Custom Tab, through
`resolveWebPath` rather than a second mechanism — so its "a query hands off" rule
gains exactly one exception, `run` on an event page.
The emulator walk found three defects that 563 green tests did not:
- the three player game-data rows read `/player/shard/*` and were not gated, so
they rendered and 404'd; the test meant to catch that asked whether every row
*with a feature* declared the capability, and those three have none. It now
asks by route.
- `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made
kotlinx refuse the entire body and a 200 rendered as a server error — latent on
the public results table for every visitor.
- a drawer route's view model outlives a sign-out, so signing in as a second
account showed it the first account's participation history with no request
made at all.
570 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4