docs(site): the engagement system — cutover 7 of 7 (edge → main)
#28
@@ -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. |
|
||||
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. |
|
||||
| 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. |
|
||||
|
||||
## 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.
|
||||
|
||||
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 community’s own installation. We have no copy, no access and no way to obtain one.
|
||||
- **Retention:** Held by the deployment, under its operator’s policy
|
||||
- **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 one’s 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 app’s 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
|
||||
|
||||
**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 browser’s user-agent string, truncated** — With the row; blanked on removal.
|
||||
- **The web server’s 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.
|
||||
|
||||
@@ -38,6 +38,7 @@ export const docsSidebar = [
|
||||
{ label: 'Teams', slug: 'docs/administration/teams' },
|
||||
{ label: 'Moderation', slug: 'docs/administration/moderation' },
|
||||
{ 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: 'Managing modules', slug: 'docs/administration/managing-modules' },
|
||||
{ label: 'The shard connection', slug: 'docs/administration/the-shard-connection' },
|
||||
@@ -114,6 +115,7 @@ export const plannedSidebar = {
|
||||
'Teams',
|
||||
'Moderation',
|
||||
'Notifications and email',
|
||||
'Engagement rules',
|
||||
'Message templates',
|
||||
'Managing modules',
|
||||
'The shard connection',
|
||||
|
||||
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal file
175
src/content/docs/docs/administration/engagement-rules.mdx
Normal 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.
|
||||
@@ -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
|
||||
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/).
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**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
|
||||
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">
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
You can change what any of these messages say — see
|
||||
[Message templates](/docs/administration/message-templates/) — and see who was actually
|
||||
sent what in **Admin → Engagement → Send log**.
|
||||
[Message templates](/docs/administration/message-templates/) — decide which of them are sent
|
||||
at all under [Engagement rules](/docs/administration/engagement-rules/), and see who was
|
||||
actually sent what in **Admin → Engagement → Send log**.
|
||||
|
||||
## The Discord bridges
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Everyone else is getting mail, so the transport is fine. Check
|
||||
|
||||
@@ -285,9 +285,10 @@ export const collected = [
|
||||
title: 'Everything you read and post in the app',
|
||||
body:
|
||||
'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 ' +
|
||||
'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.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
@@ -304,6 +305,40 @@ export const collected = [
|
||||
'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 one’s ' +
|
||||
'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 app’s 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',
|
||||
scope: 'app',
|
||||
@@ -401,6 +436,34 @@ export const collected = [
|
||||
},
|
||||
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',
|
||||
scope: 'deployment',
|
||||
|
||||
@@ -29,7 +29,7 @@ export const legal = {
|
||||
* 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.
|
||||
*/
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user