|
|
|
|
@@ -1334,7 +1334,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
|
|
|
|
|
| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase |
|
|
|
|
|
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`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 · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
|
|
|
|
|
| **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 |
|
|
|
|
|
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
|
|
|
|
|
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
|
|
|
|
|
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
|
|
|
|
|
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
|
|
|
|
|
@@ -1660,6 +1660,7 @@ mails `uo.cheat.detected` to the player it detected. Fewer people is not less ex
|
|
|
|
|
- `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
|
|
|
|
|
*(Phase 7 filled it in. Not a version bump: the signature is the one 1.7.0 declared.)*
|
|
|
|
|
- `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`
|
|
|
|
|
@@ -1745,7 +1746,9 @@ phases before the registry replaced it. What did *not* land is the behavioural h
|
|
|
|
|
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
|
|
|
|
|
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
|
|
|
|
|
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
|
|
|
|
|
Phase 7 shipped would be a backlog.
|
|
|
|
|
Phase 7 shipped would be a backlog. **Phase 7 changed it to `instant`** once there was a surface to
|
|
|
|
|
look at: an inbox item wakes no device and leaves no building, and the backlog this paragraph feared
|
|
|
|
|
cannot happen against an empty table. See Phase 7's decision 1.
|
|
|
|
|
|
|
|
|
|
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
|
|
|
|
|
whole-set body forces a client that only manages email to send every push row back or wipe them. And
|
|
|
|
|
@@ -2606,7 +2609,7 @@ started writing, which is the column Phase 9's bounce correlation reads.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### Phase 7 — The in-app channel (core + web)
|
|
|
|
|
### Phase 7 — The in-app channel (core + web) ✅
|
|
|
|
|
|
|
|
|
|
`user_notifications`, the in-app `DeliveryChannel`, `GET /auth/me/notifications` + mark-read, and the web
|
|
|
|
|
surface (bell + list). Push tickles gain a `ref` that deep-links into the inbox.
|
|
|
|
|
@@ -2616,6 +2619,159 @@ no-op; mark-read is idempotent; a user cannot read another user's row (asserted
|
|
|
|
|
the model); `url` is relative-only, validated by the same character-class rule `pageUrlTemplate` uses.
|
|
|
|
|
**Guardrails:** swagger + route manifest; the sanitize path for `body`.
|
|
|
|
|
|
|
|
|
|
#### As built — 7 (2026-08-31)
|
|
|
|
|
|
|
|
|
|
The third channel gets behaviour, the oldest one gets a `deliver` at last, and the preferences endpoint
|
|
|
|
|
Phase 3 shipped with no surface gets one. **Four decisions were settled by the org lead before any
|
|
|
|
|
code**, two of them widening the phase past its own acceptance line.
|
|
|
|
|
|
|
|
|
|
##### Decision 1 — `inapp` defaults to `instant`, and it is the only channel that does
|
|
|
|
|
|
|
|
|
|
`coreChannels.js` deferred this in as many words: "whether the inbox is opt-out once it is real is a
|
|
|
|
|
Phase 7 decision with a live surface to look at." The surface exists now, and the answer is opt-OUT.
|
|
|
|
|
|
|
|
|
|
The argument for opt-IN was never about in-app. §7.1 Q1 is standard marketing-email practice and Phase
|
|
|
|
|
3's `push` default is about a device somebody is holding; **an inbox item wakes nothing and leaves
|
|
|
|
|
nothing** — it is a row on a page the user chose to open, on this deployment, costing one glance. Left
|
|
|
|
|
at `off` the channel would ship dead: no rule could reach anybody until every user found a toggle for a
|
|
|
|
|
channel they had never seen deliver anything. The backlog Phase 3 worried about cannot happen either —
|
|
|
|
|
the table is empty at cutover, rules default to `enabled = 0`, and every rule carries a per-hour
|
|
|
|
|
ceiling.
|
|
|
|
|
|
|
|
|
|
##### Decision 2 — the phase takes the two pieces its acceptance line omitted
|
|
|
|
|
|
|
|
|
|
Two earlier phases assigned work here that Phase 7's own bullets never mention, and both were taken:
|
|
|
|
|
|
|
|
|
|
- **`push` gets its `deliver`** (§2603's "the push and in-app channels' `deliver`"). Without it a rule
|
|
|
|
|
naming push still finished `failed` in the send log — the oldest sink in the system, unreachable from
|
|
|
|
|
the engine. It is the channel that got behaviour last because until the inbox existed there was
|
|
|
|
|
nothing for a content-free tickle to point at.
|
|
|
|
|
- **The web per-channel preferences screen** (Phase 3's as-built: "the screens are Phase 7 (web) and
|
|
|
|
|
Phase 8 (app)"). The endpoint had shipped with no consumer on either platform.
|
|
|
|
|
|
|
|
|
|
##### Decision 3 — the inbox takes `/notifications`; the preferences move under it
|
|
|
|
|
|
|
|
|
|
`/auth/me/notifications/*` was already the preferences namespace — `streams`, `subscriptions`,
|
|
|
|
|
`channels`, `teams` — and `/account/notifications` was already the preferences *page*, with a bell icon
|
|
|
|
|
in the portal nav. Content and settings are different kinds of thing, and **the plain word belongs to
|
|
|
|
|
the content**: it is what a person means when they say "notifications", and what the bell opens.
|
|
|
|
|
|
|
|
|
|
So the inbox is `GET /auth/me/notifications` and the page is `/account/notifications`; the preferences
|
|
|
|
|
screen moved to `/account/notifications/settings` and gained its own nav row. Route order is not
|
|
|
|
|
incidental and is commented as such: the four named preference sub-paths are declared above, and the one
|
|
|
|
|
parameterised path added below them is a **POST** whose `:id` is digits-only, so nothing can shadow
|
|
|
|
|
`streams` or `channels`.
|
|
|
|
|
|
|
|
|
|
##### Decision 4 — `ctx.inbox.push` respects a preference where one exists
|
|
|
|
|
|
|
|
|
|
The rule-less sink has no trigger declaration to project from, no rule to pick a template and no
|
|
|
|
|
audience to resolve. It now writes the inbox directly **unless** `triggerId` names a *registered*
|
|
|
|
|
trigger and that user's effective `inapp` mode is not `instant`: a toggle somebody switched off must not
|
|
|
|
|
be walkable around by the module that owns the trigger behind it. An id nothing has registered has no
|
|
|
|
|
toggle on any screen, so there is no preference to protect and the item is written.
|
|
|
|
|
|
|
|
|
|
Scoped preferences are deliberately not consulted — a scope is a property of an *event* (`team:12`), and
|
|
|
|
|
a caller with no declaration has no scope to name. The engine's path, which does, still applies them.
|
|
|
|
|
|
|
|
|
|
##### The block → column mapping, which is the whole of how a template becomes a row
|
|
|
|
|
|
|
|
|
|
`user_notifications` has `title` / `body` / `url` where email has a subject and a document. The in-app
|
|
|
|
|
renderer (`templates.renderInappByKey`) maps by block **role**: the first `email.heading` is the title,
|
|
|
|
|
the first `email.button` is the url, everything else is the body. A second heading or button is ordinary
|
|
|
|
|
body content, which is what an operator who added one meant.
|
|
|
|
|
|
|
|
|
|
**The body is TEXT, not the email HTML**, and that is load-bearing rather than a shortcut. The `email.*`
|
|
|
|
|
renderer produces markup built for mail clients — table rows, inline hex colours, a light-only palette
|
|
|
|
|
declared with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a
|
|
|
|
|
pale card floating in a dark one. `toText` is the same content with none of that, and it is the part
|
|
|
|
|
every block already promises. The consequence worth stating: **there is no operator markup on this
|
|
|
|
|
surface to sanitize, and no way for one to appear.** The phase's "sanitize path for `body`" guardrail is
|
|
|
|
|
discharged by the column never holding markup in the first place, which is a stronger guarantee than a
|
|
|
|
|
sanitizer.
|
|
|
|
|
|
|
|
|
|
##### Five things the tree contradicted, or the build found
|
|
|
|
|
|
|
|
|
|
- **The shipped `inapp.event` seed named variables nothing supplies.** Phase 5a wrote it before the
|
|
|
|
|
channel that renders it existed, declaring `body` and `url` — but a trigger declares domain names
|
|
|
|
|
(`teamName`, `threadTitle`) and `projection.project` fills the gaps with the *structural* ones
|
|
|
|
|
(`title`, `intro`, `actionUrl`). Every rendering would have produced a title and nothing else.
|
|
|
|
|
Renamed to `notify.event`'s vocabulary at **`seedVersion` 2**, which is §4.6.1 property 1 restated for
|
|
|
|
|
this channel: a new trigger must render with no authoring at all.
|
|
|
|
|
- **The dedupe index is scoped to the USER, which is narrower than the outbox's.** `engagement_outbox`
|
|
|
|
|
scopes to `(rule, user, channel)` because one event legitimately becomes one row per channel; an inbox
|
|
|
|
|
has no channel dimension, so two rows for one event would be **one item shown twice**. Same family of
|
|
|
|
|
defect as the global index Phase 4a found in §4.2a, in the opposite direction.
|
|
|
|
|
- **The push tickle's `ref` needed an ordering to be worth anything.** A rule spanning `inapp` and
|
|
|
|
|
`push` enqueues two independent rows and the outbox sweeps `ORDER BY due_at, id`, so the ref only
|
|
|
|
|
resolves if the in-app row was enqueued first. `engine.liveChannels` now sorts `inapp` ahead of the
|
|
|
|
|
rest (`CHANNEL_ORDER`) — an ordering, not a dependency: the ref is a **hint**, null when there is no
|
|
|
|
|
row, and the app's contract stays wake-and-pull.
|
|
|
|
|
- **There was no retention policy for this table at all**, and neither the outbox nor the send log
|
|
|
|
|
bounds it (both hold one row per *delivery*; an inbox item outlives its delivery by design).
|
|
|
|
|
`utils/userNotificationsPrune.js` is `teamActivityPrune`'s shape with one policy difference:
|
|
|
|
|
**read items only.** Age alone would delete the evidence for "I was never told", which is the
|
|
|
|
|
complaint this table answers. The horizon is `settings.user_notifications_retain_days`, default 90.
|
|
|
|
|
- **Staff had no reachable inbox, and only the live rig could see it.** `/auth/me/notifications` is
|
|
|
|
|
role-agnostic — behind `requireAuth` only, like every `/auth/me` route — so the server, the tests
|
|
|
|
|
and the API all agreed a staff member had an inbox. On the web they did not: `RequirePlayer` sends
|
|
|
|
|
anyone who is not a player out of `/account` (staff manage their own account under `/admin/account`),
|
|
|
|
|
so the bell pointed at a page that redirects. **Signed in as an admin, the feature was unreachable.**
|
|
|
|
|
Fixed by mounting the same two components at `/admin/notifications` and
|
|
|
|
|
`/admin/notifications/settings`, adding the bell to the admin header, and putting the one mapping in
|
|
|
|
|
`client/src/lib/notificationPaths.js` with its own test. One trap inside the fix worth keeping:
|
|
|
|
|
`allowedPathsFor` turns an `end: true` nav row into an EXACT match, so marking the admin row exact
|
|
|
|
|
left `/admin/notifications/settings` outside the allowlist and bounced staff off their own
|
|
|
|
|
preferences screen — the row has to cover its sub-routes.
|
|
|
|
|
|
|
|
|
|
##### What an operator and a user actually see
|
|
|
|
|
|
|
|
|
|
- **The bell** sits in the public site header and in the player portal's own header, renders nothing
|
|
|
|
|
when signed out, and **polls** its badge once a minute — pausing while the tab is hidden and
|
|
|
|
|
refreshing the moment it comes back. There is nothing to push over: the site's two SSE streams are
|
|
|
|
|
the shard's, neither is per-user, and a third authenticated stream carrying one integer would mean an
|
|
|
|
|
open connection per signed-in tab forever.
|
|
|
|
|
- **The preferences screen is now a matrix**, not a checkbox list. The push-only stream list it replaced
|
|
|
|
|
was a strict subset: `/notifications/channels` already returns every push stream *and* every event
|
|
|
|
|
trigger with the effective mode on each channel that applies, so a trigger-only id simply has no push
|
|
|
|
|
cell and core never has to explain which kind of id a row is. The two legacy whole-set endpoints are
|
|
|
|
|
untouched and are that surface's push projection, so **the shipped Android app keeps its wire shape**.
|
|
|
|
|
|
|
|
|
|
##### What was verified
|
|
|
|
|
|
|
|
|
|
- **28 new tests**: 23 in `engagementInapp.test.js` (the five acceptance criteria, the role mapping, the
|
|
|
|
|
four `ctx.inbox.push` cases, the tickle's exact key set, and the route-level ownership check) and 5 in
|
|
|
|
|
`userNotificationsSql.test.js` — a throwaway MariaDB, because three properties here are a *server*
|
|
|
|
|
contract rather than a reading of this code: a UNIQUE index admitting many NULLs, `INSERT IGNORE`
|
|
|
|
|
reporting `affectedRows = 0` on a duplicate, and `read_at IS NULL` making mark-read idempotent.
|
|
|
|
|
- **Two existing tests moved with the behaviour, and both moves are the point.**
|
|
|
|
|
`engagementEngine`'s "a channel with no `deliver()` finishes failed" named `inapp` (and `email` before
|
|
|
|
|
it) and so was rewritten by every phase that gave a channel behaviour; it now registers a throwaway
|
|
|
|
|
channel, because the property was never about a particular one. `notificationChannelPrefs`'s defaults
|
|
|
|
|
assertion carries decision 1.
|
|
|
|
|
- **Swagger and the route manifest** carry the four new routes, with four new component schemas.
|
|
|
|
|
- **A live rig**: MariaDB + a booted server + the real outbox worker + a browser. The walk is where
|
|
|
|
|
the staff-reachability defect came from, and it also proved the three things unit tests cannot —
|
|
|
|
|
that `liveChannels`' ordering really does put the in-app row first (a rule stored as
|
|
|
|
|
`["push","inapp"]` enqueued outbox 2 = inapp before outbox 3 = push, and the tickle carried
|
|
|
|
|
`ref: "notification:2"`); that **two rules on one event produce three outbox rows and exactly ONE
|
|
|
|
|
inbox item**, with the send log saying "already in this inbox (duplicate dedupe key)" rather than
|
|
|
|
|
claiming a second delivery; and that the retention worker drops an aged READ row while leaving an
|
|
|
|
|
equally aged UNREAD one. The preferences matrix wrote exactly one row for the one cell that changed.
|
|
|
|
|
|
|
|
|
|
**One thing this phase did NOT wire, and it is worth knowing before Phase 11.** `news.post` is a
|
|
|
|
|
declared trigger that **nothing emits through the engine** — `coreTriggers.js` says so in as many words
|
|
|
|
|
("these declare; nothing here emits yet") and Phase 6 migrated only the four `team.*` ones, so the
|
|
|
|
|
admin publish path still fires a raw `pushDispatch.publish` beside the engine rather than through it.
|
|
|
|
|
The consequence for this phase: on a real deployment the only in-app items a rule can produce today
|
|
|
|
|
come from the four Team triggers. Wiring the news emitter is a one-line `ctx.events.emit`-shaped change
|
|
|
|
|
that belongs with whoever owns that decision, not smuggled into the channel's own phase. **Written up as
|
|
|
|
|
§7.1 Q9**, which sets out the three other things a publish already fires (an announce leg, a module's
|
|
|
|
|
post hook, the raw tickle), which of them the engine replaces and which it must not touch, and the
|
|
|
|
|
continuity question that has to be answered before anyone writes the line. Recommended home: Phase 11.
|
|
|
|
|
|
|
|
|
|
**Still later phases':** the app's inbox screen and the tickle → pull → inbox path (Phase 8), and the
|
|
|
|
|
suppression list, which this channel has no equivalent of — there is no address to suppress.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### Phase 8 — The in-app channel (Android)
|
|
|
|
|
@@ -2674,10 +2830,19 @@ starts early and lands independently.
|
|
|
|
|
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
|
|
|
|
|
until the v5 overlay is deployed, which the `required: false` declaration already permits.
|
|
|
|
|
|
|
|
|
|
**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is
|
|
|
|
|
declared with no caller, so a rule naming it can never fire; wiring it is one `ctx.events.emit`-shaped
|
|
|
|
|
call in `announceIfNewlyPublished`, gated on the same job-id transition signal the push already uses.
|
|
|
|
|
**The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a
|
|
|
|
|
channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person
|
|
|
|
|
notification. What the emit replaces is the raw `pushDispatch.publish` beside them, and Q9's continuity
|
|
|
|
|
question has to be answered before it does.
|
|
|
|
|
|
|
|
|
|
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
|
|
|
|
|
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
|
|
|
|
|
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
|
|
|
|
|
cancels the pending mail.
|
|
|
|
|
cancels the pending mail; **a news post published on the rig reaches a rule, and the town-crier leg and
|
|
|
|
|
every registered post hook still fire exactly as they did.**
|
|
|
|
|
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
@@ -2810,7 +2975,7 @@ day it ships.
|
|
|
|
|
|
|
|
|
|
## Part 7 — Open questions and forward-compat notes
|
|
|
|
|
|
|
|
|
|
### 7.1 Questions for the org lead — seven answered, one still open
|
|
|
|
|
### 7.1 Questions for the org lead — seven answered, two 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
|
|
|
|
|
@@ -2873,6 +3038,52 @@ day it ships.
|
|
|
|
|
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
|
|
|
|
|
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
|
|
|
|
|
Kotlin compile error during the cutover window.
|
|
|
|
|
9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
|
|
|
|
|
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
|
|
|
|
|
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a
|
|
|
|
|
declared payload contract with **no caller**, and on a real deployment the only in-app or email items
|
|
|
|
|
a rule can produce today come from Teams. Phase 7 found this and deliberately did not wire it, on the
|
|
|
|
|
grounds that a channel's own phase is not the place to give another phase's trigger an emitter.
|
|
|
|
|
|
|
|
|
|
**The call itself is one line. The care is entirely in what it must not disturb.**
|
|
|
|
|
`admin.controller.js`'s `announceIfNewlyPublished` already fans one publish four ways, and they are
|
|
|
|
|
different in kind — three of them are *not* the engagement engine's business:
|
|
|
|
|
|
|
|
|
|
| What fires on a publish | Whose | What kind of thing it is | The engine's? |
|
|
|
|
|
| --- | --- | --- | --- |
|
|
|
|
|
| `announceJobs.enqueueIfNeeded` → `announce_job_legs` | core, with legs registered by modules — `module-uo` owns `towncrier` | a one-shot **delivery to a channel of the deployment**, with retry and classification | **No** |
|
|
|
|
|
| `registries.dispatchPostHook('onSaved')` | modules, via `registerPostHook` (API 1.1.0) | idempotent **state mirroring** — it also runs on delete, and refreshes silently on an edit | **No** |
|
|
|
|
|
| `pushDispatch.publish('news.post', { ref })` | core | a **per-person notification**, to whoever subscribed | **Yes — this is the one that should become an emit** |
|
|
|
|
|
| *(missing)* `engagementEmit.emit('core', 'news.post', …)` | core | rules → email / in-app / push | **Yes** |
|
|
|
|
|
|
|
|
|
|
`registries.js` already states the first two apart and why they were not folded together ("a leg is a
|
|
|
|
|
one-shot DELIVERY with retry and classification; a post hook maintains idempotent STATE, has to run on
|
|
|
|
|
delete as well as save, and refreshes silently on an edit"). **Adding the engine makes a third
|
|
|
|
|
distinction of the same kind, not a replacement for either.** A module's town-crier leg and a module's
|
|
|
|
|
news gump must keep firing exactly as they do; what changes is only that the raw tickle stops being
|
|
|
|
|
the one person-facing sink and becomes one channel of a rule.
|
|
|
|
|
|
|
|
|
|
**What a module gets out of this, stated so nobody widens it by accident.** A module already has two
|
|
|
|
|
doors onto a news publish — the announce leg and the post hook — and it keeps both. What it does *not*
|
|
|
|
|
get is the ability to fire `news.post` itself: the id's owner is core, `ctx.events.emit` binds the
|
|
|
|
|
owner at the call and never reads it from the arguments, and §7.2's one namespace means an id has
|
|
|
|
|
exactly one owner across both facets. A module that wants a person-facing notification of its own
|
|
|
|
|
declares its own trigger through `registerEventTriggers`. That is the whole of "modules can use it".
|
|
|
|
|
|
|
|
|
|
**Three things to settle before anyone writes the line:**
|
|
|
|
|
- **Continuity, and it is the same shape as G22 and Phase 6's decision 3.** Today publishing news
|
|
|
|
|
tickles every `news.post` subscriber directly. If the emit *replaces* that call, push stops the
|
|
|
|
|
moment this lands and stays stopped until an operator enables a rule — silently, because `enabled`
|
|
|
|
|
defaults to `0`. Either core seeds a `news.post` rule (and then: enabled, against the standing
|
|
|
|
|
default, or disabled with a banner as the Team rules got?), or the raw tickle stays beside the emit
|
|
|
|
|
for one release and is removed once a rule is known to exist.
|
|
|
|
|
- **The transition signal must be reused, not re-derived.** `enqueueIfNeeded` returning a truthy job
|
|
|
|
|
id is the single "newly published news" test, and the push call already piggybacks on it
|
|
|
|
|
deliberately so an edit or a re-publish does not re-fire. The emit must gate on the same value; a
|
|
|
|
|
second reading of the transition is a second chance to disagree with the first.
|
|
|
|
|
- **Which phase owns it.** Recommendation: **Phase 11**, which already ships "the first real rule" and
|
|
|
|
|
is where a declared trigger first gets a caller. It is core work rather than `module-uo`'s, so that
|
|
|
|
|
phase's title understates it — say so there rather than inventing a phase for one call site.
|
|
|
|
|
|
|
|
|
|
### 7.2 One namespace, or two?
|
|
|
|
|
|
|
|
|
|
|