The cutover's operator-facing note, in the home Phase 1 created. Phase 13 owes it, and
two earlier phases named it as their own obligation: Phase 6 for the Team rules
(decision 3) and Phase 11 for news.post (7.1 Q9). Both stop mail or push that is
sending today, and neither says anything when it does.
Three required actions, ordered, because the order is load-bearing:
1. Update the shard. The protocol pin lives in the UO module's schema, so it is the
MODULE update -- not the core one -- that moves uo_link_config.protocol to 5 and
starts the site sending X-UOLink-Version: 5. A shard still on 4 answers 409 from
that boot on. Named explicitly, along with the out: update the site and leave the
module until the shard host can be reached.
2. Team email arrives disabled -- four rules, the screen's own banner, and the fact
that only email moved (the tickle and the Discord bridge are untouched).
3. News push stops until the seeded rule is switched on.
Four behaviour changes that need no action: the unsubscribe link now narrows to one
channel rather than muting outright, email and push are opt-in while the in-app inbox
is on, a rule is checked against its trigger's audience ceiling, and a bounce now
suppresses an address with Suppressions the only screen that lifts one.
Two claims were checked against the tree rather than the plan, and one of them was
wrong first time: coreApi is enforced at LOAD, not at install, so an old core does not
refuse the 0.5.0 install -- it accepts it and then fails to load the module. The note
says that, and says the restart is required, because modules are read from disk at boot.
The Data section carries Phase 12's finding 2 rather than burying it: nothing in this
schema expires on its own.
AI-assisted: written with Claude Code.
Co-Authored-By: Claude <noreply@anthropic.com>
Records Phase 12 against runicgateway.com#26 (12a, mergeable) and #27 (12b, a draft held
for the cutover window).
The split is the org lead's decision, and building the phase sharpened the reason the
timing note only gestured at: checkFacts.mjs fetches from `main`, so the moment
platform.json says protocol 5 the repo is red for the whole edge window -- and three of
its values cannot be written today at all, because bundle.tag/sidecar/overlay name an
artefact that does not exist until Phase 13 republishes it.
Phase 13's order gains the fill-in step, written to be mechanical rather than remembered:
the check names its own expected values, so it is "run checkFacts and copy the says
column", plus the two pages that quote the bundle to re-read first.
Two of the phase's own statements were already stale. moduleApi is 1.9.0, not 1.7.0
(Phase 11 moved it twice after that sentence was written), and Phase 7 had already fixed
the capabilities.mjs "Web" claim. Phase 13's step 3 corrected to 1.9.0 as well.
Three findings, none of them in the phase's text:
1. /privacy had been publishing a false sentence since Phase 8 -- app-content claimed
nothing is cached offline, and Phase 8 shipped a DataStore snapshot of the inbox.
That row is an INPUT to the generated Play Data Safety answers, so it is a store
review matter rather than a doc nit.
2. Nothing in the engagement schema has a retention sweep. cooldowns' prune() has no
caller, finished outbox rows are never deleted, and the send log and suppression
list grow forever. Recorded rather than fixed -- a sweep is a website change and
outside this phase.
3. The one page whose whole subject is the protocol number was the one page that
hardcoded it, against platform.json's own rule. Plus the trap for whoever does this
again: MDX does not evaluate an expression inside backticks.
AI-assisted: written with Claude Code.
Co-Authored-By: Claude <noreply@anthropic.com>
The walk is the last piece of 11b and it was not a formality. It found six
defects, four of them in code shipped by earlier phases, and none of the six was
visible in a unit test: each is a disagreement between two things that agree with
each other in a fixture.
Three of the fixes were decisions rather than repairs, all settled by the org
lead before any code:
11 `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel
it -> add `uo.house.refreshed`, a 26th trigger with a body and a rule
12 a rule with a cooldown delivered on exactly ONE of its channels
-> `channel` joins the cooldown key; a cooldown is per DELIVERY
13 `uo.vendor.expiring` could not fire, because the market sweep does not
diff fees -> widen BridgeMarket.Signature() with exempt +
periodsRemaining
Files:
website/ENGAGEMENT.md the three decisions, the four repairs, and what the
walk proved rung by rung; the 11b bullet and the §8.6
family table now read 26 triggers / 34 bodies
website/BACKEND_DESIGN.md engagement_cooldowns gains `channel` in its PRIMARY
KEY, with the migration's information_schema guard and
why MariaDB forces one
link/v5.md the sweep has to DIFF the fees or the frame never
comes -- stated as the general rule for the next
enrichment, since it is emit cadence and not shape
modules/uo/API.md §5.7a the cancel-shaped trigger and the Ageless-vs-
LikeNew ServUO fact; §5.7b every link comes from
config/clientPaths.js, and the two mistakes that made
every call-to-action a dead link
Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>.
Co-Authored-By: Claude <noreply@anthropic.com>
Four decisions settled by the org lead before any 11b code, plus MODULE_API
1.9.0 and what building it found.
DECISION 7 is the one 11a could not have discovered, because 11a seeded nothing.
The plan's own sentence — "24 rules, all enabled = 0; bespoke template bodies" —
described work with NO MECHANISM to land in: templateSeeds.js and coreRules.js
are core files with core arrays, and registries.js has no registerTemplates.
Adds api.registerEngagementSeeds({ templates, ruleGroups }). Templates re-ensure
every boot; rule groups are one-shot per named group, which is 11a's seed-key
finding stated as an API rather than as a warning.
DECISION 8: the game-powered families read in-universe, with a per-family sender
rather than one voice. The crown writes about the crown's business and nothing
else. This is also why the bodies had to be the module's: §5.2 says core stays
ignorant of a module's domain, and check:modules reads identifiers, never prose —
so the boundary is honoured deliberately here rather than leaned on.
DECISION 9: nine bodies stay plain. A failed-login notice written as a letter is
indistinguishable in register from the phishing mail it warns about.
DECISION 10 corrects a fact the plan had wrong by omission. §8.6 records that
uo.points.rank_changed cannot address a person, and the same reasoning was
silently assumed to cover the governor. city.update's `governor` is written by
BridgeJson.Actor(), which emits acct and webId — the new governor is addressable
today, no protocol change. uo.governor.appointed is the 25th trigger.
Also records what building it found: the no-conditionals/empty-string collision
that the presentational fragments exist for, the render sweep that found three
defects no check could see (an optional in a subject line, multi-optional detail
lines, a trailing fragment used as a leading one), the trigger-bound template
that could not reference its own unsubscribe link, and the trigger-id/template-key
grammar mismatch.
docs/modules/uo/API.md gains §5.7 and §5.8.
Co-Authored-By: Claude <noreply@anthropic.com>
The documentation half of Phase 11a (website#177, Module-uo#22). Three files,
each carrying one obligation from 6.0b's Phase 11a row.
**MODULE_API.md -> 1.8.0.** A seventh audience ceiling, `admin`, as a child of
`staff`. One addition, no removal, so minor -- every declaration valid under
1.7.0 is valid now and no stored value changes. 2.4's ceiling bullet now names
seven values rather than six, and says the thing a module author actually needs:
`admin` is the ONLY pair in the whole lattice with real containment, so
`permits('staff','admin')` holds and it is the only place `permits` is true
between two values below `authenticated`.
Also corrects two 2.3 rows that were stale rather than wrong: `ctx.inbox.push`
has not thrown since Phase 7, and both members now name the consumer that
actually calls them.
**modules/uo/API.md gains 5**, the full trigger catalogue -- which 6.0b asked for
in as many words ("the full trigger catalogue, its audiences and its ceilings,
not one entry"). The 24 triggers grouped by ceiling with the reason for each
ceiling; the four rows that deliberately do NOT ship with their recorded reasons;
the two that need a patch tier or a v5 overlay; the three things a rule cannot
express and are therefore done in the mapper; the three registered audiences; and
the ingest ORDERING, which is load-bearing rather than incidental.
**BACKEND_DESIGN.md -- the news publish path.** It described a direct
`pushDispatch.publish('news.post', ...)` from the create/publish-post path. That
call is gone: it is an emit through the engagement engine now, so news push rides
a rule, and core seeds that rule disabled. The note says so as an operator-visible
upgrade step, names the separate one-shot settings key and states what did NOT
change -- the announce legs and the post hooks still fire exactly as they did.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 11 was widened on 2026-08-31 to ship every ✅ row of §8.6 rather than a
single rule, and the phase text asked for its 11a/11b split to be confirmed with
the org lead before any code. Confirming it turned up five further questions the
plan had not asked, three of which change what ships — so this records all six
before implementation starts, on Phase 2's precedent.
The three carve-outs all came from checking §8.6's rows against what the WEBSITE
can address rather than against what the wire carries:
• uo.guild.joined duplicates core's team.member.joined — a UO guild IS a Team
and teamSync already emits on every roster reconcile, so an operator who
enables the obvious two rules gets two mails for one join. `left` and
`disbanded` still ship; core has no equivalent for either.
• uo.link.requested has no addressable recipient by construction (the account
is not yet linked) and a ~5-minute TTL no channel can beat.
• uo.points.rank_changed ships `subscribers` only: points.board's top[] names
a mobile serial and shard_account_links is keyed by account, so the personal
half would reach some players and silently not others.
The other three:
• The lattice gains `admin`, as a child of `staff` — the operator-facing family
is described as admin-audience everywhere and `staff` is admin/editor/
moderator, so ceilinging it there would mail the staff audit digest to every
editor. It is the only genuine refinement in the tree, so permits/meet/
meetAll are untouched. MODULE_API_VERSION → 1.8.0.
• §7.1 Q9 (news.post continuity): the emit REPLACES the raw tickle and core
seeds the rule disabled — the Phase 6 precedent. News push stops on upgrade;
the admin banner and the release note are the mitigations, and both are named
as phase deliverables.
• The split is confirmed and specified: 11a is declarations/mappings/ceilings,
server only, no rule or template seeded; 11b is the seeded set and the live
walk.
Also records the threshold-trigger mechanism uo.vendor.expiring and
uo.economy.milestone need — rule conditions compare against literals only, so
"within 24 hours of dismissal" is not expressible as a condition and the crossing
is tracked module-side — and names module-uo's first three registered audiences.
Co-Authored-By: Claude <noreply@anthropic.com>
Design of record for the bump, plus the reference updates it obliges.
Engagement Phase 10, as built.
link/v5.md is new. Three enrichments in one bump, because a bump costs a sidecar
release, a republished bundle and an operator update on every shard — a field left
out costs a whole second round of that, not a follow-up commit.
a. house.decay gains ownerName and a nested `schedule`. estimatedCollapse is
present ONLY where it is exact: under dynamic decay each stage's duration is
drawn at random on entry, so collapse is knowable only once the house is
already at IDOC; under static decay it is exact at every stage. Omitted
rather than approximated — an absent field is honest where a wrong date
becomes a dated promise in a player's mail.
b. vendor.listing gains ownerAcct — a character name identifies nobody, only the
game account joins to shard_account_links — and a `fees` block resolving
PlayerVendor.PayTimer's dismissal rule into a dismissalAt instant. There is
deliberately no daysRemaining: a pay period is a UO day on an old-system
shard, so the name would be wrong by a factor of twelve.
c. account.login.result is a NEW kind. account.login.attempt fires from a veto
hook that runs before the auth decision and constructs with Accepted = true,
so it fires on successful logins too and cannot carry a verdict.
INTEGRATION.md: the version banner to 5, the Housing and Market field tables and
examples, and the new kind beside the attempt — with the attempt's own row
corrected to say why it is not a failure signal.
PLAN.md: 5.1's "failed-login signals" row was wrong about what AccountLogin can
tell you; 5.3 and 5.4 gain the two new signals; 5.4's dynamic-decay model gets the
consequence for publishing a collapse time, and the CanDecay rule gets its testing
trap. 7's frames are marked as 1.0's design sketch rather than the shipped wire,
which they have not been for four versions.
ENGAGEMENT.md: Phase 10 as built. Two decisions taken during the build (both field
groups nested, so one admin rule governs each and a future field inherits the gate;
no daysRemaining), the three visibility classifications, and the pin improvement —
the schema test now asserts the declarations AGREE rather than that they all say a
literal, which is the protocol-4 bug seen from the other side.
Co-Authored-By: Claude <noreply@anthropic.com>
Two scope decisions taken by the org lead on 2026-08-31, before any Phase 10 code,
plus the three factual corrections that finding them out produced.
Phase 10 — the protocol bump now carries three enrichments, not one. The argument is
specific to this phase: a bump costs a sidecar release, a republished bundle and an
operator update on every shard, so a field left out does not cost a follow-up commit,
it costs a second bump with the same lead time and a split operator population. The
two additions:
* player-vendor fee state on vendor.listing (ownerAcct, holdGold, chargePerDay,
daysRemaining), because uo.vendor.expiring is the same "owned asset at risk with
a deadline" shape as the flagship and today has neither an address nor a deadline;
* a post-decision account.login.result, because EventSink.AccountLogin is a veto
hook that fires BEFORE the auth decision.
Moving vendor.sale out of the opt-in patch tier was explicitly declined.
Phase 11 — ships every checked row of 8.6 rather than a single rule, carving out
uo.market.item_listed (a saved search; no per-user query store exists). ~23 triggers
grouped by the audience kind each family exercises, since exercising the ceiling
lattice at scale is the point rather than volume of mail. Every rule still ships
enabled = 0 per Q3. Notes that the phase will likely want an 11a/11b split on the
4a/4b precedent, to confirm at its start.
Three corrections to 8.6, each verified against the emitters rather than the table:
* uo.vendor.sale is real and does carry ownerAcct, but lives in servuo-plugins/
patches/ (opt-in, verified only against ServUO 57.4) — dormant, not broken, on a
shard that declined the tier;
* uo.vendor.expiring had no data at all, not merely no mapper — vendor.listing
carries ownerSerial/ownerName and nothing carries held gold or daily charge;
* uo.account.login_attempt could not have been built as described — it would have
mailed "someone tried to log into your account" on every successful login.
Renamed uo.account.login_failed so the id cannot be misread again.
Also amended: the status header, scope decision 3, and 6.0b's documentation
assignment for rows 10 and 11 (v5.md now earned; the patch-tier caveat is an
operator-facing doc obligation; runicgateway.com's capability claim changes when
"one rule" becomes "the catalogue").
Co-Authored-By: Claude <noreply@anthropic.com>
Records what Phase 9 shipped (website#176) and the four decisions the org lead
settled before any of it: mechanism plus SMTP's own synchronous refusal rather
than an API transport; suppression scoped to engagement rules only; an
`address_masked` column added to §4.5's DDL; and the verification gate applied at
enqueue rather than at delivery.
The correction the phase's own text needed: "SMTP has none" is too strong. SMTP
has no asynchronous bounce feed, but a single-recipient send refused at RCPT TO
throws synchronously with the reply code intact, and mailer.js was already
catching that and discarding it.
The defect worth not repeating: `PERMANENT_CODES` is not a bounce classifier. It
answers "is retrying pointless?" and contains EAUTH, so suppressing on it would
have emptied the mailing list the first time an SMTP password expired.
Also records the one thing only the live rig could find — `engagement_sends`
has carried a `bounced` status since §4.5 and nothing had ever written it, so the
Send Log's Bounced filter matched nothing — and answers §7.1 Q1's narrower half.
- ENGAGEMENT.md: Phase 9 "As built", the §4.5 DDL, Q1's narrower half, status header
- BACKEND_DESIGN.md: §3 `engagement_suppressions`, and §7's Deliverability section
Co-Authored-By: Claude <noreply@anthropic.com>
Records Phase 8 and answers §7.1 Q8, leaving Q9 as the only open question.
ENGAGEMENT.md gains the phase's as-built section: the four decisions settled
first, the relative-url finding the live rig caught (Phase 7's contract is
relative-only, and the client half of that contract was never written down), the
ref-not-stream routing rule, the (base URL, user id) snapshot scoping that is the
actual security property, and the on-device walk. Its Status paragraph was six
phases stale and now names every phase that has landed.
android/PLAN.md §7's "no offline caching, no Room in v1" decision STANDS and now
names its one exception, with the limit it comes with: the snapshot serves a
running app, not a cold start, because the shell gates the whole app on loading
the site's appearance. Widening Phase 8 into the shell's startup model is the
org lead's call, so it is written up rather than done. §11 gains the inbox and
the per-channel screen as built.
Co-Authored-By: Claude <noreply@anthropic.com>
`news.post` is a declared trigger with no caller: `coreTriggers.js` says so in
as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming
it can never fire, so on a real deployment the only in-app or email items the
engine can produce today come from Teams. Phase 7 flagged it in passing; this
writes it down properly as an open question.
The substance is not the call — it is the three other things a news publish
already fires, and which of them the engine has any business replacing:
- the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a
one-shot delivery to a channel of the deployment, with retry. NOT the
engine's.
- a module's post hook (`registerPostHook`) — idempotent state mirroring that
also runs on delete and refreshes on a silent edit. NOT the engine's.
- the raw `pushDispatch.publish('news.post', …)` — a per-person notification.
THIS is the one that becomes an emit.
So modules keep both doors onto a news publish and neither changes. What a
module does not get is the ability to fire `news.post` itself — the id's owner
is core, `emit` binds the owner at the call, and §7.2's one namespace gives an
id exactly one owner across both facets. A module wanting its own person-facing
news notification declares its own trigger.
Three things to settle first, recorded rather than decided: continuity (the
emit replacing the tickle stops push silently until a rule is enabled — G22's
shape, and Phase 6 decision 3's), reusing the job-id transition signal rather
than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose
title understates it — a pointer and an extra acceptance line land there too.
Code: RunicGateway/website#175
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 2fd5d065b7)
ENGAGEMENT.md gains the Phase 7 as-built: the four decisions the org lead
settled before any code (in-app defaults to `instant`; the phase takes push's
`deliver` and the web preferences screen; the inbox takes `/notifications` and
the settings move under it; `ctx.inbox.push` respects a preference where one
exists), the block-role mapping that turns a template into a row, and five
things the tree contradicted or the build found — including the one only the
live rig could see, that staff had no reachable inbox at all.
Two earlier passages amended where the phase made them false: Phase 2's
"`ctx.inbox.push` throws until Phase 7" and Phase 3's "`inapp` is declared
`off`". Both kept as history with the correction beside them.
BACKEND_DESIGN.md gains the four inbox routes in the `/auth/me` table and a
`user_notifications` entry in the table inventory — the user-scoped dedupe
index, why `body` is text rather than the email HTML, and the retention policy.
Code: RunicGateway/website#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front,
the audience problem that shaped the phase, why scope_key is not subject_key, the
one place decision 4 as phrased could not ship, the projection's rule and what it
refuses to guess, the digest correcting Phase 4a rather than only implementing
§4.2b, and the three defects the build found. §4.2b and §6.0b updated with it.
TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own
thing. It now says which of the three sinks moved and which did not, where each
of the four properties it always claimed lives now, that Team email is OFF until
an operator turns a rule on, and what the generalized unsubscribe token does.
§6.3's `last_digest_at` is marked as no longer read.
BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the
unsubscribe routes — the canonical /public/engagement pair plus the /public/teams
path kept permanently because mail is not editable once sent.
Code: RunicGateway/website#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.
The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.
Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.
Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website#TBD. §6.0b assigns this phase two documents; both land here.
ENGAGEMENT.md gains an "As built — 5a" section: the three decisions the survey
forced, the argument for two registries rather than one, why the ternaries stayed
at the call site, and the two behaviour changes an operator will notice.
BACKEND_DESIGN.md gains the `engagement_templates` table, the two-block-registry
split, the token grammar, and the §7 note that mail is now multipart.
Co-Authored-By: Claude <noreply@anthropic.com>
Six defects that only driving the two screens in a browser could find, plus two
environment facts worth not re-deriving:
- rebuilding client/dist while the server is running blanks the whole SPA. The
HTML shell resolves core's hashed bundle filename at boot, so after a
rebuild it points at a file that no longer exists; core's bundle 404s,
window.__rg is never published, and modules/uo/entry.js throws MODULE_API.md
§3.1's "core did not publish its shared dependencies" into a blank page. The
error names core, and core is not at fault. Restart after every build.
- a window.confirm blocks CDP entirely, so the two destructive actions cannot
be driven from a script and a session that opens one is stuck until a human
dismisses it. Exercise those paths over the API.
- [x] AI-assisted: written with Claude Code (Opus)
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website#171.
ENGAGEMENT.md gains an "As built - 4b" section: the four decisions the org lead
settled, the correctness argument behind the standalone enable route, the three
honesty fields on the reach preview, and the two defects the live walk found in
Phase 4a's own code (a rule pointing at a dormant segment reading as healthy,
and the delete refusal's "1 rule still use"). It also records the throwaway
module rig that made the whole segment half walkable at all - core declares no
audiences, so on a stock local stack none of the §5.1a arithmetic can be
exercised without one.
§5.1a gains the composition UI it was owed: its own nav entry rather than a tab,
"exclude" offered only under "all of", the stored ceiling displayed and never
chosen, and a tree nested deeper than the composer renders shown read-only
rather than flattened.
BACKEND_DESIGN.md's route table gains the twelve routes, including why the
enable switch is a PATCH of one column and why deleting a segment in use is a
409 rather than a cascade.
- [x] AI-assisted: written with Claude Code (Opus)
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.
ENGAGEMENT.md
- Phase 4 is split 4a / 4b, with what each owes.
- Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
(android CI) is open.
- The as-built: the gate order and why two of its placements are load-bearing,
the segment rule the design never stated (not is legal only inside an and,
and contributes no ceiling), dormancy three ways and why audience_segment_id
has no foreign key, the conditions grammar's two fail-closed properties, and
why emit does not await the engine.
- Section 4.1's cooldown statement and section 4.2a's dedupe index are
corrected in place, so the design sections stop teaching the two defects.
BACKEND_DESIGN.md
- Five new tables in the schema inventory, each with the reasoning a reader
would otherwise have to reconstruct: why subject_key is in the primary key,
why the dedupe index is scoped, why the send log survives an account
deletion and is not a second address book, and why a rule points at a
segment without a foreign key doing it.
No route table changes - Phase 4a adds no routes.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
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>