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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user