From 66257ebcb53c3ab1e3863e46318526fedeb0c60c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 31 Aug 2026 02:27:48 -0500 Subject: [PATCH] =?UTF-8?q?docs(website):=20=C2=A77.1=20Q9=20=E2=80=94=20c?= =?UTF-8?q?ore's=20own=20news.post=20emitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `news.post` is a declared trigger with no caller: `coreTriggers.js` says so in as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming it can never fire, so on a real deployment the only in-app or email items the engine can produce today come from Teams. Phase 7 flagged it in passing; this writes it down properly as an open question. The substance is not the call — it is the three other things a news publish already fires, and which of them the engine has any business replacing: - the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a one-shot delivery to a channel of the deployment, with retry. NOT the engine's. - a module's post hook (`registerPostHook`) — idempotent state mirroring that also runs on delete and refreshes on a silent edit. NOT the engine's. - the raw `pushDispatch.publish('news.post', …)` — a per-person notification. THIS is the one that becomes an emit. So modules keep both doors onto a news publish and neither changes. What a module does not get is the ability to fire `news.post` itself — the id's owner is core, `emit` binds the owner at the call, and §7.2's one namespace gives an id exactly one owner across both facets. A module wanting its own person-facing news notification declares its own trigger. Three things to settle first, recorded rather than decided: continuity (the emit replacing the tickle stops push silently until a rule is enabled — G22's shape, and Phase 6 decision 3's), reusing the job-id transition signal rather than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose title understates it — a pointer and an extra acceptance line land there too. Code: RunicGateway/website#175 Co-Authored-By: Claude (cherry picked from commit 2fd5d065b7d0a95088f13880dad0a510be0ded8d) --- website/ENGAGEMENT.md | 64 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 37cfb7e..c69a8b2 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -2764,7 +2764,10 @@ declared trigger that **nothing emits through the engine** — `coreTriggers.js` 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. +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. @@ -2827,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. --- @@ -2963,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 @@ -3026,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? -- 2.49.1