Companion to website#<pr>. Records Phase 1b as built, and corrects two things
the plan got wrong before anyone builds on them.
ENGAGEMENT.md
- Phase 1b step 2 said to pin the index to a case-insensitive collation. Every
_ci collation MariaDB offers here is also accent-insensitive, so that index
would refuse jose@x.com once josé@x.com existed and the de-duplication would
have cleared a legitimate account's address. The as-built block records the
generated-column design that shipped instead, and the second-order version of
the same bug that a seeded fixture caught in the de-dupe query itself.
- §0.6 named two callers of isDuplicateUsername(). There are five, and the
three it omits fail worse than the two it names.
BACKEND_DESIGN.md — the users table (already stale: it predated the player
account work), plus email_verifications and email_dedupe_report, and the five
new routes.
UPGRADE_NOTES.md — an operator entry, because the de-duplication is the kind of
quiet change this file exists for: nothing breaks, and the affected users find
out the next time they try to reset a password.
api-route-inventory.json — regenerated from the manifest; still ungated.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to RunicGateway/website's collapse of /admin/account (6 routes) and
/player/account (8 routes) onto /auth/me/account, which was already a strict
superset of both.
- BACKEND_DESIGN.md: the two router-tree entries go; the /auth/me prose is
rewritten from "additive, the older routes stay for web back-compat" to the
single surface it now is, recording why /auth/me was the one to keep and
that gating was equivalent. account.controller.js moved to router/v1/auth/.
The /player-group paragraph loses account.router.js from its mount list.
- ENGAGEMENT.md: new Phase 1a records the collapse as built, and §0.6 finding 4
is corrected — it named router/v1/player/account.router.js, which is gone.
Phase 1b's change-and-verify flow now lands on /auth/me/account and nowhere
else, which was the reason to do this first: a self-service field would
otherwise have been written three times, in 1b and again in Phase 3.
- android/PLAN.md §6.4/§8: the "routes stay for web back-compat" note is now
false. The app needed no change — MeApi.kt was already 100% /auth/me/*.
- API_V2_PLAN.md: a forward pointer only. Its router inventories are a record
of the domain split as it landed and are deliberately left as written.
api-route-inventory.json is regenerated wholesale, not partially updated. It is
a generated mirror of server/routes.manifest.json with no CI gate, and it had
drifted on its own: 168 entries against the manifest's 203, missing every Teams
route and still listing the two Gmail connect routes Phase 1 deleted. It is now
byte-identical to the manifest. A gate for it is flagged in ENGAGEMENT.md
Phase 1a as later work — a mirror nothing checks will drift again.
PROJECT_TREE.md is deliberately untouched: it is auto-generated by the
sync-project-tree workflow in the website repo, which regenerates it from
tracked files on main.
Co-Authored-By: Claude <noreply@anthropic.com>
Engagement Phase 1's documentation (ENGAGEMENT.md §6.0b, row 1).
BACKEND_DESIGN.md §7 is REWRITTEN rather than amended, as the plan
requires: it documented Gmail OAuth2 as the mechanism. It now covers the
transport registry and why credentialFields is a single declaration
three consumers read, the credential blob and its fail-safe decrypt, the
no-phone-home rule and its CI guardrail, the three SMTP postures, why
Send test is the only verification left, the five failure contracts, and
the silent upgrade. The §2 tree, the PR-4 route-count note, the contact
row and the dashboard row are corrected alongside it — the dashboard now
returns warnings[].
UPGRADE_NOTES.md is new, and is the home for every later phase's
operator note as well (1b, 6, 10 and 13 each owe one). Newest first, and
scoped deliberately: an upgrade that needs nothing does not get an entry.
The entries worth writing are the ones that fail QUIETLY, which is
exactly what the Gmail removal does — mail stops, nothing errors, and
the only signal is a dashboard warning.
ENGAGEMENT.md records Phase 1 as built, with the four things that are
not what the plan says: only the transport half of §3.1 was built (a
channel nothing calls is a shape frozen too early), `enabled` now gates
every sender where it used to gate none of the direct ones, the G22
warning reads the deprecated column on purpose, and `npm run swagger`
had to be fixed first — the "per-file route limit" phases 8 and 9
recorded does not exist, and the real rule matters to every later phase
here, all of which add routes.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase -1 is now executed, and running it disproved the correction made in the
previous commit. android-app's `edge` did not exist: the push reported
`[new branch]`, and `git ls-remote --heads origin edge` confirms it. §6.0a was
right the first time — three repos needed a branch cut, not two.
The cause is worth keeping, because it read as a real measurement. `git fetch
origin` does not prune, so android-app's `refs/remotes/origin/edge` survived the
server-side deletion after the M12 cutover. rev-parse resolved it and
`rev-list --left-right --count` returned "1 behind" — a plausible number for a
stale branch, which is exactly what it looked like. A remote-tracking ref is a
cache, not an answer; ls-remote is.
Records Phase -1 as complete: five fast-forwards (module-uo 9, installer 7,
servuo-plugins 7, website 5, link 3) plus three branches cut from main
(android-app, runicgateway.com, Integration-kit).
Assisted-By: Claude Code (Opus 5)
Co-Authored-By: Claude <noreply@anthropic.com>
Four of the eight open questions in ENGAGEMENT.md §7.1 were answered on
2026-08-28. Q1's answer turned out to carry a whole phase with it.
- Q5: document all three SMTP postures, lead with a relay, name
Gmail-app-password as the migration path off OAuth2.
- Q3: rules stay operator-editable data, but `enabled` defaults to 0 and
every rule carries a hard per-hour send ceiling. Adds max_sends_per_hour
to engagement_rules — the ceiling is what makes "data" safe to choose
over "code".
- Q7: no campaigns surface at all. Lists DO exist, but only module-declared
and powered by module data, on a surface core exposes to every module.
Operators may compose them; composition must NARROW, never widen. Adds
§5.1a, api.registerAudiences and engagement_audience_segments.
- Q1: opt-in only, users.email becomes UNIQUE, and the verification gate is
an admin setting (on for fresh installs, off for upgrades).
New §0.6 records why the UNIQUE index is not a one-line ALTER, verified in
the tree rather than assumed:
- ensureSchema() runs the ALTER block on every boot, so ADD UNIQUE INDEX
against a table holding duplicates stops the site from starting.
- isDuplicateUsername() tests only ER_DUP_ENTRY/1062 and never which index
collided, so register would answer "that username is already taken" for a
duplicate email, and provisionSsoPlayer would retry usernames for an email
conflict until it exhausts PROVISION_MAX_TRIES and fails opaquely.
- SSO auto-provisioning manufactures those duplicates and marks addresses
verified merely for existing — which is also why dedupe is oldest-wins
rather than verified-wins. CLAUDE.md's "identities are never
auto-provisioned" is stale.
- No route lets a user change their own address, so a verification gate has
no flow to gate; Phase 1b builds one.
New Phase 1b sequences the fix before the index, dedupes oldest-wins with an
admin report, and keeps the collision error generic, rate-limited and out of
the bot scorer. Phase 9 loses the verification flow to it and therefore no
longer blocks Phase 11.
Also corrects §6.0a: android-app DOES have an edge (1 behind main), so
Phase -1 is six fast-forwards and two branch creations, not five and three.
Assisted-By: Claude Code (Opus 5)
Co-Authored-By: Claude <noreply@anthropic.com>
Three additions the org lead called for, all in ENGAGEMENT.md.
The branching model (new §6.0a). Every phase PR in every repo targets `edge`;
`main` is touched exactly once, by the cutover. Two blocking findings checked
on 2026-08-28: every existing `edge` is stale (0 ahead of `main`, behind by 16
docs / 9 module-uo / 7 installer / 7 servuo-plugins / 5 website / 3 link) and
must be fast-forwarded before the first phase PR, and three repos have no
`edge` at all — android-app, runicgateway.com and Integration-kit. Also records
that android-app's pr-checks.yml triggers only on PRs into `main`, so Phase 8
lands with no CI and the cutover is its first real build, as happened to all
nine M12 phase PRs.
Documentation as a phase deliverable (new §6.0b). A phase-by-phase table
assigning the specific docs each phase owes, in `docs/` and in every other
repo, so nothing is deferred to a cleanup pass. §7.4 becomes the inventory
that table draws from rather than a list of things to do at the end.
Two new phases. Phase 12 is runicgateway.com, which is not optional polish:
scripts/checkFacts.mjs fetches each fact's authority from the source repo's
`main`, so `protocol` 4 to 5 and `moduleApi` 1.6.0 to 1.7.0 fail its build on
their own. The site also currently documents the opposite of what Phase 1
ships — notifications-and-email.mdx carries a "There is no SMTP option" aside —
its capabilities list claims a web notification channel that will not exist
until Phase 7, and PLAY_DATA_SAFETY.md and /privacy generate from one inventory
that an engagement mailer materially changes. Because checkFacts reads `main`,
the site stays green through the whole `edge` period and breaks at the cutover,
so Phase 12 must be written before Phase 13 and merged in the same window.
Phase 13 is the cutover itself, ordered rather than per-repo-independent: docs,
then servuo-plugins and link together (a protocol bump has three declaration
sites and a `main` holding a v5 sidecar with a v4 overlay cannot pair), then
website, module-uo, Integration-kit, android-app, and runicgateway.com last
because every fact it fetches has to be true on `main` first.
Adds an eighth open question (fix the Android CI trigger, or accept the cutover
as its first build) and a Phase -1 to the sequencing diagram for the edge
fast-forward.
Co-Authored-By: Claude <noreply@anthropic.com>
Scoping investigation for an in-house, game-agnostic email and engagement
system: modules declare domain events and their data contract, core owns the
rules, preferences, templates and delivery.
Records the current-state map (notifications, email, the module contract and
the job/scheduling infrastructure), a gap list, the proposed schema additions,
the module registration mechanism, an eleven-phase plan with an acceptance
check per phase, and a catalogue of what the system could be used for.
Five findings contradict the brief this started from and shape the plan:
- There is no in-app channel. Core has one sink, the content-free push tickle;
the in-app inbox has to be built, not adapted.
- Email is already two-thirds of an engagement system, scoped to Teams. The
Teams pipeline is generalised and migrated onto the new one, not duplicated.
- The IDOC example's payload is not on the wire, and estimated_collapse is not
exactly knowable in advance: ServUO draws each decay stage's duration at
random when the stage is entered, so it is exact only at IDOC.
- Event-name collision handling already exists (registries.js namespaced() +
apply()), so the brief's forward-compat note is already satisfied.
- MODULE_API_VERSION 1.6.0 is on main now, so the engagement additions take a
real 1.7.0 rather than joining 1.6.0 in place.
Five scope decisions settled by the org lead are recorded at the top: the
in-app channel is in scope, the Teams pipeline is migrated, the house.decay
protocol enrichment is in scope, Gmail OAuth2 is removed rather than retained
as a transport, and the system ships with seeded templates plus an editor.
No code. Nothing is implemented until the org lead approves the phase.
Co-Authored-By: Claude <noreply@anthropic.com>
Two operator-facing errors, both found by following the documentation exactly
against a real deployment while writing the runicgateway.com installation
journey.
**The shard screen moved and the guide did not.** INSTALL.md §5 and PLAN.md both
print the installer's handoff block, which said to paste the four values at
`<site>/admin/shard`. Those screens belong to the `uo` module now, and a module
owns one path segment wherever it appears (MODULE_SYSTEM.md §2.8), so the page
is `/admin/uo/link`. The old path does not 404 — the SPA sends the operator to
the dashboard, so the link looks like it worked and the values have nowhere to
go. The instruction under the block now names the path, and a note warns anyone
whose earlier run printed the old one. API routes are unaffected by the module
rule and keep `/api/v1/admin/shard/*`; the note says so, because that is the
distinction the next editor will need. Matches installer#22.
**The README snapshot's quickstart could not boot.** website's root
`.env.example` never listed SECRET_ENC_KEY, and the "set at least" list omitted
both it and BOT_INTERNAL_KEY — each of which the server refuses to start without
in production, BOT_INTERNAL_KEY even on a deployment running no bot. Synced from
website#163, which fixes the file itself and was verified by booting the
published image from a clean `cp .env.example .env`.
Co-Authored-By: Claude <noreply@anthropic.com>
Four documents still describe the pre-module-system website. None of them
misconfigures anything, which is why they survived; all four mislead a
reader trying to understand how the system is actually put together.
ARCHITECTURE.md placed shardIngest.js and uoLinkClient.js inside the
website backend. Both live in module-uo/server/utils/ - verified, they
are not in website/server/src at all. The document claimed to be "the
canonical copy of the diagram; the same diagram is embedded in the
website's README", and the two had silently diverged: the live README's
diagram has the module subgraph, the loader, and the game behind the
module, and this one did not. The diagram is now the live one verbatim,
the surrounding prose attributes the shard integration to the module, and
the intro no longer frames core as game-aware. The SSE bullet gains the
distinction the live README makes: the module declares which kinds are
public, core enforces the split.
website-README.md had drifted from the live README by 28 lines, all of
them the "Three ways in, and none of them is a build" section - the admin
panel, the MODULES environment variable, and by hand - which is now the
primary module-install story. Re-synced verbatim, since a faithful
snapshot is the file's whole purpose. The diff was purely additive; the
snapshot contained nothing the live README had dropped.
README.md's index was missing thirteen documents, not the four the audit
had found: TEAMS.md, ARCHITECTURE.md, TRUSTED_DEVICES_MFA.md and
MODERATION_APPEALS.md, and also link/v4.md - the current protocol -
android/THEMING_AND_NAV.md, ci/SONARQUBE.md, installer/PROJECT_TREE.md,
modules/kit-acceptance.md, modules/uo/API.md, modules/uo/SCHEMA.md,
website/test-plan.md and the two API_V2 documents. The layout block
already advertised a ci/ directory that had no section. Every markdown
file outside the issue templates is now indexed, and every link resolves.
API_V2_SKELETON.md is listed as superseded, which is what its own header
says.
BACKEND_DESIGN.md was titled "UOMysticmoon Website - Backend Design"
though it is core's contract and core is game-agnostic. Retitled, with a
note that nothing in it is instance-specific. Its hardcoded public
contact address is now described as what it is - seeded from
BRAND_CONTACT_EMAIL into the contact_email setting, with UOMysticmoon as
the example instance.
Co-Authored-By: Claude <noreply@anthropic.com>
Reverses settled decision 19. The argument for a declared version is still true
and the trade it made was still wrong, which is worth separating: its cost is
paid on every release, and the drift it prevents is something review catches
anyway. Between the phase 3 release on 2026-08-12 and today, v0.3.0 was the only
release Module-uo ever cut, while nine Teams phases and the cutover landed on its
`main` - none of them touching `module.json`'s version line, so none of them
producing a bundle.
Records what replaced it: `link`'s engine, the tag as the number that ships,
CI writing it into the bundle's `module.json`, the declaration demoted to a
floor, and the `workflow_dispatch` backdoor for a `module.json` change with no
releasable code behind it.
The original subsection is left standing with the amendment beneath it rather
than rewritten, as decision 33 asks of a record that has been overtaken.
Pairs with Module-uo's `.gitea/workflows/release.yml` and the same change to the
Integration kit's template.
Co-Authored-By: Claude <noreply@anthropic.com>
The phase ran before the cutover as Part 12 planned, and found what it was meant
to: the inverted slot direction worked for module-uo and nobody else. Recorded
beside the phase entry, with the org lead's two decisions of the day - core
offers a contribution rather than naming a slot, and the template grows a real
provider rather than a snippet.
Also records the live-rig walk and the one defect it found that no test could:
PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in
silence, so every page built from the kit's template had been rendering its
heading with nothing under it.
Co-Authored-By: Claude <noreply@anthropic.com>
Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule
the eighth and ninth members were given - and it is a correction rather than an
addition.
As first written, the inverted slot direction had core fill three literal
uo.guild.* names. That worked for module-uo and silently did nothing for anyone
else: a module declaring clan.detail under its own id got an empty page and no
error, because "a fill for a slot nobody declared is not an error" is exactly
the rule that makes an unknown name invisible. It also put a module identifier
inside core, in string literals the Sec 5.2 checker masks by construction.
Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three
contributions core offers - team.activity, team.forum, team.notify - as a
table, with the rules that follow from the direction: the member is optional, a
slot that asks for nothing stays empty, more than one slot may ask for the same
contribution, and asking for one core does not offer THROWS at the declaration
rather than rendering empty forever.
TEAMS.md's two accounts of the inversion (Part 3's supersession note and the
phase 3 amendment) say the same thing.
Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in
phase 3 and three places still said eight.
Found by phase 11 while writing the chapter that teaches this shape to an
audience outside this org.
Co-Authored-By: Claude <noreply@anthropic.com>
The org lead cancelled the capability layer on 2026-08-19, deferring it until a
second integration is wanted or it is asked for by name. Phase 11 is now the last
phase of the bet.
The same argument that put phase 10 last is the argument for not doing it yet:
with one integration built, the refactor would extract a capability surface from a
single implementation and have nothing to check the extraction against. It is
cheaper and better-informed the day a second platform exists, because that
platform is what proves which of the five capabilities the seam needs.
Three places pointed forward at it and now say what is true instead:
- Sec 7.2 and Sec 7.3 both justify the Admin -> Teams panels by "phase 10 makes
the platform a registry lookup". The decision survives its reason: an operator
should not have to know which platform is configured to find the panel, and that
holds whether or not the registry is ever built.
- Sec 8.2 keeps the Matrix comparison and the capability table, with a note that
no registry is built either. The research did its job by keeping core's calls
phrased as eligibility questions rather than as Discord operations; what is
absent is the indirection, so `discord` is named directly in the bridge, the
voice provisioner and the command dispatcher.
The phase entry keeps its body rather than deleting it, because the argument for
the layer is what a future phase would start from.
Co-Authored-By: Claude <noreply@anthropic.com>
Amends `TEAMS.md` §7.3 inline, marks phase 9 done in Part 12, and adds the
`team_integrations` row to `BACKEND_DESIGN.md`'s schema table. Pairs with
**website#159**.
Co-Authored-By: Claude <noreply@anthropic.com>
Amends §7.2 inline and marks phase 8 done in Part 12; adds the
team_integration_config row to BACKEND_DESIGN.md's schema table.
Two of the amendments are things the tree disproved rather than choices:
- §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY
columns to NOT NULL, so `team_id NULL` is unrepresentable and the override
mechanism has no base case. Confirmed against a real MariaDB (error 1048).
- §7.2's visibility gate has no data source on either side and cannot have one:
the streams carry no visibility, a forum thread is members-only by
construction rather than by a column, and core cannot see a channel's
permissions. The gate becomes an attributed operator acknowledgement.
Co-Authored-By: Claude <noreply@anthropic.com>
The command that proves the seam is the MODULE's `/guild`, not core's `/team`:
§7.1 was written before phase 3 settled that Teams is a contract primitive with
no core surface, and a core `/team` publishes the same invented noun that got
core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the
same reason — `/teams/:slug` does not exist.
The re-register nudge is its own bot endpoint rather than a ride on
`/internal/config`, whose body carries the decrypted bot token. `actor` carries
`role` beside `isStaff`, since a module with its own audience rungs cannot place
a caller from a boolean. And "deregistration is free" needed a second half: it
holds across the restart an uninstall asks for, not across the runtime toggle,
so liveness is asked at both the pull and the dispatch.
MODULE_API.md stops saying `registerSlashCommands` throws and documents it —
every member of 1.6.0 is live now.
Co-Authored-By: Claude <noreply@anthropic.com>
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).
One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.
Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.
The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.
Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.
Co-Authored-By: Claude <noreply@anthropic.com>
Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.
Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.
`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set in phase 2 — a contract owes a bump only once it has landed on `main`,
and 1.6.0 has only ever been on `edge`.
Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).
BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.
Co-Authored-By: Claude <noreply@anthropic.com>
TEAMS.md §5.4, §5.5.7 (new), §5.6 and the Part 12 phase entry; BACKEND_DESIGN.md's
schema and admin route tables.
**The largest change is a decision, not a description.** §5.6's first rule said
"a leader may also see and act on reports for their own Team, but staff always
receive them". The org lead settled on 2026-08-18 that the leader half is
**decided against, not deferred**: the gap the section exists to close is that
leaders moderate their own forum and a Team's leaders are exactly the people who
will not report their own Team, so a leader-visible queue hands a complaint about
a leader back to them — and a read-only leader view still tells them who reported
what. Recorded as an amendment rather than by editing the sentence away, because
the reasoning for the original is what makes the correction legible.
**§5.6's `content_reports` DDL does not work as written, and the amendment says so
rather than quietly swapping it.** With `status` in the unique key, CLOSED rows
collide with each other: dismiss a report, let the behaviour recur, dismiss the
second one, and the UPDATE lands on a tuple that already exists — so the queue
starts throwing duplicate-key errors on the first repeat reporter. The shipped
table keys on a generated `open_marker`, the same encoding
`team_forum_grants.active_marker` uses. Three smaller departures are recorded
beside it: `handled_note`, the two username snapshots §2.10 asks for everywhere
else, and a real CASCADE on `team_id`.
**New §5.5.7 for `teams_forum_edit_window_minutes`** (0–1440, default 15), and the
rule under it: the window is resolved on the server TWICE — the read path stamps
`canEdit`/`editableUntil` so a client knows whether to draw the control, the write
re-derives it from `created_at` before allowing anything. The read is advice and
the write is enforcement, because a time-bounded permission must not take its
clock from the party it bounds. That is also why the key is not published: the
client needing the number is the admin screen, and the client needing the decision
already has it per post.
**§5.4 gains three notes its route table does not carry**: thread creation splits
authority by TYPE rather than widening the leader gate (and reports it as two
booleans, since one would make a client guess which right it described); post
moderation is its own route whose validator accepts all eight actions so the model
can say "pin applies to a thread, not to a post"; and a reply's three refusal codes
are chosen to be distinguishable — 404 absent, 400 announcement, 409 locked — with
locked refusing staff too.
The Part 12 entry records what the phase disproved, its four acceptance criteria,
that it spans ONE repo where phase 4 needed two, and the single defect the live rig
found. It also notes that phase 4 shipped `uploads` with the default off, so §5.6's
"pull reports forward if uploads is enabled anywhere" never triggered.
Co-Authored-By: Claude <noreply@anthropic.com>
Records what building phase 4 settled, and what it disproved.
The structural correction first: TEAMS.md 3.1 gave the forum a CORE page and phase
3 deleted every core Team page. The ROUTES were unaffected — they are all /player
and /admin — but the participant surface had no home, and 5.4's route table did not
notice. Settled the way phase 3 settled the activity feed: module-uo declares a
second place on its guild page and core fills it, so the phase spans two repos
rather than the one the plan named.
Two slots rather than one, because a slot holds one component and the first fill
wins; the panel navigates by search param because a thread must be linkable and
core cannot mount a route on a page it does not own.
Two findings from the sanitiser worth not re-deriving: `rel` has to be on the
allowlist for the transform that WRITES it to survive, or every forum link ships
without noopener; and the bare-URL linkifier runs after sanitising, over escaped
text only, which is the property that makes it safe rather than an injection point.
Also recorded: the upload sweep runs regardless of the current image mode, which is
the mechanism behind the dialog's promise that disabling uploads does not delete
what is already there; the two routes the table lacked; and the org lead's decision
that all three proposed acknowledgement additions ship.
BACKEND_DESIGN gains the four forum tables and the reasoning a reader of the schema
alone would miss — why the guard is at the route and never at the data, why no
stored body ever contains an <img>, what `uploads` mode hardens, and what the
acknowledgement actually records. MODULE_API's inverted-slot section gains the rule
a module needs: one slot per PLACE, not one per page.
Co-Authored-By: Claude <noreply@anthropic.com>
The org lead's correction to Part 3, and the inverted extension-slot direction
it forces.
TEAMS.md: §3.1's routes, §3.4's two slots and §3.5's three nav entries are all
marked superseded in place, and Part 12's phase 3 entry gains the amendment
explaining why — core does not own the word for a Team, so the module that owns
the vocabulary owns the page. The five corrections found by building are kept
alongside it.
MODULE_API.md: 1.6.0's list swaps the two client slots for
registry.declareModuleSlot + Slot in the UI kit, and a new §3.7a documents the
inverted direction: what forced it, the enforced namespace, why core's fills are
applied at mount rather than eagerly, and why a fill for an undeclared slot is a
no-op where §3.7's unknown slot throws.
BACKEND_DESIGN.md: the by-external-id lookup route.
Co-Authored-By: Claude <noreply@anthropic.com>
TEAMS.md gains a dated amendment on phase 3 with five corrections, all found by
building the thing it describes:
- §3.2 and §3.4 contradict each other about `team.member.row`'s props, and
§3.2 wins because it is the security rule. A client slot can only receive
what the browser was sent, so §3.4's `{ memberKey, userId, displayName }`
means publishing both identifiers in every public roster, module installed
or not. The slot is redeclared with what core can honestly supply.
- §3.3's projection is an EIGHTH MODULE_API member where 1.6.0 listed seven.
Settled by the org lead: 1.6.0 is amended in place, on the rule Protocol 4
was given in phase 2 — a contract owes a bump only once it has reached
`main`.
- "the module declines" needed splitting in two before it could be built. No
module at all withholds nothing and must serve the roster whole; a module
whose rungs could not be consulted must serve none of it. Only the second
fails closed, or bare core shows an empty roster on every Team page.
- the module answers with member KEYS, not rows, so it can narrow what is
published and cannot widen it.
- core's five activity kinds are four until the forum lands, and a Team's
FIRST roster emits no join items at all.
§2.11's route table gains the activity endpoint it never had, and MODULE_API.md
documents `projectRoster`, the inverted fail-closed semantics that make it
different from every other provider call, and `ctx.teams.activity.push`'s item
shape and its four contractual properties.
BACKEND_DESIGN.md: the seventh Team table, its retention, and the three public
routes' new behaviour — `enabled` on the index, the slot props on the single
Team, the per-caller row projection on the roster, and the feed.
Co-Authored-By: Claude <noreply@anthropic.com>
Documents phase 2 of docs/website/TEAMS.md across the three files that had to
change, and records the five places building it disagreed with the design.
## MODULE_API.md — 1.6.0
The Team surface becomes contract: `api.registerTeamProvider(...)`,
`ctx.teams.publish` / `ctx.teams.reconcile` / `ctx.teams.activity.push`,
`api.registerSlashCommands(...)`, and the two client slots. Additions only, so
minor; module-uo's `coreApi: "^1.3.0"` still resolves.
Per the org lead's decision, one 1.6.0 covers the whole surface rather than a
minor per phase -- so the document names the phase against each member, and the
two that cannot work yet are marked as present-and-throwing rather than left to
be discovered at runtime.
`registerTeamProvider` gets the fullest treatment because it is the first
registration where core calls the MODULE and waits for an answer. The envelope,
the 10-second budget and the refusal semantics are all contract, not
implementation: they are how a module says "I cannot answer" without core hearing
"there is nothing". `ctx.teams` is documented as push-only, with the reason there
is no reader — a module answers questions about Teams, it does not ask them.
## BACKEND_DESIGN.md
The six Team tables, the rename rule, the active-only uniqueness encoding, the
per-column account-deletion decisions, and all eighteen routes across the three
tier tables.
Two entries there exist to stop a future reader "fixing" them: why
`team_forum_grants` does not use the obvious generated column, and why the two
columns TEAMS.md never mentioned have to exist.
## TEAMS.md — five amendments, marked as amendments with their date
- **§2.5's SQL and §2.10's decision cannot both hold.** MariaDB refuses ON
DELETE SET NULL on a base column of a stored generated column (1901), so
§2.5's `active_user` forces the CASCADE that §2.10 exists to prevent. §2.10
wins; the marker is re-encoded for identical semantics.
- **`team_forum_grants` lands in phase 2**, so the four-path resolver is written
once and its non-contamination tests are real.
- **Two columns the document did not contemplate**, both serving §2.4's gates:
`roster_synced_at`, because sync state is per MODULE and gate 3 leaves one
Team behind while the others sync; and `members_empty_since`, gate 4's
per-Team quarantine.
- **`leader` on the member shape is not path 2.** Taking §2.3 and §2.5 both
literally gives one column two writers, and the roster writes first — so a
refused `getTeamLeaders()` silently demoted everyone. Found by its own test.
- **§2.8.2's matcher needed two narrow widenings**, both real impersonation
vectors the whole-word rule missed: a term matches a name word's singular
("Guild of Moderators"), and a run of single-letter words is compared joined
("G.M."). Neither re-admits substring matching.
Pairs with website (Teams phase 2) and Module-uo (the provider).
Refs docs/website/TEAMS.md Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
Adds v4.md as the spec of record for `guild.roster` and `guild.leave`, and
corrects the two older documents that Protocol 4 makes wrong.
PROTOCOL_2.md §10.1 already described this design — hold a member-serial set,
diff it each sweep, emit join/leave — and 2.0 then shipped only the half needing
no new state, folding membership into the board signature as a serial *sum*. The
section has read ever since as though the whole thing were built. It now says
which half shipped, and carries the correction that doing it produced: a sum is
not a safe stand-in for a set, because one member joining and another leaving
between two sweeps offset each other and the guild reads as unchanged.
INTEGRATION.md gains both kinds in the event catalogue, the `roster` key on
GET /guilds, and the three things an integrator gets wrong otherwise — that
`guild.leave`'s `who` is a bare serial rather than an actor object (the mobile has
already left, so there is nothing to attribute), that `acct` is genuinely optional
on a member, and that a guild with no `roster` key is not the same as one with an
empty roster.
v4.md documents what the phase found as well as what it built: the missing store
migration and the user_version decision, why the roster lives in its own column
rather than inside the guild.update snapshot, why a split roster is reassembled in
memory rather than appended to the column, and why guild.leave gets no board
projection at all. §6 records that the reassembly bug was invisible to every unit
test — they all exercised single-frame rosters — and only the live rig caught it.
TEAMS.md is amended where this phase disagreed with it: Phase 1 spans five repos,
not four, because installer/backup.rs justifies skipping the sidecar database on
reasoning the migration falsifies. The user_version decision is recorded there too,
since the design of record did not contemplate a migration mechanism at all.
Co-Authored-By: Claude <noreply@anthropic.com>
"Runic Gateway" (two words) is the correct presentation; "RunicGateway" is
accepted only where the name has to condense to a single token — the Gitea org,
a package name, a URL segment. Three prose uses in TEAMS.md had the condensed
form for no reason, including the upload warning text, and are corrected. The
`RunicGateway/<repo>` slugs throughout android/PLAN.md are condensed by
necessity and are left alone.
This turned out to matter beyond presentation. The reserved-name matcher (2.8)
specified whole-word matching over a normalisation that case-folds, strips
punctuation and collapses whitespace — under which "RunicGateway" is a SINGLE
word and would never have matched the two-word term "Runic Gateway". The
condensed form is the one an impersonator reaches for, precisely because it is
what the org and every URL already use, so the check would have missed its most
likely input.
Multi-word terms are now additionally compared with whitespace removed on both
sides, so `Runic Gateway` matches `RunicGateway`, `runic-gateway`, `Runic_Gateway`
and `RUNIC GATEWAY` alike. The widening applies only to terms CONTAINING
whitespace, which keeps it clear of the single-word terms where whole-word
matching is doing the false-positive work: `admin` is still compared as a word
and still does not fire on "Badminton". Listing the condensed form as a separate
reserved term was the alternative and was rejected — it is a second thing to keep
in sync and would still miss the hyphenated and underscored variants.
The same reasoning applies to a deployment's own BRAND_NAME, which is free text
and may well be spaced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
The drafted text was a placeholder the doc explicitly flagged as needing the org
lead's ownership. Replaced with the supplied wording, which is better in three
ways: it is factual rather than legalistic, it enumerates the specific
responsibilities being accepted (moderation, storage and backups, legal
compliance, community policy) instead of gesturing at them, and it states plainly
that RunicGateway provides no hosted storage or content moderation services.
Recorded as two surfaces rather than one, because they behave differently: a
settings help text that is always on screen and explains the setting, and a
confirmation dialog shown only when changing the mode to uploads, which is what
the acknowledgement actually records.
The dialog carries two checkboxes and the API still takes one `acknowledge: 1`.
Recording two booleans would add nothing — there is no reachable state where an
operator agreed to one clause and not the other and proceeded — while the stored
version is what answers the question that matters later: which text did they
agree to?
Three additions are proposed on top and marked as droppable, since none is
liability language and none changes what is being agreed to: that uploads are
attributed and staff-removable (the reason the attribution table exists), that
disabling uploads later does NOT delete files already uploaded, and that anyone
with forum access can upload — including manually granted accounts with no linked
game identity. Also adds the advisory `remote` mode needs, which the upload
wording correctly does not cover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
Teams become a core platform primitive rather than a mechanism for generating
Discord infrastructure: a module stays authoritative for who a Team is and who
belongs to it, and core owns everything the platform attaches to it — pages,
roster, activity, forums, notifications and optional external integrations.
Investigated against the working tree rather than the brief, which corrected
three of its premises:
- guild.update carries member/online COUNTS, not a roster, and one leader. Every
membership-derived feature has no data source today, so a protocol change
(PROTOCOL_VERSION 3 -> 4, guild.roster + guild.leave) is the gating phase.
- There is no module->core news hook to generalise. ctx.posts is read-only and
both registerPostHook and registerAnnounceLeg run core->module, so the activity
feed needs its own ingestion member modelled on ctx.push.publish.
- Core has no SSE at all; it left with the module cutover. Live roster status is
module-projected through an extension slot rather than a new core transport.
Also settled: the four-path permission model (game membership / leadership /
forum access / external access) with the non-contamination invariant; envelope-
returning provider methods so module unavailability is structurally staleness
and never an authoritative empty; reserved-name screening with auto-hide and an
admin-approval gate on the three actions that publish untrusted game-sourced
strings; forum admin controls with a renderer-owned image path; abuse reporting;
email as a third notification sink; and a capability-based integration contract
rather than a shared interface, with the Matrix research behind that choice.
Part 10 sorts every artifact into contract / core-internal / module-owned / wire
protocol, because almost none of this is contract: the surface is ten members,
and the ~15 tables are core-internal and off limits to a module even though the
module is what populates them.
Proposes MODULE_API_VERSION 1.6.0 (minor, additions only) and an 11-phase plan.
Design only — nothing is implemented, and MODULE_API.md remains normative.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
The module system reached `main` on 2026-08-12 (website#150) and `edge` was
deleted. Several present-tense claims across two plans became false at that
merge; this is the pass that closes them.
MODULE_SYSTEM.md's status banner was the loudest: it still announced "in
implementation - Phase 2's core scaffolding is landing, PRs 1-7 of 9 done",
three phases stale. It now says SHIPPED and gives the shape of what landed.
The substantive addition is 2.9's "as landed" section: the four decisions that
settled the cutover (none of them a code question), and what the merge does to a
LIVE deployment - no DROP TABLE anywhere, one guarded and already-verified
column drop in 228 changed files, no newly-required env var, and a declared
module starting on the same first boot rather than waiting for an operator
click. That paragraph is the one a future cutover will want, and none of it was
written down anywhere before.
Phase 5 is marked COMPLETE in the phase list (2.11.1 already recorded slice 3
closing it), decision 11 carries its landing date, and 2.11.1's "the pin is one
of the things the cutover has to revisit" is resolved in place - the sha did not
move, only the branch label, so the coreApi equality assertion stays armed.
The Android note is the one that changes meaning rather than tense. It said the
`gameAccountSignup` fix "must land before that cutover"; the org lead released
that gate instead, so the note now records a LIVE regression - the shipped app
hides game-account creation from every user - rather than a scheduling item. An
accepted break and a forgotten one differ only by whether someone wrote it down.
Co-Authored-By: Claude <noreply@anthropic.com>
Slice 3 of Phase 5 (MODULE_SYSTEM.md 2.11.1), and the phase's last slice.
docs/modules/kit-acceptance.md is decision 5's deliverable: a cold agent given the
Integration Kit and the documents it links to — never core's source, never
module-uo — built a working module for a second game, which was then installed
into a real core and taken through MODULE_API.md 7.7's browser smoke. Verdict
recorded whichever way it went, and it went **yes, with caveats**: one pass, no
core source, and three of the four normative documents never opened.
The finding that justifies the two-stage shape is the one the agent structurally
could not reach, because it had no core to render against. A module page built
exactly as the kit teaches renders OUTSIDE the site: PublicLayout supplies the
chrome and not the body, and the `shell-... page-body` wrapper every core public
page writes for itself is two class names that appear in no contract. That is
3.4's own stated failure — "a module page that does not look like the site it is
installed in" — reached by following 3.4.
Fixed in core rather than documented at the reader, so the class names stay
core's private business and the theming workstream keeps its freedom to rename
them: PublicLayout takes an opt-in `shell` width, MODULE_API_VERSION 1.5.0
(website#148, merges first).
- MODULE_API.md 1.1: 1.5.0's entry, and a new bump-table row — adding an
OPTIONAL prop or argument is minor. "A member's signature changes" is major
because a call already written changes meaning, and an optional prop changes
none; the table now says what it means rather than leaving it to be argued.
- MODULE_API.md 3.4: the shell prop, why a module names a width and never a
class, and the eight-vs-seven miscount the run also turned up — the kit had
faithfully carried it out of the contract into the template, which is the
never-re-specify rule working exactly as designed on a wrong input.
- rust-dryrun.md: coreApi ^1.3.0 -> ^1.5.0, as a dated correction per decision 33.
It is the only complete module.json in the kit's reading path and nothing
checks a JSON block inside a Markdown file, which is the reusable half.
- MODULE_SYSTEM.md 2.11.1: slice 3 recorded, plus the third finding worth
generalising — a check whose failure message asserts a diagnosis has to be
right about it. `check:swagger` failed on a pristine template on Windows
(CRLF) while blaming the routes, green on the Linux runner forever.
- Decision 34: core owns the page body as well as the chrome.
The banner does not come off. Decision 32 makes that a person's to remove, this
run exercised the website-module half only (the module has no sidecar, so
chapters 3 and 4 were never tested), and an agent does not skim or give up.
Co-Authored-By: Claude <noreply@anthropic.com>
Decision 2 of §2.11.1 still said the kit's CI "checks three things"; it runs five.
Two were added as the things they guard came into existence, and the pattern is
worth naming rather than leaving as CI archaeology: each exists because a class of
claim in that repo had nothing looking at it. checkRenameSites (slice 0) holds the
template's rename checklist against the tree in both directions.
checkChapterPaths (slice 2) requires every path a chapter names in backticks to
exist — not a markdown link, so the link check never saw those, and not code, so
nothing else did.
Its anchor list is STATED, not derived from the tree, for the reason API §3.6 gives
about the template's own build guard: a derived list cannot fail when what it
derives from changes, so a renamed `template/` would stop being checked at the
moment every mention of it became wrong. An anchor matching nothing fails the
build, the same rule §5.2's exemptions follow.
Also records Phase 5 progress (slices 0-2 landed; only the acceptance run is
outstanding) and slice 2's verification, which is the shape decision 5 scales up:
chapter 1's walkthrough was RUN against a real core on `edge`, including the three
failures the chapter tells a reader to cause on purpose, and the chapter now quotes
core's own messages for them.
Integration-kit half: Integration-kit#3.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 4 closed; this is the plan for Phase 5, the Integration Kit, settled with
the org lead on six decisions.
MODULE_SYSTEM.md
- §2.11.1 (new): Phase 5's shape — measured starting state, the six decisions,
the four slices, the acceptance mechanism.
- §2.11: layer 2 rewritten. The sidecar is the default and only path to a game,
and it is a non-blocking dumb forwarder that persists before it forwards. A
game that already ships a remote-control surface gets a thin sidecar, not none.
- §2.10: a MODULE_API_VERSION bump now carries a pass over the kit.
- Part 3: decisions 28–33.
- The Phase 5 line in Part 2's phase list: STARTED 2026-08-12.
MODULE_API.md
- §2.7 gains one prohibition: a module does not open a connection to a game
server from the website process — a game socket, RCON, a query port, an
engine admin API — with the three reasons it exists (the website is the
internet-facing process, the sidecar owns the durable copy, neither side can
stall the other). Normative prose with no CI behind it, stated so that every
second module does not re-decide it.
- §1.1: the 1.4.0 entry. No member changed; minor deliberately.
modules/rust-dryrun.md
- §2 "Talking to the game" rewritten from "No sidecar" to a thin RCON sidecar,
with a dated Correction note recording what the exercise originally concluded
and why it was overruled. Corrected in view rather than silently rewritten.
Pairs with website#… (the 1.4.0 bump, into edge); either order.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 4 slice 4. BACKEND_DESIGN.md had never been de-UO'd: Phase 3 rewrote core's
code and README, but §5.2's identifier check reads code, not prose, so nothing
ever looked at the design document. It still described 27 shard_* tables, 20 UO
route rows and the shard visibility ladder as core's, a phase after core stopped
being able to serve any of them.
Moved, text unchanged:
BACKEND_DESIGN §3 six shard_* schema sections (226 lines) → modules/uo/SCHEMA.md
BACKEND_DESIGN §4 13 public + 7 admin UO route rows → modules/uo/API.md
BACKEND_DESIGN §6.5 the audience ladder (70 lines) → modules/uo/API.md §4
Core keeps the seam and gains the eight /admin/modules routes it had never
documented. §6.5 becomes "Module-owned audience boundaries": core's security
boundary ends at authentication, roles and the session, and a module that serves
game data brings its own.
Also fixed on the way: users.router.js was still listed as 15 routes (it is 9 —
six went to the extension slot), and the push section still promised
config/shardStreams.js "moves out with it" four slices after it left.
The acceptance table now carries results. Criterion 2 was proved for real against
module-uo v0.3.0 on an empty database, which turned up the uninstall ordering
defect fixed in website#146.
AI disclosure: this contribution was AI-assisted (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
MODULE_SYSTEM.md §2.7.2 gains the slice 3 write-up: why the id and version are
written out in MODULES (the offline no-op has to be a file read, not a fetch),
the four decisions, and the table of what the smoke proved against the real
v0.3.0 release on a fresh database.
Two sections are corrected rather than extended. §2.5's "declarative module set"
promise did not anticipate that the two install surfaces need a rule about who
wins — the declaration owns the volume, the row owns whether a module runs — and
decision 4's "resolution runs before the server starts" is true of the SCAN, not
of the process: it runs inside start(), which is what buys it the database.
MODULE_API.md §2.6 no longer says the fragments are replayed by ensureSchema().
They are, for every caller except the server, which scans the volume later than
it ensures the schema and so replays them itself. That was a live defect for an
afternoon: it announced itself only as the "no module scan in this process" skip
line, which is correct output for `npm run seed` and means the opposite in a
booting server, and on a database that already had the tables the module started
perfectly. Nothing in the contract moves, so MODULE_API_VERSION is unchanged.
BACKEND_DESIGN.md: the boot sequence in the tree, and MODULES beside the
module_source_hosts setting it installs through.
Co-Authored-By: Claude <noreply@anthropic.com>
The Modules screen as built (website#143), and the three things standing it
up against a live server exposed. Two of them are older than this phase and
neither was reachable from a test.
- A fresh install over a row the previous boot left `startup_failed` rendered
the old failure - "module directory not present on the volume", one second
after the files were written there - and, because that branch is not
pending, suppressed the restart banner the install had just told the
operator to use. Fixed by a derivation rather than a special case: the
loader scans once at require time, so a module on the volume with no live
record arrived after that scan and anything the row says predates it.
- The boot refresh had been nulling every install's provenance. source and
sha256 exist for this screen and never survived a restart, because
lifecycle.boot() re-records with neither and the upsert assigned both
unconditionally. It could not have been found before Phase 4 wrote the first
non-null value those columns ever had - and the model's test fake
reproduced the defect faithfully, assigning exactly like the SQL.
- The restart killed the server outright on Windows. process.kill(pid,
'SIGTERM') reaches the graceful handler on Linux and is unconditional
termination where POSIX signals do not exist, so a Windows host got no
module onShutdown, no pool close and no log flush. process.emit('SIGTERM')
reaches the same listener everywhere. The test had stubbed process.kill and
asserted the call - precisely the call whose meaning differs by platform.
That last one generalises: deployment is Linux containers and would never
have shown it. A smoke that only runs where the code ships cannot find a class
of defect that only bites the people developing it.
Also documents the COALESCE in BACKEND_DESIGN's installed_modules section.
Co-Authored-By: Claude <noreply@anthropic.com>
The install service and admin API as built (website#142), plus the two
things §2.7.2 got wrong before there was any code to check them against.
The top-level directory rule was checking against nothing real. module-uo's
release workflow packs module-uo-<version>/, not uo/, so "reject a top-level
directory whose name is not the manifest's id" would have refused every
bundle that exists. That level is stripped instead - its name belongs to
whoever published the bundle, the directory it lands in has to be the id the
loader scans for - and what replaces the check is stronger: the UNPACKED
module.json must agree with the install manifest about id and version.
And tar has to be pinned forward rather than merely depended on. Installing
it gets 6.x, which npm audit reports as critical, and the advisory list reads
as this feature's own threat model: hardlink traversal via drive-relative
linkpath, symlink poisoning, hardlink escape through a symlink chain, PAX
size override on GNU long-name headers, decompression DoS. Refusing symlink
and hardlink entry types outright is what takes the extractor off most of
that list rather than depending on the library to contain them.
Also records the measurement behind the two-pass unpack, which was assumed in
the plan and is now known: node-tar DOES reject an escaping member, but late.
An archive whose fourth member escapes throws and leaves the first three on
disk - and the loader only asks whether module.json is present, so a
half-unpacked bundle is a module as far as the next boot is concerned.
Refreshes api-route-inventory.json (158 -> 166 public) and the route count in
BACKEND_DESIGN, and documents module_source_hosts beside the other seeded
settings keys - bootstrapped from the environment, owned by the database.
Co-Authored-By: Claude <noreply@anthropic.com>
Both came out of writing slice 1 against §2.5 rather than from planning, and
one of them means §2.5 was wrong rather than merely vague.
5. Purge is offered inside the uninstall flow, because it cannot be offered
after it. §2.5 promised a disabled row an operator could purge later — but
purge.sql is a file INSIDE the module directory that uninstall deletes, so
"later" would have left a Purge button with nothing to run. The destructive
choice now sits at the one moment the file is still there: an opt-in
checkbox in the uninstall dialog, beside the standalone Purge action on a
module that is still installed. "Never bundled into uninstall" becomes
"never implied by uninstall". The cost is stated rather than hidden — an
operator who does not tick it keeps their tables, and reinstalling is how
they get the ability to drop them back.
6. The host allowlist bootstraps from MODULE_SOURCE_HOSTS and then lives in
the database, admin-editable, with the variable no longer overriding it —
the shape core already uses for seeded settings. The argument for keeping
it in the environment does not survive contact with what this screen is: an
admin who can install a module already has code execution in the process,
so an admin who can name a host has gained nothing they did not have.
Also renames the subsection to six decisions and updates acceptance criterion
2 to name the checkbox.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 3 closed with core at 158 routes and module-uo releasable; phase 4 is
the first phase that is not about the boundary. It answers how a module gets
onto a box an operator owns, and how it comes off again.
Adds MODULE_SYSTEM.md §2.7.2 recording the measured starting state (the
producer side is finished — module-uo's release.yml already publishes the
tarball, the install manifest and SHA256SUMS, and module.json declares
purge.sql — while core has the installed_modules provenance columns and
nothing that fetches, verifies, unpacks or purges), the four org-lead
decisions, the five slices, the unpack threat model and the acceptance table.
The four decisions:
1. Restart is a button. Install, uninstall and re-enable only take effect at
boot; recording a pending change and telling the operator to restart the
container contradicts §2.4's "no shell access to the box", so an admin
action runs the shutdown and exits, and the supervisor the shipped compose
file already declares brings it back.
2. The install source is a pasted manifest URL, not a catalog — a catalog
would make core's release cadence decide which modules exist. Safety is the
declared sha256 plus an https host allowlist, which also stops the endpoint
being an SSRF primitive.
3. Disable dispatches that one module's onShutdown before flipping the guard.
As built, disable makes a module invisible rather than stopped — module-uo
keeps its sidecar socket open and keeps ingesting into shard_* tables —
which is wrong for the case the button exists for. Enable is not the mirror:
there is no onBoot re-dispatch and the hooks were never promised re-entrant,
so enable flips the row and offers the restart.
4. The declarative Docker set is an environment variable, resolved before the
server starts, idempotent and offline-safe: an already-unpacked module at
the declared version is a no-op.
Amends §2.4 (disable is no longer a pure guard flip) and §2.5 (what "admin
selects the module" and "restart" concretely mean). Records re-entrant
lifecycle hooks as a second candidate for a future MODULE_API major bump,
beside the identity-provider gap the rust dry run found.
Co-Authored-By: Claude <noreply@anthropic.com>
Records the slice that closes the extraction, and adds the two documents phase 3
owed: docs/modules/uo/ and the module-rust dry run.
**The slice found an obligation neither repo had built.** MODULE_API.md §2.8 and
§6.1a settle the OpenAPI fragment in detail — a module ships one, core merges the
fragments of started modules into /api/docs.json. Neither half existed, so the 72
URLs module-uo serves were in no spec at all. §2.8 and §6.1a now record what was
built, including the four things settled while building it: the filename is fixed
rather than declared, a module namespaces what it DEFINES and references core's
shared schemas by core's name, the generator derives its prefixes from the
module's own register() call, and swagger-autogen's diagnostics have to be
captured because it reports a broken annotation and then prints Success.
**§5.3 gains the design decision the frozen manifest actually made:** it is a
SUBTRACTION, not a prefix filter. Generating the manifest without the module and
then with it answers "what does the module serve" AND "did core lose anything",
and the second is the one §1.2 promises to the shipped Android app. A module that
shadowed a core route cannot appear as an addition anywhere.
**BACKEND_DESIGN.md §4.0.1** is new: /api/docs.json is assembled per request, the
two generated artifacts are core's alone, and the route count was still 228.
**docs/modules/** is new, per §2.10 (module documentation aggregates here, not in
module repos): docs/modules/uo/README.md orients a reader on what module-uo
serves, owns and needs from an operator, and links out to the feature docs that
already existed rather than restating them.
**docs/modules/rust-dryrun.md** is phase 3's fourth acceptance criterion. A
written, deliberately unimplemented module for Rust — chosen because it wipes
monthly, runs several servers rather than one shard, identifies by Steam, and
ships RCON so there is no sidecar to write. The contract generalises: same
manifest, same seven registration calls, same schema rules, and six of the UI
kit's seven members wanted by a game with nothing in common with the one the kit
was curated from.
It found one real gap — **a module cannot register an identity provider**, and
"Sign in with Steam" is what a Rust community expects. Recorded as the first
candidate for a future MODULE_API_VERSION bump rather than bolted on: an identity
provider participates in session creation, and §2.7's link-only SSO policy has to
survive it.
Also: website-README.md refreshed from the repo (it was several changes stale),
and three settled decisions added (18-20).
Pairs with Module-uo#6 and website#141.
Co-Authored-By: Claude <noreply@anthropic.com>
MODULE_SYSTEM.md §2.7.1 gains the slice 4 record: the 190 dead lines of UO API
bindings slice 3 left behind (with zero consumers, which is why nothing caught
them), the copy rewrite and why the words stay in code rather than becoming a
config surface, the two settings rows core had no business seeding, and the
check that keeps all of it out. The slice table row is corrected to what
actually moved, and README.md is recorded as deliberately deferred to slice 5
rather than quietly skipped.
The defect gets its own subsection with the SQL, because it is a shape that will
recur: a guard in core and the statement it guards in a fragment run in the wrong
order, since core's schema is replayed in full before any fragment. Verified
against a real MariaDB in all three states, including the pinned-instance case
that makes it a one-shot rather than a re-bump.
MODULE_API.md §5.2 stops describing a grep and describes what was built — four
details promoted to contract because each was wrong first: whole-word matching
(a substring pass flags `defaultImage`, which contains "ultIma"), the
comments-then-strings character walk, grandfathering exemptions that fail the
build when they stop matching, and `git ls-files` over a directory walk.
BACKEND_DESIGN.md's settings table gains the keys a MODULE seeds, and the
ordering rule behind them.
Co-Authored-By: Claude <noreply@anthropic.com>
The client half's move: 35 files / 5,332 lines (the table said 51 / ~3,700 and
is corrected, not re-derived to match). The seven-kit-members-plus-format claim
held exactly, so the kit needed no additions.
Contract, 1.2.0 -> 1.3.0, all additive: `icon` on a nav item, a third slot
`player.invite.accepted`, and `api.BASE` — which §3.5 specified from the first
draft and shared.js had never published, because nothing needed it until a
module had to build an EventSource URL.
§2.3's narrowing note said game-signup policy was core's business. It was wrong
in both directions: the setting's help text names Bridge.cfg, and slice 1 had
shipped a controller calling a function the narrowing does not expose, so
POST /player/shard/account answered 500 for every caller until this slice.
§3.7 gains the rule the invite slot needed: core may ask whether a slot is
filled when the answer changes CONTROL FLOW, never when it changes decoration.
Decoration goes inside `<Slot wrap>` — that is the footer-separator bug.
§7.7 gains what running it against the real module found, rather than a
throwaway: the unguarded portal icon, the relative-MODULES_DIR trap, and the two
operational notes (a module needs its own npm ci; copy the directory, never
symlink it).
Also recorded: the nav-override cost is worse than "a hidden row may reappear" —
the review instance had the nine UO rows gathered into a dropdown section, and
the whole section is lost. And `api-route-inventory.json` was a slice stale at
228 routes; refreshed to the 158 core actually serves.
Co-Authored-By: Claude <noreply@anthropic.com>
The contract as written had core branch on hasExtension to decide about its own
decoration around a slot. That is right when nothing is installed and wrong when
something is installed and fails: the slot IS filled, so the separator renders,
and the component then throws into the boundary and leaves the separator behind
on its own. Core decorates through <Slot wrap> now, inside the boundary, and
hasExtension is gone rather than kept as a trap for the next caller. Every unit
test passed both before and after -- the 7.7 browser smoke is what saw it.
Also recorded, neither a defect: core's own fill occupies a slot, so a module
cannot fill either one until the client half deletes core's (worth stating,
because a module written against 1.2.0 today cannot use them); and core's UO
sections on the user-detail page now fail to load, which is slice 1 removing the
routes rather than anything this slice did.
Co-Authored-By: Claude <noreply@anthropic.com>
Two findings from surveying the client half, and one org-lead decision that
turns them into the same piece of work.
The client half does not divide into public and admin after all. Routes slice by
area, but useShardFeed and shardEvents are imported by eight public pages AND
three admin views, and a shared leaf moves with its last consumer -- the same
rule the server half taught. Of the three ways out, two trade a real cost for a
boundary that lasts one review, so the client half is one slice. The nav rows
keep their feature provider as a consequence (rows without it fail open, so
disabled surfaces would advertise themselves again), and VendorSales turns out
to have no public consumer at all.
Its three consumers include core's own UserDetail, which renders six UO sections
with nowhere to put them: the server got declareSlot/registerExtension in phase
2 PR 4 and the client never got the twin. SiteFooter's link to a URL the
extraction deletes is the same gap one component over.
So slice 2 is core-only and adds rather than moves: client extension slots
(API 3.7), the site.footer.status and admin.users.detail slots, core filling
both itself. MODULE_API_VERSION goes to 1.2.0.
A slot is named for a PLACE, never for a meaning -- core supplies the position
and the styling, the module owns the label, the target, the data and whether it
renders at all. Typing a slot by its content would put game semantics back into
core, which is what this phase removes.
This is also the one slice where core merges before module-uo, because a chunk
cannot call registerExtension before the function exists. Harmless here: the
slice only adds, and core fills both slots with its own components under owner
id `core`, so the page is unchanged and the mechanism is proved before anything
moves.
Co-Authored-By: Claude <noreply@anthropic.com>
The whole server half is out: 40 files, ~9,674 lines, 27 of 68 tables. The
acceptance criterion held exactly -- core's manifest goes 228 to 158 public
routes and the 70 that left reappear byte-identical once the module loads, with
routes.guards identical across all 228.
The contract grew to 1.1.0: ctx.activity.log, ctx.users.getById,
ctx.site.baseUrl, ctx.middleware.rateLimit + accountChangeLimiter, and a fourth
registry, registerPostHook. Each is documented with why it could not be
vendored, because that reasoning is the useful part -- an admin action a module
performs belongs in core's ONE audit log, a second rate-limit store is a limit
enforced by two counters, and core's CMS was calling a UO file directly.
§2.7.1 gains the slice record: the core.js port mechanism and its consequence
(require order is load-bearing), the vendoring line (pure leaf helpers may be
copied, security controls may not), the two core defects the extraction exposed,
the one deliberate behaviour change, and the one test that looked like it should
move and should not.
Co-Authored-By: Claude <noreply@anthropic.com>
Found before writing any of it. §2.7.1 split the server extraction five ways by
feature; it does not divide, because two contract rules compose.
A mount prefix is claimed whole -- ownedByCore probes the live tier router and
registerRoutes validates single-segment prefixes -- and /admin/shard is one
386-line router carrying 25 routes across atlas, clilocs, shard-ops, visibility,
market and account links. Meanwhile a model cannot be shared across the boundary
(§5.1), so it moves with the last route that consumes it. Take the closure and
every prefix is in it: /public/atlas holds shardAtlas with /admin/shard, which
holds shardState/shardEvents/shardMarket with /public/shard, which holds
uoLinkConfig with /admin/uo-link, and shardClilocs/shardLinks with /player/shard.
Landing any one of the old slices alone would either strand core importing
modules/uo/ -- what acceptance criterion 2 forbids -- or delete routes core is
still serving.
Giving the admin routes their own prefixes would divide it and is rejected: it
changes API URLs, which §1.2 promises not to do, and not hypothetically. The
shipped Android app calls POST /api/v1/admin/shard/kick, /ban, /unban,
/broadcast and the three /pages routes.
So the table is now six slices: 0 the skeleton (done), 1 the whole server half
as ONE PR per repo structured as five commits along the old slice lines, 2-4 the
client halves, 5 close the phase. The client half is unaffected and still slices
cleanly -- the registry takes routes per area, with no prefix atomicity and no
shared models, the same asymmetry that let the two halves be separated at all.
Co-Authored-By: Claude <noreply@anthropic.com>