Commit Graph

489 Commits

Author SHA1 Message Date
7b7a26f0ac docs(website): per-channel notification preferences (engagement Phase 3)
The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.

`ENGAGEMENT.md`:
- Phase 3's as-built: the three decisions settled before any code, why the
  channel registry could not wait for Phase 6, what the sparse PUT buys, the
  projection stated as an invariant, and the staff-ceiling filter.
- **The phase's own acceptance line was wrong and is struck through.** "A fresh
  user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN,
  push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no
  row genuinely does mean notified. Push STREAM subscriptions have never worked
  that way — `notification_subscriptions` holds a row only on opt-in — so
  `instant` would have projected the entire catalog into the legacy GET for
  every existing user. §3.1's comment is corrected in the same pass.

`BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the
`notification_channel_prefs` table entry (including that absence means the
channel's default rather than `off`, and that all three agreeing on `off` today
is a fact about the declarations and not about the table), and a note on
`notification_subscriptions` that it is now the push projection.

`android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working
and the `{"streams":[]}` gotcha still applies to that endpoint. The new section
documents the superset endpoint for whenever the app adopts it: render toggles
from each item's `channels` rather than a hardcoded three, `modes` is the
effective mode and the client must not re-implement the defaulting, the PUT is
sparse so the empty-array gotcha does NOT apply here, and one id may be missing
that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff
caller).

Companion to website#169.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:09:16 -05:00
6c19a0608a Merge pull request 'docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)' (#181) from docs/engagement-trigger-registry into edge
Reviewed-on: #181
2026-08-29 11:47:23 +00:00
58c0797fc2 docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)
MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on
`edge`" paragraphs are now marked historical rather than current, which is the
correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the
in-place-amendment rule those paragraphs invoke no longer applies and every
addition from here takes a version of its own.

