docs(site): the engagement system — cutover 7 of 7 (edgemain) #28

Merged
whitlocktech merged 17 commits from edge into main 2026-09-01 18:04:23 +00:00
9 changed files with 286 additions and 9 deletions
Showing only changes of commit d01fd55f43 - Show all commits

View File

@@ -32,6 +32,7 @@ Where the console offers free text about security practices, two things are wort
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. | | App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. |
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the consoles free-text security section if it asks. | | Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the consoles free-text security section if it asks. |
| Messages | Other user-generated content | No | No | Not collected by us. | | Messages | Other user-generated content | No | No | Not collected by us. |
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. |
| Device or other IDs | Device or other IDs | No | No | Not collected. | | Device or other IDs | Device or other IDs | No | No | Not collected. |
## Each answer, and why it is the truthful one ## Each answer, and why it is the truthful one
@@ -83,12 +84,23 @@ Push is off until you enable it. When you do, the app mints a random, unguessabl
**Messages → Other user-generated content.** Not collected by us. **Messages → Other user-generated content.** Not collected by us.
Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives. Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
- **Why that answer:** Content is written to the communitys own installation. We have no copy, no access and no way to obtain one. - **Why that answer:** Content is written to the communitys own installation. We have no copy, no access and no way to obtain one.
- **Retention:** Held by the deployment, under its operators policy - **Retention:** Held by the deployment, under its operators policy
- **Read from:** `PLAN.md §9 section 2` - **Read from:** `PLAN.md §9 section 2`
### A snapshot of your notifications, so the inbox opens without a signal
**Messages → Other in-app messages.** Not collected by us. Stored on the device only.
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first ones messages.
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it.
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
- **In detail:** Signing out deletes the snapshot outright. It lives in the apps ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
### No analytics, no crash reporting, no advertising ### No analytics, no crash reporting, no advertising
**Device or other IDs → Device or other IDs.** Not collected. **Device or other IDs → Device or other IDs.** Not collected.
@@ -116,4 +128,4 @@ Not part of the Data Safety form — that form is about the app — but a review
- **Your browsers user-agent string, truncated** — With the row; blanked on removal. - **Your browsers user-agent string, truncated** — With the row; blanked on removal.
- **The web servers access log** — Short-term operational retention, then rotated away. - **The web servers access log** — Short-term operational retention, then rotated away.
Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores. Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores.

View File

@@ -38,6 +38,7 @@ export const docsSidebar = [
{ label: 'Teams', slug: 'docs/administration/teams' }, { label: 'Teams', slug: 'docs/administration/teams' },
{ label: 'Moderation', slug: 'docs/administration/moderation' }, { label: 'Moderation', slug: 'docs/administration/moderation' },
{ label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' }, { label: 'Notifications and email', slug: 'docs/administration/notifications-and-email' },
{ label: 'Engagement rules', slug: 'docs/administration/engagement-rules' },
{ label: 'Message templates', slug: 'docs/administration/message-templates' }, { label: 'Message templates', slug: 'docs/administration/message-templates' },
{ label: 'Managing modules', slug: 'docs/administration/managing-modules' }, { label: 'Managing modules', slug: 'docs/administration/managing-modules' },
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' }, { label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
@@ -114,6 +115,7 @@ export const plannedSidebar = {
'Teams', 'Teams',
'Moderation', 'Moderation',
'Notifications and email', 'Notifications and email',
'Engagement rules',
'Message templates', 'Message templates',
'Managing modules', 'Managing modules',
'The shard connection', 'The shard connection',

View File

@@ -0,0 +1,175 @@
---
title: Engagement rules
description: Decide what your site mails and shows people — the rule editor, saved audiences, the trigger catalog and the send log that answers "did they actually get it".
---
import { Aside } from '@astrojs/starlight/components';
[Notifications and email](/docs/administration/notifications-and-email/) is about where a
message goes. [Message templates](/docs/administration/message-templates/) is about what it
says. This page is the part in between: **what makes one get sent at all.**
A **rule** is four decisions — *when* (a trigger), *to whom* (an audience), *by what*
(channels), and *how often* (timing). **Admin → Engagement → Rules.**
## Nothing sends until you turn it on
Every rule arrives switched **off**. That is true of the ones you make, and it is true of
the ones your modules ship with them: install a game module and you get a shelf of ready
rules, all dark, none of them mailing anybody. Turning one on is a deliberate, separate
act.
The same caution runs through the rest of the screen. Every rule carries a **hard ceiling
on sends per hour** — you cannot save one without a number — because the failure mode of an
automated mailer is not a wrong message, it is ten thousand of them at four in the morning.
<Aside type="caution" title="Upgrading? Your Team emails are here now">
Team notification email used to be its own pipeline. It is engagement rules now, and — like
every other seeded rule — those four rules arrive **disabled**. If your members were getting
Team mail before an upgrade, it stops until you turn them on. The admin dashboard says so
while it is true.
</Aside>
## What a rule is made of
**The trigger** is the event that fires it: a house falling into disrepair, a post being
published, a login failing. Pick it from what is registered — see
[the catalog](#the-trigger-catalog) below. A rule's trigger is **fixed once the rule
exists**: its cooldowns, its pending messages and its whole send history are about one
event, so changing it would silently be a different rule wearing the same name. Make a new
one instead.
**The audience** is who hears about it. Some are built in — the person the event is about,
everyone subscribed to it, staff. Others come from your modules and are named in their own
vocabulary. You can also point a rule at a **saved audience** you composed yourself; see
[Audiences](#audiences).
**The channels** are how it reaches them: on the site, by email, by push. A rule can name
more than one, and each channel picks its own template — the same event can be a sentence
in the inbox and a properly laid-out letter in the mail.
**The timing** is the part worth reading twice.
- A **delay** holds the message before it goes, so a situation that resolves itself never
produces a message at all.
- **Cancel on** names the events that call it back. A warning that a house is about to
collapse waits fifteen minutes and is cancelled outright if the owner turns up and
repairs it — nobody is told their house was in danger after it stopped being in danger.
- A **cooldown** is the "not again for a while" limit, counted **per person, per subject
and per channel**. Per subject, so a cooldown about one house says nothing about another.
Per channel, so "one a day about this house" means one email *and* one inbox item, which
is what an operator setting that limit means.
## Audiences
**Admin → Engagement → Audiences** is where you build a named set of people out of the ones
your modules declare — *members of this Team*, *the sitting governors* — and combine them:
all of these, any of these, none of these.
One rule governs the whole screen: **composition narrows and never widens.**
- The ceiling of a saved audience is **derived** from the tightest thing in it, never
chosen. That is true of "any of" too, where the intuitive answer — the widest of the two —
is the wrong one. A ceiling says what an expression is *allowed* to reach, not what it
happens to resolve to today.
- **"None of" is only offered inside an "all of" group.** Alone it would have to mean
"everybody except these", which is a broadcast built out of a short list, and it is not
offered anywhere it would mean that.
- Two audiences with no relationship between them — staff and "the person this is about",
say — have no honest combined ceiling, so the save is refused rather than guessing which
side to take.
Before you save a rule, the editor shows you a **reach preview**: a number, never a list of
names. It will also tell you when a number is a floor rather than an answer, and when an
audience resolves to nobody at all and why.
## The ceiling, and why a rule will not offer the audience you expected
Every trigger declares the **widest audience a rule may ever give it**. It is the security
boundary of the whole system, and it is set in code by whoever declared the event, not in
the admin panel. Staff-only events cannot be widened into public ones by anybody, including
you.
Seven values, and they are a **tree, not a ladder**:
| Ceiling | Who that is |
| --- | --- |
| `everyone` | Everyone, including signed-out visitors |
| `authenticated` | Any signed-in user |
| `subscribers` | Signed-in users subscribed to this event |
| `members` | Members of a module-declared list |
| `staff` | Staff only — admins, editors and moderators |
| `admin` | Administrators only |
| `owner` | Only the user the event is about |
<Aside type="note" title="Fewer people is not less exposure">
The tempting reading is a ladder — that a staff-only event could obviously also go to just
one person. It cannot, and the example is the whole argument: cheat detection is a
staff-only event, and "just one person" would be *the player it was detected on*. The
question a ceiling answers is never how many, it is **which**.
</Aside>
So `staff` does not permit `owner`, `members` does not permit `subscribers`, and the editor
simply does not offer you the audiences the trigger forbids. The one exception proves the
rule: `admin` sits under `staff`, because every administrator really is staff.
## The trigger catalog
**Admin → Engagement → Triggers** lists every event a rule can be built on, and it is
read-only on purpose — **there is no table behind it**. A trigger is declared in code, by
the site or by an installed module, so what you are looking at is whatever registered on
this boot. Uninstall a module and its triggers stop appearing; nothing was deleted.
Two things it shows that are invisible everywhere else:
- **The variables** each event carries, with an example of each. This is the list a template
is allowed to reference — when a message comes out with a hole in it, this is the screen
that says why.
- **The ceiling**, so when the rule editor offers you a narrower set of audiences than you
expected, you can see the number it is obeying.
### Dormant rules
A rule can be switched on and still be unable to fire — most often because the module that
declared its trigger, or the audience it points at, is no longer installed. Those are
badged **dormant** in the list, with the reason, because "this rule cannot fire" is a
different fact from "this rule is off" and you need both. The on/off switch keeps working
on a dormant rule, deliberately: a rule whose module has gone is exactly the rule you most
want to be able to stop.
## The send log
**Admin → Engagement → Send Log** answers one question: *did that person get that message,
and if not, why not?* Every attempt is a row — when, what fired it, which user, which
channel, and the result. Filter by result to go straight to what failed.
| Result | What it means |
| --- | --- |
| **Sent** | Handed to the channel successfully |
| **Failed** | The attempt errored — the reason is on the row, not hidden in a tooltip |
| **Not sent** | Suppressed before it was attempted: unsubscribed, unverified, or on the [suppression list](/docs/administration/troubleshooting/) |
| **Bounced** | The receiving server rejected it after the fact |
| **Marked as spam** | The recipient reported it |
Test sends from the template editor land here too, labelled as such, so you can confirm
your own test arrived before turning a rule on for real.
<Aside type="tip" title="Two things it will not show you, on purpose">
**The email address.** The log stores a one-way hash of it — enough to tie a bounce back to
a delivery, not enough to become a second address book.
**A name.** It holds the user id, and that is deliberate: joining the account list in would
quietly turn a delivery log into a staff-readable directory. Paste the id into Moderation,
which is where a person's record belongs.
</Aside>
## What a game module brings
A module declares its own triggers and its own audiences, in its own vocabulary, and it may
ship rules and message bodies to go with them. The Ultima Online module ships a large family
of them — houses falling to ruin, vendors running out of gold, a governor being seated, a
guild's fortunes — written in the voice of an in-world office rather than a system alert.
All of them arrive **disabled**, like every other seeded rule. Read the list in
**Admin → Engagement → Triggers**, turn on the ones your shard should send, and check the
send log the first time each one fires.

View File

@@ -129,4 +129,5 @@ rejected sender address, a bad password, a relay that will not accept your domai
The log does not store anybody's email address. It keeps a one-way fingerprint instead, so The log does not store anybody's email address. It keeps a one-way fingerprint instead, so
that a bounce can be matched back to a delivery without the log itself becoming a second that a bounce can be matched back to a delivery without the log itself becoming a second
copy of your members' addresses. copy of your members' addresses. The rest of that screen — and the rules that decide a
message is sent at all — is [Engagement rules](/docs/administration/engagement-rules/).

View File

@@ -9,6 +9,9 @@ Four separate delivery paths, each optional, each off until you configure it. A
configures none of them still works — it just never reaches anyone who is not looking at configures none of them still works — it just never reaches anyone who is not looking at
it. it.
This page is about the paths themselves. What decides that a particular message gets sent
down one of them is a rule — see [Engagement rules](/docs/administration/engagement-rules/).
## Email ## Email
**Admin → Settings → Email delivery.** The site sends contact-form messages, invitations, **Admin → Settings → Email delivery.** The site sends contact-form messages, invitations,
@@ -154,6 +157,10 @@ member sets their own. The defaults are not symmetrical, and the asymmetry is de
- **Muting a Team silences all three for that Team**, whatever the grid says, without - **Muting a Team silences all three for that Team**, whatever the grid says, without
touching any of their other Teams. touching any of their other Teams.
The operator's side of the same question — which events exist, and how wide an audience each
one may ever be given — is [Engagement rules](/docs/administration/engagement-rules/). When
a message went nowhere and you want to know why, the send log there is the screen that says.
<Aside type="caution" title="Nothing here retries"> <Aside type="caution" title="Nothing here retries">
The announcement dispatcher sends once, and the Team notification bridge states plainly that The announcement dispatcher sends once, and the Team notification bridge states plainly that
a message is sent once and not retried. If Discord is down when a post is published, that a message is sent once and not retried. If Discord is down when a post is published, that

View File

@@ -90,8 +90,9 @@ it says. What changed is that it is now precise: it stops the emails it came wit
that Team's push notifications alone, where before it silenced both. that Team's push notifications alone, where before it silenced both.
You can change what any of these messages say — see You can change what any of these messages say — see
[Message templates](/docs/administration/message-templates/) — and see who was actually [Message templates](/docs/administration/message-templates/) — decide which of them are sent
sent what in **Admin → Engagement → Send log**. at all under [Engagement rules](/docs/administration/engagement-rules/), and see who was
actually sent what in **Admin → Engagement → Send log**.
## The Discord bridges ## The Discord bridges

View File

@@ -114,6 +114,22 @@ a Team. See [Teams](/docs/administration/teams/).
- **A missed announcement does not come back.** Nothing retries; the post itself is still - **A missed announcement does not come back.** Nothing retries; the post itself is still
on the site. on the site.
## Nothing is sent for one particular event
Mail works, other notifications arrive, but this one thing never produces anything. The
answer is almost always in **Engagement → Rules**, and it is one of four:
- **The rule is off.** Every rule ships disabled, including the ones your modules bring
with them, so "installed" is not "on".
- **The rule is badged *dormant*.** It is switched on but cannot fire — usually because the
module that declared its trigger, or the audience it points at, is no longer installed.
- **It fired and was held back by its own cooldown**, which is per person, per subject and
per channel. The Send Log shows nothing for a message that was never queued.
- **The audience resolves to nobody.** The rule editor's reach preview is the fastest way
to find that out — it will tell you the count is zero and why.
[Engagement rules](/docs/administration/engagement-rules/) walks through all four.
## One person stopped receiving email ## One person stopped receiving email
Everyone else is getting mail, so the transport is fine. Check Everyone else is getting mail, so the transport is fine. Check

View File

@@ -285,9 +285,10 @@ export const collected = [
title: 'Everything you read and post in the app', title: 'Everything you read and post in the app',
body: body:
'Forum posts, Team activity, character and shard information, notification ' + 'Forum posts, Team activity, character and shard information, notification ' +
'preferences: all of it is a live read or write against the deployment. Nothing is ' + 'preferences: all of it is a live read or write against the deployment. Apart from ' +
'cached for offline use and nothing is duplicated anywhere else — the app with no ' + 'the notification snapshot described in the next entry, nothing is cached for ' +
'signal is an app with no content, which is a limitation and also an accurate ' + 'offline use and nothing is duplicated anywhere else — the app with no signal is ' +
'an app with almost no content, which is a limitation and also an accurate ' +
'description of where the data lives.', 'description of where the data lives.',
retention: { retention: {
summary: 'Held by the deployment, under its operators policy', summary: 'Held by the deployment, under its operators policy',
@@ -304,6 +305,40 @@ export const collected = [
'access and no way to obtain one.', 'access and no way to obtain one.',
}, },
}, },
{
id: 'app-inbox-cache',
scope: 'app',
title: 'A snapshot of your notifications, so the inbox opens without a signal',
body:
'The app keeps the most recent notifications it has already fetched — at most ' +
'thirty, and only the first page — on the device, so opening the inbox shows you ' +
'what you had rather than a spinner. It is a copy of what the deployment already ' +
'sent you and it is refreshed from there; nothing is written here that was not ' +
'read from your own account. It is scoped to the account that fetched it, so a ' +
'second person signing in on the same phone is never shown the first ones ' +
'messages.',
retention: {
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
detail:
'Signing out deletes the snapshot outright. It lives in the apps ordinary ' +
'preference store rather than the encrypted one — sign-in tokens are the thing ' +
'that store is for — which is worth stating plainly: on a device where someone ' +
'has root, these are readable, and they are notification bodies rather than ' +
'credentials.',
},
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
play: {
category: 'Messages',
type: 'Other in-app messages',
collected: false,
shared: false,
answer: 'Not collected by us. Stored on the device only.',
because:
'The snapshot is written on the phone from data the deployment had already ' +
'delivered. It is not uploaded anywhere, and no server we operate is on either ' +
'end of it.',
},
},
{ {
id: 'app-no-analytics', id: 'app-no-analytics',
scope: 'app', scope: 'app',
@@ -401,6 +436,34 @@ export const collected = [
}, },
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports', source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
}, },
{
id: 'deploy-engagement',
scope: 'deployment',
title: 'Notifications, and the record of what was sent',
body:
'An operator can have the site notify people about things that happen on it — on ' +
'the site, by email, by push — so an address is now used for more than getting ' +
'into an account. Each member chooses this per notification and per channel, and ' +
'email and push are both off until they ask for them. Alongside that the site ' +
'keeps a delivery log: what fired, which account, which channel, whether it ' +
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
'that bounce or are reported as spam go on a suppression list, which stores the ' +
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
'operator can see what was suppressed without the list becoming a second address ' +
'book.',
retention: {
summary: 'Kept until the operator removes them; nothing here expires on its own',
detail:
'Stated plainly because it is the answer people assume the other way round: ' +
'the delivery log, the suppression list and the per-person rate limits have no ' +
'retention sweep, so they are as long as the site is old. Deleting an account ' +
'detaches its rows from it rather than deleting them — a delivery history stops ' +
'naming a person, and a suppressed address stays suppressed.',
},
source:
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
'notification_channel_prefs',
},
{ {
id: 'deploy-game-data', id: 'deploy-game-data',
scope: 'deployment', scope: 'deployment',

View File

@@ -29,7 +29,7 @@ export const legal = {
* from git: a build timestamp would move on every rebuild and tell a reader nothing, * from git: a build timestamp would move on every rebuild and tell a reader nothing,
* and a commit date would move when a stylesheet changed. * and a commit date would move when a stylesheet changed.
*/ */
lastUpdated: '2026-08-24', lastUpdated: '2026-09-01',
/** /**
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31). * The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).