docs(engagement): Phase 11b — the seed API, the in-universe voice, the governor's letter

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>
This commit is contained in:
2026-09-01 01:14:53 -05:00
parent dc7d7c9a40
commit 9d50e5cacf
3 changed files with 385 additions and 10 deletions

View File

@@ -3268,11 +3268,192 @@ and 5a/5b precedent:
regenerated. The acceptance that belongs here is the catalogue-shaped half: **no staff- or
admin-ceiling trigger appears by name in a player's preferences catalogue**, and `check:modules`
proves core gained no UO identifier.
- **11b — the seeded templates, the seeded rules, and the live walk.** 24 rules, all `enabled = 0`;
bespoke template bodies for the families that earn one and `notify.event` + the structural
projection for the rest (§4.6.1 property 1 is precisely the claim that most of them need no
authoring, and 24 triggers is the first test of it that means anything). Everything in the
acceptance list below that needs a running shard is 11b's.
- **11b — the seeded templates, the seeded rules, and the live walk.** **25** rules, all
`enabled = 0` — the twenty-fourth is `uo.governor.appointed`, added by decision 10 below. Sixteen
in-universe bodies on two channels each (decisions 8 and 9), and core's `notify.event` +
`inapp.event` + the structural projection for the nine that stay plain — §4.6.1 property 1 is
precisely the claim that a trigger needs no authoring, and the nine are the test of it that
means something. All of it reaches the database through **`registerEngagementSeeds`**, which
decision 7 adds because no mechanism for a module-supplied template or rule existed. Everything
in the acceptance list below that needs a running shard is 11b's.
#### Four more decisions, settled by the org lead before any 11b code (2026-09-01)
Phase 11a shipped the declarations and stopped exactly where the plan said it would. Standing it up
raised four questions the plan had not asked, two of which are mechanism and two of which are voice.
All four were settled the same day, before any code, and three of them changed what 11b builds.
| | Question | Decision |
|---|---|---|
| 7 | a module cannot ship a template or a rule — core's seeders are core's | **add `api.registerEngagementSeeds({ templates, ruleGroups })`**; `MODULE_API_VERSION` → **1.9.0** |
| 8 | the register the game-powered bodies are written in | **in-universe, with a per-family in-fiction sender** — the crown writes about the crown's business, and nothing else |
| 9 | which bodies stay plain | **the nine security and operational ones**; the other sixteen go in-universe |
| 10 | the newly-elected governor is addressable and receives no letter | **add `uo.governor.appointed`** — a 25th trigger, `owner`-audienced, beside the existing broadcast |
**Decision 7 is the one 11a could not have discovered, because 11a seeded nothing.** `templateSeeds.js`
and `coreRules.js` are core files with core arrays in them, and there is no `registerTemplates` or
`registerRules` anywhere in `registries.js`. So the sentence above — "24 rules, all `enabled = 0`;
bespoke template bodies" — described work with **no mechanism to land in**. A module could have
reached `engagement_templates` through the `ctx.query` it already holds, and that was rejected for the
reason every raw-SQL-into-core-tables shortcut is rejected: it would re-implement `seedOne`'s
`customized` skip, its `seed_version` comparison and its block validation outside the file that owns
them, and the copy would drift the first time core improved the original. The registry keeps every one
of those in core and lets a module supply data — which is the same split `registerEventTriggers`
already makes, and the same reason it makes it.
> **Templates are re-ensured every boot; rule groups are one-shot.** That asymmetry is not an
> oversight, it is the two mechanisms this system already has, applied unchanged. A template row
> carries `seed_key` + `seed_version` + `customized`, so re-ensuring is how a better default reaches a
> deployment without stealing an operator's edit (§4.6.1 property 3) — and a template added in a later
> module version reaches every deployment, not only fresh ones. A rule is the opposite: re-ensuring one
> would resurrect a rule an operator deleted and reset one they enabled. So rules arrive in **named
> groups**, each with its own one-shot settings guard, which is 11a's seed-key finding stated as an API
> rather than as a warning. A rule added to an existing group reaches fresh installs only; a rule that
> must reach existing deployments takes a new group. The module names its groups, so the module owns
> that choice, and gets a docs paragraph telling it which one it is making.
**Where it runs is load-bearing, and it is not `seedDefaults()`.** `server.js` calls `seedDefaults()`
**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader —
so at the moment core seeds its own templates, no module has registered anything. Module seeds
therefore run in `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and **before**
the `onBoot` dispatch: a module whose `onBoot` warms a cache may assume its rules exist, and a module
the operator has disabled or that failed to load is skipped rather than seeded. Failure is `safe()`'d
like every other step there — a template that would not seed costs the shipped default, never the boot.
**Decision 8 is why the bodies are the module's and could not have been core's even with a mechanism.**
"A letter from Lord Blackthorn" is Britannian canon; §5.2's rule is that core stays ignorant of every
word of a module's domain. `check:modules` would not have caught it — it reads identifiers, imports and
route literals, never prose (`checkModuleIdentifiers.js` says so in its own header, and says why) — and
that is precisely the argument for taking the boundary seriously here rather than leaning on the
checker. A `templateSeeds.js` in core containing `uo.house.idoc_warning` and the word *Britannia* would
defeat the extraction while passing every check in the repo.
**The sender is per family, not one voice across all sixteen.** The crown was the tempting answer and
it is the wrong one: a shard where Blackthorn writes to you personally about a champion spawn is a
shard where the letter about your governorship means nothing. So the court writes about the crown's
business — the governorship, the elections — and everything else has the sender its own subject
implies:
| Family | In-fiction sender |
| --- | --- |
| `uo.governor.appointed`, `uo.governor.elected`, `uo.election.opened` | **Lord Blackthorn's court** — the seat, the ballot, the realm's offices |
| `uo.house.idoc_warning`, `uo.house.collapsed` | the **Office of Deeds** — a clerk with a ledger and a duty to warn |
| `uo.vendor.expiring`, `uo.vendor.sale` | the **Merchants' Guild** — a factor rendering accounts |
| `uo.guild.left`, `uo.guild.disbanded` | a **guild herald** |
| `uo.champ.started`, `uo.champ.boss_up` | the **town crier** — which is also the leg a module already owns |
| `uo.skill.capped`, `uo.quest.complete` | the **guildmaster of that craft** |
| `uo.character.death`, `uo.character.murdered` | the **Chronicler of the Dead** |
| `uo.points.rank_changed` | the **keeper of the rolls** |
**Decision 9 draws the line at the two places fiction costs something real.** A security notice must be
distinguishable from flavour — a failed-login mail written as *"a stranger sought entry to thy
account"* is indistinguishable in register from the phishing mail it is warning about, and that is not
a trade worth making for atmosphere. And an operator reading `uo.cheat.detected` at two in the morning
wants a name, a rule, a location and a timestamp, not a scroll. So nine stay plain and use core's
generic bodies:
```
PLAIN (9, core's notify.event / inapp.event)
uo.account.login_failed uo.account.unlinked ← security
uo.server.up uo.server.down ← infrastructure
uo.page.new uo.cheat.detected ← staff ceiling
uo.audit.staff_action uo.economy.milestone
uo.world.saved ← admin ceiling
IN-UNIVERSE (16, module-uo's own bodies)
house ×2 vendor ×2 skill/quest/death ×4
guild ×2 civic ×3 champ ×2 points ×1
```
**Both channels, and the digest is deliberately not one of them.** Each in-universe trigger ships an
`email` body — the letter — and an `inapp` body in the same voice, because a rule fires on both
channels at once and a player who reads the inbox item and then the mail must not find two different
narrators. Thirty-two rows, which is data rather than design. The **digest** stays core's generic
`notify.digest`: a day of events rolled into one list is not a letter from anyone, and dressing a
bulleted summary as correspondence is where this device stops being charming.
**Decision 10 exists because a fact in the plan was wrong by omission.** §8.6 records that
`uo.points.rank_changed` cannot address a person, because `points.board.top[]` names a mobile serial
and links are keyed by account — and the same reasoning was silently assumed to cover the governor.
It does not. `city.update`'s `governor` field is written by `BridgeJson.Actor()`, which emits
`serial`, `name`, **`acct`** and even `webId` when the account is linked. The new governor is
addressable **today**, with no protocol change and no new wire field. So the letter the whole voice
decision was chosen to make possible has a recipient, and 11b ships it:
- **`uo.governor.elected`** stays exactly as 11a declared it — `subscribers`, ceiling
`authenticated`, the town-square announcement that a city has a new governor.
- **`uo.governor.appointed`** is new — `audience: 'owner'`, `ceiling: 'owner'`, `subjectKey: 'city'`,
resolved through `ownerAccount: ev.governor.acct` on the same `city.update` frame, in the same
transition branch, guarded by the same never-on-first-sight rule. It is the letter.
Widening the existing trigger to two audiences was considered and refused for a reason worth keeping:
one trigger means one rule means **one template**, and the town's bulletin and the governor's letter
are not the same text. Two triggers is also what lets an operator run the announcement and leave the
letter off, or the reverse.
**This takes the phase to 25 triggers**, and the count in §8.6 and in 11a's summary should be read
that way from here. It does not take another `MODULE_API` bump: a trigger declaration is module data,
and 1.9.0 is decision 7's.
#### What building it found
Four things, and the first two are the ones a reader of this section should not have to re-derive.
**A template has no conditionals, and an in-universe body is a sentence.** That combination is the
whole engineering content of decision 8, and it is not obvious until a body is written. An unset
optional interpolates to the **empty string** (`interpolate.js` — no filters, no ternaries, by
design), which is exactly right for `notify.event`, whose variables are structural and each sit on
their own line. It is wrong for a letter: *"Be it known that , recorded to thy name, is this day
found ."* So the ternary stays at the call site and its result arrives as a declared optional, which
is Phase 5a's `forWhom` precedent unchanged — and the module now declares two shapes of them:
- a **LABEL** always has a value and can carry a sentence's spine. `houseLabel` is the house's name
and region, falling back to its seal number, because a warning has to name *something* the owner
can act on and a seal number is worse prose and better than a blank.
- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so the sentence closes
cleanly without it: `{{slainBy}}.` renders "has fallen." or "has fallen at the hands of a lich
lord."
The labels are declared `required: false` deliberately, and that is a real trade rather than an
oversight: a required variable missing **refuses the emit**, and a dropped notification is worse than
a cosmetic hole. Which leaves nothing at runtime to notice a mapper that forgot one — so
`engagementSeeds.test.js` asserts every label is supplied on every path that emits its trigger. That
test is the guarantee the declaration deliberately does not make.
**A render sweep over all 32 bodies is what found the defects, and none of them were visible in the
source.** Every body was rendered twice — once with every declared `example`, once with **required
variables only** — and scanned for the punctuation a vanished token leaves behind. Three real
defects, none of which any existing check could see:
1. **An optional variable in a SUBJECT LINE.** `A notice concerning thy house at {{region}}` renders
*"…thy house at "* for a house outside a named region. A subject is the one place a hole is
unmissable, and it is also the one place no block validator looks.
2. **Multi-optional detail lines.** *"On hand: gold. Charged each period: gold. Periods remaining:
."* on a pre-v5 vendor frame, where the whole fee block is absent. Fixed the way `place()` already
argues for coordinates: the line is **assembled in the mapper** from the parts actually present,
and a frame with nothing to report produces no line rather than a punctuated empty one.
3. **A trailing fragment used as a leading one**, opening an in-app body with a stray space.
**A trigger-bound template could not reference its own unsubscribe link.** `emailChannel.deliver`
computes an unsubscribe token per recipient and merges it last over the projection, so
`{{unsubscribeUrl}}` always *rendered* — but `variablesFor` takes a trigger-bound template's variable
list from the **trigger's** declaration, and a trigger has no business declaring a fact about how the
mail was delivered. So the token was undeclared, and the save-time undeclared-variable check would
have refused the first operator who tried to **edit** one of the sixteen bodies. Rendering right and
then refusing the edit is the worst of both. Core gains `DELIVERY_VARIABLES` beside
`AMBIENT_VARIABLES` — declared separately because they apply to a different set: ambient facts are
about the deployment and reach every body, delivery facts are about the send and reach the
trigger-bound ones, which is precisely the set that is engagement mail. **Nothing had ever taken this
path**, because core's `notify.event` declares the variable in its own seed and is bound to no
trigger.
**A trigger id and a template key have different grammars, and one of these ids uses both
characters.** `uo.champ.boss_up` is a legal trigger id (§7.2 relaxed the id grammar to admit `_`
inside a segment, precisely so `uo.house.idoc_warning` could exist) and an **illegal** template key —
`engagement_templates.key` is matched by `templates.KEY_RE`, which admits `.` and `-` and not `_`. So
the body for that trigger is keyed `uo.champ.boss-up`. Caught at registration as a boot failure,
which is the right place, and asserted in the module's tests so the next person meets it as a test
name rather than as a puzzle.
**Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or
admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to
@@ -3287,6 +3468,13 @@ rule switched on, a publish tickles the same subscribers it tickled before this
it tickles nobody and the admin screen says why.** Two more the new ceiling owes: **a rule cannot be
saved giving an `admin`-ceiling trigger a `staff` audience**, and **an editor is not a recipient of
`uo.audit.staff_action`** while an admin is.
Four more the 2026-09-01 decisions owe: **a module's seeded rule group is seeded once and only
once** — an operator who deletes one does not find it back after a restart, and one they enabled
is still enabled — while **a module template whose `seedVersion` moved is re-ensured unless the
operator edited it**; **an election on the live rig produces a letter to the new governor AND the
announcement to subscribers, from two rules either of which can be switched off alone**; and
**`check:modules` is still clean with sixteen Britannian bodies in the tree**, which is the
decision-8 boundary stated as a check rather than as a promise.
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point —
which is a materially stronger claim now that the module registers two dozen UO-named triggers.