diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ae06cec..2e66ec3 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -180,13 +180,16 @@ 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 + engagement.router.js (14) /admin/engagement — adminOnly, + the declared event catalog (three + table-free reads, served from the + module registries) plus the rules + and audience segments an operator + configures over it, and the + count-only reach preview. Templates and the send log land under this same prefix in ENGAGEMENT.md - Phases 4 and 5 + Phase 5 email.router.js (4) /admin/email — outbound mail: transport + credentials + send test — adminOnly. The two @@ -1224,6 +1227,13 @@ file a route sits in — that is the property the route manifest freezes. | 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 | `/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 | `/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 | diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 0b240a7..9fe9151 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -1,9 +1,9 @@ # The Engagement System — findings and plan -**Status:** design of record. **Phases 1, 1a, 1b, 2, 3 and 4a are built** (Phase 1: website#165 + +**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a and 4b are built** (Phase 1: website#165 + docs#178, with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b: website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 + docs#182; Phase 4a: -website#170 + docs#183); everything from Phase 4b on is still design. +website#170 + docs#183; Phase 4b: website#171 + docs#184); everything from Phase 5 on is still design. The scope decisions below are settled; **seven of the eight questions in §7.1 are answered** — Q1, Q3, Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question), and **Q2 and Q4 on 2026-08-29 at the start of Phase 4**. Q1's answer added a @@ -1173,6 +1173,28 @@ trigger declaration — G24's reasoning applies unchanged, and both are cheap no into a rule model that already has rows in it. The composition UI belongs with the rules screen in **Phase 4**. `module-uo`'s first real audiences come in **Phase 11**. +**The composition UI, as built in Phase 4b.** Its own nav entry, **Admin → Engagement → Audiences**, +rather than a tab of the rules screen: a segment is reusable, independently edited, and deleted by a +refusal that has to say how many rules still point at it. The composer offers one top-level +all-of/any-of over audience leaves, each leaf rendering the `params` its declaration names (an `int` +param is sent as a number, because the server type-checks it). Three things it does that follow from +the rules above rather than from taste: + +- **“Exclude” appears only under “all of”.** `not` is legal only as a child of `and`, so switching the + group to “any of” drops the exclusions rather than composing a tree the server will refuse — and + says so, because keeping them and failing at save would be worse than either. +- **The stored ceiling is displayed, never chosen.** The list shows what each saved audience *may* + reach beside what it *does* reach right now (on demand, as a count), which is the pair an operator + needs in order to understand a refusal. +- **A tree nested deeper than the composer renders is not editable, and says so** rather than being + flattened — the same posture the rule editor takes with a nested condition. The stored grammar + allows more nesting than any screen should offer. + +The rule editor's half is the other side of the same rule: it offers only the audiences and saved +segments the trigger's ceiling **permits**, derived from the `permits` list `GET /admin/engagement/triggers` +serves. That is an affordance and not a boundary — the client must not hold a second copy of the +lattice — and the save re-checks every time. + ### 5.2 The seam, end to end ``` @@ -1294,7 +1316,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i | **2** Trigger registry | `website/MODULE_API.md` §1.1 (**1.7.0** + correct the stale "1.6.0 has only ever been on `edge`" paragraph), §2.3 (`ctx.events`, `ctx.inbox`), §2.4 (`registerEventTriggers`, `registerAudiences`), the dormant-rule note (landed as §6.8) · `website/ENGAGEMENT.md` §4.3 and §5.1a kept true · `BACKEND_DESIGN.md` route table | **Both deferred to the Phase 13 cutover window, deliberately — see Phase 2's as-built.** `Integration-kit`'s `ci/core-ref.json` pins a **`main`** sha, so the equality check stays green (and must stay green) for the whole `edge` period; `runicgateway.com`'s `checkFacts.mjs` *fetches* from `main`, so setting `platform.json.moduleApi` → 1.7.0 now would turn that repo red immediately | | **3** Channel preferences | `website/BACKEND_DESIGN.md` route table · `android/PLAN.md` §11 | — | | **4a** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built, and the two §4 defects it corrects) · `BACKEND_DESIGN.md` table inventory | — | -| **4b** Rules screen | `website/BACKEND_DESIGN.md` route table · `website/ENGAGEMENT.md` §5.1a composition UI | — | +| **4b** Rules screen ✅ | `website/BACKEND_DESIGN.md` route table (the twelve routes, incl. the `PATCH …/enabled` argument and the count-only preview) · `website/ENGAGEMENT.md` §5.1a composition UI | Landed with the phase (docs#184) | | **5a/5b** Templates + editor | `website/ENGAGEMENT.md` §4.6 · a template-authoring section in `BACKEND_DESIGN.md` or its own doc | **`runicgateway.com`**: a new admin docs page for the template editor | | **6** Email channel + Teams migration | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing | **`runicgateway.com`**: `administration/teams.mdx` notification section | | **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel | @@ -1767,7 +1789,7 @@ its five siblings), audience resolution, condition evaluation, delay and cancell save-path validation the admin surface will call. **No HTTP surface at all** — provably done when a fired trigger produces an outbox row and a send-log entry with no UI in the picture. -#### 4b — the admin surface +#### 4b — the admin surface ✅ Admin → Engagement → Rules, the §5.1a segment composition UI, and the routes underneath them. Q4's answer places it in **its own top-level nav group** (below), so 4b also creates the group that @@ -1915,6 +1937,99 @@ first shipped a mailer. On a real deployment the path is unreachable anyway — --- +#### As built — 4b (2026-08-29) + +The admin surface over the Phase 4a engine: **Admin → Engagement → Rules**, **Admin → Engagement → +Audiences**, the twelve routes under them and the reach preview. Nothing in the engine changed; what +changed is that an operator can now reach it, which is what makes every rule in 4a's model a rule a +person will actually meet. + +**Four decisions the org lead settled before any code**, all four the recommendation: + +| | Question | Decision | +|---|---|---| +| Segments | a fifth nav entry, a sub-tab, or inline in the rule editor | **its own nav entry, "Audiences"** — a segment is a reusable object with its own list, its own edit and a delete that is *refused with a count*, and none of those has anywhere to live inside a screen about rules | +| The switch | a `PATCH …/enabled` route, or a full `PUT` | **its own route**, writing that column and no other | +| Preview | count only, count plus a sample, or none in 4b | **count only, on demand** | +| Delete | hard delete with a confirm, or disable-only | **hard delete** — the send log survives it | + +**The enable switch is the decision with the most content in it, and it is a correctness argument +rather than a convenience one.** A `PUT` re-validates, and validation is against the registries *as +they are now*. So the rules a re-validating toggle cannot switch off are exactly: a rule whose module +has been uninstalled, a rule naming a channel that is gone, and a rule whose trigger has since +**narrowed** its ceiling underneath a saved audience. Those are the three rules an operator most +urgently wants stopped. `PATCH …/enabled` writes one column and always works. Switching a rule *on* +without re-validation is safe for a different reason: `audiences.permitted` runs again at send time, +so an enabled-but-no-longer-permitted rule resolves to nobody rather than to the wrong people. + +**The reach preview calls the engine's own resolver**, `audiences.resolveForRule`, rather than a +second query that agrees with it today — a preview built out of its own SQL can be wrong about the +one thing it exists to say. It answers a **count and nothing else**: not a sample, not names. The +resolver's output for a module-declared segment is a set of players derived from game data, and an +editor that rendered them would be a user-enumeration surface reached from a screen about mail +scheduling. Three fields exist because the bare number would otherwise be a lie: + +- **`capped`** — every audience query is bounded at `MAX_AUDIENCE` (5000), so a count landing exactly + on the bound is a floor. The screen says "at least 5000", never "5000". +- **`reason`** — an `owner` audience resolves per event from an id the event carries, so it has no + advance answer. It reports 0 *with the reason*, because a bare 0 reads as "nobody". +- **`permitted`** — whether the trigger's G24 ceiling allows the reach just counted. Without it the + editor shows a healthy number beside a save the server will refuse, which reads as a bug in the + save rather than as the ceiling doing its job. + +**Two defects found by walking it against a live server**, neither of which any test in the tree +would have caught, and both of them in Phase 4a's code rather than 4b's: + +1. **A rule pointing at a *dormant* segment was reported as healthy.** `listAnnotated` asked only + whether the segment ROW still existed (§7.3's case: the segment was deleted). The other shape of + the same failure is §5.1a rule 4's: the segment row is exactly where it was, and every audience in + it belongs to a module that has been uninstalled. Both leave the rule reaching nobody; only one + leaves nothing behind. Uninstalling a module under an enabled rule produced a rule the screen + showed as **on, healthy and firing**, which is the one thing this screen exists not to do. The + walk over the expression now lives in `engagement/segments.js` as `missingAudiences`, and both the + segment list and the rule list ask it, so the two cannot disagree about what dormant means. +2. **"1 rule still use this segment."** The delete refusal pluralised the noun and not the verb. It is + a one-word fix and it is in the sentence an operator reads at the moment they are being told no. + +**Three smaller things the screens decided:** + +- **A rule's trigger is fixed once the rule exists.** `engagementRules.db.update` never carried + `trigger_id`, and 4b is where that becomes a stated rule rather than an omission: a rule's cooldown + rows, its pending outbox rows and its send-log history are all about one trigger id, and + re-pointing the rule silently re-attributes all three. The editor renders the field read-only and + says why; changing the trigger means a new rule. +- **A condition tree the editor cannot render is shown, not flattened.** The editor offers the flat + half of the grammar — one and/or over a list of comparisons, every operator a dropdown narrowed to + the picked variable's declared type. `A AND (B OR C)` flattened to `A AND B AND C` fires on + different events and the operator would have no way to know the save had done it, so such a rule + opens read-only with its JSON visible and one honest choice: leave it, or clear it and start again. +- **Literals are coerced to the type the trigger declared**, in the client, before the save. Every + value in an HTML input is a string and the server rightly refuses `{cmp: 'gt', value: "5"}` against + an `int`. A value that does not parse is passed through **unchanged** rather than becoming `NaN` or + `false`, so the refusal names the variable instead of the rule saving cleanly having compared + against a number nobody typed. + +**Where the logic lives.** `client/src/lib/engagementRules.js` — plain JS, 25 tests — holds +everything the two screens *decide*: the form↔payload mapping, which audiences a trigger permits +(derived from the `permits` list the server sends, never a second copy of the lattice), the reach +sentence, the `not`-placement check, the condition round trip. None of it is a boundary; the server +decides and the engine re-checks. It is in a `.js` because the test runner cannot reach a `.jsx`, and +because a client-side copy of a security rule is a copy that drifts — so the client holds the +*affordance* (do not offer what will be refused) and the server holds the answer. + +**The rig that made the live walk possible, and worth reusing.** Core declares **no audiences** — it +knows no game vocabulary — and `module-uo` 0.3.0 declares none either, so on a stock local stack the +whole segment half of this phase is untestable. A ~20-line throwaway module dropped into +`website/modules/rig/` (a `module.json` plus a `server/index.js` calling `api.registerAudiences`) +declares four audiences at three different ceilings and makes the entire §5.1a arithmetic walkable: +`OR` taking the tighter ceiling, two incomparable ceilings being refused rather than guessed, +`members AND NOT staff` being allowed, a parameterised audience, the 409 on a segment in use, and — +by deleting the directory and restarting — dormancy. `modules/*` is gitignored, and the module needs +its ids **namespaced to its own id** (`rig.roster.governors`, not `roster.governors`); the registry +refuses anything else, and it also refuses an id with no dot in it at all. + +--- + ### Phase 5 — Templates: the seeded set, then the editor Two slices, landing in this order **on purpose** — the seeded set has to exist before the editor, so the