Compare commits

..

15 Commits

Author SHA1 Message Date
2fd5d065b7 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>
2026-08-31 02:27:48 -05: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
73c3a467e7 Merge pull request 'docs(website): the email channel and the Teams migration as built (engagement Phase 6)' (#187) from docs/engagement-email-channel into edge
Reviewed-on: #187
2026-08-31 06:07:57 +00:00
c93151b352 docs(website): the email channel and the Teams migration as built (engagement Phase 6)
ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front,
the audience problem that shaped the phase, why scope_key is not subject_key, the
one place decision 4 as phrased could not ship, the projection's rule and what it
refuses to guess, the digest correcting Phase 4a rather than only implementing
§4.2b, and the three defects the build found. §4.2b and §6.0b updated with it.

TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own
thing. It now says which of the three sinks moved and which did not, where each
of the four properties it always claimed lives now, that Team email is OFF until
an operator turns a rule on, and what the generalized unsubscribe token does.
§6.3's `last_digest_at` is marked as no longer read.

BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the
unsubscribe routes — the canonical /public/engagement pair plus the /public/teams
path kept permanently because mail is not editable once sent.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:12:15 -05:00
2161119c8c Merge pull request 'docs(website): the template editor as built (engagement Phase 5b)' (#186) from docs/engagement-template-editor into edge
Reviewed-on: #186
2026-08-29 23:37:22 +00:00
556124562b docs(website): the template editor as built (engagement Phase 5b)
ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.

The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.

Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.

Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:14:12 -05:00
c03dfc14ba Merge pull request 'docs(website): the seeded template set as built (engagement Phase 5a)' (#185) from docs/engagement-templates into edge 2026-08-29 18:13:47 +00:00
aa5c1d63b0 docs(website): the seeded template set as built (engagement Phase 5a)
Companion to website#TBD. §6.0b assigns this phase two documents; both land here.

ENGAGEMENT.md gains an "As built — 5a" section: the three decisions the survey
forced, the argument for two registries rather than one, why the ternaries stayed
at the call site, and the two behaviour changes an operator will notice.

BACKEND_DESIGN.md gains the `engagement_templates` table, the two-block-registry
split, the token grammar, and the §7 note that mail is now multipart.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:50 -05:00
315a7c7fca Merge pull request 'docs(website): the engagement admin surface as built (Phase 4b)' (#184) from docs/engagement-rules-admin into edge
Reviewed-on: #184
2026-08-29 17:29:06 +00:00
73e1669a5c docs(website): the browser pass on the engagement screens (Phase 4b)
Six defects that only driving the two screens in a browser could find, plus two
environment facts worth not re-deriving:

  - rebuilding client/dist while the server is running blanks the whole SPA. The
    HTML shell resolves core's hashed bundle filename at boot, so after a
    rebuild it points at a file that no longer exists; core's bundle 404s,
    window.__rg is never published, and modules/uo/entry.js throws MODULE_API.md
    §3.1's "core did not publish its shared dependencies" into a blank page. The
    error names core, and core is not at fault. Restart after every build.
  - a window.confirm blocks CDP entirely, so the two destructive actions cannot
    be driven from a script and a session that opens one is stuck until a human
    dismisses it. Exercise those paths over the API.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:26:41 -05:00
97b652fed3 docs(website): the engagement admin surface as built (Phase 4b)
Companion to website#171.

ENGAGEMENT.md gains an "As built - 4b" section: the four decisions the org lead
settled, the correctness argument behind the standalone enable route, the three
honesty fields on the reach preview, and the two defects the live walk found in
Phase 4a's own code (a rule pointing at a dormant segment reading as healthy,
and the delete refusal's "1 rule still use"). It also records the throwaway
module rig that made the whole segment half walkable at all - core declares no
audiences, so on a stock local stack none of the §5.1a arithmetic can be
exercised without one.

§5.1a gains the composition UI it was owed: its own nav entry rather than a tab,
"exclude" offered only under "all of", the stored ceiling displayed and never
chosen, and a tree nested deeper than the composer renders shown read-only
rather than flattened.

BACKEND_DESIGN.md's route table gains the twelve routes, including why the
enable switch is a PATCH of one column and why deleting a segment in use is a
409 rather than a cascade.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:18 -05:00
5924276fe7 Merge pull request 'docs(website): the engagement engine as built (Phase 4a)' (#183) from docs/engagement-engine into edge
Reviewed-on: #183
2026-08-29 13:24:26 +00:00
deaf491dc5 docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.

ENGAGEMENT.md
  - Phase 4 is split 4a / 4b, with what each owes.
  - Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
    (android CI) is open.
  - The as-built: the gate order and why two of its placements are load-bearing,
    the segment rule the design never stated (not is legal only inside an and,
    and contributes no ceiling), dormancy three ways and why audience_segment_id
    has no foreign key, the conditions grammar's two fail-closed properties, and
    why emit does not await the engine.
  - Section 4.1's cooldown statement and section 4.2a's dedupe index are
    corrected in place, so the design sections stop teaching the two defects.

BACKEND_DESIGN.md
  - Five new tables in the schema inventory, each with the reasoning a reader
    would otherwise have to reconstruct: why subject_key is in the primary key,
    why the dedupe index is scoped, why the send log survives an account
    deletion and is not a second address book, and why a rule points at a
    segment without a foreign key doing it.

No route table changes - Phase 4a adds no routes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:42 -05:00
713e6fa6c8 Merge pull request 'docs(website): per-channel notification preferences (engagement Phase 3)' (#182) from docs/engagement-channel-prefs into edge
Reviewed-on: #182
2026-08-29 12:11:04 +00:00
7b7a26f0ac docs(website): per-channel notification preferences (engagement Phase 3)
The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.

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

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

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

Companion to website#169.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:09:16 -05:00
4 changed files with 3033 additions and 1465 deletions

View File

@@ -1250,6 +1250,67 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set" > `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields. > `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29)
Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added
`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`.
**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their
exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep
working — `notification_subscriptions` is now the **push projection** of the new table, and every
write to either fans out to the other. That was the acceptance criterion the phase was built against,
with the empty-array case tested explicitly.
**What the new endpoint adds, for whenever the app adopts it:**
```jsonc
// GET /auth/me/notifications/channels
{
"channels": [ // the delivery-channel registry
{ "id": "push", "label": "Push", "carriesContent": false,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] },
{ "id": "email", "label": "Email", "carriesContent": true,
"defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] },
{ "id": "inapp", "label": "On the site", "carriesContent": true,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }
],
"items": [ // every subscribable id, streams AND triggers
{ "id": "news.post", "label": "News posts", "description": "…",
"personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated",
"channels": ["push", "email", "inapp"],
"modes": { "push": "instant", "email": "off", "inapp": "off" } }
]
}
```
Four properties the UI should be built on rather than around:
- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an
event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries
no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it —
so **render the toggles from `channels`, never from a hardcoded three**.
- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the
server substitutes that channel's `defaultMode`. The client never has to know which it is looking
at, and must not re-implement the defaulting.
- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send
only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`.
Everything not named is left alone, so the notifications screen can save one toggle without holding
the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not
apply here at all**: there is no "clearing the last one" case, because turning something off is a
row like any other. `prefs` is still required, so a DTO field with no default is still the right
shape.
- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does
not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored
state, so re-render from it rather than assuming the request took.
**One id may be missing from `items` that the app expects.** A trigger whose declared audience
`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.
## 12. Build & CI (Gitea Actions) ## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other

View File

@@ -180,13 +180,19 @@ server/
from a manifest URL, enable, from a manifest URL, enable,
disable, uninstall, purge, restart disable, uninstall, purge, restart
and the source allowlist and the source allowlist
engagement.router.js (2) /admin/engagement — adminOnly, engagement.router.js (22) /admin/engagement — adminOnly,
the declared event catalog. Read the declared event catalog (three
only and table-free: it serves the table-free reads, served from the
module registries. Rules, templates module registries) plus the rules
and the send log land under this and audience segments an operator
same prefix in ENGAGEMENT.md configures over it, the count-only
Phases 4 and 5 reach preview, the message
templates and their sandboxed
preview / test send, and the send
log (G15). Two of these are POSTs
that write nothing: preview and
test-send act on the draft in the
request, not the stored row
email.router.js (4) /admin/email — outbound mail: email.router.js (4) /admin/email — outbound mail:
transport + credentials + send transport + credentials + send
test — adminOnly. The two test — adminOnly. The two
@@ -461,6 +467,298 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT `PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
replaces the whole set. Nothing is pushed unless the user subscribed. replaces the whole set. Nothing is pushed unless the user subscribed.
Engagement phase 3 made this the **push projection** of `notification_channel_prefs` below. It keeps
its exact shape and stays what `utils/pushDispatch` reads — the shipped Android client cannot be
changed from this side — and the general table carries the channel dimension it lacks.
### notification_channel_prefs — which channel, in which mode (engagement phase 3)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | a stream id **or** a trigger id — **one namespace** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.2), which is what keeps this key single-column |
| channel | VARCHAR(32) NOT NULL | `email` / `push` / `inapp`, from the delivery-channel registry (`src/engagement/channels.js`) |
| mode | ENUM('off','instant','digest') NOT NULL DEFAULT 'off' | `digest` only where the channel declares `supportsDigest` |
| updated_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id, channel)`, `INDEX(channel, mode)`.
**A row exists only where the user has expressed something, and absence is the *channel's* default,
not `off`.** That default lives in the channel registry and nowhere else (§3.1, G9: push, email and
in-app do not agree on it). All three currently declare `off`, so absence and off happen to coincide
today — a fact about the declarations, not about this table, and code must not assume it. The column
`DEFAULT` is the value a write with no mode takes, not the meaning of a missing row.
**It is a superset of `notification_subscriptions`, which becomes its push projection.** The shipped
Android client's wire shape is frozen (`{streams:[…]}`), so the old table stays exactly what
`utils/pushDispatch` reads and every write to either fans out to the other. The invariant both
directions maintain: **a `push` row with `mode <> 'off'` ⟺ a `notification_subscriptions` row.** An
explicit `off` is *stored* rather than deleted — folding "I turned this off" back into "I never said"
is only harmless while the default is off. Existing subscriptions are carried across by an
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
`announce_jobs → announce_job_legs` one it copies.
### engagement_rules — the operator's configuration (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| trigger_id | VARCHAR(96) NOT NULL | a declared trigger id. **No FK and no existence check** — a trigger is declared in code, so a rule naming one no module currently registers is *dormant*, never deleted ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.3) |
| name | VARCHAR(160) NOT NULL | |
| enabled | TINYINT(1) NOT NULL DEFAULT **0** | off by default, so no import, seed or restore can start mailing on its own (§7.1 Q3) |
| audience | VARCHAR(32) NOT NULL DEFAULT 'owner' | a ceiling name — `owner` / `staff` / `subscribers` / `members` / `authenticated` / `everyone` |
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
| cancel_on | JSON NULL | trigger ids that cancel a pending row for the same subject |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(trigger_id, enabled)` — the engine's one indexed read per emit.
**`audience_segment_id` carries no foreign key on purpose.** The two options a database offers are
both wrong here: `ON DELETE CASCADE` would delete an operator's rules, and `ON DELETE SET NULL` would
silently fall the rule back to its plain `audience` column — and that fallback reaches a **different
set of people**, which is the failure §5.1a rule 4 exists to prevent. A rule whose segment is gone is
dormant and sends nothing, and deleting a segment a rule still uses is refused in the model.
### engagement_audience_segments — operator-composed audiences (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| name | VARCHAR(160) NOT NULL | |
| expression | JSON NOT NULL | a boolean tree of module-declared audience ids + params |
| ceiling | VARCHAR(32) NOT NULL | **derived, never operator-typed** — the narrowest ceiling in the tree |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
**Composition narrows, never widens.** `A OR B` takes the *tighter* of the two ceilings, not the
looser: a ceiling states what an expression is allowed to reach, not what it will resolve to, so the
boolean operator's direction is irrelevant. Two incomparable ceilings have no meet and the save is
refused rather than resolved to a guess (`src/modules/ceilings.js`). `not` is legal only inside an
`and` — a complement needs a set to be taken from, and "everyone except…" is a broadcast built out of
a narrow audience — and it contributes no ceiling of its own, since excluding people cannot widen.
The ceiling is a **stored column rather than a runtime computation** so an audit can read what a rule
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
ceiling cannot retroactively widen a segment saved under the old one.
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
| col | type | notes |
|---|---|---|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
| last_fired_at | DATETIME NOT NULL | |
| fire_count | INT NOT NULL DEFAULT 1 | |
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
(rule, user) alone silently drops three of them.
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
connector's default `foundRows: true` makes a no-op update report 1 rather than 0 — under which every
cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
### engagement_outbox — the send queue (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| 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 |
| 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 |
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
| attempts / last_error / sent_at | | |
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
`INDEX(rule_id, user_id, subject_key, status)`.
**The unique key is scoped, and a global one would have been a data-loss bug.** A dedupe key names the
*event*; one event legitimately becomes one row per (rule, user, channel), so a fifty-person audience
on two channels is a hundred rows carrying the same key. A global `UNIQUE(dedupe_key)` admits the first
and silently ignores the rest.
**A row is claimed with a compare-and-set** — `UPDATE … SET status='sending' WHERE id=? AND
status='scheduled'` — and the sweeper the server reports `affectedRows = 1` to owns it (§7.1 Q2). That
makes the outbox safe for two app instances; the other four workers in this codebase are still
single-instance, so the deployment as a whole is not. A row stranded in `sending` by a crashed process
is reclaimed after a window, because `status='scheduled'` would otherwise never match it again.
### engagement_sends — the send log (engagement phase 4a)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| outbox_id | BIGINT NULL | |
| rule_id | INT NULL | |
| trigger_id | VARCHAR(96) NOT NULL | |
| user_id | INT NULL FK→users(id) **ON DELETE SET NULL** | the log survives an account deletion |
| channel / transport | VARCHAR(32) | which channel, and which mail transport actually carried it |
| address_hash | CHAR(64) NULL | sha256 — enough to correlate a bounce (Phase 9), useless as a mailing list |
| status | ENUM('sent','failed','suppressed','bounced','complained') | |
| detail | VARCHAR(500) NULL | |
| created_at | DATETIME | |
`INDEX(trigger_id, created_at)`, `INDEX(user_id, created_at)`, `INDEX(rule_id, created_at)` — the last
of those is the per-rule hourly ceiling's count, which runs once per rule per event.
G15: "did user X get the mail?" has never been answerable on this deployment. A row is written for
**every terminal outcome**, not only success — "no, and here is why" is an answer this table has to be
able to give — and the hourly ceiling counts only `sent`, so a broken transport cannot silently consume
a rule's budget and mute it.
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
### engagement_digest_state — how far each digest has got (engagement phase 6)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | |
| scope_key | VARCHAR(190) NOT NULL DEFAULT '' | `''` = deployment-wide; `team:12` = one Team. NOT NULL with a `''` default because it is a PRIMARY KEY column and MariaDB coerces a nullable one anyway — the same workaround `team_integration_config` and `teams.active_key` both carry |
| last_digest_at | DATETIME NULL | stamped **only on a successful send** |
| updated_at | DATETIME | |
`PRIMARY KEY (user_id, channel, scope_key)`, `INDEX(channel, last_digest_at)` — the worker's driving
question is "whose digest is due?", which is a range scan of that index rather than of every digest ever
sent.
**This is a digest's only state, and deliberately not a digest queue.** The engine writes an outbox row
per (rule, user, channel) at emit time carrying a snapshot of the payload; that is right for an instant
send and wrong for a digest, which is re-derived from the source tables when it goes out. Three
properties depend on the re-derivation: a two-day outage sends one digest rather than replaying two
days, a post hidden after it was written is not in the query, and — the security one — a user who lost
access between the post and the send is no longer in the recipient set. So `engine.subscribedTo`
enqueues **`instant` recipients only**.
Lifted out of `team_notification_prefs.last_digest_at`, which was a worker's column on a user's
preferences row; `schema.sql` backfills it with an `INSERT IGNORE … SELECT`, replay-safe by the primary
key rather than by a flag.
### engagement_templates — the message bodies (engagement phase 5a)
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| `key` | VARCHAR(96) NOT NULL **UNIQUE** | the stable id a rule's `template_keys` map and `mailer` name |
| name | VARCHAR(160) NOT NULL | what the admin list shows |
| trigger_id / trigger_version | VARCHAR(96) NULL / INT NULL | **no foreign key**, for the reason `engagement_rules.trigger_id` has none: a trigger is declared in code. NULL = a reusable template not tied to one trigger, which is what every transactional seed is |
| channel | VARCHAR(32) NOT NULL | one template per channel; a rule names a set |
| subject | VARCHAR(300) NULL | email only, and it interpolates. NULL is how a non-email template says it has none |
| blocks | MEDIUMTEXT NOT NULL | a JSON block array, validated + sanitized on write against the `email.*` registry — never raw operator HTML |
| text_body | MEDIUMTEXT NULL | an authored plain-text part that **replaces** the generated one; NULL = generated from each block's `toText` |
| status | ENUM('draft','published') DEFAULT 'draft' | |
| protected | TINYINT(1) DEFAULT 0 | editable, not deletable — the `pages.protected` flag, for the same reason: the system breaks without a password-reset body |
| seed_key / seed_version / customized | VARCHAR(96) NULL / INT NULL / TINYINT(1) DEFAULT 0 | the "ship a better default without stealing an operator's work" mechanism — see below |
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(trigger_id, channel, status)`, `INDEX(seed_key)`.
**The three seed columns are one mechanism, and the guard lives in SQL.** On boot the seeder runs an
`INSERT IGNORE` per shipped template and, when the row already exists, a single
`UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A read-then-write would leave a
window in which a concurrent boot overwrites an edit an operator made a moment earlier; putting
`customized = 0` in the UPDATE's own WHERE closes it. (MariaDB's `ON DUPLICATE KEY UPDATE` cannot carry
a WHERE, which is why this is two statements rather than the upsert ENGAGEMENT.md §4.6.1 sketches.) A
customized row whose shipped default has moved on is **surfaced**, never applied.
**A missing or unusable row renders the shipped default rather than nothing.** `renderByKey` falls back
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
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`,
`email.button`, `email.divider`, `email.image`, `email.itemList`) are **siblings, not one registry**.
Three reasons, in order of what they cost if ignored:
1. **Email blocks render on the server.** A page block carries `schema` / `sanitize` / `cacheTTL` and is
drawn by React in `client/src/blocks/`; a mail body is a string this process produces, so an email
definition carries `toHtml` and `toText`. `registerBlock` freezes a fixed field set and would drop
both silently.
2. **One registry would be one namespace.** The page registry's only server consumer is
`pages.model.js`; putting `email.heading` in that Map makes a CMS page containing an email block
validate and save, with nothing on the client able to draw it.
3. The entry shapes differ — `cacheTTL` and `container` mean nothing to a mail body, a renderer nothing
to a cached page block.
What *is* shared is shared by binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
(`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`) are factories the two
registries each bind. ENGAGEMENT.md §4.4's "do not build a second editor" is honoured where it is about
the editor — Phase 5b drives the `email.*` family through the existing block/prop-panel machinery.
### Template variables — the token grammar (engagement phase 5a)
`{{ name }}`, a bare declared variable name, and nothing else: no filters, no conditionals, no loops, no
dotted paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is why
the grammar needs no loop. Three consequences worth knowing before authoring one:
- **Interpolation is HTML-escaped in the HTML part and raw in the text part.** There is no raw-HTML
variable type (§4.6.2) — a module supplies data, not markup.
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
about where it points; a substituted value that is not http(s)/same-origin loses its href and renders
as inert text rather than as a link a reader has no reason to distrust.
- **Presentational conditionals live at the call site**, not in the template. `mailer` computes
` for the account “Darrow”` with a ternary and passes the *result* as a variable, whose declared
`example` shows exactly what it produces.
Four **ambient** variables — `siteName`, `siteUrl`, `logoUrl`, `year` — are available to every template
and are merged **over** whatever a caller passes. A caller supplies the message; the deployment supplies
its identity, and letting a caller override it would mean mail that claims to be from somewhere else.
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9) ### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
@@ -837,7 +1135,12 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) | | POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices | | GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | | GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| 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/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}` | | 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 **Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
@@ -995,7 +1298,8 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all | | GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them | | GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result | | GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams | | GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| POST · GET | `/engagement/unsubscribe/:token` | one-click unsubscribe (engagement phase 6, [`ENGAGEMENT.md`](ENGAGEMENT.md)). **The only write in this tier and the only routes with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC naming a **channel and a scope**, and its whole capability is "turn that channel off for that scope, for one account": it reads nothing, cannot turn anything back on, and names no other scope. POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, scope) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to unsubscribe people who asked for nothing |
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push | | POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. | | — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
@@ -1068,6 +1372,21 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` | | POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted | | DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
| GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 | | GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
| GET | `/engagement/audiences` | every declared audience a rule may be pointed at, with its params and ceiling. The `resolve` function is **never served** — an audience answers with user ids on the server side only, so a module still cannot enumerate addresses. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| GET | `/engagement/channels` | every registered delivery channel a rule may send on, with its `defaultMode`. From the delivery-channel registry, so the rule editor offers exactly the set the save path checks and a module-registered channel appears with no client release. `adminOnly` |
| GET | `/engagement/audience-preview` | `?audience=` **or** `?audienceSegmentId=`, plus an optional `?triggerId=`. Runs the **same resolver the engine runs** and answers `{count, capped, ceiling, dormant, reason, permitted}` — a **count only**, never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true at the 5000-row audience bound, where the count is a floor and not a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries; `permitted` is whether the trigger's G24 ceiling allows the reach just counted. `adminOnly` |
| GET · POST | `/engagement/rules` | list every rule annotated with **dormancy** (and why), or create one. A new rule must name a currently-registered trigger, arrives `enabled: 0` (§7.1 Q3) and has its audience checked against that trigger's ceiling. `400` carries every problem in `errors[]`, not just the first. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.5 |
| GET · PUT · DELETE | `/engagement/rules/:id` | read, replace or delete one rule. The **trigger is not updatable** — a rule's cooldowns, its pending outbox rows and its send-log history are all about one trigger id, and re-pointing it would silently re-attribute all three. An existing rule may keep naming an unregistered trigger, so a dormant rule stays editable. `DELETE` cascades its cooldowns and pending outbox rows; `engagement_sends` carries **no foreign key**, so the send log outlives the rule |
| PATCH | `/engagement/rules/:id/enabled` | flip that column and no other, **without re-validating the rule**. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating `PUT` refuses to save. Turning one on is safe unvalidated because the engine re-checks the ceiling at send time |
| GET · POST | `/engagement/segments` | list every saved audience segment annotated with dormancy (and which audience ids are missing), or save a new one. The stored `ceiling` is **derived** as the narrowest in the expression and is never taken from the caller; `not` is legal only as a child of `and`; two incomparable ceilings have no meet and the composition is refused rather than guessed. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| PUT · DELETE | `/engagement/segments/:id` | update (re-deriving the ceiling) or delete. **`409` while any rule still points at it**, with the count in the message. No foreign key does this on purpose: `CASCADE` would delete an operator's rules and `SET NULL` would silently fall each rule back to its plain `audience` column, which reaches a *different set of people* |
| GET | `/engagement/templates` | every message template, each annotated with three separately-meaningful warnings: `dormant` (pinned to a trigger no installed module declares, so its variables cannot be checked and nothing will send it), `triggerBehind` (the module is installed but its declaration has moved on past the version this template was authored against) and `seedBehind` (a newer shipped default exists and was **not** applied, because a person had edited this row). `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.6.2 |
| GET | `/engagement/templates/:id` | one template plus `variables` — the palette the editor offers, resolved from the trigger declaration or, for a template tied to no trigger, from the shipped seed, merged with the ambient variables every template may use. Served with the row so the editor never guesses what is legal |
| PUT | `/engagement/templates/:id` | edit any template, **including a shipped default, in place**: the save sets `customized = 1`, which is what stops the next seed bump from taking the edit back. `key` and `channel` are **immutable and the attempt is refused rather than ignored** — `mailer` renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of the route: a token (or an `email.itemList` naming a bare variable) referencing something the trigger does not declare is refused **with the variable named**, and a `published` template whose plain-text part renders empty is refused — checked by *rendering* with the declared examples, because whether a text part exists depends on what each block's `toText` does with these props |
| POST | `/engagement/templates/:id/duplicate` | the **only** way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a `draft`, is never `protected`, and **inherits the source's `seed_key`** — that is what carries its variable palette, not bookkeeping: a seedless, triggerless copy would resolve to the ambient variables alone and be refused for the tokens it was copied with. `409` on a taken key |
| DELETE | `/engagement/templates/:id` | `409` for a `protected` template — the system breaks without a password-reset body, so those are editable and not deletable — and `409` while any rule's `template_keys` points at the key, **naming the rules**. The same answer a segment in use gets, for the same reason: the alternative is a rule that silently stops producing mail |
| POST | `/engagement/templates/:id/preview` | renders the body **in the request**, not the stored row, using each variable's declared `example` — which is why `example` is a required part of a trigger declaration rather than documentation. A `POST` that writes nothing: an editor that could only preview what was already saved would make saving the way to find out whether a change was right. Returns both parts as JSON strings; the client renders the HTML inside `<iframe sandbox="" srcdoc>` with **no `allow-scripts`**. Serving it as a document from this origin would run operator-authored HTML under the site's own CSP with access to its cookies |
| POST | `/engagement/templates/:id/test-send` | sends what is on screen, saved or not, through the configured transport, and records the attempt in `engagement_sends` **including when it fails** — the outcome an operator most needs a record of. `trigger_id` is `NOT NULL` and a transactional template has no trigger, so the row is logged under the synthetic **`core.admin.test-send`**, which is deliberately not a registered trigger. It does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user. `409` when mail is unconfigured, `502` when the relay refuses |
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1–200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment | | GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1–200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said | | GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact | | GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
@@ -1234,6 +1553,19 @@ must land an admin on a screen that says "unconfigured", not a 500 that takes th
must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
additive-only discipline. additive-only discipline.
**What each sender still owns is its recipient, its headers and its failure contract — not what it
says.** Engagement Phase 5a moved every subject and body out of `mailer.js` into `engagement_templates`
rows (§4.6.1); the file's five senders call one seam, `engagement/templates.renderByKey`, which falls
back to the shipped seed when the row is missing or unusable. Two consequences:
- **Mail is now `multipart/alternative`.** Nothing here had an HTML part before. The **text part is
byte-identical** to what the deleted literals built — pinned by `test/emailTemplates.test.js`, whose
expected strings *are* those literals — and the HTML part is new, table-based and inline-styled.
- **Subjects now resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
`settings.getInstanceName()` — the admin-set `site_title`, falling back to `BRAND_NAME`. On a
deployment that never set a site title nothing changes; on one that did, the subject finally says
what the site calls itself.
**No phone-home.** No transport may ship a default host, port, endpoint or sender **No phone-home.** No transport may ship a default host, port, endpoint or sender
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is ([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is

File diff suppressed because it is too large Load Diff

View File

@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
would be a refactor away from being used. would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus **As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
places:
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
> record of what a row meant before the migration. **The two preference columns are unchanged and are
> still the authority for Team notifications** — the engine reads them rather than replacing them.
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the - **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams` per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
@@ -1608,7 +1613,7 @@ places:
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public. "is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused ### 6.4 Email — the third sink
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
running neither currently gets no notification that someone replied to their own thread** — which is running neither currently gets no notification that someone replied to their own thread** — which is
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
same event**, not a fourth pipeline. same event**, not a fourth pipeline.
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off. > **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge > line still describes what a recipient receives; what changed is who decides to send it. The design of
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an > record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
unguessable topic. It carries the thread title, an excerpt and a link; never the full post. > the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
> every other doc that touches a contract it does not own.
#### What moved, and what did not
`teamNotify.js` had three sinks. **One moved:**
| Sink | Where it lives now |
| --- | --- |
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
digest, `notify.event` for the two roster events and for announcements).
#### The four properties this section always claimed, and where each one lives now
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
off.** All three still hold, and the first is now explicit rather than incidental:
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
the event says who it is about.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
topic. It carries the thread title, an excerpt and a link; never the full post.
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
silences every channel, and `email_mode` decides email and says nothing about the others. The
alternative — intersecting it with the newer per-stream preference — would have silenced every
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
and as of Phase 6 there is a second gate above it, below.
#### **Team email is OFF until an operator turns it on**
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
off; the release note says it too. Push and the Discord bridge are unaffected.
#### The digest
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
pending-items table — and, in Phase 6, over the engine's own outbox:
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
2. a post a moderator hid after it was written is simply not in the query;
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
they are not emailed content they can no longer read.
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
notifications every time the mail provider had a bad minute.
**What Phase 6 changed is the state, not the design.** The stamp moved from
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
properties above. The worker is also gated on an enabled email rule, so switching Team email off
switches off both halves of it rather than the instant half only.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is - **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`. `team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default > **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is > would start mailing every member of every Team the moment an operator connects a mail transport.
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already > Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
> has. > already has.
- **The digest computes at send time and keeps no queue** (as built). The only state is - **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three `team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
properties fall out, and the third is why it was chosen over a pending-items table: a deployment rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator argument survives as the default rather than as a sink the code declines to call.
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no #### One-click unsubscribe
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
eat a day of somebody's notifications every time the mail provider had a bad minute. site shows.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink. > **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken. > absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client > must mean what clicking it once meant. The capability is deliberately the narrowest that does the
writes the preference the site shows. > job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property > anything back on, and names no other scope. `version` is the only revocation a stateless design can
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so > offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The > needed rather than after.
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one** >
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the > **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at > narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
> once — and it exists before it is needed rather than after. > email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
> > token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the > and read as the email channel for that Team, which is a reading of what they always meant.
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The >
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC > **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and > site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to > `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid > 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no > not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
> session behind it. > nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
> session behind it.
>
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there. is already being built there.