| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) |
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) |
| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**; built on `edge`, not yet released |
| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**, shipped 2026-09-01 as bundle 2026.09.01 (sidecar v2.1.0 + overlay v1.1.0) |
@@ -55,7 +55,7 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
The current version is **5**. It is built but **not yet released** — the last shipped pairing is protocol 4, in sidecar **v2.0.0**and overlay **v1.0.0**.
The current version is **5**, shipped on 2026-09-01 in sidecar **v2.1.0** and overlay **v1.1.0** — resolve them as bundle **2026.09.01**, never as "latest of each". The pairing before it was protocol 4, sidecar **v2.0.0**+ overlay **v1.0.0**.
- Every response carries an **`X-UOLink-Version: 5`** header.
- Every response carries an **`X-UOLink-Version: 5`** header.
-`GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
-`GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is
still design. **PHASE 10 IS BUILT (2026-08-31)** — the protocol bump; see its as-built below and
[`../link/v5.md`](../link/v5.md). **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:**
the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of
§8.6** rather than a single rule. **Phase 11 was then scoped on the same day** — six decisions, three
carve-outs, a seventh value in the ceiling lattice and an 11a/11b split; see its own decision block.
The scope decisions
below are settled; **all nine 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),
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter) was answered
**2026-08-31 at the start of Phase 11**, along with five further decisions that changed what that
phase ships — see its own decision block. 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
The cutover cut `link`**v2.1.0**, the plugin overlay **v1.1.0**, `module-uo`**v1.1.0** and the
(Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have
paired bundle **2026.09.01** (protocol **5**); `MODULE_API_VERSION` is **1.9.0**. Two of Phase 13's
none.
seven steps were missed in the window and landed afterwards — see its as-built, which is also where
the four findings of the cutover itself are recorded.
**Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:** the protocol
bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of §8.6**
rather than a single rule. **Phase 11 was then scoped on the same day** — six decisions, three
carve-outs, a seventh value in the ceiling lattice and an 11a/11b split; see its own decision block.
The scope decisions below are settled; **all nine 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), **Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start
of Phase 8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and
the phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter) was
answered **2026-08-31 at the start of Phase 11**, along with five further decisions that changed what
that phase ships — see its own decision block. Per CLAUDE.md § Conventions, no implementation starts
without the org lead's approval of the phase it belongs to, **and that still holds for Phase 14.**
**Branching:** every phase landed on **`edge`** in its repo, and `main` was touched once, by the
cutover (Phase 13). §6.0a records the blocking precondition it opened with — six `edge` branches
stale, three repos with none — and Phase 13's as-built records how it closed: **every `edge` was
deleted on merge rather than fast-forwarded**, which is the convention now. Phase 14 comes after the
cutover and so goes to `main` through an ordinary feature branch.
**Scope decisions, settled by the org lead (2026-08-28):**
**Scope decisions, settled by the org lead (2026-08-28):**
@@ -1388,7 +1391,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **11a** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship · `website/MODULE_API.md` §1.1 (**1.8.0**) and the ceiling vocabulary wherever it is enumerated · `BACKEND_DESIGN.md` — the `news.post` publish path now runs through the engine | `module-uo/README.md` |
| **11a** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship · `website/MODULE_API.md` §1.1 (**1.8.0**) and the ceiling vocabulary wherever it is enumerated · `BACKEND_DESIGN.md` — the `news.post` publish path now runs through the engine | `module-uo/README.md` |
| **11b** Seeded rules + templates | `website/ENGAGEMENT.md` this phase as built · a release note naming **the seeded-disabled `news.post` rule as an upgrade step** (decision 5) — without it a deployment loses news push silently | **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims |
| **11b** Seeded rules + templates | `website/ENGAGEMENT.md` this phase as built · a release note naming **the seeded-disabled `news.post` rule as an upgrade step** (decision 5) — without it a deployment loses news push silently | **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims |
| **12** Public site | `website/ENGAGEMENT.md` this phase as built, incl. the 12a/12b split and Phase 13's fill-in step | **`runicgateway.com`**, in full — see the phase. **Two PRs**: 12a (#26) mergeable now, 12b (#27) a draft held for the cutover window |
| **12** Public site | `website/ENGAGEMENT.md` this phase as built, incl. the 12a/12b split and Phase 13's fill-in step | **`runicgateway.com`**, in full — see the phase. **Two PRs**: 12a (#26) mergeable now, 12b (#27) a draft held for the cutover window |
| **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md`if this is a headline capability |
| **13** Cutover ✅ | `README.md` index rows · every doc's status line · **this phase as built**, incl. the two steps the window missed | `.profile/README.md`— the org lead called this a headline capability, so it landed |
| **14** Retention | `website/ENGAGEMENT.md` this phase as built · `BACKEND_DESIGN.md` table inventory · the settings keys it adds | **`runicgateway.com`**: `/privacy` + **`PLAY_DATA_SAFETY.md`**, both generated from `src/data/collection.mjs`, whose `deploy-engagement` row says "nothing here expires on its own" and stops being true |
**One thing this table is protecting against.**`runicgateway.com` appears in eight rows, and it is the
**One thing this table is protecting against.**`runicgateway.com` appears in eight rows, and it is the
only repo here whose checks are *fetching* these values rather than being told them — see Phase 12.
only repo here whose checks are *fetching* these values rather than being told them — see Phase 12.
@@ -1843,7 +1847,7 @@ see something a preference actually governs.
---
---
### Phase 4 — The engine: rules, cooldowns, outbox
### Phase 4 — The engine: rules, cooldowns, outbox ✅
**Split into 4a and 4b**atthestartofthephase,onthesameargumentthatsplitPhase5:thehalf
**Split into 4a and 4b**atthestartofthephase,onthesameargumentthatsplitPhase5:thehalf
together; **5 and 7 did not**, and were done afterwards as **Integration-kit#9** and
**runicgateway.com#28**, with **Module-uo#27** clearing a leftover from step 4. What the window cut:
`link` **v2.1.0**, the plugin overlay **v1.1.0**, `module-uo` **v1.1.0**, and the paired bundle
**2026.09.01** (protocol 5). `website` releases nothing by design.
**Every `edge` was deleted rather than fast-forwarded**, in seven repos. The acceptance line above
asks for the fast-forward so the next workstream starts from a clean branch; deleting reaches the
same place more bluntly, since §6.0a's Phase -1 cuts `edge` fresh anyway — and a branch that does not
exist cannot be the stale one somebody branches from, which was the actual failure. Only
`Integration-kit` ended up in the state §6.0a warns about, one commit behind `main` once its own step
landed late; fast-forwarded on the day. **The convention is now delete-on-merge**, and Phase -1 cuts
`edge` rather than trusting one it finds.
**1. Gitea's `raw` API route is CDN-cached for six hours, and a stale read fails BOTH ways.**
`checkFacts.mjs` failed `runicgateway.com` for a `moduleApi` that was correct: it had been served
`website`'s `version.js` from a fortnight earlier (`Cache-Control: public, max-age=21600`,
`cf-cache-status: HIT`, `Age: 15713`), and no edit in that repository could have made it pass. The
false red is the cheap half. **The same run reported the already-republished bundle triple as still
current** — a stale read is just as able to say "nothing has moved", and that repo's whole bargain is
that it goes red when the platform moves. All three cross-repository checks now read the `contents`
endpoint, which answers `private, must-revalidate` and is not cached; a request `Cache-Control:
no-cache` header does **not** bust the CDN, and a cache-busting query param was rejected as
papering over the mechanism rather than choosing the right one.
**2. The Integration-kit equality check never goes red on its own, and step 5 is the only thing that
makes anyone look.** It clones the ref **the kit itself pins**, so a core that moves past that pin
changes nothing there. The kit sat three minor versions behind the platform for the whole
workstream — teaching a 1.6.0 contract with no triggers, audiences or seeds in it — and was green
throughout. Between cutovers the kit is not wrong, it is **dated**, and `ci/core-ref.json` is where
the date is written down. Read step 5's obligation as *someone re-reads the chapters*, never as
*CI will tell us*.
**3. `registerEngagementSeeds` does not validate the body it seeds, and the kit's own example was
malformed.** The call checks that `blocks` is a non-empty array and stops; the body is validated by
the block registry, which runs in the template editor and in the renderer and nowhere else. The
draft template shipped a heading `level: 2` — the registry takes `'h1' | 'h2' | 'h3'` — and no block
`id`s at all, so it would have registered cleanly, seeded cleanly, and failed the first time an
operator opened it. It was found by running the template's `register()` through **core's real
`stage()` validators** at the pinned ref, which the kit's CI does not do and cannot: it runs the
template against `test/_fakes.js`, and a fake accepts what core refuses. Two smaller corrections came
out of the same run — core **does** validate `subjectKey` against the declared variables and refuses
the module, and `ctx.events.emit` **throws outside production** rather than only dropping and
logging. Both had been written into the chapter the other way round.
The general lesson is §5.3's, arriving from the other side: a registration surface is only as
teachable as the thing that checks it. Where the registry validates, the book can describe the
error; where it does not — and `blocks` is the one place it does not — the book has to say so, and
the template needs a test of its own. It has one now, verified by breaking it.
**4. `runicgateway.com`'s `pr-checks.yml` triggers on PRs into `main` only**, so not one of the seven
site PRs in this workstream was gated, and the cutover was their first CI run in eight phases. Same
shape as `android-app`'s trigger before Q8 fixed it (§6.0a), and left alone rather than fixed: that
repo's checks read the *source repos' `main`*, so running them on an `edge` PR would have been red
for the whole window by design. Phase 12's split exists for the same reason. What it costs is real
though — 12b's fill-in was verified locally and merged on that evidence alone.
**Still open after this phase:** the acceptance walk above — the clean install from `main`, and the
`Greatly` transition producing exactly one email and one in-app item to the linked owner and nothing
to anyone else — has **not** been run. `.profile/README.md` (step 8) landed: the org lead called the
engagement system a headline capability. And Phase 12's second finding became **Phase 14**.
---
### Phase 14 — Retention: the engagement schema has no sweep
**Status: scoped, not started.** Phase 12 found this and recorded it without fixing it (finding 2 of
its as-built), on the grounds that a sweep is a `website` change and outside a documentation phase.
Nothing after it picked the finding up, so it is stated here as the phase it always was.
**Four tables grow without bound**, and they are not one problem with one horizon:
| Table | What accumulates | The constraint on a horizon |
| --- | --- | --- |
| `engagement_cooldowns` | one row per (rule, user, subject, channel) per fire, read once per fire | **`engagementCooldowns.db.prune(olderThan)` already exists and has no caller.** The horizon must exceed the longest `cooldown_seconds` on any enabled rule, or a pruned row makes the next fire a *first* fire — the model says so and leaves it to the caller |
| `engagement_outbox` | every enqueued delivery, including terminal `sent` / `failed` / `cancelled` / `suppressed` rows | only terminal rows are eligible; a `scheduled` row may be days out by design (`delay_seconds`) |
| `engagement_sends` (the send log) | one row per delivery attempt | **two live readers.** The per-rule hourly cap reads `idx_engs_rule_window (rule_id, created_at)`, so a horizon under an hour breaks Q3's ceiling; Admin → Engagement → Send Log is the operator's only answer to "was this person told", so a short one blinds the screen that exists to be looked at |
| `engagement_suppressions` | one row per suppressed address, forever | **the odd one out, and probably correct as it is.** A suppression is a standing decision; ageing out a `bounce` row means the next send re-mails an address that already hard-bounced, which is exactly how a sender loses a domain's reputation. If anything expires here it is `unverified`, and that is a decision to take rather than a default to assume |
**The shape is already in the tree, twice.** `utils/teamActivityPrune.js` and
`utils/userNotificationsPrune.js` are the same worker — `setInterval` + `unref` + `stop()`, a horizon
read from a `*_retain_days` setting, started and stopped in `server.js`. Phase 7 wrote the second one
for `user_notifications` after finding that table had no policy either, with one policy difference
worth carrying: it deletes **read items only**, because age alone would destroy the evidence for "I
was never told". The same question has to be answered per table here rather than assumed.
**Documentation this owes.** `runicgateway.com` again, and for Phase 12's reason: `/privacy` and the
generated **Play Data Safety** answers currently publish, for the `deploy-engagement` row, *"kept
until the operator removes them; nothing here expires on its own"*. That is the true answer today and
becomes false the moment this lands, and it is generated from one inventory (`src/data/collection.mjs`)
rather than written twice. Also `BACKEND_DESIGN.md`'s table inventory, and an operator-facing note for
whichever settings keys this adds.
**For the org lead, before any code:** the four horizons (or three, plus "suppressions do not
expire"), and whether the send log's retention is allowed to be shorter than the operator screen's
usefulness — the alternative is that the screen learns to say "older entries have been swept" rather
than showing a silently truncated history.
**Acceptance:** every one of the four tables has a stated policy — a sweep with a horizon, or a
recorded decision that it does not expire and why; the cooldown horizon is checked against the
longest enabled rule's cooldown rather than picked; `/privacy` and the Play answers are regenerated
from the inventory; and a rig run shows the sweep deleting terminal rows while leaving a `scheduled`
Stage E 10 ──────────────────────────────────── 11a ── 11b (10 parallel from day one; 11 needs 6 + 10)
Stage E 10 ──────────────────────────────────── 11a ── 11b (10 parallel from day one; 11 needs 6 + 10)
Stage F 12 ── 13 (12 written before 13, merged in its window)
Stage F 12 ── 13 (12 written before 13, merged in its window)
Stage G └─ 14 (retention; after the cutover, so straight to `main`)
── all of the above onto `edge` ──
── all of the above onto `edge` ──
13 is the only thing that touches `main`
13 is the only thing that touches `main`
...and 14, which comes after it
```
```
**Phase -1 is blocking and takes minutes.** Every `edge` is 0 ahead / 3–16 behind `main` (§6.0a), so
**Phase -1 is blocking and takes minutes.** Every `edge` is 0 ahead / 3–16 behind `main` (§6.0a), so
@@ -3996,7 +4109,8 @@ Nothing here is "documentation to do at the end" — a phase is not done until i
web channel that does not exist until Phase 7) · new admin pages for rules, templates and per-channel
web channel that does not exist until Phase 7) · new admin pages for rules, templates and per-channel
preferences · **`PLAY_DATA_SAFETY.md` + `/privacy`**, both generated from one inventory that an
preferences · **`PLAY_DATA_SAFETY.md` + `/privacy`**, both generated from one inventory that an
engagement mailer materially changes.
engagement mailer materially changes.
- `.profile/README.md` — only if this lands as a headline capability.
- `.profile/README.md` — only if this lands as a headline capability. **It did:** the org lead
called it one, and the org landing page names the engagement system in the Phase 13 window.
---
---
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.