docs: the engagement workstream — cutover 1 of 7 (edge → main)
#200
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 13, step 1 of 7.
docsgoes first so the reference exists before the code that needs it.Merge order — this PR is a dependency of every other one
docs← this PRservuo-plugins+linktogether — one protocol bump with three declaration sites; CI then republishes the paired bundlewebsitemodule-uoIntegration-kitandroid-apprunicgateway.com, last, because every fact it fetches has to be true onmainfirstMerge docs#199 first
This PR tracks
edge, so it will carry #199 (the operator release note) once that merges. Do not merge this before #199 or the cutover ships without the note naming the two things that stop silently.What lands
48 commits, a clean fast-forward (
mainis 0 commits ahead).website/ENGAGEMENT.mdwebsite/BACKEND_DESIGN.mdwebsite/MODULE_API.mdregisterEventTriggers,registerAudiences,registerEngagementSeeds,ctx.events.emit,ctx.inbox.push, the ceiling latticewebsite/UPGRADE_NOTES.mdlink/v5.mdaccount.login.resultlink/INTEGRATION.md,link/PLAN.mdmodules/uo/API.mdandroid/PLAN.mdwebsite/TEAMS.mdNothing here is a behaviour change — it is the documentation half of a workstream whose code follows in steps 2–7.
Companion to RunicGateway/website's collapse of /admin/account (6 routes) and /player/account (8 routes) onto /auth/me/account, which was already a strict superset of both. - BACKEND_DESIGN.md: the two router-tree entries go; the /auth/me prose is rewritten from "additive, the older routes stay for web back-compat" to the single surface it now is, recording why /auth/me was the one to keep and that gating was equivalent. account.controller.js moved to router/v1/auth/. The /player-group paragraph loses account.router.js from its mount list. - ENGAGEMENT.md: new Phase 1a records the collapse as built, and §0.6 finding 4 is corrected — it named router/v1/player/account.router.js, which is gone. Phase 1b's change-and-verify flow now lands on /auth/me/account and nowhere else, which was the reason to do this first: a self-service field would otherwise have been written three times, in 1b and again in Phase 3. - android/PLAN.md §6.4/§8: the "routes stay for web back-compat" note is now false. The app needed no change — MeApi.kt was already 100% /auth/me/*. - API_V2_PLAN.md: a forward pointer only. Its router inventories are a record of the domain split as it landed and are deliberately left as written. api-route-inventory.json is regenerated wholesale, not partially updated. It is a generated mirror of server/routes.manifest.json with no CI gate, and it had drifted on its own: 168 entries against the manifest's 203, missing every Teams route and still listing the two Gmail connect routes Phase 1 deleted. It is now byte-identical to the manifest. A gate for it is flagged in ENGAGEMENT.md Phase 1a as later work — a mirror nothing checks will drift again. PROJECT_TREE.md is deliberately untouched: it is auto-generated by the sync-project-tree workflow in the website repo, which regenerates it from tracked files on main. Co-Authored-By: Claude <noreply@anthropic.com>Companion to website#<pr>. Records Phase 1b as built, and corrects two things the plan got wrong before anyone builds on them. ENGAGEMENT.md - Phase 1b step 2 said to pin the index to a case-insensitive collation. Every _ci collation MariaDB offers here is also accent-insensitive, so that index would refuse jose@x.com once josé@x.com existed and the de-duplication would have cleared a legitimate account's address. The as-built block records the generated-column design that shipped instead, and the second-order version of the same bug that a seeded fixture caught in the de-dupe query itself. - §0.6 named two callers of isDuplicateUsername(). There are five, and the three it omits fail worse than the two it names. BACKEND_DESIGN.md — the users table (already stale: it predated the player account work), plus email_verifications and email_dedupe_report, and the five new routes. UPGRADE_NOTES.md — an operator entry, because the de-duplication is the kind of quiet change this file exists for: nothing breaks, and the affected users find out the next time they try to reset a password. api-route-inventory.json — regenerated from the manifest; still ungated. Co-Authored-By: Claude <noreply@anthropic.com>MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on `edge`" paragraphs are now marked historical rather than current, which is the correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the in-place-amendment rule those paragraphs invoke no longer applies and every addition from here takes a version of its own. §2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and `registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md §7.3 asked this document to hold: a rule, a template and an audience outlive the module that declared them, so `trigger_id` is a plain VARCHAR with no cascade and an unregistered id shows DORMANT rather than erroring or being auto-deleted. The failure that prevents is specific — an id that stops resolving must never silently become a send to a different set of people. ENGAGEMENT.md records Phase 2 as built, and three things it did not previously say: §5.1a now DEFINES the ceiling lattice. The document named "narrowest" and "tightest" throughout and never said what narrower meant, and the obvious reading is a security defect: under a flat total order a `staff`-ceilinged trigger permits an `owner` audience, i.e. a rule that mails cheat detection to the player it detected. It is containment, not size, and incomparable ceilings have no bound at all. §7.2 is answered — ONE namespace, against the recommendation in its own text — with the two knock-on effects that only appeared once it was built (a relaxed id grammar, a shared legacy allowlist) and the risk that did not materialise (the push catalog is untouched, so the shipped app sees no change). §7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after Phase 9. §4.3 is kept true to what shipped: `audience` and `ceiling` are two fields rather than one, `'computed'` is gone from the audience vocabulary, and `example` is enforced rather than encouraged. §6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window work, not this window: the integration kit pins a website `main` sha and runicgateway.com FETCHES its facts from `main`, so doing either now would turn a green repo red for the whole edge period — for the kit, against the explicit rule in its own pin ("written against what shipped, never what is in flight"). BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the adminOnly line; api-route-inventory.json regenerated. Co-Authored-By: Claude <noreply@anthropic.com>The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and `android/PLAN.md` §11 — plus the phase's as-built section and one correction the build forced. `ENGAGEMENT.md`: - Phase 3's as-built: the three decisions settled before any code, why the channel registry could not wait for Phase 6, what the sparse PUT buys, the projection stated as an invariant, and the staff-ceiling filter. - **The phase's own acceptance line was wrong and is struck through.** "A fresh user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN, push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no row genuinely does mean notified. Push STREAM subscriptions have never worked that way — `notification_subscriptions` holds a row only on opt-in — so `instant` would have projected the entire catalog into the legacy GET for every existing user. §3.1's comment is corrected in the same pass. `BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the `notification_channel_prefs` table entry (including that absence means the channel's default rather than `off`, and that all three agreeing on `off` today is a fact about the declarations and not about the table), and a note on `notification_subscriptions` that it is now the push projection. `android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working and the `{"streams":[]}` gotcha still applies to that endpoint. The new section documents the superset endpoint for whenever the app adopts it: render toggles from each item's `channels` rather than a hardcoded three, `modes` is the effective mode and the client must not re-implement the defaulting, the PUT is sparse so the empty-array gotcha does NOT apply here, and one id may be missing that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff caller). Companion to website#169. Co-Authored-By: Claude <noreply@anthropic.com>Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections building it forced on this document's own design sections. ENGAGEMENT.md - Phase 4 is split 4a / 4b, with what each owes. - Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8 (android CI) is open. - The as-built: the gate order and why two of its placements are load-bearing, the segment rule the design never stated (not is legal only inside an and, and contributes no ceiling), dormancy three ways and why audience_segment_id has no foreign key, the conditions grammar's two fail-closed properties, and why emit does not await the engine. - Section 4.1's cooldown statement and section 4.2a's dedupe index are corrected in place, so the design sections stop teaching the two defects. BACKEND_DESIGN.md - Five new tables in the schema inventory, each with the reasoning a reader would otherwise have to reconstruct: why subject_key is in the primary key, why the dedupe index is scoped, why the send log survives an account deletion and is not a second address book, and why a rule points at a segment without a foreign key doing it. No route table changes - Phase 4a adds no routes. Co-Authored-By: Claude <noreply@anthropic.com>Six defects that only driving the two screens in a browser could find, plus two environment facts worth not re-deriving: - rebuilding client/dist while the server is running blanks the whole SPA. The HTML shell resolves core's hashed bundle filename at boot, so after a rebuild it points at a file that no longer exists; core's bundle 404s, window.__rg is never published, and modules/uo/entry.js throws MODULE_API.md §3.1's "core did not publish its shared dependencies" into a blank page. The error names core, and core is not at fault. Restart after every build. - a window.confirm blocks CDP entirely, so the two destructive actions cannot be driven from a script and a session that opens one is stuck until a human dismisses it. Exercise those paths over the API. - [x] AI-assisted: written with Claude Code (Opus) Co-Authored-By: Claude <noreply@anthropic.com>`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 <noreply@anthropic.com> (cherry picked from commit2fd5d065b7)Two scope decisions taken by the org lead on 2026-08-31, before any Phase 10 code, plus the three factual corrections that finding them out produced. Phase 10 — the protocol bump now carries three enrichments, not one. The argument is specific to this phase: a bump costs a sidecar release, a republished bundle and an operator update on every shard, so a field left out does not cost a follow-up commit, it costs a second bump with the same lead time and a split operator population. The two additions: * player-vendor fee state on vendor.listing (ownerAcct, holdGold, chargePerDay, daysRemaining), because uo.vendor.expiring is the same "owned asset at risk with a deadline" shape as the flagship and today has neither an address nor a deadline; * a post-decision account.login.result, because EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision. Moving vendor.sale out of the opt-in patch tier was explicitly declined. Phase 11 — ships every checked row of 8.6 rather than a single rule, carving out uo.market.item_listed (a saved search; no per-user query store exists). ~23 triggers grouped by the audience kind each family exercises, since exercising the ceiling lattice at scale is the point rather than volume of mail. Every rule still ships enabled = 0 per Q3. Notes that the phase will likely want an 11a/11b split on the 4a/4b precedent, to confirm at its start. Three corrections to 8.6, each verified against the emitters rather than the table: * uo.vendor.sale is real and does carry ownerAcct, but lives in servuo-plugins/ patches/ (opt-in, verified only against ServUO 57.4) — dormant, not broken, on a shard that declined the tier; * uo.vendor.expiring had no data at all, not merely no mapper — vendor.listing carries ownerSerial/ownerName and nothing carries held gold or daily charge; * uo.account.login_attempt could not have been built as described — it would have mailed "someone tried to log into your account" on every successful login. Renamed uo.account.login_failed so the id cannot be misread again. Also amended: the status header, scope decision 3, and 6.0b's documentation assignment for rows 10 and 11 (v5.md now earned; the patch-tier caveat is an operator-facing doc obligation; runicgateway.com's capability claim changes when "one rule" becomes "the catalogue"). Co-Authored-By: Claude <noreply@anthropic.com>Design of record for the bump, plus the reference updates it obliges. Engagement Phase 10, as built. link/v5.md is new. Three enrichments in one bump, because a bump costs a sidecar release, a republished bundle and an operator update on every shard — a field left out costs a whole second round of that, not a follow-up commit. a. house.decay gains ownerName and a nested `schedule`. estimatedCollapse is present ONLY where it is exact: under dynamic decay each stage's duration is drawn at random on entry, so collapse is knowable only once the house is already at IDOC; under static decay it is exact at every stage. Omitted rather than approximated — an absent field is honest where a wrong date becomes a dated promise in a player's mail. b. vendor.listing gains ownerAcct — a character name identifies nobody, only the game account joins to shard_account_links — and a `fees` block resolving PlayerVendor.PayTimer's dismissal rule into a dismissalAt instant. There is deliberately no daysRemaining: a pay period is a UO day on an old-system shard, so the name would be wrong by a factor of twelve. c. account.login.result is a NEW kind. account.login.attempt fires from a veto hook that runs before the auth decision and constructs with Accepted = true, so it fires on successful logins too and cannot carry a verdict. INTEGRATION.md: the version banner to 5, the Housing and Market field tables and examples, and the new kind beside the attempt — with the attempt's own row corrected to say why it is not a failure signal. PLAN.md: 5.1's "failed-login signals" row was wrong about what AccountLogin can tell you; 5.3 and 5.4 gain the two new signals; 5.4's dynamic-decay model gets the consequence for publishing a collapse time, and the CanDecay rule gets its testing trap. 7's frames are marked as 1.0's design sketch rather than the shipped wire, which they have not been for four versions. ENGAGEMENT.md: Phase 10 as built. Two decisions taken during the build (both field groups nested, so one admin rule governs each and a future field inherits the gate; no daysRemaining), the three visibility classifications, and the pin improvement — the schema test now asserts the declarations AGREE rather than that they all say a literal, which is the protocol-4 bug seen from the other side. Co-Authored-By: Claude <noreply@anthropic.com>The documentation half of Phase 11a (website#177, Module-uo#22). Three files, each carrying one obligation from 6.0b's Phase 11a row. **MODULE_API.md -> 1.8.0.** A seventh audience ceiling, `admin`, as a child of `staff`. One addition, no removal, so minor -- every declaration valid under 1.7.0 is valid now and no stored value changes. 2.4's ceiling bullet now names seven values rather than six, and says the thing a module author actually needs: `admin` is the ONLY pair in the whole lattice with real containment, so `permits('staff','admin')` holds and it is the only place `permits` is true between two values below `authenticated`. Also corrects two 2.3 rows that were stale rather than wrong: `ctx.inbox.push` has not thrown since Phase 7, and both members now name the consumer that actually calls them. **modules/uo/API.md gains 5**, the full trigger catalogue -- which 6.0b asked for in as many words ("the full trigger catalogue, its audiences and its ceilings, not one entry"). The 24 triggers grouped by ceiling with the reason for each ceiling; the four rows that deliberately do NOT ship with their recorded reasons; the two that need a patch tier or a v5 overlay; the three things a rule cannot express and are therefore done in the mapper; the three registered audiences; and the ingest ORDERING, which is load-bearing rather than incidental. **BACKEND_DESIGN.md -- the news publish path.** It described a direct `pushDispatch.publish('news.post', ...)` from the create/publish-post path. That call is gone: it is an emit through the engagement engine now, so news push rides a rule, and core seeds that rule disabled. The note says so as an operator-visible upgrade step, names the separate one-shot settings key and states what did NOT change -- the announce legs and the post hooks still fire exactly as they did. Co-Authored-By: Claude <noreply@anthropic.com>Four decisions settled by the org lead before any 11b code, plus MODULE_API 1.9.0 and what building it found. DECISION 7 is the one 11a could not have discovered, because 11a seeded nothing. The plan's own sentence — "24 rules, all enabled = 0; bespoke template bodies" — described work with NO MECHANISM to land in: templateSeeds.js and coreRules.js are core files with core arrays, and registries.js has no registerTemplates. Adds api.registerEngagementSeeds({ templates, ruleGroups }). Templates re-ensure every boot; rule groups are one-shot per named group, which is 11a's seed-key finding stated as an API rather than as a warning. DECISION 8: the game-powered families read in-universe, with a per-family sender rather than one voice. The crown writes about the crown's business and nothing else. This is also why the bodies had to be the module's: §5.2 says core stays ignorant of a module's domain, and check:modules reads identifiers, never prose — so the boundary is honoured deliberately here rather than leaned on. DECISION 9: nine bodies stay plain. A failed-login notice written as a letter is indistinguishable in register from the phishing mail it warns about. DECISION 10 corrects a fact the plan had wrong by omission. §8.6 records that uo.points.rank_changed cannot address a person, and the same reasoning was silently assumed to cover the governor. city.update's `governor` is written by BridgeJson.Actor(), which emits acct and webId — the new governor is addressable today, no protocol change. uo.governor.appointed is the 25th trigger. Also records what building it found: the no-conditionals/empty-string collision that the presentational fragments exist for, the render sweep that found three defects no check could see (an optional in a subject line, multi-optional detail lines, a trailing fragment used as a leading one), the trigger-bound template that could not reference its own unsubscribe link, and the trigger-id/template-key grammar mismatch. docs/modules/uo/API.md gains §5.7 and §5.8. Co-Authored-By: Claude <noreply@anthropic.com>The walk is the last piece of 11b and it was not a formality. It found six defects, four of them in code shipped by earlier phases, and none of the six was visible in a unit test: each is a disagreement between two things that agree with each other in a fixture. Three of the fixes were decisions rather than repairs, all settled by the org lead before any code: 11 `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel it -> add `uo.house.refreshed`, a 26th trigger with a body and a rule 12 a rule with a cooldown delivered on exactly ONE of its channels -> `channel` joins the cooldown key; a cooldown is per DELIVERY 13 `uo.vendor.expiring` could not fire, because the market sweep does not diff fees -> widen BridgeMarket.Signature() with exempt + periodsRemaining Files: website/ENGAGEMENT.md the three decisions, the four repairs, and what the walk proved rung by rung; the 11b bullet and the §8.6 family table now read 26 triggers / 34 bodies website/BACKEND_DESIGN.md engagement_cooldowns gains `channel` in its PRIMARY KEY, with the migration's information_schema guard and why MariaDB forces one link/v5.md the sweep has to DIFF the fees or the frame never comes -- stated as the general rule for the next enrichment, since it is emit cadence and not shape modules/uo/API.md §5.7a the cancel-shaped trigger and the Ageless-vs- LikeNew ServUO fact; §5.7b every link comes from config/clientPaths.js, and the two mistakes that made every call-to-action a dead link Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>. Co-Authored-By: Claude <noreply@anthropic.com>The cutover's operator-facing note, in the home Phase 1 created. Phase 13 owes it, and two earlier phases named it as their own obligation: Phase 6 for the Team rules (decision 3) and Phase 11 for news.post (7.1 Q9). Both stop mail or push that is sending today, and neither says anything when it does. Three required actions, ordered, because the order is load-bearing: 1. Update the shard. The protocol pin lives in the UO module's schema, so it is the MODULE update -- not the core one -- that moves uo_link_config.protocol to 5 and starts the site sending X-UOLink-Version: 5. A shard still on 4 answers 409 from that boot on. Named explicitly, along with the out: update the site and leave the module until the shard host can be reached. 2. Team email arrives disabled -- four rules, the screen's own banner, and the fact that only email moved (the tickle and the Discord bridge are untouched). 3. News push stops until the seeded rule is switched on. Four behaviour changes that need no action: the unsubscribe link now narrows to one channel rather than muting outright, email and push are opt-in while the in-app inbox is on, a rule is checked against its trigger's audience ceiling, and a bounce now suppresses an address with Suppressions the only screen that lifts one. Two claims were checked against the tree rather than the plan, and one of them was wrong first time: coreApi is enforced at LOAD, not at install, so an old core does not refuse the 0.5.0 install -- it accepts it and then fails to load the module. The note says that, and says the restart is required, because modules are read from disk at boot. The Data section carries Phase 12's finding 2 rather than burying it: nothing in this schema expires on its own. AI-assisted: written with Claude Code. Co-Authored-By: Claude <noreply@anthropic.com>