docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)

MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on
`edge`" paragraphs are now marked historical rather than current, which is the
correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the
in-place-amendment rule those paragraphs invoke no longer applies and every
addition from here takes a version of its own.

§2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until
Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and
`registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md
§7.3 asked this document to hold: a rule, a template and an audience outlive
the module that declared them, so `trigger_id` is a plain VARCHAR with no
cascade and an unregistered id shows DORMANT rather than erroring or being
auto-deleted. The failure that prevents is specific — an id that stops
resolving must never silently become a send to a different set of people.

ENGAGEMENT.md records Phase 2 as built, and three things it did not previously
say:

§5.1a now DEFINES the ceiling lattice. The document named "narrowest" and
"tightest" throughout and never said what narrower meant, and the obvious
reading is a security defect: under a flat total order a `staff`-ceilinged
trigger permits an `owner` audience, i.e. a rule that mails cheat detection to
the player it detected. It is containment, not size, and incomparable ceilings
have no bound at all.

§7.2 is answered — ONE namespace, against the recommendation in its own text —
with the two knock-on effects that only appeared once it was built (a relaxed
id grammar, a shared legacy allowlist) and the risk that did not materialise
(the push catalog is untouched, so the shipped app sees no change).

§7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after
Phase 9.

§4.3 is kept true to what shipped: `audience` and `ceiling` are two fields
rather than one, `'computed'` is gone from the audience vocabulary, and
`example` is enforced rather than encouraged.

§6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window
work, not this window: the integration kit pins a website `main` sha and
runicgateway.com FETCHES its facts from `main`, so doing either now would turn a
green repo red for the whole edge period — for the kit, against the explicit
rule in its own pin ("written against what shipped, never what is in flight").

BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the
adminOnly line; api-route-inventory.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 06:40:48 -05:00
parent 88de7e3331
commit 58c0797fc2
4 changed files with 384 additions and 22 deletions

View File

@@ -180,6 +180,13 @@ server/
from a manifest URL, enable,
disable, uninstall, purge, restart
and the source allowlist
engagement.router.js (2) /admin/engagement — adminOnly,
the declared event catalog. Read
only and table-free: it serves the
module registries. Rules, templates
and the send log land under this
same prefix in ENGAGEMENT.md
Phases 4 and 5
email.router.js (4) /admin/email — outbound mail:
transport + credentials + send
test — adminOnly. The two
@@ -1011,7 +1018,7 @@ These rows are configuration that happens to need a login.
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
the whole gate. The ops/config capabilities — `modules`, `email`, `discord-bot`, `settings`, and
the whole gate. The ops/config capabilities — `modules`, `email`, `engagement`, `discord-bot`, `settings`, and
`PUT /site-mode` — are `adminOnly`. There is no residual file: every admin route is declared in a
capability router.
@@ -1059,6 +1066,8 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
| 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 |
| 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 | `/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 |
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |