|
|
|
|
@@ -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
|
|
|
|
|
|