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>
Protocol 4 shipped on 2026-08-19 as sidecar v2.0.0 and overlay v1.0.0,
paired in bundle 2026.08.19. Three documents still said 3.
INTEGRATION.md contradicted itself: its event catalog fully documents the
protocol-4 kinds, including a subsection headed "On Protocol 4", while
its normative section 2 still declared X-UOLink-Version: 3, "protocol": 3
in /health and ws.hello, and a worked JS client sending "3". That is the
contract a third-party integrator implements against, so it mattered
most. Section 2 now states the current version and what shipped it, and
gains a v4 paragraph alongside v2 and v3. The "(Protocol 3.0)" markers on
individual features are left alone - those record which version
introduced a kind and are correct.
Two stale examples the audit had not spotted: the --print-config sample
reported version 0.1.0, and the minimal browser client told readers to
check ev.protocol === 1, a leftover from protocol 1.
INSTALL.md is the one that actively misconfigured a real install. Line
413 is the "Protocol version" value an operator pastes into Admin ->
Shard, and the document's own troubleshooting table says a stale number
comes back as a 409 that "looks exactly like your shard going offline".
Corrected along with the stale bundle, overlay and sidecar versions
throughout, including Appendix A's copy-pasteable curl commands, which
pointed at overlay v0.1.1 and sidecar v1.1.0.
v4.md still said the cutover had not happened. It has. Its outstanding-
work note said the five-rung shard visibility walk was "outstanding for
the cutover", which is now self-contradictory - so it says plainly that
the cutover shipped without it and no result is recorded anywhere.
guild.roster carries actor objects for every member of every guild, the
widest surface any protocol version has added to that check, so it is
worth walking against the released pair.
No code or contract changes. Every value verified against the Gitea API:
link main sidecar/src/main.rs PROTOCOL_VERSION, servuo-plugins
overlay.toml, and current.json on the installer bundles branch.
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>
Two corrections from the org lead on the transport rewrite.
It is an OXIDE plugin, not "a mod loaded by the server's mod framework". Oxide is
what modded Rust servers run, and naming it is the difference between a design a
reader can start from and one they have to go and pick a framework for.
And the architecture is ONE SERVER, ONE SIDECAR - not one sidecar fronting a
community's several servers, which is the arrangement a UO-shaped reading reaches
for and which this document had. Rust servers in practice sit on separate VMs, so
a shared sidecar would have to be reached across a network by plugins that are
supposed to talk to it over loopback: it trades the invariant that makes the
design safe for a saving in process count.
The cost lands on the module, which is the right place for it - it holds one
client per configured server rather than one client to an aggregator - and it
makes the Team provider's `complete` answerable rather than vague, since "every
team there is" now means every team on THIS server. Five of six sidecars
reachable is `complete` left off, and core adds and updates without archiving.
Recorded as a reevaluable assumption rather than a principle, because that is
what it is. Nothing in the contract objects either way: core is not in this
conversation at all, which finding 2 now says.
Co-Authored-By: Claude <noreply@anthropic.com>
Overruled by the org lead: RCON is not used. Rust gets the same three-part shape
UO has - a plugin inside the game that dials out, a sidecar that persists before
it forwards, a module that talks only to the sidecar - and the plugin is a mod
loaded by the server's mod framework, exposing data through hooks.
The document had RCON as its premise, so the correction reaches further than the
transport paragraph:
- The reason Rust is a good second game changes. It was "its server speaks a
protocol nobody has to write". It is now "its server is a BINARY" - the
opposite of ServUO, which is source a shard owner compiles - so the way in is
a published mod API and the shard-dials-out invariant has to survive that
change of footing. It does, unchanged, which is a stronger result than the
one the document originally claimed.
- The announce leg sends a command down the socket the mod already holds,
rather than calling rcon.say.
- The provider refuses when no mod is connected, not when RCON is unreachable.
- Two hooks answer questions UO had to work for: a wipe arrives as an event, and
membership is real-time - so this module's Team provider is event-driven with
a baseline on connect rather than sweep-driven. The provider contract does not
change by a line, which is the part worth keeping: core never needed to know
how the data arrives.
The 2026-08-12 correction block stays and a second one is added beside it rather
than editing the history out - this document's own convention, and the thing that
makes it worth reading twice. It also records what the correction COSTS: this
project no longer has a worked example of "a game that already speaks a
remote-control protocol, so its sidecar is thin".
Co-Authored-By: Claude <noreply@anthropic.com>
The kit's README sends a reader here FIRST - it is the shortest honest picture of
the whole job - and it predated Teams, so it taught a second game to build its
teams as private module data and never mentioned the provider. The two places the
contract changed since it was written are now in it, and nothing else moved:
all four findings stand, including the identity gap, which is still the one a
real second module hits first.
What Rust adds that UO does not, and why it was worth revisiting rather than
noting:
- externalId must survive a rename and a Rust team HAS no name - it is a numeric
team id in the save. The right answer, and the one a designer is least likely
to reach for.
- `complete` is per SERVER, not per community. Six servers are six team spaces,
so a provider that can reach five must leave `complete` off or core archives
every team on the sixth.
- A wipe empties every team, so { ok: true, complete: true, teams: [] } is TRUE
once a month and core archiving all of them is correct - which is exactly why
an unreachable RCON must answer { ok: false } instead. The two states are one
API call apart and only the module can tell them apart.
- The team route carries a server id as well as a team id, so the external id is
<serverId>:<teamId>. Core stores that and never parses it; an external id is
opaque to core by design, and this is the case that shows why.
Also: rust_teams stays the module's table and core's teams stays core's, which is
the boundary worth stating in the one document where both appear; and the kit is
nine members now, not seven.
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>
Amends Protocol 4 in place rather than bumping it: the protocol has not reached
`main`, and a bump is owed only once a protocol has been released.
The roster shipped as the standard actor object, which carries no rank. Teams
phase 2 found the consequence -- the website could learn leadership only from the
board's single `leader` field, so it could name exactly one leader while a UO
guild routinely has several, and TEAMS.md §2.5 treats multiple leaders as the
normal case.
Roster members now carry `rank` (0-4, 4 being Leader) plus `rankCliloc`, or
`rankName` where a shard's custom rank definitions use literal names. Rank is on
roster members only -- it is a property of a mobile's membership of THIS guild,
not of the mobile, and every other actor the bridge writes is a bystander, a
killer or a governor.
Both files carry the trap this found, because it is the kind of thing a consumer
gets wrong silently: **`PlayerMobile.GuildRank` returns Leader for anyone at
GameMaster or above, whatever their real rank.** It is a gameplay convenience so
staff can operate a guild stone, and the true value has no accessor -- so the
bridge omits the rank entirely for a staff account rather than publish a
leadership claim it knows is false. An absent rank therefore means "not known",
and a consumer must read it as neither 0 (which silently demotes them) nor
leadership (which republishes the lie).
INTEGRATION.md also states the other half plainly for an outside integrator:
`guild.update`'s single `leader` is the founder-leader, not the set of leaders,
so "who leads this guild" is a read of the roster's ranks.
Recorded too: the sidecar needs no change and no store migration, because it
treats roster members as opaque values and never reads a field inside one. That
is the forwarder design paying off, and it is worth having written down the next
time someone adds a member field.
Pairs with servuo-plugins (the emitter) and Module-uo (the ingest and the
provider).
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