Compare commits

...

9 Commits

Author SHA1 Message Date
efce1d88aa docs(engagement): widen Phase 10 to three wire enrichments and Phase 11 to the full trigger catalogue
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>
2026-08-31 11:06:34 -05:00
33c0d71e4a Merge pull request 'docs(engagement): Phase 9 as built — deliverability, suppression and bounces' (#191) from docs/engagement-deliverability into edge
Reviewed-on: #191
2026-08-31 15:53:11 +00:00
221c5a9c9e docs(engagement): Phase 9 as built — deliverability, suppression and bounces
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>
2026-08-31 10:49:04 -05:00
400873b83a Merge pull request 'docs(engagement): the in-app channel on Android, as built (Phase 8)' (#190) from docs/engagement-inapp-android into edge
Reviewed-on: #190
2026-08-31 14:36:33 +00:00
b1851ad8c1 docs(engagement): the in-app channel on Android, as built (Phase 8)
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>
2026-08-31 09:28:07 -05:00
feeb2cac11 Merge pull request 'docs(website): §7.1 Q9 — core's own news.post emitter' (#189) from docs/engagement-news-emitter-note into edge
Reviewed-on: #189
2026-08-31 07:30:54 +00:00
66257ebcb5 docs(website): §7.1 Q9 — core's own news.post emitter
`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)
2026-08-31 02:28:20 -05:00
3274c7864c Merge pull request 'docs(website): the in-app channel as built (engagement Phase 7)' (#188) from docs/engagement-inapp-channel into edge
Reviewed-on: #188
2026-08-31 07:23:05 +00:00
d9abe3d941 docs(website): the in-app channel as built (engagement Phase 7)
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>
2026-08-31 02:11:46 -05:00
3 changed files with 783 additions and 61 deletions

View File

@@ -759,6 +759,18 @@ Mirrors the website's "degrade gracefully" invariant:
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
added later without reworking the repository layer (its typed results already isolate the UI from the
data source). No `Room` dependency in the initial build.
- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app
inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push
code already uses, capped at the server's own default page size. An inbox is a short, read-only,
newest-first list with a server-side cursor and no joins, so what "works offline" needs is the
newest page and the badge, not a database. **Every snapshot is scoped to (base URL, user id)** and
handed back only to that pair, which is what stops one account's notifications surfacing under
another's session on the teardown paths that never reach a logout (a dead refresh token, a server
switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard.
**The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of
`RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't
reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's
startup model, and it was left for the org lead rather than widened into Phase 8.
---
@@ -1307,9 +1319,48 @@ Four properties the UI should be built on rather than around:
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified.
### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31)
**The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its
gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content,
`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS`
is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the
old route still lands somewhere sensible.
**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7
shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being
read), the unread count that rides along on every page, and the two mark-read writes. Reads are
optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or
going offline right after reading everything would bring the badge back on the next cold open. The
drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than
on a timer - the tickle is what says "something happened", so polling would be a second, worse copy
of push.
**Two things the app has to do that the backend contract does not state:**
- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`),
which is right for a browser already on the site and a dead link on a phone.
`InboxViewModel.linkFor` resolves it against the configured base with OkHttp's `HttpUrl.resolve`,
which absolutises the path *and* returns null for anything that would not end up http(s) - so a
`javascript:` or `intent:` url in a notification body opens nothing. The live rig is what caught
this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing
at all.
- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER
id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
that a row exists, and the contract stays wake-and-pull.
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from
**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the
app and a fourth channel would too, without a release. A trigger-only id shows no push control rather
than a dead switch, and on a shard with no push relay the push controls are absent with the reason in
a note beside the list (email and on-site preferences are still worth setting there). Each change is
one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops
shows up as the control springing back.
## 12. Build & CI (Gitea Actions)

View File

@@ -574,7 +574,8 @@ cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
@@ -623,6 +624,35 @@ a rule's budget and mute it.
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
Phase 9 gave two of those statuses their first writers. `suppressed` means the address was on the
suppression list and **no transport call was made**; `bounced` means one was, and the mailbox does
not exist. `complained` still has none — it needs a provider feedback loop, which SMTP has not got.
### engagement_suppressions — addresses we have stopped mailing (engagement phase 9)
| col | type | notes |
|---|---|---|
| address_hash | CHAR(64) NOT NULL PK | sha256 of the **lower-cased, trimmed** address |
| address_masked | VARCHAR(190) NULL | `d***@example.com`. Phase 9's one addition to the planned DDL |
| channel | VARCHAR(32) NOT NULL DEFAULT 'email' | |
| reason | ENUM('bounce','complaint','manual','unverified') | |
| detail | VARCHAR(500) NULL | e.g. `hard bounce: 5.1.1` |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | the admin, for a manual row; **NULL for an automatic one**, which is what separates the two |
| created_at | DATETIME | |
`INDEX(created_at)`, `INDEX(reason, created_at)` — the screen's two orderings.
G16. **Keyed on the address, not the user**, and after Phase 1b made addresses unique that is a
choice rather than a workaround: a bounce arrives as an address, it does not know which account was
behind it, and it stays true after that account changed its address or was deleted.
Writes are `INSERT IGNORE`, so **the first reason an address was suppressed is the one that
survives** — an address that hard-bounced in March and was manually re-added in June still reads
`bounce`, because that is the fact explaining why the mail stopped. An upsert would let the most
recent write overwrite the diagnosis.
`address_masked` exists because a hash-only table cannot be operated; the reasoning and the routes
are in §7's *Deliverability* subsection.
### engagement_digest_state — how far each digest has got (engagement phase 6)
| col | type | notes |
|---|---|---|
@@ -680,6 +710,45 @@ to the in-code seed whenever the row is absent or its `blocks` will not parse
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
what makes it safe for a password-reset mail to depend on this table at all.
### user_notifications — the in-app inbox (engagement phase 7)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
| read_at | DATETIME NULL | |
| created_at | DATETIME | |
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
contract already promises every block can produce. It also means there is no operator markup on this
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
only reason the template exists at all.
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
worker that file is modelled on.
### The two block registries — pages and mail (engagement phase 5a)
`server/src/blocks/` (the CMS page family) and `server/src/emailBlocks/` (`email.heading`, `email.text`,
@@ -1098,6 +1167,10 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
| GET · PUT | `/me/notifications/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channel’s default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
| GET | `/me/notifications` | cookie / bearer | `?limit&before&unread` | **one page of the caller's in-app inbox** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5 G17, phase 7), newest first. `before` is a **keyset cursor** (the previous page's last id), never an offset: the list gains rows at the top while it is being read. `limit` defaults to 30, capped at 100. Carries `unread`, the count for the whole inbox rather than the page, so a client rendering both a list and a badge cannot show them disagreeing. **No parameter names a user** — the caller is the only account any of these four routes can read |
| GET | `/me/notifications/unread-count` | cookie / bearer | — | `{unread}`. Its own route because it is **polled**: asking "is there anything new" must not make the server assemble a page of bodies to answer with one integer |
| POST | `/me/notifications/:id/read` | cookie / bearer | — | mark one item read. **Idempotent** — the statement carries `read_at IS NULL`, so a second call does not move the stamp. **404 both** when no such item exists and when it belongs to another account: the same answer on purpose, so this cannot be used to ask whether an id is anybody's |
| POST | `/me/notifications/read-all` | cookie / bearer | — | mark the whole inbox read; returns `{ok, changed, unread:0}` |
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
authenticated role, behind `requireAuth` **only** — any active account, never a specific role. A
@@ -1558,6 +1631,72 @@ credentials.
stops**. The admin dashboard warns whenever the deprecated Gmail token is present and no replacement
credential is; see [`UPGRADE_NOTES.md`](UPGRADE_NOTES.md).
### Deliverability: suppression, bounces and the verification gate *(engagement phase 9)*
Engagement Phase 9 ([`ENGAGEMENT.md`](ENGAGEMENT.md) Phase 9). Two mechanisms decide that a person
who is *in* a rule's audience does not get the mail, and they are deliberately at different points
in the pipeline.
**`engagement_suppressions` — checked at DELIVERY.** Keyed on `address_hash` (sha256 of the
lower-cased address), because a bounce arrives as an address and stays true after the account behind
it changed its address or was deleted. An outbox row can sit through a rule's `delay_seconds` grace
window and an address can bounce inside it, so the only correct check is the one taken immediately
before the transport call — which is also what produces the `status='suppressed'` row in
`engagement_sends` with no transport call at all.
**The verification gate — applied at ENQUEUE.** With the `email_verification_required` setting on
(seeded in Phase 1b: `on` for a fresh install, `off` for an upgrade), an unverified address is
excluded before an outbox row is written. It hangs off a channel's optional **`eligible(userIds)`**
registration rather than living in the engine: being unverified is an *email* fact, and a rule
spanning email and in-app must still reach that person's inbox. Only `email` declares one. The
excluded count comes back so the admin reach preview reports it instead of quietly promising a
number the engine will not deliver.
**Scope: engagement rules only.** Password resets, invites, verification mails and the contact form
still attempt to a suppressed or unverified address. This is the posture `passwordReset.controller.js`
already took — user-initiated mail must not be blocked by a background system's opinion, and one
reset to a dead mailbox is not a reputation problem, whereas a rule mailing thousands of people
weekly is.
**What may write a `bounce` row is narrower than "the send failed".** `src/engagement/bounceClassify.js`
is the only judge, and it is deliberately **not** `mailer.PERMANENT_CODES` — that set answers "is
retrying pointless?" and contains `EAUTH` and `554`, so reusing it would mean one stale SMTP password
suppressing every address the worker touched, silently. The classifier reads the **RFC 3463 enhanced
status** first (`5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` suppress; `5.3.x`, `5.5.x` and
`5.7.x` never do, being about the server or our standing with it), and falls back — only for `550`,
`551` and `553`, and only past a veto list — to a phrase match. **Anything it is unsure about is not
suppressed:** a false negative costs one retry next month, a false positive costs a person who
silently stops hearing from the deployment.
SMTP has no *asynchronous* bounce or complaint feed — that is where an API-based provider would earn
its place — but a single-recipient send refused at `RCPT TO` throws synchronously with the reply
code intact, which is the highest-value signal there is and is what this reads. `sendNotification`
therefore returns an `smtp: { code, responseCode, response }` triple alongside its classification;
`retry` and `detail` cannot answer "was this the recipient's fault", since `550 5.1.1` and
`550 5.7.1` are an identical `retry: false`.
**Statuses.** `engagement_sends.status` gains two real writers: `suppressed` (declined to try) and
`bounced` (tried, the mailbox does not exist). `engagement_outbox.status` records `bounced` as
`failed` — its ENUM has no such value and, from the queue's point of view, a bounced row is one that
finished unsuccessfully. `complained` still has no writer: it needs a provider feedback loop.
**Routes** (all `adminOnly`, under `/api/v1/admin/engagement`):
| Route | Notes |
| --- | --- |
| `GET /suppressions` | Paged, filterable by `reason` / `channel` / `search`, plus unfiltered `byReason` totals |
| `POST /suppressions` | `reason` is forced to `manual` — an admin typing an address is not evidence of a bounce. An address already listed answers 200 with `created: false`, not 409 |
| `DELETE /suppressions` | The only way out of the list. The address goes in the **body**, not the path: a path parameter lands in the access log, the browser history and every proxy in front of the deployment |
**Neither route ever returns `address_hash`**, the same rule `GET /sends` follows: a sha256 of every
address on the deployment, handed to a browser, is an offline dictionary attack. What the list
returns is `address_masked` — `d***@example.com` — which Phase 9 added to §4.5's DDL because a
hash-only table cannot be operated: an operator has to be able to see a whole domain refusing mail
and to let back in somebody who fixed their mailbox. The domain survives intact for the first; the
local part is destroyed rather than shortened, so the column can never be read back as an address
book. The consequence is that **lifting a suppression needs the full address typed in** — the screen
genuinely does not have it, which is the privacy design working rather than a rough edge.
---
## 7.5 Logging & observability

