docs(website): the engagement admin surface as built (Phase 4b) #184
@@ -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 |
|
||||
|
||||
@@ -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,138 @@ 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.
|
||||
|
||||
**Six more defects came out of driving the two screens in a browser**, after the API walk above had
|
||||
already found the two in Phase 4a's code. None of them is visible from a test or from `curl`, and two
|
||||
of them cost an operator something real:
|
||||
|
||||
1. **The Audience dropdown rendered empty before a trigger was chosen.** `audienceChoicesFor(null)`
|
||||
answers `[]` — correctly, because without a trigger there is no ceiling and therefore nothing it
|
||||
may legitimately offer. But a `<select>` with zero options reads as a control that is *broken*,
|
||||
not one that is waiting. It now carries "Choose a trigger first…" and is disabled.
|
||||
2. **A `members` audience with no saved audience reaches nobody, and only the preview button said
|
||||
so.** This is the design (§5.1a: `members` is the ceiling for "a module-declared list", and
|
||||
without a list core cannot guess) — but it is also the **default** the instant an operator picks
|
||||
any `members`-ceiling trigger, which turns a documented property into a trap: the rule saves, gets
|
||||
switched on, and mails nobody, with nothing on the screen saying so unless the operator happens to
|
||||
press Preview. The editor now says it inline, before the save, and stands down once a preview has
|
||||
answered the same question more precisely.
|
||||
3. **The composer offered "exclude" on the only row**, which builds an `and` whose every child is a
|
||||
complement. The server refuses that correctly — "has nothing but complements, there is no set to
|
||||
exclude from" — but only after a save. It is one checkbox away at all times, so the composer now
|
||||
refuses it inline, in the operator's words.
|
||||
4. **The template-key input truncated its own placeholder** and the text said "optional until Phase
|
||||
5", which is a sentence about this document rather than about the operator's deployment.
|
||||
5. **"segment" leaked into a screen that says "saved audience" everywhere else.** The API, the schema
|
||||
and this document should keep saying *segment* — one word for one table — but an operator meets
|
||||
the concept under a heading that reads "Audiences", and a sentence that switches vocabulary
|
||||
mid-screen reads as a sentence about something else. Translated at the point of display only.
|
||||
6. The composer repeated its "AUDIENCE" heading above every row; the heading belongs to the group.
|
||||
|
||||
**And one thing that is worth knowing but is not a defect in this phase:** 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 still points at a filename that no longer exists;
|
||||
core's bundle 404s, `window.__rg` is never published, and `modules/uo/entry.js` throws the
|
||||
"core did not publish its shared dependencies" error from `MODULE_API.md` §3.1 into a blank page.
|
||||
**Restart the server after every client build.** The error names core, and core is not at fault.
|
||||
|
||||
**A `window.confirm` freezes browser automation.** The two destructive actions here use it, matching
|
||||
`InvitesAdmin` and `ModulesAdmin`, and that is the right call for the product — but a native dialog
|
||||
blocks CDP entirely, so a delete cannot be driven from a script and a session that opens one is stuck
|
||||
until a human dismisses it. Exercise those two paths over the API instead.
|
||||
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user