docs(engagement): Phase 13 as built, and Phase 14 — retention #203

Merged
whitlocktech merged 2 commits from docs/engagement-phase-13-as-built into main 2026-09-01 18:55:14 +00:00
Member

Closes the workstream's documentation, and opens the one phase it leaves behind.

Phase 13 was the only phase with no "As built" section

§6.0b makes that a phase's own deliverable, and this one has more to record than "it merged":

  • The window missed steps 5 and 7. Integration-kit and runicgateway.com landed afterwards (Integration-kit#9, runicgateway.com#28), with Module-uo#27 clearing a leftover from step 4 — a core-ref pin still naming a branch the cutover had deleted.
  • Gitea's raw API route is CDN-cached for six hours, and a stale read fails both ways. It failed runicgateway.com for a moduleApi that was correct — a fortnight-old blob, cf-cache-status: HIT, Age: 15713 — and the same run reported the already-republished bundle triple as still current. The false red is the cheap half; the false green is the one that matters for a repo whose whole bargain is going red when the platform moves. All three cross-repository checks read contents now.
  • The Integration-kit equality check never goes red on its own. It clones the ref the kit itself pins, so the kit sat three minor versions behind the platform — teaching a 1.6.0 contract with no triggers, audiences or seeds in it — and was green throughout. Step 5 is the only thing that makes anyone look. Between cutovers the kit is not wrong, it is dated, and ci/core-ref.json is where the date is written down.
  • registerEngagementSeeds does not validate the body it seeds, and the kit's own example body was malformed because of it. Found only by running the template's register() through core's real stage() validators — which the kit's CI does not do and cannot: it runs against fakes, and a fake accepts what core refuses.
  • Every edge was deleted rather than fast-forwarded. Recorded as the convention, on the grounds that a branch which does not exist cannot be the stale one somebody branches from — which was §6.0a's actual failure. The two that survived (installer, Integration-kit) are level with main as of today.

Phase 12's second finding becomes Phase 14

Scoped, not started, and it needs your approval like any other phase. Four tables have no retention sweep, and they are not one problem with one horizon — the table in the section says why each is different:

  • engagement_cooldownsengagementCooldowns.db.prune() already exists and has no caller. The horizon must exceed the longest cooldown_seconds on any enabled rule, or a pruned row turns the next fire into a first fire.
  • engagement_outbox — only terminal rows are eligible; a scheduled row may legitimately be days out.
  • engagement_sendstwo live readers: the per-rule hourly cap reads idx_engs_rule_window, so a horizon under an hour breaks Q3's ceiling, and the Send Log screen is the operator's only answer to "was this person told".
  • engagement_suppressionsprobably should not expire at all. Ageing out a bounce row means the next send re-mails an address that already hard-bounced, which is how a sender loses a domain's reputation.

The shape already exists twice in the tree (teamActivityPrune, userNotificationsPrune). The phase owes runicgateway.com too: /privacy and the generated Play Data Safety answers currently publish "kept until the operator removes them; nothing here expires on its own" — true today, false the moment this lands.

Two things I'd want from you 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.

Bookkeeping

  • on the phase headings that were done and unmarked — 4, 9, 10, 11, 12, 13
  • the status header still said "everything from Phase 10 on is still design"; it now leads with the cutover, the versions it produced, and the two things still open
  • the branching note still described §6.0a's opening state as current
  • three status lines outlived the cutover: link/v5.md and this repo's index row both said protocol 5 was built but unreleased, and INTEGRATION.md still named v2.0.0 + v1.0.0 as the shipped pairing

Still open after this

Phase 13's acceptance walk has not been run — 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. It is named as outstanding in the as-built rather than quietly ticked.

  • AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

Closes the workstream's documentation, and opens the one phase it leaves behind. ### Phase 13 was the only phase with no "As built" section §6.0b makes that a phase's own deliverable, and this one has more to record than *"it merged"*: - **The window missed steps 5 and 7.** `Integration-kit` and `runicgateway.com` landed afterwards (Integration-kit#9, runicgateway.com#28), with Module-uo#27 clearing a leftover from step 4 — a `core-ref` pin still naming a branch the cutover had deleted. - **Gitea's `raw` API route is CDN-cached for six hours, and a stale read fails *both* ways.** It failed `runicgateway.com` for a `moduleApi` that was correct — a fortnight-old blob, `cf-cache-status: HIT`, `Age: 15713` — and *the same run* reported the already-republished bundle triple as still current. The false red is the cheap half; the false green is the one that matters for a repo whose whole bargain is going red when the platform moves. All three cross-repository checks read `contents` now. - **The Integration-kit equality check never goes red on its own.** It clones the ref *the kit itself pins*, so the kit sat three minor versions behind the platform — teaching a 1.6.0 contract with no triggers, audiences or seeds in it — and was green throughout. Step 5 is the only thing that makes anyone look. Between cutovers the kit is not wrong, it is **dated**, and `ci/core-ref.json` is where the date is written down. - **`registerEngagementSeeds` does not validate the body it seeds**, and the kit's own example body was malformed because of it. Found only by running the template's `register()` through core's **real** `stage()` validators — which the kit's CI does not do and cannot: it runs against fakes, and a fake accepts what core refuses. - **Every `edge` was deleted rather than fast-forwarded.** Recorded as the convention, on the grounds that a branch which does not exist cannot be the stale one somebody branches from — which was §6.0a's actual failure. The two that survived (`installer`, `Integration-kit`) are level with `main` as of today. ### Phase 12's second finding becomes Phase 14 Scoped, **not started**, and it needs your approval like any other phase. Four tables have no retention sweep, and they are not one problem with one horizon — the table in the section says why each is different: - `engagement_cooldowns` — **`engagementCooldowns.db.prune()` already exists and has no caller.** The horizon must exceed the longest `cooldown_seconds` on any enabled rule, or a pruned row turns the next fire into a *first* fire. - `engagement_outbox` — only terminal rows are eligible; a `scheduled` row may legitimately be days out. - `engagement_sends` — **two live readers**: the per-rule hourly cap reads `idx_engs_rule_window`, so a horizon under an hour breaks Q3's ceiling, and the Send Log screen is the operator's only answer to *"was this person told"*. - `engagement_suppressions` — **probably should not expire at all.** Ageing out a `bounce` row means the next send re-mails an address that already hard-bounced, which is how a sender loses a domain's reputation. The shape already exists twice in the tree (`teamActivityPrune`, `userNotificationsPrune`). The phase owes `runicgateway.com` too: `/privacy` and the generated Play Data Safety answers currently publish *"kept until the operator removes them; nothing here expires on its own"* — true today, false the moment this lands. **Two things I'd want from you 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. ### Bookkeeping - ✅ on the phase headings that were done and unmarked — **4, 9, 10, 11, 12, 13** - the status header still said *"everything from Phase 10 on is still design"*; it now leads with the cutover, the versions it produced, and the two things still open - the branching note still described §6.0a's opening state as current - **three status lines outlived the cutover**: `link/v5.md` and this repo's index row both said protocol 5 was built but unreleased, and `INTEGRATION.md` still named v2.0.0 + v1.0.0 as the shipped pairing ### Still open after this **Phase 13's acceptance walk has not been run** — 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. It is named as outstanding in the as-built rather than quietly ticked. - [x] AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 18:17:39 +00:00
Phase 13 was the only phase in the workstream with no as-built section, which
6.0b makes a phase's own deliverable. It has one now, and it has something to
record beyond "it merged":

  * the window missed steps 5 and 7 (Integration-kit, runicgateway.com); they
    landed afterwards, with a third PR clearing a leftover from step 4;
  * Gitea's `raw` API route is CDN-cached for six hours, and a stale read fails
    BOTH ways -- it failed a correct site AND reported an already-republished
    bundle as current. All three cross-repo checks read `contents` now;
  * the Integration-kit equality check never goes red on its own. It clones the
    ref the kit itself pins, so the kit sat three minor versions behind, green,
    for the whole workstream. Step 5 is the only thing that makes anyone look;
  * `registerEngagementSeeds` does not validate the body it seeds, and the kit's
    own example was malformed. Only a run through core's REAL registry found it,
    which the kit's CI does not do and cannot;
  * every `edge` was deleted rather than fast-forwarded. Recorded as the
    convention, because a branch that does not exist cannot be branched from
    stale -- which was 6.0a's actual failure.

Phase 12's second finding becomes **Phase 14**, scoped and not started: four
tables with no retention sweep, and they are not one problem with one horizon.
`engagementCooldowns.db.prune()` already exists with no caller; the send log has
two live readers (the hourly cap's index and the operator screen) that bound how
short its horizon may be; and suppressions probably should NOT expire at all,
because ageing out a bounce re-mails an address that already hard-bounced. The
phase owes `/privacy` and the Play answers, which today say "nothing here expires
on its own" -- true now, false the moment it lands.

Also: ticks on the phase headings that were done and unmarked (4, 9, 10, 11, 12,
13), a status header that still said everything from Phase 10 on was design, and
three status lines that outlived the cutover -- `link/v5.md` and this README's
index row both said protocol 5 was built but unreleased, and INTEGRATION.md named
the v2.0.0/v1.0.0 pairing as the shipped one.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-09-01 18:47:55 +00:00
The walk ran and PASSED. Released artefacts only: the released installer,
checksum-verified, resolving bundle 2026.09.01; the overlay compiled against
stock ServUO 57.4 with zero errors; a fresh `main` clone of website with
module-uo v1.1.0 installed from its release manifest rather than copied in.
Forcing a house to Greatly produced exactly one email and one in-app item to the
linked owner and nothing to anyone else -- every row in every delivery table
names that one user.

Two defects, both in shipped core, neither visible to any test:

  * `seedRuleGroup()` reads its settings stamp, inserts the whole group, and
    writes the stamp AFTER the loop. Two processes booting in the same moment
    both read "not seeded" and both insert -- 52 UO rules where the module ships
    26, under one stamp. The function's own comment names the cost: a duplicate
    rule is two mails per event. A `--scale app=2` or a rolling restart does this
    on purpose.
  * `checkSeedRule` permits a `digest` template key in as many words, and
    sixteen in-universe UO rules ship one; `engagementRules.model.js` then
    rejects any templateKeys entry that is not one of the rule's channels. So an
    operator who opens "House - decay warning" and presses Save gets a 400
    naming a key they never typed, and the only way to save is to delete the
    digest body.

Neither is fixed here -- this PR documents a cutover, and a core change belongs
in its own.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 924204f8ba into main 2026-09-01 18:55:14 +00:00
whitlocktech deleted branch docs/engagement-phase-13-as-built 2026-09-01 18:55:15 +00:00
Sign in to join this conversation.
No description provided.