diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 7fed015..6781145 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 | diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 95239ae..5fd7eab 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -1,12 +1,13 @@ # The Engagement System — findings and plan -**Status:** design of record. **Phases 1, 1a and 1b are built** (Phase 1: website#165 + docs#178, with -website#164 as its prerequisite; Phase 1a: website#166 + docs#179); everything from Phase 2 on is still -design. The scope decisions below are -settled; **four of the eight questions in §7.1 were answered by the org lead on 2026-08-28** — Q1, Q3, Q5 -and Q7, and Q1's answer added a whole phase (**Phase 1b**, unique email addresses). Q2, Q4, Q6 and -Q8 remain open and block Phases 4, 5b, 2 and 8 respectively. Per CLAUDE.md § Conventions, no implementation -starts without the org lead's approval of the phase it belongs to. +**Status:** design of record. **Phases 1, 1a, 1b and 2 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); everything from Phase 3 on is still design. The scope decisions below are settled; **six of +the eight questions in §7.1 are answered** — Q1, Q3, Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at +the start of Phase 2, which also settled §7.2's namespace question. Q1's answer added a whole phase +(**Phase 1b**, unique email addresses). **Q2, Q4 and Q8** remain open and block Phases 4, 5b and 8 +respectively. Per CLAUDE.md § Conventions, no implementation starts without the org lead's approval of +the phase it belongs to. **Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover (Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have @@ -736,8 +737,11 @@ api.registerEventTriggers([{ id: 'uo.house.idoc_warning', // .-prefixed, checked by the existing namespaced() label: 'House approaching collapse', description: 'A player house dropped into a late decay stage.', + kind: 'event', // 'event' | 'scheduled' (G25); default 'event' subjectKey: 'house', // which variable identifies the subject, for cooldowns - audience: 'owner', // 'owner' | 'subscribers' | 'computed' + audience: 'owner', // the DEFAULT a rule is created with + ceiling: 'owner', // the widest a rule may EVER be given (G24, §5.1a) + version: 1, // bumped on a rename or a type change variables: [ { name: 'character', type: 'string', required: true, example: 'Darrow' }, { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, @@ -769,6 +773,17 @@ Four properties, each with a reason: prebuilt `engagement-triggers.json` in its bundle**, for the same reason it ships a prebuilt swagger fragment: core never has its sources. +**Two corrections from building it (Phase 2).** `audience` and `ceiling` are two fields, not one: the +first is the default a rule is created with, the second is the maximum it may be raised to, and the +registry refuses a default that the ceiling does not permit. And `'computed'` is gone from the +`audience` vocabulary — a rule pointing at a composed segment says so by naming the segment, so a +third pseudo-value that means "look elsewhere" would be a value the ceiling arithmetic cannot compare. +Both fields take the same six-value vocabulary (§5.1a). + +**`example` is enforced, not encouraged.** A variable without one is refused at registration. Property +3 above is right and a soft version of it is worth nothing: the moment one variable has no example, +previewing that template needs a live game event again. + **Versioning.** A variable's *addition* is additive and needs nothing. A **rename or a type change** breaks every stored template referencing it, so a trigger declaration carries `version`, bumped like a block's prop-schema version, and templates store the trigger version they were authored against. A template @@ -1105,6 +1120,41 @@ api.registerAudiences([{ to the empty set and the rule referring to it shows as dormant — never an error, never auto-deleted, never a silent send to a *different* set of people because the id stopped resolving. +**The lattice itself — settled in Phase 2, because this document named it everywhere and defined it +nowhere.** "Narrowest" needs an ordering, and the obvious one is wrong: + +``` +everyone anyone at all, signed in or not + └── authenticated any signed-in user + ├── subscribers signed-in users who opted into this id + ├── members a module-declared list (a Team, the governors) + ├── staff admin / editor / moderator + └── owner the one user the event is about +``` + +**It is containment, not size.** The tempting model is a flat total order — `self < owner < staff < +members < authenticated < everyone`, compared with `<=` — and under it a trigger ceilinged at `staff` +also permits `owner`, so a rule could mail `uo.cheat.detected` to the player it detected. Fewer people +is not less exposure; the question is always *which* people. + +So the four leaves are **mutually incomparable**, deliberately: `owner` is not a subset of +`subscribers` (an owner need not have subscribed), `staff` is not a subset of `members`, and no pair +of them has a common descendant. Three consequences: + +- `permits(ceiling, candidate)` is "walk `candidate` up the tree and see whether you reach `ceiling`", + and it **fails closed** on anything it does not recognise. +- `meet(a, b)` — the narrower of two — exists only when one is an ancestor of the other. Two + incomparable ceilings have **no bound at all**, and the composition is REFUSED rather than resolved + to a guess. Union-widens is the intuitive implementation and it is the wrong one; picking a side + when there is no answer is the second-wrong one. +- The direction of the boolean operator is irrelevant. `A AND B` takes the tighter ceiling exactly as + `A OR B` does, because a ceiling states what an expression is *allowed* to reach, not what it will + resolve to. An empty composition has no bound and is `null`, never `everyone`. + +The vocabulary travels with the trigger catalog (`GET /admin/engagement/triggers` serves it), so the +rule editor never offers an audience the server will refuse. The server is still the boundary: Phase 4 +re-checks every rule save. + **Where it lands.** The registration surface and the ceiling arithmetic belong in **Phase 2**, with the trigger declaration — G24's reasoning applies unchanged, and both are cheap now and expensive to retrofit into a rule model that already has rows in it. The composition UI belongs with the rules screen in @@ -1228,7 +1278,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i | **1** Remove Gmail OAuth2, SMTP | `website/BACKEND_DESIGN.md` §7 **rewritten** (not amended — it documents Gmail OAuth2 as *the* mechanism); route tables lose `/admin/email/connect/*` | `website/README.md` + `.env.example` wherever they point at Connect Gmail · **`runicgateway.com`**: `notifications-and-email.mdx` (its "There is no SMTP option" aside is now false), `configuration.mdx:62`, `troubleshooting.mdx:101`, `system-architecture.mdx:117` · a release note | | **1a** One self surface | `website/BACKEND_DESIGN.md` — the `/auth/me` prose and the `/player`+`/admin` router trees · `api-route-inventory.json` regenerated · `website/ENGAGEMENT.md` this phase | `android/PLAN.md` §6.4/§8 — the "routes stay for web back-compat" note is now false · `website/API_V2_PLAN.md` historical tables get a pointer | | **1b** Unique email | `website/BACKEND_DESIGN.md` — the `users` table (the "not unique" note is now false), the new change/verify routes, and the de-dupe migration as an operator-visible upgrade step | `website/README.md` upgrade notes · a release note naming the admin report and the verification-gate default | -| **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`), §7.3's dormant-rule note · `website/ENGAGEMENT.md` §4.3 kept true | `Integration-kit`: `ci/core-ref.json` re-pinned (the equality check goes red **on purpose**) + chapter 2 gains a "registering a trigger" section · **`runicgateway.com`**: `platform.json.moduleApi` → 1.7.0 | +| **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 | — | | **4** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built) · `BACKEND_DESIGN.md` table inventory | — | | **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 | @@ -1255,8 +1305,13 @@ top. No code. **Acceptance:** merged into `docs/`; §7.1's open questions each answered or explicitly deferred before the phase that depends on them starts. **Q1, Q3, Q5 and Q7 were answered on 2026-08-28** (§7.1), which -unblocked Phases 1, 2 and 4 and added Phase 1b. Still outstanding: **Q6 before Phase 2**, **Q2 before -Phase 4**, **Q4 before Phase 5b**, **Q8 before Phase 8**. +unblocked Phases 1, 2 and 4 and added Phase 1b; **Q6 and §7.2 were answered on 2026-08-29**, at the +start of Phase 2 and before a line of it was written. Still outstanding: **Q2 before Phase 4**, **Q4 +before Phase 5b**, **Q8 before Phase 8**. + +This acceptance criterion has now paid for itself twice. Q6 and §7.2 were both tagged "decide in +Phase 2", and §7.2's answer went **against** the recommendation in the text — which is exactly the +outcome that is cheap to act on before the registry exists and expensive afterwards. --- @@ -1482,7 +1537,7 @@ reconstructing "was this install fresh?" afterwards is guesswork. --- -### Phase 2 — The trigger registry and the variable contract +### Phase 2 — The trigger registry and the variable contract ✅ `api.registerEventTriggers` + `ctx.events.emit` in `modules/registries.js` and `modules/loader.js`; `MODULE_API_VERSION` → 1.7.0 on both halves; core registers its own triggers (news, the four Team @@ -1505,6 +1560,94 @@ trigger's ceiling**; `engagement-triggers.json` diffs zero in CI. **Guardrails:** the new manifest `--check` (this is where the "manifest-style guardrail" the brief asks about belongs); `check:modules` proves core's own trigger ids name no game concept. +#### As built (2026-08-29) + +**Three design questions were settled by the org lead before any code**, and two of them the plan had +deferred to this phase on purpose: + +| | Question | Decision | +|---|---|---| +| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle | +| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet | +| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed | + +**One namespace was the more invasive of the two options and it is worth stating what it bought and +what it cost.** It buys `notification_channel_prefs.stream_id` staying single-keyed (§4.5): under two +namespaces its primary key would have needed a `kind` discriminator, and `news.post` would have named +two different things forever. It costs a new rule in `registries.js` — **an id has exactly one owner +across both facets** — enforced in both directions, so a module cannot attach a payload contract to +another module's stream and cannot claim a stream id another module declared a trigger for. Core's +five trigger ids *are* its five stream ids, so the same-owner upgrade case is exercised on every boot +rather than only by a module. + +Three consequences fell out of it that the §7.2 text did not anticipate: + +- **The id grammar had to be relaxed, not just shared.** `STREAM_ID` did not admit `_`, and §4.3's own + worked example is `uo.house.idoc_warning`. Two grammars over one namespace would mean an id that is + legal as a trigger and illegal as the stream it is the same event as. It is a relaxation only — + every id valid before is valid now, and no stored id changes. +- **The legacy allowlist had to be shared too.** The seven grandfathered `uo.*` stream ids are exempt + from the prefix rule for triggers as well, and it cannot be otherwise: under one namespace + `idoc.warning` is a single id, so if `uo` may hold it unprefixed as a stream it may hold it + unprefixed as a trigger. Any other answer means those seven could never gain a payload contract. +- **The push catalog is unchanged.** `allStreams()` still serves the stream facet only, so a + trigger-only id (`uo.house.idoc_warning`) does not appear in the catalog the shipped Android client + reads. A trigger-only id gets email and in-app preferences in Phase 3 and no push toggle, which is + correct — there is nothing to push it to. + +**The ceiling lattice is the one thing this plan named everywhere and defined nowhere**, and getting +it wrong would have been a security defect rather than a rough edge. §5.1a now carries the definition; +the short version is that the tempting flat ordering — self < owner < staff < members < authenticated +< everyone — permits a `staff`-ceilinged trigger to be given an `owner` audience, which is a rule that +mails `uo.cheat.detected` to the player it detected. Fewer people is not less exposure. + +**What landed:** + +- `server/src/modules/ceilings.js` — the six-value lattice, `permits`, `meet`, `meetAll` +- `registerEventTriggers` / `registerAudiences` on the existing `stage()` + `apply()` discipline, with + the cross-facet collision checks and the shared legacy allowlist +- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and + logs in prod; the owner is bound by core and never read from the arguments +- `ctx.inbox.push` — present and **throws** until Phase 7, the shape 1.6.0 settled on +- `config/coreTriggers.js` — core's five, registered through `registerCore()` +- `GET /admin/engagement/{triggers,audiences}` — admin-only, served from the registries, no table +- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json` +- `MODULE_API_VERSION` → **1.7.0** on both halves; `MODULE_API.md` §1.1, §2.3, §2.4 and a new §6.8 + +**Two things §6.0b's Phase 2 row assigns that deliberately do NOT land in this window**, and neither +is a slip: + +- **`integration-kit`'s `ci/core-ref.json`.** The pin names a website **`main`** sha and + `checkCoreApi.js` asserts equality with what that sha declares. 1.7.0 is on `edge`, `main` still + says 1.6.0, so the kit is green and moving the pin now would pin the book to a commit that is still + in flight — which the pin's own prose forbids ("the kit is written against what shipped"). The + re-pin and chapter 2's "registering a trigger" section belong in the **Phase 13 cutover window**. +- **`runicgateway.com`'s `platform.json.moduleApi` → 1.7.0.** `scripts/checkFacts.mjs` *fetches* the + authority from the source repo's `main`. Setting it to 1.7.0 today turns that repo red immediately + and for the whole `edge` period. It is Phase 12's, in the cutover window, exactly as the phase says. + +**Four smaller decisions taken at build time:** + +| | Decision | Why | +|---|---|---| +| `ceiling` required, no default | registration fails without one | there is no safe value to guess: `owner` silently breaks a broadcast, `authenticated` silently widens a staff-only event | +| `example` required per variable | registration fails without one | §4.3 property 3 is right and a soft version of it is worthless — without an example, preview needs a live game event, which is how template systems ship untested | +| audiences get their own id space | not the trigger/stream namespace | an audience names a set of PEOPLE, a trigger names an EVENT; `uo.team.members` as both is not a collision | +| `url` variables are site-relative | validated like `pageUrlTemplate` | a payload variable ends up in an href in an email; `//evil.test/x` passes an "is it rooted" check and is protocol-relative | + +**One acceptance criterion is only half-dischargeable here, and that is not a slip.** "A rule cannot be +saved with an audience wider than its trigger's ceiling" needs `engagement_rules`, which is Phase 4's. +What Phase 2 owes and delivers is the arithmetic that check will call (`permits` / `meet` / `meetAll`, +with tests for the incomparable cases a total order would have waved through) **and** the same rule +applied where a row already exists: a declaration whose default `audience` is wider than — or +incomparable with — its own `ceiling` is refused at registration. Phase 4 adds the second call site, +not the second implementation. + +**Left for later, deliberately:** nothing consumes an emitted event yet. `emit` validates and logs, +and Phase 4 replaces that log line with the engine call. Core's five declarations are registered but +not yet *emitted* — the Team pipeline keeps its hardcoded mail until Phase 6 migrates it onto the +engine, and this is what it migrates onto. + --- ### Phase 3 — Channel preferences @@ -1789,7 +1932,7 @@ day it ships. ## Part 7 — Open questions and forward-compat notes -### 7.1 Questions for the org lead — five answered 2026-08-28, four still open +### 7.1 Questions for the org lead — six answered, three still open 1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles @@ -1818,10 +1961,13 @@ day it ships. relay** (Mailgun/SES/Postmark); name Gmail-with-an-app-password (`smtp.gmail.com:587`) explicitly as the migration path off OAuth2 for the existing deployment (§1.2a). Phase 1 owes all three in `runicgateway.com`'s `notifications-and-email.mdx` as well as `BACKEND_DESIGN.md` §7. -6. **Time-based triggers (G25) — design now, build when?** Recommendation: design the declaration in - Phase 2 so `kind: 'scheduled'` exists in the contract, build the evaluator after Phase 9. The - lifecycle uses in §8.5 are the highest-value non-game triggers on the list and the first thing anyone - will ask for after the IDOC mail works. +6. ✅ **ANSWERED — time-based triggers (G25), design now, build when?** As recommended: the + declaration lands in Phase 2 so `kind: 'scheduled'` is in the contract, the manifest and every + stored declaration from day one; the evaluator is built after Phase 9. Registration accepts + `scheduled` today and `ctx.events.emit` **refuses** to fire one — an evaluator's trigger is not a + caller's — so `kind` means something from the moment it is declarable rather than from the moment + it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and + the first thing anyone will ask for after the IDOC mail works. 7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be built if they are powered by game data — say team X members or governors or whatever — thru the uo @@ -1853,9 +1999,23 @@ and triggers are close enough to collide conceptually — `idoc.warning` (a stre - **Two namespaces**: streams stay exactly as they are (push only), triggers are new and separate. Zero risk now, permanent duplication later. -Recommendation: **two namespaces through Phase 8, then converge in a later workstream** — the shipped -Android client reads the stream catalog and the seven ids are already grandfathered once. Decide this in -Phase 2; it is cheap then and expensive in Phase 6. +Recommendation was **two namespaces through Phase 8**, on the grounds that the shipped Android client +reads the stream catalog. + +✅ **ANSWERED 2026-08-29 by the org lead: ONE namespace.** A trigger is a payload contract attached to +an id that may also carry a subscription toggle; `news.post` names one event whichever question is +being asked of it. They stay two REGISTRATIONS with two shapes — a stream entry is a subscription +toggle and says nothing about payload — but **an id has exactly one owner across both facets**, and +that is checked in both directions. + +The risk the recommendation was hedging against does not materialise, and that is worth recording +because it is the part that looked expensive: **the push catalog is untouched.** `allStreams()` still +serves the stream facet only, so the shipped app sees exactly the seven-plus-five ids it saw before, +and a trigger-only id simply has no push toggle. What one namespace actually costs is the ownership +check; what it buys is `notification_channel_prefs.stream_id` staying single-keyed, where two +namespaces would have forced a `kind` discriminator into its primary key and left `news.post` naming +two things forever. See Phase 2's as-built for the two knock-on effects (a relaxed id grammar and a +shared legacy allowlist) that only appeared once it was implemented. ### 7.3 Forward-compat: the note the brief asked for, corrected diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 74c0611..38646c7 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,42 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.6.0' +const MODULE_API_VERSION = '1.7.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. +**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals +and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. +`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) · +`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3). + +**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked +— *a contract owes a bump only once it has landed on `main`* — was true when they were written and is +not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has +only ever been on `edge`" are **historical, not current**. Everything added from here takes a version +of its own. That is also why the integration kit does not go red until the engagement cutover: +`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha +declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover +window (`ENGAGEMENT.md` Phase 13). + +**As in 1.6.0, the number states the whole surface and the members arrive by phase.** +`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7); +everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled +on: a member of a declared version that were simply absent would make the version a lie, and one that +silently accepted data into a table that does not exist would be worse than either. + +**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification +stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot +attach a payload contract to another module's stream and cannot claim a stream id another module has +declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the +same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable +after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment +(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See +`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision. + **1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` · @@ -228,6 +257,45 @@ module-uo does not need is on the list. | `ctx.teams.publish` | `(event) => Promise` | `model/teams/teamSync` | the Team provider's module (1.6.0) | | `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | | `ctx.teams.activity.push` | `(items) => Promise`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) | +| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | a module's own event mapper (1.7.0) | +| `ctx.inbox.push` | `(userId, item) => Promise` — **throws until Phase 7** | the in-app channel | not yet (1.7.0) | + +**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with +`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam +(`website/ENGAGEMENT.md` §5.2). + +```js +ctx.events.emit('uo.house.idoc_warning', { + subject: '0x40001234', // optional — else read from the declared subjectKey + data: { house: 'The Silver Anvil', decayStatus: 'Greatly' }, + ownerUserId: 812, // optional — the module resolves it; core never sees a game account + dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters + occurredAt: new Date(), // optional, defaults to now +}) +``` + +Six things about it are contract rather than implementation: + +- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling + module's id and is never read from the arguments. Without that, `emit` would be a way to fire + another module's event with a payload of your choosing, and every rule an operator wrote against + that trigger would fire on it. +- **The payload is validated against the declaration at EMIT, not at render.** A missing `required` + variable or a wrong type is **thrown in development and dropped-and-logged in production** — the + posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a + game-event handler, and a contract problem of core's must not become the module's control flow. + Undeclared keys are dropped rather than rejected; they could never be interpolated anyway. +- **It returns `undefined` and never throws in production.** There is nothing a module could + correctly do with a delivery failure from inside an event handler, so there is nothing to await. +- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game + account is and must not learn; the module maps its own account to a user and passes the result. +- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls + back to the variable the declaration's `subjectKey` names. +- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused. + +**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's +inbox directly without going through a rule. It is **present and throws** until the in-app channel +lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent. **`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers* questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a @@ -308,6 +376,8 @@ api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerPostHook({ onSaved, onDeleted }) api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 +api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0 +api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -567,6 +637,94 @@ but its own reply. `ok` is core's verdict and sits outside the envelope, so a ha claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who switches a module off does not leave a live handler behind it. +**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload +contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves, +what a rule is written against, and what a template may interpolate +(`website/ENGAGEMENT.md` §4.3). + +```js +api.registerEventTriggers([{ + id: 'uo.house.idoc_warning', // .-prefixed, one namespace with stream ids + label: 'House approaching collapse', + description: 'A player house dropped into a late decay stage.', + kind: 'event', // 'event' | 'scheduled'; default 'event' + subjectKey: 'house', // which variable identifies the cooldown subject + audience: 'owner', // the DEFAULT a rule is created with + ceiling: 'owner', // the widest a rule may EVER be given + version: 1, // bumped on a rename or a type change + variables: [ + { name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }, + { name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' }, + ], +}]) +``` + +Six things about it are contract rather than implementation: + +- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across + both facets. A module may declare both for the same id — that is one event with a subscription + toggle *and* a payload contract, and it is what core does with its own five — but it may not attach + a contract to another owner's stream, and the refusal names the holder and the facet. The seven + grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams, + because under one namespace they are the same ids. +- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a), + and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated` + would silently widen a staff-only event. The six values are `everyone`, `authenticated`, + `subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a + `staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default + `audience` wider than, or incomparable with, the ceiling is refused at registration. +- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and + test-sending a template possible without a live game event, which is the reason template systems go + untested. A variable without one is refused. +- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and + no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is + validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href. +- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`, + which looks like the feature working right up until two subjects share it. +- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type + change; a template records what it was authored against and renders with a warning rather than + interpolating `undefined`. + +A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a +prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by +`npm run engagement:manifest` and gated in CI with `--check`. + +**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its +own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the +governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it. + +```js +api.registerAudiences([{ + id: 'uo.team.members', + label: 'Members of a team', + params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only + ceiling: 'members', + resolve: async (params) => [/* user ids */], +}]) +``` + +Four things about it are contract rather than implementation: + +- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an + address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not + become the back door that lets it — core maps ids to addresses on its own side, after preferences, + suppression and the verification gate. +- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label + and a `resolve` it may call. The same boundary `registerNotificationStreams` holds. +- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty + set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a + *different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A + resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it + does return are filtered to positive integers before core uses them. +- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still + checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the + intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all + and the save is refused rather than guessed. Composition UI is Phase 4's. + +**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE +and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing +predates them. + **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle @@ -1746,6 +1904,33 @@ injected chunk executes and calls `window.__rg.registry` (§3.3). --- +### 6.8 A trigger, a rule and an audience outlive the module that declared them + +The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here +rather than there: it is a rule about **the contract**, not about the engagement system. + +A trigger id is namespaced by its owner and collision-checked at registration, exactly as a +notification stream is. What is *not* expressed by that is a rule or a template referring to a +trigger whose module has been uninstalled. + +> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so +> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same +> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would +> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the +> module should resume it."* +> +> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as +> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: it +> resolves to the empty set and shows dormant, which is not the same answer as "resolved to nobody" +> and must not be rendered as if it were. The failure this prevents is specific: an id that stops +> resolving must never silently become a send to a **different** set of people. + +`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template +records the trigger `version` it was authored against, so a declaration that has since been bumped +produces a warning in the admin list rather than silently interpolating `undefined`. + +--- + ## Part 7 — What the spike proved The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately diff --git a/website/api-route-inventory.json b/website/api-route-inventory.json index ea1427e..0ca2665 100644 --- a/website/api-route-inventory.json +++ b/website/api-route-inventory.json @@ -73,6 +73,14 @@ "method": "POST", "path": "/api/v1/admin/email/test" }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/audiences" + }, + { + "method": "GET", + "path": "/api/v1/admin/engagement/triggers" + }, { "method": "GET", "path": "/api/v1/admin/invites"