View File

@@ -1,14 +1,21 @@
# The Engagement System — findings and plan
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a and 4b are built** (Phase 1: website#165 +
docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b:
website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 + docs#182; Phase 4a:
website#170 + docs#183; Phase 4b: website#171 + docs#184); everything from Phase 5 on is still design.
The scope decisions below are settled; **seven of the eight questions in §7.1 are answered** — Q1, Q3,
Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's
namespace question), and **Q2 and Q4 on 2026-08-29 at the start of Phase 4**. Q1's answer added a
whole phase (**Phase 1b**, unique email addresses); Q4's answer and the phase's size split **Phase 4
into 4a and 4b**. **Q8** remains open and blocks Phase 8. Per CLAUDE.md § Conventions, no
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a, 4b, 5a, 5b, 6, 7, 8 and 9 are built**
(Phase 1: website#165 + docs#178, with website#164 as its prerequisite; Phase 1a: website#166 +
docs#179; Phase 1b: website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 +
docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Phase 5a: website#172 +
docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 +
runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android
half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is
still design. **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:**
the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of
§8.6** rather than a single rule. The scope decisions
below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question),
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter, added
2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no
implementation starts without the org lead's approval of the phase it belongs to.
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
@@ -19,7 +26,10 @@ none.
1. **The in-app channel is in scope.** It does not exist today and has to be built, not adapted.
2. **The Teams notification pipeline is generalized and migrated onto the new system**, not built beside it.
3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION` bump.
3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION`
bump. **Widened 2026-08-31:** that single bump now carries **three** enrichments (house decay,
player-vendor fee state, and a post-decision login result), because a second bump would cost another
release + bundle + operator update. See Phase 10.
4. **Gmail OAuth2 is removed, not retained as a transport.** SMTP is the baseline; the OAuth2 consent
flow, its two routes, its borrowed Google client and its stored refresh token all go. See §1.2a for
what that deletes and §6/Phase 1 for the operator cutover it forces.
@@ -941,13 +951,30 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
INDEX idx_engs_user (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- G16. Keyed on the ADDRESS, not the user: users.email is not unique.
-- G16. Keyed on the ADDRESS, not the user. That was written when users.email was
-- not unique; after Phase 1b it stays, for a better reason: a bounce arrives as
-- an ADDRESS, does not know which account was behind it, and stays true after
-- that account changed its address or was deleted.
--
-- **`address_masked` and `created_by` are Phase 9's additions to this DDL.** The
-- hash-only table cannot be operated: an operator reading sha256 digests cannot
-- tell three typos from a whole domain refusing mail, and un-suppressing somebody
-- who fixed their mailbox is the one action the table must support. The domain
-- survives so a domain-wide failure is visible; the local part is DESTROYED
-- rather than shortened, so the column can never be read back as an address book.
-- `created_by` is what separates a row an admin typed from one the outbox worker
-- wrote (NULL).
CREATE TABLE IF NOT EXISTS engagement_suppressions (
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
channel VARCHAR(32) NOT NULL DEFAULT 'email',
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
detail VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
channel VARCHAR(32) NOT NULL DEFAULT 'email',
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
detail VARCHAR(500) NULL,
created_by INT NULL, -- the admin, for a manual row; NULL if automatic
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- G17: the in-app inbox. Core, game-agnostic, content-carrying.
@@ -1334,11 +1361,11 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase |
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
| **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
| **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel |
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
| **8** In-app (Android) ✅ | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase |
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing **and §Market** (tables + examples) + the new `account.login.result` row · `link/PLAN.md` §5/§7 · **a `link/v5.md`** — the bump now carries three enrichments across three subsystems, which is what earned v3 and v4 their own design docs | `servuo-plugins/overlay.toml` · `servuo-plugins/patches/README.md` — `vendor.sale` stays in the tier and the docs must say what that costs · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship | `module-uo/README.md` · **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims |
| **12** Public site | — | **`runicgateway.com`**, in full — see the phase |
| **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability |
@@ -1660,6 +1687,7 @@ mails `uo.cheat.detected` to the player it detected. Fewer people is not less ex
- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and
logs in prod; the owner is bound by core and never read from the arguments
- `ctx.inbox.push` — present and **throws** until Phase 7, the shape 1.6.0 settled on
*(Phase 7 filled it in. Not a version bump: the signature is the one 1.7.0 declared.)*
- `config/coreTriggers.js` — core's five, registered through `registerCore()`
- `GET /admin/engagement/{triggers,audiences}` — admin-only, served from the registries, no table
- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json`
@@ -1745,7 +1773,9 @@ phases before the registry replaced it. What did *not* land is the behavioural h
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
Phase 7 shipped would be a backlog.
Phase 7 shipped would be a backlog. **Phase 7 changed it to `instant`** once there was a surface to
look at: an inbox item wakes no device and leaves no building, and the backlog this paragraph feared
cannot happen against an empty table. See Phase 7's decision 1.
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
whole-set body forces a client that only manages email to send every push row back or wipe them. And
@@ -2606,7 +2636,7 @@ started writing, which is the column Phase 9's bounce correlation reads.
---
### Phase 7 — The in-app channel (core + web)
### Phase 7 — The in-app channel (core + web) ✅
`user_notifications`, the in-app `DeliveryChannel`, `GET /auth/me/notifications` + mark-read, and the web
surface (bell + list). Push tickles gain a `ref` that deep-links into the inbox.
@@ -2616,9 +2646,162 @@ no-op; mark-read is idempotent; a user cannot read another user's row (asserted
the model); `url` is relative-only, validated by the same character-class rule `pageUrlTemplate` uses.
**Guardrails:** swagger + route manifest; the sanitize path for `body`.
#### As built — 7 (2026-08-31)
The third channel gets behaviour, the oldest one gets a `deliver` at last, and the preferences endpoint
Phase 3 shipped with no surface gets one. **Four decisions were settled by the org lead before any
code**, two of them widening the phase past its own acceptance line.
##### Decision 1 — `inapp` defaults to `instant`, and it is the only channel that does
`coreChannels.js` deferred this in as many words: "whether the inbox is opt-out once it is real is a
Phase 7 decision with a live surface to look at." The surface exists now, and the answer is opt-OUT.
The argument for opt-IN was never about in-app. §7.1 Q1 is standard marketing-email practice and Phase
3's `push` default is about a device somebody is holding; **an inbox item wakes nothing and leaves
nothing** — it is a row on a page the user chose to open, on this deployment, costing one glance. Left
at `off` the channel would ship dead: no rule could reach anybody until every user found a toggle for a
channel they had never seen deliver anything. The backlog Phase 3 worried about cannot happen either —
the table is empty at cutover, rules default to `enabled = 0`, and every rule carries a per-hour
ceiling.
##### Decision 2 — the phase takes the two pieces its acceptance line omitted
Two earlier phases assigned work here that Phase 7's own bullets never mention, and both were taken:
- **`push` gets its `deliver`** (§2603's "the push and in-app channels' `deliver`"). Without it a rule
naming push still finished `failed` in the send log — the oldest sink in the system, unreachable from
the engine. It is the channel that got behaviour last because until the inbox existed there was
nothing for a content-free tickle to point at.
- **The web per-channel preferences screen** (Phase 3's as-built: "the screens are Phase 7 (web) and
Phase 8 (app)"). The endpoint had shipped with no consumer on either platform.
##### Decision 3 — the inbox takes `/notifications`; the preferences move under it
`/auth/me/notifications/*` was already the preferences namespace — `streams`, `subscriptions`,
`channels`, `teams` — and `/account/notifications` was already the preferences *page*, with a bell icon
in the portal nav. Content and settings are different kinds of thing, and **the plain word belongs to
the content**: it is what a person means when they say "notifications", and what the bell opens.
So the inbox is `GET /auth/me/notifications` and the page is `/account/notifications`; the preferences
screen moved to `/account/notifications/settings` and gained its own nav row. Route order is not
incidental and is commented as such: the four named preference sub-paths are declared above, and the one
parameterised path added below them is a **POST** whose `:id` is digits-only, so nothing can shadow
`streams` or `channels`.
##### Decision 4 — `ctx.inbox.push` respects a preference where one exists
The rule-less sink has no trigger declaration to project from, no rule to pick a template and no
audience to resolve. It now writes the inbox directly **unless** `triggerId` names a *registered*
trigger and that user's effective `inapp` mode is not `instant`: a toggle somebody switched off must not
be walkable around by the module that owns the trigger behind it. An id nothing has registered has no
toggle on any screen, so there is no preference to protect and the item is written.
Scoped preferences are deliberately not consulted — a scope is a property of an *event* (`team:12`), and
a caller with no declaration has no scope to name. The engine's path, which does, still applies them.
##### The block → column mapping, which is the whole of how a template becomes a row
`user_notifications` has `title` / `body` / `url` where email has a subject and a document. The in-app
renderer (`templates.renderInappByKey`) maps by block **role**: the first `email.heading` is the title,
the first `email.button` is the url, everything else is the body. A second heading or button is ordinary
body content, which is what an operator who added one meant.
**The body is TEXT, not the email HTML**, and that is load-bearing rather than a shortcut. The `email.*`
renderer produces markup built for mail clients — table rows, inline hex colours, a light-only palette
declared with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a
pale card floating in a dark one. `toText` is the same content with none of that, and it is the part
every block already promises. The consequence worth stating: **there is no operator markup on this
surface to sanitize, and no way for one to appear.** The phase's "sanitize path for `body`" guardrail is
discharged by the column never holding markup in the first place, which is a stronger guarantee than a
sanitizer.
##### Five things the tree contradicted, or the build found
- **The shipped `inapp.event` seed named variables nothing supplies.** Phase 5a wrote it before the
channel that renders it existed, declaring `body` and `url` — but a trigger declares domain names
(`teamName`, `threadTitle`) and `projection.project` fills the gaps with the *structural* ones
(`title`, `intro`, `actionUrl`). Every rendering would have produced a title and nothing else.
Renamed to `notify.event`'s vocabulary at **`seedVersion` 2**, which is §4.6.1 property 1 restated for
this channel: a new trigger must render with no authoring at all.
- **The dedupe index is scoped to the USER, which is narrower than the outbox's.** `engagement_outbox`
scopes to `(rule, user, channel)` because one event legitimately becomes one row per channel; an inbox
has no channel dimension, so two rows for one event would be **one item shown twice**. Same family of
defect as the global index Phase 4a found in §4.2a, in the opposite direction.
- **The push tickle's `ref` needed an ordering to be worth anything.** A rule spanning `inapp` and
`push` enqueues two independent rows and the outbox sweeps `ORDER BY due_at, id`, so the ref only
resolves if the in-app row was enqueued first. `engine.liveChannels` now sorts `inapp` ahead of the
rest (`CHANNEL_ORDER`) — an ordering, not a dependency: the ref is a **hint**, null when there is no
row, and the app's contract stays wake-and-pull.
- **There was no retention policy for this table at all**, and neither the outbox nor the send log
bounds it (both hold one row per *delivery*; an inbox item outlives its delivery by design).
`utils/userNotificationsPrune.js` is `teamActivityPrune`'s shape with one policy difference:
**read items only.** Age alone would delete the evidence for "I was never told", which is the
complaint this table answers. The horizon is `settings.user_notifications_retain_days`, default 90.
- **Staff had no reachable inbox, and only the live rig could see it.** `/auth/me/notifications` is
role-agnostic — behind `requireAuth` only, like every `/auth/me` route — so the server, the tests
and the API all agreed a staff member had an inbox. On the web they did not: `RequirePlayer` sends
anyone who is not a player out of `/account` (staff manage their own account under `/admin/account`),
so the bell pointed at a page that redirects. **Signed in as an admin, the feature was unreachable.**
Fixed by mounting the same two components at `/admin/notifications` and
`/admin/notifications/settings`, adding the bell to the admin header, and putting the one mapping in
`client/src/lib/notificationPaths.js` with its own test. One trap inside the fix worth keeping:
`allowedPathsFor` turns an `end: true` nav row into an EXACT match, so marking the admin row exact
left `/admin/notifications/settings` outside the allowlist and bounced staff off their own
preferences screen — the row has to cover its sub-routes.
##### What an operator and a user actually see
- **The bell** sits in the public site header and in the player portal's own header, renders nothing
when signed out, and **polls** its badge once a minute — pausing while the tab is hidden and
refreshing the moment it comes back. There is nothing to push over: the site's two SSE streams are
the shard's, neither is per-user, and a third authenticated stream carrying one integer would mean an
open connection per signed-in tab forever.
- **The preferences screen is now a matrix**, not a checkbox list. The push-only stream list it replaced
was a strict subset: `/notifications/channels` already returns every push stream *and* every event
trigger with the effective mode on each channel that applies, so a trigger-only id simply has no push
cell and core never has to explain which kind of id a row is. The two legacy whole-set endpoints are
untouched and are that surface's push projection, so **the shipped Android app keeps its wire shape**.
##### What was verified
- **28 new tests**: 23 in `engagementInapp.test.js` (the five acceptance criteria, the role mapping, the
four `ctx.inbox.push` cases, the tickle's exact key set, and the route-level ownership check) and 5 in
`userNotificationsSql.test.js` — a throwaway MariaDB, because three properties here are a *server*
contract rather than a reading of this code: a UNIQUE index admitting many NULLs, `INSERT IGNORE`
reporting `affectedRows = 0` on a duplicate, and `read_at IS NULL` making mark-read idempotent.
- **Two existing tests moved with the behaviour, and both moves are the point.**
`engagementEngine`'s "a channel with no `deliver()` finishes failed" named `inapp` (and `email` before
it) and so was rewritten by every phase that gave a channel behaviour; it now registers a throwaway
channel, because the property was never about a particular one. `notificationChannelPrefs`'s defaults
assertion carries decision 1.
- **Swagger and the route manifest** carry the four new routes, with four new component schemas.
- **A live rig**: MariaDB + a booted server + the real outbox worker + a browser. The walk is where
the staff-reachability defect came from, and it also proved the three things unit tests cannot —
that `liveChannels`' ordering really does put the in-app row first (a rule stored as
`["push","inapp"]` enqueued outbox 2 = inapp before outbox 3 = push, and the tickle carried
`ref: "notification:2"`); that **two rules on one event produce three outbox rows and exactly ONE
inbox item**, with the send log saying "already in this inbox (duplicate dedupe key)" rather than
claiming a second delivery; and that the retention worker drops an aged READ row while leaving an
equally aged UNREAD one. The preferences matrix wrote exactly one row for the one cell that changed.
**One thing this phase did NOT wire, and it is worth knowing before Phase 11.** `news.post` is a
declared trigger that **nothing emits through the engine** — `coreTriggers.js` says so in as many words
("these declare; nothing here emits yet") and Phase 6 migrated only the four `team.*` ones, so the
admin publish path still fires a raw `pushDispatch.publish` beside the engine rather than through it.
The consequence for this phase: on a real deployment the only in-app items a rule can produce today
come from the four Team triggers. Wiring the news emitter is a one-line `ctx.events.emit`-shaped change
that belongs with whoever owns that decision, not smuggled into the channel's own phase. **Written up as
§7.1 Q9**, which sets out the three other things a publish already fires (an announce leg, a module's
post hook, the raw tickle), which of them the engine replaces and which it must not touch, and the
continuity question that has to be answered before anyone writes the line. Recommended home: Phase 11.
**Still later phases':** the app's inbox screen and the tickle → pull → inbox path (Phase 8), and the
suppression list, which this channel has no equivalent of — there is no address to suppress.
---
### Phase 8 — The in-app channel (Android)
### Phase 8 — The in-app channel (Android) ✅
Inbox screen, unread badge, and the tickle → pull → inbox path. App-store cadence, separate repo,
separate release.
@@ -2626,7 +2809,85 @@ separate release.
**Acceptance:** a tickle wakes the app, which pulls and shows the item; the inbox works offline from
cache; the existing preferences screen gains the per-channel toggles from Phase 3.
**Note:** `android-app`'s `pr-checks.yml` triggers only on PRs into `main`, so phase PRs onto a working
branch get **no CI** — the cutover PR is the first real run. Plan for that.
branch get **no CI** — the cutover PR is the first real run. Plan for that. **Resolved: §7.1 Q8 was
answered "fix it" and the trigger now runs on PRs into `edge` too, as this phase's first commit.**
#### As built — 8 (2026-08-31)
The four inbox routes Phase 7 shipped had no consumer on either platform; this is the Android one.
**Four decisions were settled by the org lead before any code**, and one of them resolves a
contradiction the phase carried from the day it was written.
1. **§7.1 Q8 — fix the CI trigger, as the first commit.** `pr-checks.yml` now runs on PRs into
`edge` as well as `main`. All nine M12 phase PRs merged with no CI at all and engagement Phase 8
was about to repeat it; the alternative was finding a Kotlin compile error inside the cutover
window with a whole workstream's diff to bisect. `sonarqube.yml` is untouched — it is a
push-on-`main` analysis, not a PR gate, so no phase PR was ever expected to run it.
2. **The offline snapshot is a JSON blob, not Room.** This phase's acceptance line ("the inbox works
offline from cache") **contradicted a decision already recorded in `docs/android/PLAN.md` §7**:
*"Offline caching is not a v1 requirement (decided)… does not ship a Room cache in v1. No `Room`
dependency in the initial build."* §7's decision stands and this is its one named exception. An
inbox is a short, read-only, newest-first list with a server-side cursor and no joins, so what
"works offline" needs is the newest page and the badge — one blob in the DataStore the push code
already uses, capped at the server's own default page size.
3. **The drawer's "Notifications" is the inbox; the settings are behind its gear.** Exactly the
arrangement Phase 7 shipped on the web (the bare path is the content, `…/settings` the
preferences), and what a person means when they tap the word.
4. **The settings screen moved onto `/notifications/channels`**, rendering a control per channel that
applies to each id and per mode that channel accepts.
**The one thing the app must do that no plan said: resolve the item's `url`.** Phase 7's acceptance
specifies `url` is **relative-only** and validates it as such — right for a browser already on the
site, and a dead link on a phone. The live rig caught it: every item came back as
`/guilds/the-silver-anvil/forum/403`, and the app's first cut only opened `http(s)`-prefixed strings,
so **every link in the inbox did nothing**. It now resolves against the configured base with OkHttp's
`HttpUrl.resolve`, which absolutises the path *and* returns null for anything that would not end up
http(s) — so a `javascript:` or `intent:` url in a notification body opens nothing at all. The
relative-only contract did not change; the client half of it was simply never written down.
**Three further things the build settled:**
- **The tickle's `ref` is what routes it, not its stream.** An engagement rule's tickle carries the
TRIGGER id as `stream` (§7.2's one namespace), and the app's shipped `forStream` map knows only the
eight push streams — so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)`
sends anything whose ref starts with `notification:` to the inbox and leaves every other tickle on
the route it has always had. The ref is not decoded past that prefix and is never rendered: it is a
hint that a row exists, and the contract stays wake-and-pull, exactly as `pushChannel.js` says.
- **The snapshot is scoped to (base URL, user id), and that is the security property** — not the
clear-on-logout beside the push deregistration. A cache is only ever handed back to the pair that
wrote it, so the teardown paths that never reach a logout at all (a dead refresh token, a server
switch) cannot surface one person's notifications under another's session.
- **Reads are optimistic and are not rolled back.** The row flips locally, the server's post-write
`unread` replaces the local guess, and a failure is left alone: un-reading a row under someone's
finger looks like a bug, and the next refresh corrects it. A local read also rewrites the snapshot,
without which going offline right after reading everything would bring the badge back on the next
cold open.
**Verified on the live rig** (throwaway emulator + the real server on `edge` + real rows emitted
through `ctx.events.emit` → engine → outbox → `inappChannel`, never hand-written):
- the drawer badge showed **3**, the inbox listed the three items newest-first with unread dots and
local-zone timestamps, and a tap marked one read (3 → 2, server-side row updated);
- tapping an item opened its resolved link in a Custom Tab at the configured shard;
- one email chip wrote **exactly one row** — `(35, news.post, email, instant)` — and disturbed nothing
else, which is the sparse PUT's whole point;
- the push controls were **absent**, correctly, because the rig's shard advertises no relay, with the
reason in a note beside the list rather than replacing the screen (email and on-site preferences are
still worth setting on a shard with no push);
- with the network cut, the inbox rendered all five items from the snapshot under **"Offline — showing
what was saved on this device."**, unread count and read flags intact;
- a simulated tapped tickle (`STREAM=team.forum.post`, `REF=notification:5` — the exact extras
`PushNotifier` builds) landed on the inbox rather than Home.
**One finding this phase did NOT fix, deliberately.** *Offline works in a running app, not on a cold
start* — and the reason is the app shell, not the inbox. `MainActivity` gates the whole of `RunicApp`
on loading the site's appearance (M12), so an offline launch shows "Can't reach the site / Retry" and
never reaches the drawer at all. Widening this phase into the shell's startup model is the kind of
out-of-scope blocker that is a reason to ask, not a licence to widen the PR, so it is written up here
for the org lead. The acceptance line holds for the case the cache exists to serve — the app you were
just using, on a train — and fails for the case where the app was killed first.
**Still later phases':** nothing of this channel. The app now consumes every route Phase 7 built.
---
@@ -2645,40 +2906,232 @@ verification mechanism 1b already built, with bounces following.
transport call; a hard bounce suppresses the address; with the Phase 1b gate `on`, an unverified address
is excluded from engagement rules but still receives password resets; with it `off`, it receives both.
#### As built (2026-08-31)
**Four decisions, settled by the org lead before any code:**
1. **Mechanism plus SMTP's real signal; no API transport.** The phase's own text says "SMTP has none —
this is where the API-based providers earn their place", and that is too strong. SMTP has no
*asynchronous* bounce or complaint feed, but a single-recipient send refused at `RCPT TO` throws
synchronously with the reply code intact — the highest-value deliverability signal there is, and
`mailer.js` was already catching it as a `PERMANENT_CODE` and throwing it away. So this phase reads
it. A transport MAY declare a bounce handler; none does, and **no webhook route ships** — a route
with no producer is §7.1 Q9's problem in a different costume.
2. **Suppression scopes to engagement rules only.** Password resets, invites, verification mail and
the contact form still attempt. This is the posture `passwordReset.controller.js` already stated
for the verification gate, and the argument carries: user-initiated mail must not be blocked by a
background system's opinion, and one reset to a dead mailbox is not a reputation problem.
3. **`address_masked` is added to §4.5's DDL.** The hash-only table cannot be operated — an operator
staring at sha256 digests cannot tell three typos from a whole domain refusing mail, and
un-suppressing somebody who fixed their mailbox is the one action the table must support.
4. **The verification gate filters at ENQUEUE, not at delivery.**
**The defect this phase exists to have avoided: `PERMANENT_CODES` is not a bounce classifier.**
The obvious implementation is "the mailer already tells us a failure is terminal, so suppress on
that". `mailer.PERMANENT_CODES` is `{550, 553, 554, EENVELOPE, EAUTH}`, and it answers a different
question — *is retrying pointless?* `EAUTH` is the operator's password being wrong and `554` is a
relay-wide policy refusal; neither says anything about the recipient. Under that implementation **one
stale SMTP credential suppresses every address the outbox worker touches**, with a clean send log, no
warning, and a mailing list that has to be rebuilt by hand. So `bounceClassify.js` is its own judge:
- the **RFC 3463 enhanced status** decides on its own where there is one — `5.1.1`, `5.1.2`, `5.1.3`,
`5.1.6`, `5.1.10` and `5.2.1` suppress, and `5.3.x`, `5.5.x` and `5.7.x` explicitly never do, being
about the server or about our standing with it;
- without one, a phrase match applies **only** after `550`/`551`/`553` has already narrowed the
failure to the recipient address, and only past a veto list — `552` and `554` are excluded from even
that, because a full mailbox gets emptied and "transaction failed" is what a relay says when it does
not want to say why;
- **anything it is unsure about is not suppressed.** A false negative costs one retry next month; a
false positive costs a person who silently stops hearing from the deployment and cannot find out.
`sendNotification` now returns an `smtp: { code, responseCode, response }` triple so this is
answerable at all: `retry` and `detail` cannot distinguish `550 5.1.1` from `550 5.7.1`, which are an
identical `retry: false` and mean completely different things.
**The two mechanisms sit at different points, and the split is the design.** A suppression can appear
inside a rule's `delay_seconds` grace window, so the only correct check is the one taken immediately
before the transport call — which is also what produces the `status='suppressed'` row with no
transport call that the acceptance line asks for. Being unverified is a *standing* property, stable
across that window, so excluding at delivery would write an outbox row purely to throw it away — and
on a deployment that upgraded before verifying anybody, one rule firing would write thousands of
`suppressed` rows nobody can read.
**The gate is a channel hook, not an engine branch.** It hangs off a new optional
`registerDeliveryChannel({ eligible })`, and `email` is the only channel that declares one. Both
alternatives were wrong in a way the build made obvious: filtering the shared audience before the
per-channel loop silences the wrong sink — **a rule spanning email and in-app must still put an item
in an unverified user's inbox**, since being unverified is a reason not to mail somebody and no reason
at all to hide their notifications — and an `if (channel === 'email')` in `engine.js` puts one
channel's rule inside the generic engine. Its excluded counts flow into `summary.ineligible` and into
the admin reach preview, which until now reported an audience size that was never the number of people
who would get a mail.
**Both new checks fail OPEN**, and the `try/catch` in `eligible` is load-bearing rather than habit:
`applyRule` awaits it *before* the per-user loop, so an uncaught throw abandons the whole rule for
every channel it names — a rule that silently sent nothing, with a clean log and an empty outbox. That
is G22's shape again, and the recoverable mistake is mail going out.
**The live rig found the one defect the stubs could not.** Against a real MariaDB, a real SMTP
conversation (mailpit) and the real engine and worker, a hard bounce was recorded as `status='failed'`
— honest, but `engagement_sends.status` has carried **`bounced`** since §4.5 and nothing had ever
written it, so the Send Log's "Bounced" filter matched nothing and always would have. It is now a
distinct outcome, because "the relay would not take this" and "this mailbox does not exist" send an
operator to two different places. `engagement_outbox.status` still records it as `failed`: that ENUM
has no `bounced`, and from the queue's point of view a bounced row is one that finished
unsuccessfully. `complained` still has no writer, and cannot have one without a provider feedback loop.
**Verified on the live rig**, seven rungs, each one a real send or a real refusal:
1. baseline — three recipients, three mails in mailpit, three `sent` rows;
2. one address suppressed by hand — **two** mails, and a `suppressed` row naming the reason;
3. suppression lifted — three mails again;
4. gate `on` — two mails, `ineligible: { unverified: 1 }`, and **no send-log row at all** for the
excluded user, which is what enqueue-time exclusion means;
5. a `550 5.1.1` from the relay — two mails, one `bounced` row, and `b***@example.test` written to the
suppression list with `detail: hard bounce: 5.1.1`;
6. the same rule again — that address `suppressed`, with no transport call;
7. **a password reset to the suppressed address still arrived**, which is decision 2 proved rather
than asserted.
The three admin routes were then driven over real HTTP: `created_by` records the admin on a manual row
and stays **NULL** on the worker's automatic one, which is what separates them in the list; a repeat
POST answers `200 {created:false}` rather than a 409; DELETE matches case-insensitively, which is the
whole point of hashing a folded address; no response contains `address_hash`; and all three refuse a
signed-out caller.
**Still later phases':** an API-based transport with a signed webhook receiver, which is what would
bring asynchronous bounces and `complained` to life. Nothing here blocks it — a transport may declare
a bounce handler today.
---
### Phase 10 — Protocol bump: `house.decay` enrichment *(parallel from day one)*
### Phase 10 — Protocol bump: three wire enrichments *(parallel from day one)*
Four repos plus the overlay declaration, per CLAUDE.md's "The bridge is a contract":
**Scope widened 2026-08-31, by the org lead, before any code.** The phase was written as the
`house.decay` enrichment alone. It now carries **three** enrichments, for one reason that is a property
of this phase and of no other: **a protocol bump costs a sidecar release, a republished bundle and an
operator update on every shard.** A field left out here does not cost a follow-up commit — it costs a
*second* bump with the same three-part lead time, and an operator population split across two protocol
versions in the meantime. So everything the engagement workstream is known to need from the wire is
decided **now** and rides the single v4 → v5 bump.
The two additions come from Phase 11's expanded trigger set (below). Each was verified against the
emitters rather than assumed, and each closes a gap that §8.6 did not know it had:
- **Player-vendor fee state.** `uo.vendor.expiring` is the same shape as the flagship — an owned asset
at risk, with a real deadline — and is the second-strongest driver in the catalogue. But
`vendor.listing` carries `ownerSerial` and `ownerName` and **no `ownerAcct` and no fee state at
all**, so the trigger can today neither be addressed to a person nor know that anything is expiring.
- **A login *outcome*.** `EventSink.AccountLogin` is a pre-decision veto hook — the emitter's own
comment says so ("Fires before the auth decision, so this is an attempt, not a result"). It therefore
fires on every **successful** login too, and `uo.account.login_attempt` as §8.6 describes it
("someone tried to log into your game account") cannot be built on it: it would mail a security alert
every time the player themselves logged in. This is the one addition that is a new emitter rather
than new fields on an existing one.
**Explicitly NOT in this bump:** moving `vendor.sale` out of the opt-in patch tier. It stays where it
is (see §8.6's corrected row), which leaves `uo.vendor.sale` dormant on a shard that declined the
patches — a documentation obligation, not a protocol one.
| Repo | Change |
| --- | --- |
| `servuo-plugins/` | `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) |
| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitter** |
| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); houses board carries the new fields |
| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version |
| `docs/` | `docs/link/INTEGRATION.md` §Housing table + example, `docs/link/PLAN.md` §5/§7 |
| `servuo-plugins/` | **(a)** `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) · **(b)** `BridgeMarket`'s `vendor.listing` gains `ownerAcct`, `holdGold`, `chargePerDay`, `daysRemaining` · **(c)** a new post-decision `account.login.result` (`acct`, `accepted`, `rejectReason`, `ip`) beside the existing pre-decision attempt |
| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitters** |
| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); the houses board carries the new decay fields; `account.login.result` is a forwarded kind like any other — the sidecar stays a dumb forwarder |
| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version; **`shardVisibility.js`'s `KIND_FEATURE` gains `account.login.result`** — rule 2 fails an unmapped kind closed to admin-only, which is the right answer for a frame carrying an IP, but it must be *chosen* here rather than inherited by accident |
| `docs/` | `docs/link/INTEGRATION.md` §Housing and §Market tables + examples, `docs/link/PLAN.md` §5/§7 |
**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not the
PowerShell stub) shows a real transition carrying the new fields; a v4 overlay paired with a v5 sidecar
is **refused by the installer**, not mis-parsed; CI publishes a bundle whose manifest pairs v5 with v5.
**Two things the emitter work must not get wrong**, both following from §0.3's finding about how
ServUO actually decays:
- `estimatedCollapse` is exact **only** at IDOC, because dynamic decay draws each stage's duration at
random when the stage is entered. Emitting it at an earlier stage would publish a guess as a fact.
- `daysRemaining` for a vendor **is** exact (`HoldGold / ChargePerDay`), unlike the house. The two
fields must not be documented as though they carried the same confidence — a template that says
"your house collapses on the 4th" and one that says "your vendor is dismissed in 2 days" are making
very different promises.
**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not
the PowerShell stub) shows **(a)** a real decay transition carrying the new fields, **(b)** a seeded
player vendor whose listing carries a fee state that falls as its held gold is drawn down, and **(c)** a
*failed* game login producing `account.login.result accepted:false` where a successful one produces
`accepted:true`; a v4 overlay paired with a v5 sidecar is **refused by the installer**, not mis-parsed;
CI publishes a bundle whose manifest pairs v5 with v5; and the five-rung shard visibility walk still
shows no leak, with `account.login.result` reaching **admin only**.
**Note:** this phase's lead time is a release plus a bundle plus an operator update, which is why it
starts early and lands independently.
---
### Phase 11 — module-uo's triggers and the first real rule
### Phase 11 — module-uo's triggers: the full catalogue, and core's `news.post`
`module-uo` registers `uo.house.idoc_warning` (and siblings), emits from `shardIngest`, and ships the
"greatly damaged" mapping. **The `Greatly` transition mapping needs no protocol change** and can ship
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
until the v5 overlay is deployed, which the `required: false` declaration already permits.
**Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo`
registers `uo.house.idoc_warning` "(and siblings)", and Part 8's preamble used to say "Phase 11 ships
exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with one
carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment,
and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here.
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
**What that is worth, and it is not "more mail".** One trigger proves that a module can register a
trigger. Twenty-odd triggers spanning five audience kinds are the first real test of the things Parts
3–5 asserted and nothing has yet stressed: that the ceiling lattice holds when most triggers are *not*
public, that `check:modules` still finds no UO identifier in core when the module is this large, and
that a player's preferences screen stays legible when the catalogue is real rather than a demo. Every
rule ships `enabled = 0` (Q3), so this is a catalogue an operator turns on, not a switch that floods
anyone.
**The carve-out: `uo.market.item_listed` does not ship here**, for the reason §8.6 already gives — it
is a *saved search*, not a trigger. Its audience is "users whose stored query matches this listing",
and no per-user query store exists anywhere in the tree. It is its own workstream on top of this one.
`vendor.listing` remains ingested; nothing is lost by waiting.
**Two rows ship only because Phase 10 does.** `uo.house.idoc_warning`'s enrichment and
`uo.vendor.expiring` both need v5 fields; `uo.account.login_attempt` needs v5's new
`account.login.result` emitter and is renamed **`uo.account.login_failed`** to say what it actually
is. Everything else is mapping-only and can ship whether or not the bump has landed — the flagship's
`Greatly` mapping included, which **needs no protocol change** and can ship with Phase 6 if Phase 10
is still in flight, the trigger simply omitting `nextStage`/`estimatedCollapse` until the v5 overlay is
deployed, exactly as the `required: false` declaration already permits.
**The set, grouped by the audience kind each family exercises** — which is the point of grouping them
this way, since the audience kind is the thing being tested:
| Family | Triggers | Audience kind | Notes |
| --- | --- | --- | --- |
| **Owned asset at risk** | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All three resolve through `ownerAcct` → `shard_links` |
| **Passive income** | `uo.vendor.sale` | `owner` | **Patch-tier only** — see §8.6's corrected row; dormant on a shard that declined the patches, and the seeded rule's description must say so |
| **Personal security** | `uo.account.login_failed`, `uo.account.unlinked`, `uo.link.requested` | `owner`, ceiling `owner` | `uo.link.requested` is also the linking funnel: a player ran `[link` in game, finish it on the site |
| **Personal milestone** | `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | `owner`, opt-in | The two death triggers are a killfeed some players want and most do not — both ship `enabled = 0` and default `off` per channel |
| **Social / civic** | `uo.guild.joined`, `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry |
| **Come online now** | `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down` | `subscribers` | **`uo.server.up`/`down` is the cooldown table's stress test** — a flapping shard emits both repeatedly. Hard per-rule cooldown, not a per-send one |
| **Leaderboard** | `uo.points.rank_changed` | `subscribers`, `owner` | Fires both ways (you entered a top N; you were pushed out) |
| **Staff-facing** | `uo.page.new`, `uo.cheat.detected`, `uo.audit.staff_action` | ceiling `staff` / `admin` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism |
| **Operator-facing** | `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant |
**Also core's own `news.post` emitter, which this phase's title has always understated** (§7.1 Q9).
`news.post` is declared with no caller, so a rule naming it can never fire; wiring it is one
`ctx.events.emit`-shaped call in `announceIfNewlyPublished`, gated on the same job-id transition signal
the push already uses. **The announce legs and the post hooks are untouched** — a module's town-crier
leg is a delivery to a channel of the deployment and its news-gump hook is state mirroring, neither of
which is a per-person notification. What the emit replaces is the raw `pushDispatch.publish` beside
them, and Q9's continuity question has to be answered before it does.
**On the size of this phase.** It is now the largest in the workstream: ~23 trigger declarations, their
audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's
nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings —
server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and
that split should be confirmed with the org lead at the start of the phase rather than assumed here.
**Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or
admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
cancels the pending mail.
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
cancels the pending mail; a player vendor drawn down to its last day produces one `uo.vendor.expiring`
to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one
produces none; a shard restarted three times in a minute produces **one** downtime mail, not six; and
**a news post published on the rig reaches a rule, with the town-crier leg and every registered post
hook still firing exactly as they did.**
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point —
which is a materially stronger claim now that the module registers twenty-odd UO-named triggers.
---
@@ -2810,7 +3263,7 @@ day it ships.
## Part 7 — Open questions and forward-compat notes
### 7.1 Questions for the org lead — seven answered, one still open
### 7.1 Questions for the org lead — seven answered, two still open
1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
@@ -2826,6 +3279,13 @@ day it ships.
*Consequence for ordering:* Phase 9 no longer blocks Phase 11 — the verification *mechanism* moves
forward into 1b, and Phase 9 keeps only bounces and suppression.
**The narrower half is settled (2026-08-31, at the start of Phase 9): an unverified address is
excluded at ENQUEUE, and only from the email channel.** With the gate on, `emailChannel.eligible`
drops the user before an outbox row is written, so nothing is queued only to be thrown away and
the admin reach preview can report the exclusion. It is email-only because a rule spanning
channels must still put an item in that person's in-app inbox. Transactional mail — resets,
invites, verification itself — is unaffected either way.
2. ✅ **ANSWERED — multi-instance.** Neither of the two the question offered, and the third is
better than both: the outbox sweep **claims each row with a compare-and-set** —
`UPDATE … SET status='sending' WHERE id=? AND status='scheduled'` — and the instance the server
@@ -2868,11 +3328,58 @@ day it ships.
Composition must **narrow, never widen** — the segment takes the tightest ceiling it contains and is
still checked against the trigger's G24 ceiling. §5.1a is the design; Phase 2 owns the surface,
Phase 4 the composition UI, Phase 11 `module-uo`'s first real audiences.
8. **Android CI on `edge`** (§6.0a). `android-app/.gitea/workflows/pr-checks.yml` triggers only on PRs
into `main`, so Phase 8 lands with zero CI and Phase 13 is its first real build — as happened to all
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
Kotlin compile error during the cutover window.
8. **Android CI on `edge`** (§6.0a). **ANSWERED 2026-08-31 (org lead), at the start of Phase 8: fix
it, as the phase's first commit.** `pr-checks.yml` triggered only on PRs into `main`, so Phase 8
would have landed with zero CI and Phase 13 would have been its first real build — as happened to
all nine M12 phase PRs. `pull_request.branches` is now `[main, edge]`. **`sonarqube.yml` was
deliberately left alone**: it is a push-on-`main` analysis rather than a PR gate, so no phase PR
was ever expected to run it.
9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a
declared payload contract with **no caller**, and on a real deployment the only in-app or email items
a rule can produce today come from Teams. Phase 7 found this and deliberately did not wire it, on the
grounds that a channel's own phase is not the place to give another phase's trigger an emitter.
**The call itself is one line. The care is entirely in what it must not disturb.**
`admin.controller.js`'s `announceIfNewlyPublished` already fans one publish four ways, and they are
different in kind — three of them are *not* the engagement engine's business:
| What fires on a publish | Whose | What kind of thing it is | The engine's? |
| --- | --- | --- | --- |
| `announceJobs.enqueueIfNeeded` → `announce_job_legs` | core, with legs registered by modules — `module-uo` owns `towncrier` | a one-shot **delivery to a channel of the deployment**, with retry and classification | **No** |
| `registries.dispatchPostHook('onSaved')` | modules, via `registerPostHook` (API 1.1.0) | idempotent **state mirroring** — it also runs on delete, and refreshes silently on an edit | **No** |
| `pushDispatch.publish('news.post', { ref })` | core | a **per-person notification**, to whoever subscribed | **Yes — this is the one that should become an emit** |
| *(missing)* `engagementEmit.emit('core', 'news.post', …)` | core | rules → email / in-app / push | **Yes** |
`registries.js` already states the first two apart and why they were not folded together ("a leg is a
one-shot DELIVERY with retry and classification; a post hook maintains idempotent STATE, has to run on
delete as well as save, and refreshes silently on an edit"). **Adding the engine makes a third
distinction of the same kind, not a replacement for either.** A module's town-crier leg and a module's
news gump must keep firing exactly as they do; what changes is only that the raw tickle stops being
the one person-facing sink and becomes one channel of a rule.
**What a module gets out of this, stated so nobody widens it by accident.** A module already has two
doors onto a news publish — the announce leg and the post hook — and it keeps both. What it does *not*
get is the ability to fire `news.post` itself: the id's owner is core, `ctx.events.emit` binds the
owner at the call and never reads it from the arguments, and §7.2's one namespace means an id has
exactly one owner across both facets. A module that wants a person-facing notification of its own
declares its own trigger through `registerEventTriggers`. That is the whole of "modules can use it".
**Three things to settle before anyone writes the line:**
- **Continuity, and it is the same shape as G22 and Phase 6's decision 3.** Today publishing news
tickles every `news.post` subscriber directly. If the emit *replaces* that call, push stops the
moment this lands and stays stopped until an operator enables a rule — silently, because `enabled`
defaults to `0`. Either core seeds a `news.post` rule (and then: enabled, against the standing
default, or disabled with a banner as the Team rules got?), or the raw tickle stays beside the emit
for one release and is removed once a rule is known to exist.
- **The transition signal must be reused, not re-derived.** `enqueueIfNeeded` returning a truthy job
id is the single "newly published news" test, and the push call already piggybacks on it
deliberately so an edit or a re-publish does not re-fire. The emit must gate on the same value; a
second reading of the transition is a second chance to disagree with the first.
- **Which phase owns it.** Recommendation: **Phase 11**, which already ships "the first real rule" and
is where a declared trigger first gets a caller. It is core work rather than `module-uo`'s, so that
phase's title understates it — say so there rather than inventing a phase for one call site.
### 7.2 One namespace, or two?
@@ -2954,9 +3461,11 @@ Nothing here is "documentation to do at the end" — a phase is not done until i
## Part 8 — What the system could be used for
A catalogue, not a commitment. Its purpose is to check that the design in Parts 3–5 is general enough,
and to show what an operator gets for the phases they pay for. **Nothing here is scheduled**; Phase 11
ships exactly one rule.
Written as a catalogue, not a commitment — to check that the design in Parts 3–5 is general enough, and
to show what an operator gets for the phases they pay for. **§8.6 is no longer that.** On 2026-08-31 the
org lead scheduled **every ✅ row of §8.6 into Phase 11**, so for the `module-uo` section the burden of
proof has inverted: a row that does not ship needs a reason recorded in the phase. The rest of Part 8 —
§8.1–8.5 and §8.7 — remains unscheduled and stays a catalogue.
Read the availability column as: **✅** the data exists today and a rule could be written the day the
engine lands · **⚠** the data exists but needs a mapper or a resolver · **❌** needs new plumbing
@@ -3048,16 +3557,21 @@ which is the one legitimately campaign-shaped use and the one most likely to be
### 8.6 Module events — `module-uo`, grounded in what the shard actually emits
Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` would register;
core stays ignorant of every word in it.
Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` registers;
core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except
`uo.market.item_listed`, carved out there with its reason.
**Three rows were corrected on 2026-08-31**, when the set was scheduled and each claim was checked
against the emitters rather than against this table. Two of the corrections are what widened Phase 10
from one wire enrichment to three; the third is a caveat that has to reach the operator, not a defect.
| Trigger | Wire source | Data | Why anyone cares |
| --- | --- | --- | --- |
| `uo.house.idoc_warning` | `house.decay` → `Greatly` | ⚠ mapping only (the enrichment is Phase 10) | **The flagship.** Your house is decaying; log in or lose it and its contents |
| `uo.house.collapsed` | `house.decay` → `Collapsed`, `house.remove` | ✅ | The bad news, so it is not a surprise |
| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) | Your player vendor sold something, for how much |
| `uo.vendor.expiring` | `vendor.listing`, vendor fees | ⚠ | Your vendor is about to be dismissed for unpaid fees |
| `uo.account.login_attempt` | `account.login.attempt` | ✅ (already a personal stream) | Someone tried to log into your game account, from where |
| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) — **but patch-tier only**, see below | Your player vendor sold something, for how much |
| `uo.vendor.expiring` | `vendor.listing` + **v5 fee fields** | ⚠ → ✅ **after Phase 10**; `vendor.listing` carries no `ownerAcct` and no fee state today | Your vendor is about to be dismissed for unpaid fees |
| `uo.account.login_failed` | **v5 `account.login.result`** | ⚠ → ✅ **after Phase 10**; the existing `account.login.attempt` is pre-decision, see below | Someone tried to log into your game account, and failed, and from where |
| `uo.character.death` / `murdered` | `player.death`, `player.murdered` | ✅ | Opt-in; a killfeed some players want and most do not |
| `uo.skill.capped` | `skill.gain` where `base == cap` | ✅ | You hit the cap in a skill — a genuine milestone |
| `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail |
@@ -3078,7 +3592,25 @@ core stays ignorant of every word in it.
| `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
| `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend |
**Three of these are worth calling out as design pressure on Parts 3–5:**
**Three rows as originally written were wrong, and finding out cost one sweep of the emitters:**
- **`uo.vendor.sale` is real and does carry `ownerAcct` — but it lives in `servuo-plugins/patches/`,
the opt-in patch tier**, verified only against ServUO 57.4. It is a `PlayerVendorSale` EventSink the
patches *add* to core plus a subscriber that reads it, which is why it is absent from `overlay/`. A
shard that declined the tier, or runs another ServUO version, emits this kind never — so the rule is
silently dormant there rather than broken. Phase 10 deliberately does **not** try to move it into the
overlay; the obligation is that the seeded rule and the operator docs say which tier it needs.
- **`uo.vendor.expiring` had no data at all, not merely no mapper.** `vendor.listing` carries
`ownerSerial` and `ownerName` — no `ownerAcct`, so it cannot even be addressed to a website user —
and nothing anywhere on the wire carries a vendor's held gold or daily charge. Phase 10 adds all four.
- **`uo.account.login_attempt` could not have been built as described.** `EventSink.AccountLogin` is a
pre-decision veto hook; the emitter's own comment says "Fires before the auth decision, so this is an
attempt, not a result". A rule on it would have mailed "someone tried to log into your account" every
time the player logged in successfully — the exact inversion that makes people distrust security
mail. Phase 10 adds a post-decision `account.login.result`, and the trigger is renamed
**`uo.account.login_failed`** so its id cannot be misread again.
**Three others are worth calling out as design pressure on Parts 3–5:**
- **`uo.market.item_listed` is a *saved search*, not a plain trigger.** The audience is "users whose
stored query matches this listing", which `audience: 'computed'` covers but a per-user query store