§2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until
Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and
`registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md
§7.3 asked this document to hold: a rule, a template and an audience outlive
the module that declared them, so `trigger_id` is a plain VARCHAR with no
cascade and an unregistered id shows DORMANT rather than erroring or being
auto-deleted. The failure that prevents is specific — an id that stops
resolving must never silently become a send to a different set of people.

ENGAGEMENT.md records Phase 2 as built, and three things it did not previously
say:

§5.1a now DEFINES the ceiling lattice. The document named "narrowest" and
"tightest" throughout and never said what narrower meant, and the obvious
reading is a security defect: under a flat total order a `staff`-ceilinged
trigger permits an `owner` audience, i.e. a rule that mails cheat detection to
the player it detected. It is containment, not size, and incomparable ceilings
have no bound at all.

§7.2 is answered — ONE namespace, against the recommendation in its own text —
with the two knock-on effects that only appeared once it was built (a relaxed
id grammar, a shared legacy allowlist) and the risk that did not materialise
(the push catalog is untouched, so the shipped app sees no change).

§7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after
Phase 9.

§4.3 is kept true to what shipped: `audience` and `ceiling` are two fields
rather than one, `'computed'` is gone from the audience vocabulary, and
`example` is enforced rather than encouraged.

§6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window
work, not this window: the integration kit pins a website `main` sha and
runicgateway.com FETCHES its facts from `main`, so doing either now would turn a
green repo red for the whole edge period — for the kit, against the explicit
rule in its own pin ("written against what shipped, never what is in flight").

BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the
adminOnly line; api-route-inventory.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 06:40:48 -05:00
88de7e3331 Merge pull request 'docs(website): unique, changeable, verifiable email addresses (engagement Phase 1b)' (#180) from docs/unique-verifiable-email into edge
Reviewed-on: #180
2026-08-29 07:08:29 +00:00
84481ee6b1 docs(website): unique, changeable, verifiable email addresses (engagement Phase 1b)
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>
2026-08-29 01:54:05 -05:00
e833874e8b Merge pull request 'docs(website): one self-service surface — /auth/me/account (engagement Phase 1a)' (#179) from docs/collapse-account-surfaces into edge
Reviewed-on: #179
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-29 05:53:01 +00:00
35abf61277 docs(website): one self-service surface — /auth/me/account
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>
2026-08-29 00:49:40 -05:00
cde362e588 Merge pull request 'docs(website): §7 rewritten for SMTP, plus an upgrade-notes home' (#178) from docs/engagement-phase-1 into edge
Reviewed-on: #178
2026-08-29 02:10:01 +00:00
6f6b54a509 docs(website): §7 rewritten for SMTP, plus an upgrade-notes home
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>
2026-08-28 21:01:19 -05:00
7a2ad727cb Merge pull request 'docs(website): record the org lead's answers to Q1/Q3/Q5/Q7, and add Phase 1b' (#177) from docs/engagement-decisions into edge
Reviewed-on: #177
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-29 00:48:02 +00:00
a9ba4707c8 docs(website): revert the §6.0a "correction" — android-app really had no edge
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>
2026-08-28 19:44:46 -05:00
3d93b3865c docs(website): record the org lead's answers to Q1/Q3/Q5/Q7, and add Phase 1b
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>
2026-08-28 19:40:49 -05:00
2a122445c9 Merge pull request 'docs(website): add ENGAGEMENT.md, the engagement system design of record' (#176) from docs/engagement-plan into edge
Reviewed-on: #176
2026-08-29 00:07:07 +00:00
80284c0a2e docs(website): add the branching model, per-phase docs, and the site phases
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>
2026-08-28 18:53:51 -05:00
04863993aa docs(website): add ENGAGEMENT.md, the engagement system design of record
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>
2026-08-28 18:47:50 -05:00
e719c3c32e Merge pull request 'fix(link): a protocol bump owes four declaration sites, not three' (#175) from fix/protocol-4-module-pin into main
Reviewed-on: #175
2026-08-25 00:34:04 +00:00
36f8e1071d fix(link): a protocol bump owes four declaration sites, not three
v4's cross-repo obligation table named module-uo's ingest work and stopped
there, so both of that module's PIN sites — DEFAULT_PROTOCOL and the
uo_link_config.protocol column default — stayed at 3 when protocol 4 shipped.

The consequence is not partial degradation. A sidecar answers a stale client
`409 protocol version mismatch` rather than mis-parsing it, so a fresh install
read nothing at all from its shard — empty marketplace, empty guild board, no
status — until an admin edited the number by hand in Admin → Shard. Existing
installs were unaffected, which is why it went unnoticed: their stored row had
already been moved by the protocol-3 one-shot, and the stored row wins.

Found while standing up a demo deployment for the marketing site's screenshots;
fixed in Module-uo.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:23:24 -05:00
743fdbeb23 Merge pull request 'docs: the shard screen's real path, and the two keys a deploy cannot boot without' (#174) from fix/admin-shard-path-and-secret-enc-key into main
Reviewed-on: #174
2026-08-24 16:30:13 +00:00
b525386047 docs: the shard screen's real path, and the two keys a deploy cannot boot without
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>
2026-08-24 11:24:41 -05:00
afb2fea50a Merge pull request 'docs(link,installer): protocol 4 is live — correct every stale 3' (#172) from docs/protocol-4-accuracy into main
Reviewed-on: #172
2026-08-19 23:25:49 +00:00
274b2c9564 Merge pull request 'docs: hygiene sweep — the four places the docs describe a site that no longer exists' (#173) from docs/hygiene-sweep into main
Reviewed-on: #173
2026-08-19 23:25:13 +00:00
76da8ee88d docs: hygiene sweep - the four places the docs describe a site that no longer exists
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>
2026-08-19 18:04:41 -05:00
7579d6baa2 docs(link,installer): protocol 4 is live - correct every stale 3
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>
2026-08-19 17:58:26 -05:00
b6d059b1e5 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#168) from chore/sync-website-tree into main
Reviewed-on: #168
2026-08-19 18:07:35 +00:00
8abce24700 Merge pull request 'docs(modules): the module release version is derived, not declared' (#171) from docs/module-uo-release-cadence into main
Reviewed-on: #171
2026-08-19 18:07:18 +00:00
a605c03eae docs(modules): the module release version is derived, not declared
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>
2026-08-19 13:01:50 -05:00
e38792b21b Merge pull request 'docs(modules): the Rust dry run reaches the game through an Oxide plugin, not RCON' (#170) from docs/rust-dryrun-mod-transport into main
Reviewed-on: #170
2026-08-19 11:16:30 +00:00
11d20a9a17 docs(modules): name Oxide, and pair one sidecar to one server
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>
2026-08-19 04:16:06 -05:00
4e98ed6bd5 docs(modules): the Rust dry run reaches the game through a MOD, not RCON
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>
2026-08-19 04:09:41 -05:00
12594f2b13 Merge pull request 'docs(teams): the Teams bet, as built (Teams cutover 6/6)' (#169) from edge into main
Reviewed-on: #169
2026-08-19 09:02:13 +00:00
runic-docs-bot
67c9a4773c docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@963d734 [skip ci] 2026-08-19 08:57:34 +00:00
508e738527 Merge pull request 'docs(modules): the Rust dry run, revisited for Teams' (#167) from docs/rust-dryrun-teams into edge
Reviewed-on: #167
2026-08-19 08:53:54 +00:00
7e08a55f76 docs(modules): the Rust dry run, revisited for Teams
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>
2026-08-19 03:51:07 -05:00
987c0b02d2 Merge pull request 'docs(teams): phase 11 as built, and what the rig walk found' (#166) from docs/teams-phase11-record into edge
Reviewed-on: #166
2026-08-19 06:37:24 +00:00
2adf397da4 docs(teams): phase 11 as built, and what the rig walk found
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>
2026-08-19 01:31:52 -05:00
f1fb2d40b4 Merge pull request 'docs(modules): core offers a contribution, never a slot name' (#165) from docs/teams-slot-contributions into edge
Reviewed-on: #165
2026-08-19 06:18:55 +00:00
d8af33c805 Merge pull request 'docs(teams): cancel phase 10, and say what that leaves behind' (#164) from docs/teams-phase10-deferred into edge
Reviewed-on: #164
2026-08-19 06:18:10 +00:00
d10f5809b3 docs(modules): core offers a contribution, never a slot name
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>
2026-08-19 01:16:07 -05:00
88cc49225a docs(teams): cancel phase 10, and say what that leaves behind
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>
2026-08-19 00:27:22 -05:00
6f1f406fe1 Merge pull request 'docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist' (#163) from docs/teams-phase9-voice into edge
Reviewed-on: #163
2026-08-19 05:15:43 +00:00
c87034d7fe docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist
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>
2026-08-19 00:10:18 -05:00
d00ead09b6 Merge pull request 'docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold' (#162) from feature/teams-phase8-notifications-bridge into edge
Reviewed-on: #162
2026-08-19 01:32:22 +00:00
71f0b7ad90 docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold
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>
2026-08-18 20:25:41 -05:00
f5c121b02a Merge pull request 'docs(teams): phase 7 as built — five amendments to §7.1' (#161) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #161
2026-08-19 00:15:15 +00:00
953f7fcd20 docs(teams): what the phase 7 rig walk proved, and the two defects it found
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:37 -05:00
c196d03d31 docs(teams): phase 7 as built — five amendments to §7.1
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>
2026-08-18 18:53:56 -05:00
30d964c090 Merge pull request 'docs(teams): queue the integration kit as phase 11, last before the cutover' (#160) from docs/teams-phase11-queue into edge
Reviewed-on: #160
2026-08-18 23:19:26 +00:00
9f90a99362 docs(teams): queue the integration kit as phase 11, last before the cutover
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>
2026-08-18 18:18:26 -05:00
703f0dbe67 Merge pull request 'docs(teams): phase 6 as built — four deviations and a ninth contract member' (#159) from docs/teams-phase6 into edge
Reviewed-on: #159
2026-08-18 23:09:39 +00:00
5e284d5468 docs(teams): phase 6 as built — four deviations and a ninth contract member
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>
2026-08-18 14:35:49 -05:00