210 Commits

Author SHA1 Message Date
61dc692088 chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m52s
`server/tools/cliloc-export/` existed for one reason: every modern UO client
ships its cliloc table in the Mythic container, and nothing in this stack could
read it — not the site, and not ServUO's own bundled `Ultima.StringList`. So an
operator installed UOFiddler, built this against its `Ultima.dll`, ran it over
their client and copied a 5 MB file to the web host, every time they patched.

Protocol 8 phase 2 put the decompressor in the shard plugin, where the client
files already are, and module-uo imports the table over the bridge. The tool has
nothing left to do. See docs/link/v8.md §9 and docs/website/CLILOCS.md.

Nothing in core referenced it — it was a standalone .NET console app under
`server/tools/`, and that directory is now empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:38 -05:00
7d7840eb6b fix(events): midnight in an announcement is 12:00 am on the Node we ship
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m56s
`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same
request as a 12-hour clock, and it does not survive a Node upgrade: for a
locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11,
whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later
resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it
is V8's ECMA-402 behaviour rather than locale data.

The image ships node:20-alpine and CI runs Node 20, while a dev machine is
newer -- which is how this rendered correctly in front of everyone who wrote
it and wrongly for every real recipient. An event mail announcing a midnight
start said "0:00 am" while the schedule editor beside it said "12:00 AM":
one instant, two spellings, which is the exact contradiction the option was
added to prevent.

`hourCycle: 'h12'` is the request that means what was meant. `recurrence.js`
already states the mirror-image rule for `h23`, and every other formatter in
this repo and in module-uo uses `hourCycle`; there is no `hour12` left here.

This is the one test that has been red on every events PR since #192, and
the only one -- each of those runs reported `# fail 1`. Verified by running
the suite under node:20-alpine, where the test fails without this change and
2152 tests pass with it; on Node 22+ it passes either way, so the test's
comment now says that a green run on a dev machine is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:20:31 -05:00
6dd4e5e3eb fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 5m47s
Three defects the acceptance walk found in shipped code.

**The public calendar showed neither what is live nor what is recent.** §I says
`GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built,
it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and
the shipped page asks for no window at all, so it took the default of now → +31d.
A run that began five minutes ago and has three hours to go was absent; so was one
that ended an hour ago. The site contradicted itself — `live: true` on
`/site/events/<slug>` while `/site/events` served `entries: []`.

A run is an interval, not an instant. `listInWindow` now matches a run whose
occupied interval OVERLAPS the window, which fixes the admin calendar's identical
hole (a run that started last Sunday and is still going was missing from "this
week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has
somewhere to live. Forecasts are still computed from `now`, never from the tail:
a projection into the past would advertise an occurrence that did not happen.

**A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s
comment said `reverting` is not claimable "exactly as a step with a live claim is"
— but a step's claim carries `claim_expires_at` and is reclaimed when the lease
lapses, and a resource in `reverting` had no expiry and nothing released it. A
process killed mid-teardown stranded the row for good: the sweep skipped it every
15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the
recourse §I names — answered 200 and did nothing, because it claims through the
same function. On the rig it stranded a lease, which then BLOCKED the next run of
the same event from taking that value until the shard's own deadline lapsed.

The stale test is `updated_at`, which for a `reverting` row is exactly when the
claim was taken, so no column is added. `updated_at` is re-stamped explicitly and
that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`,
so without the write a second claimer would still match the row. `revert_attempts`
is untouched — a stale claim is a process that died, not an attempt that failed.

**Three facts every event announcement computed and none could use.**
`announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven
`event.*` payloads, but four triggers declared none of them and a fifth declared
one, so `validatePayload` dropped them, they were absent from the variable list an
author picks from, and every emit logged `emit carried undeclared variables` at
DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven,
with the per-trigger copies removed so the seven cannot drift.

Verified against a real ServUO + sidecar + website rig: the public page now shows
a live run as "Happening now" beside recent finished ones (it showed nothing at
all before), and a lease stranded by a real mid-teardown crash was reclaimed
within one sweep, taking `cleanup_status` from `pending` to `complete`.

The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real
MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`,
`ON UPDATE`, and above all what `affectedRows` counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:28:17 -05:00
e46842a28c fix(events): carry a module's own account of a successful step
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 8m57s
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.

`module-uo` believed it, twice, since Phase 12b:

  * `uo.item.grant` answers `{ granted, missed, why }`
  * `uo.world.save` answers `{ started: true }`

The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.

Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.

## What this adds

`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.

**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.

  * objects only — the column is JSON and the console renders keys, so a bare
    string has nothing to render under, and core inventing a key would be core
    interpreting it after all;
  * 4KB of serialised JSON, dropped rather than truncated, because half a JSON
    object is not a JSON object;
  * unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
    runner would make the log INSERT throw, inside the one write documented
    never to;
  * re-parsed rather than passed through, so core holds no live reference into
    a module's object;
  * **anything wrong with it is dropped and logged, never a failure.** A step
    that did what it was asked must not be re-run because its module's
    commentary was malformed: that is a world write repeated for a log line.

The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.

## The renderer, which is half the fix

`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.

    uo.item.grant — granted: 8, missed: 4, why: bank full, offline
    uo.world.save — started: true

**`module-uo` needs no change**: the code it already shipped starts working.

MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:48:57 -05:00
eb167558e3 fix(events): staff could not reach their own participation history
Some checks failed
PR Checks / server-tests (pull_request) Failing after 5m57s
PR Checks / client-build (pull_request) Failing after 10m14s
PR Checks / bot-tests (pull_request) Successful in 8m36s
Found by the live walk, signed in as an admin: /account/events redirected to
the dashboard. `GET /player/events/history` is behind requireAuth alone and
self-scoped on req.user.id -- staff are a superset of players -- but the WEB
has two logged-in shells, and RequirePlayer sends anyone who is not a
`player` out of /account. A single mount there is a screen the reviewing
admin can never open.

Engagement Phase 7 hit this exact wall with the inbox and answered it with
two routes, one pair of components and one mapping. `eventHistoryPath` joins
`inboxPath` and `notificationSettingsPath` in notificationPaths.js rather
than starting a second file with the same comment at the top of it. The
staff path is /admin/events/mine, in the Events section of the sidebar, and
it is the one row in that group with no `roles`.

Also: the eventAnnounce fixture carried no slug, state or `listed`, so
`eventUrl` answered undefined in every test in that file and the new code
was exercised by none of them. The fixture now looks like a definition row,
and three tests cover the link, the unlisted case and the draft case.

The run.failed assertion that came with them was reading the wrong layer:
`baseFor` assembles eventUrl for every trigger and the SEAM drops the keys a
trigger does not declare, so the declaration test is what proves it. Removed,
with a note saying where the rule actually lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 11:59:03 -05:00
1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:38 -05:00
8453762e3b feat(events): the authoring UI proper (Phase 13)
Some checks failed
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Failing after 9m1s
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.

One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.

A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.

Two defects fixed in already-merged code:

  * Creating an event has been impossible since Phase 6. `events/new` was added
    beside `events/:id` and binds no param, and React Router ranks a static
    segment above a dynamic one whatever the order -- so the editor was handed no
    id and fetched /admin/events/undefined. Worse, the failure was invisible:
    `!form` is true for every failed load, so the error state sat behind a
    spinner that never stopped.
  * 12b's searchable sources had no consumer. The server half shipped and the
    only UI that reads a source never sent a term, so the 6,707-entry spawner
    list was picked from a 2,000-entry truncation with nothing saying so.

Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 16:34:03 -05:00
37f4623068 feat(events): targeted leases, value sets and searchable sources (Phase 12b)
Some checks failed
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Failing after 5m46s
PR Checks / bot-tests (pull_request) Successful in 8m28s
The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.

Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.

Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.

Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.

`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.

Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.

`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.

The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.

2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.

Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:06:37 -05:00
809426ad73 fix(events): give a lease's ledger row a reconcile path (Phase 11b)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Failing after 5m48s
A lease row had no reconcile path at all, and nothing failed to say so.
`cleanup.js` resolves a resource to the action of the step that made it, and for
a lease that action is `core.lease` -- a CORE action, on a path a module cannot
register anything on. So every `override` row came back `unanswered` for the life
of the run, and a lease the shard had quietly dropped (a config lease is
memory-only there, so a restart reverts it by design) stayed in the ledger as
live until teardown went hunting a baseline nobody was holding.

`core.lease` gains a `reconcile()`, and `registerEventLeases` gains an optional
`inForce()`: "does the game side still have any record of this hold?"

Deliberately not `read()` plus a comparison. A value that differs from what the
run applied is DRIFT, which teardown must deliver through `restore()` so the row
lands `drifted` with the current value beside it; a reconcile that inferred
absence from a changed value would orphan the row first and tell the operator the
lease vanished rather than that somebody moved it. Only an explicit
`{ ok: true, held: false }` takes a row out -- a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave the ledger alone.

MODULE_API_VERSION stays 1.10.0, amended in place.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:44 -05:00
7d3d6d5abd feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who
took part, publishes a results table, and announces a post through the legs the
news pipeline already uses. Events owns none of the delivery: a run says what
happened and an operator's rule decides who is told, so email, the in-app inbox,
push tickles, Discord and the town crier all arrive without anything in
`events/` growing a second delivery path.

**No route was added and nothing moved.** The whole surface is two more derived
fields on a run — `participants` and `resultsPublishedAt` — and a zero-line
`routes.manifest.json` diff proves it.

Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly
where `news.post` sits, and `run.failed` at `admin` on both halves. Every one
keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot
key so a deployment that has already stamped the Team and news keys still gets
them.

**The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a
rehearsal runs for real "with announcements ceilinged to `staff`" — but a
ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as
the real thing, so the moment this phase gave a run something to announce,
rehearsing a published event would have mailed every subscriber. The emit
envelope now takes an optional `ceiling` and the send-time G24 gate applies
`meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse
every rule rather than resolving to either.

`MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so
1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third
time.

Three defects the live walk found, none visible to a unit test:

1. **A channel that reported success while reaching nobody.** The seeded
   `run.started` rule named `push`, because §8.5 and the plan both do. Push
   delivery joins `notification_subscriptions`, only ever written for an id the
   preferences screen offered push for — and it offers push only for a
   registered STREAM. So the tickle went nowhere every time while
   `pushChannel.deliver` answered "tickle published". `event.run.started` is now
   a stream as well as a trigger; the other six are not.
2. **A trigger's `description` reaches a recipient.** It is the structural
   projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing."
   put those words in an administrator's own inbox item.
3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The
   connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have
   counted every idempotent retried collect as a fresh participant.

And one caught before it shipped: ranking with a session variable is wrong here,
because `query()` takes a pool connection per call — the variable would be set
on one connection and read on another. A window function needs no session state.

## Verification

- `npm test --prefix server` — **1981 pass, 1 fail**, and that one
  (`botScore.test.js`) passes standalone at 18/18: a file-level flake under
  parallel load. Run with an empty `MODULES_DIR`, as CI does.
- `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green.
- Zero-line `routes.manifest.json` / `routes.guards.json` diff.
- A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail
  arrived, headed with the event's title and its start time in the shard's own
  zone; the rehearsal fired the same trigger and produced zero outbox rows where
  the real run produced three; `run.failed` reached the administrator's inbox
  and no player's; `core.announce.post` queued a second job without touching the
  news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert
  were run against real MariaDB 11.

## One thing for a reviewer, out of scope and not fixed

**Every `#swagger.description` in this repo is truncated in the generated spec.**
swagger-autogen does not honour a backslash-escaped apostrophe, so a description
is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is
pre-existing and repo-wide. Only the one annotation this phase edits is fixed
here (a typographic apostrophe), because otherwise this phase's own addition to
it would be dead text. The rest wants its own change.

- [x] AI-assisted: Claude Code (Opus 5).

Docs: RunicGateway/docs#TBD.

Co-Authored-By: Claude <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-04 13:06:46 -05:00
d4516739b4 Merge pull request 'fix(engagement): the trigger manifest was stale, and its check was crying wolf' (#191) from fix/engagement-manifest-crlf into edge
Reviewed-on: #191
2026-09-04 08:37:10 +00:00
82a50e5e04 fix(engagement): the trigger manifest was stale, and its check was crying wolf
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 1m5s
PR Checks / client-build (pull_request) Successful in 2m40s
PR Checks / server-tests (pull_request) Successful in 12m22s
Two defects, and the second is why the first survived a whole phase.

The manifest is stale. `engagement-triggers.json` embeds `moduleApiVersion`
deliberately -- "a stale manifest needs to know which API's rules produced it"
-- and Phase 7 (website#189) bumped MODULE_API_VERSION to 1.10.0 without
regenerating it. The committed file has said 1.9.0 ever since. One line, and
regenerating is the whole fix.

The check could not be believed. Both the test and the `--check` CI gate
compared bytes, and this repo is developed on Windows under core.autocrlf=true,
so git checks the committed LF blob out as CRLF and the comparison then calls an
unchanged manifest stale. That failure fires on every Windows checkout, says "a
trigger declaration changed", and is "fixed" by regenerating a file whose
content was already correct.

So the one check that exists to be believed had been failing for a reason
everyone had learned to write off as environmental -- including me, twice: the
Phase 7 PR recorded it as a pre-existing CRLF failure, and the Phase 8 PR
repeated the claim. It was neither pre-existing nor CRLF. A check that cries
wolf is a check nobody reads, and the genuine staleness underneath it went
unnoticed for exactly that reason.

Line endings are now normalised on both sides, which is the convention
routeManifest.js and routeManifest.test.js already use one file along -- that
pair had clearly hit this and been fixed; the engagement pair never was. What is
being asserted is that the committed manifest describes the same declarations,
and a line ending is not a declaration. Policing the encoding is .gitattributes'
job, not this check's.

Verify

- The check is still LIVE, proved by breaking it deliberately: with the content
  changed the gate exits 1; with only the line endings changed it exits 0. That
  is the whole point of the fix, so it is not taken on trust.
- `npm test` under the TAP reporter: 1950 tests, 1877 pass, 0 fail. That is
  pristine `edge`'s 1950/1876 plus the one test this repairs.

A harness note, disclosed rather than buried

The default (spec) reporter intermittently reports a FILE-level failure with all
of that file's subtests passing, no assertion, and no diagnostic beyond 'test
failed'. It named a different unrelated file on each of four runs
(requireInternalKey, routeManifest, eventAuthorize, totp) and the TAP reporter
shows zero failures over the same suite. It appears to be a reporter artifact
under concurrency rather than a failing test, but it correlates with this branch
(4/4) against pristine edge (0/2) on the same machine state, which I could not
explain and am not claiming to have. Worth its own look; it does not indicate a
product defect and no assertion fails.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 02:43:53 -05:00
fdc118166c feat(events): the resource ledger, leases and cleanup (Phase 8)
Some checks failed
PR Checks / client-build (pull_request) Successful in 3m15s
PR Checks / server-tests (pull_request) Failing after 8m21s
PR Checks / bot-tests (pull_request) Successful in 11m12s
Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN.

One table, one core action, one route, one body field, and two members added to
MODULE_API 1.10.0 in place. The safety property the whole world-write half
depends on: core now remembers what a run changed in the world, and gives it
back on every terminal path.

Four decisions settled by the org lead on 2026-09-03, all as recommended:

- A lease is acquired by a new CORE action, `core.lease`. Section F puts the
  duration bound and the two-events-one-target conflict check on core's side of
  the seam, and a lease verb per module would be both re-implemented once per
  module, advisory everywhere.
- Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A
  spawn's ref does not exist until the module answers, so what core writes
  before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never
  comes it stands, and cleanup calls revert() with the key and no resources --
  which is why section F's revert takes the key at all.
- Cleanup is one sweep over the ledger, not synthetic step rows. The
  step-shaped version costs a second retry counter beside `revert_attempts`.
- `reconcile` is declared here and TRIGGERED BY THE MODULE, through
  `ctx.events.reconcile()`. Core has no concept of the game being up, so it
  cannot decide when to ask; it asks once at its own boot.

MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`;
while it is on `edge` it is amended in place, so the whole module contract
reaches an author as one version they read once.

Verify

- `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the
  pre-existing engagementManifest CRLF failure, in a file this branch does not
  touch (`edge` before: 1950/1876/73/1). +75 tests.
- The unique key was proved against a REAL MariaDB, because nothing else can
  prove it: whether multiple NULLs collide in a unique index, whether a STORED
  generated column is recomputed on UPDATE, and whether the SET NULL foreign key
  survives beside it are properties of the server. eventRunnerSql.test.js gained
  16 tests; 65 pass against the container. The real schema.sql was applied to a
  fresh database and to an existing one.
- Client: 362 pass, and it builds. routes:manifest and swagger -- one route
  added, none moved.

The live walk found three defects, and two of them are the phase's real finding

Driven by a throwaway `rig` module in website/modules/, deleted before commit.

1. A lease was never given back at all. `core.lease` reserves its own ledger
   row, so it never went through the ledger's dirty-marking, so a run holding
   only a lease kept `cleanup_status = 'not_required'` and the cleanup leg --
   which selected on `pending` -- never looked at it.
2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing
   sweep moved the run to `incomplete`, which took it out of the leg's own scan
   for ever. The test covering the bound asserted `<= 3` and was satisfied by 1:
   a bound has two halves, and a test that only asserts the ceiling passes
   against a floor.
3. The first fix for (2) made the console lie. Spending every row's
   `revert_attempts` was a tidy way to take a `cleanup: false` run out of a
   counter-bounded scan, and the run page then rendered "3 attempts" beside
   resources nothing had ever tried. Found by opening the page.

Both (1) and (2) are the same mistake: deriving "is there anything to do" from a
summary column instead of from the rows. Neither was visible to a unit test,
because a test that calls the sweep directly never asks what would have selected
the run.

The two properties that need the process to die were walked as the plan asks.
With the module's perform() hanging, the placeholder existed while the dispatch
was in flight and nothing was named; after taskkill and a restart the reclaim
re-dispatched the same idempotency key, the retry re-used its own placeholder,
and everything was given back. Then, with the module reporting one of two
resources as no longer in force, the boot-time reconcile marked the other
`orphaned` -- never `reverted`.

This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as
Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 21:19:27 -05:00
fd9fb50351 feat(events): open the event contract to modules (Phase 7)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s
MODULE_API 1.10.0. Four names forwarded on the module-facing `api` --
registerEventActions, registerEventBudgets, registerEventLeases and
registerEventOptionSources -- one new route, and one rule made real: a
`cost()` naming a dimension no module declared is refused.

Only one of the four is new machinery. The action registry has staged
core's three actions on every boot since Phase 1; what it never had was a
way in, because loader.js builds its own `api` facade and had no method
that delegated to it. So the registry a module now reaches is one that has
been exercised on every boot for six phases.

Four decisions, settled 2026-09-03, all as recommended:

- Option sources are their own registration, modelled on registerAudiences,
  because a catalog has more than one consumer.
- An undeclared dimension is refused -- at save, at the dry run and at
  dispatch -- with its own code, because the fix is a module's declaration
  and not a deployment's cap.
- A lease is declared here and acquired by nothing; the ledger is Phase 8.
- Core registers core.options.legs, so an announce leg is a dropdown rather
  than the free-text box whose typo Phase 6's walk caught mid-run.

Proved with a throwaway module through the real loader, not with module-uo:
eventModuleContract.test.js writes a module to a real directory and lets the
loader scan it, covering all five envelope failure shapes, verify: true, the
four id spaces and dormancy on uninstall.

The live walk found the one defect nothing else could: the option-source
loader wrote its "already asked?" guard inside a setState updater and read
it on the next line, so the request was never made and the field sat on
"Reading the list..." for ever. It is a useRef now.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 14:15:04 -05:00
4077c4e79e feat(events): enablement, per-run caps and mayInvoke (Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s
Two new tables — event_action_settings (the deployment switchboard) and
event_run_budget (what a run has spent and the most it may) — plus verified_at
and verified_by on event_versions. The whole authorisation decision moves behind
one function, events/authorize.js: role, enablement, cap, and the shard's own
switch named as the layer core deliberately does not duplicate.

Three routes, none moved: GET/PUT /admin/events/actions (admin in both
directions) and POST /admin/events/:id/verify (admin, editor — a dry run
dispatches nothing).

Four decisions, settled by the org lead 2026-09-03:

- The default-off line falls between inspect and change, not between notify and
  inspect. Read literally, §K shipped core.wait disabled. The same line is the
  role floor.
- The tightest cap wins where two actions spend one dimension, pinned into the
  run at creation with the action it came from.
- A refusal follows the step's on_failure and takes health to degraded — its own
  status and its own log kind, because a refusal is not an outage.
- The verify gate is enforced for scheduled starts only: a human pressing Start
  now is the review the gate exists to require.

Derived and flagged for review: a dry run fails rather than warns on a disabled
action or an over-cap plan, and the unattended path does not re-check the
starter's role.

+111 tests (1921/1847/73/1 — the one failure pre-existing and environmental),
including a 403 walk over the real router and two concurrent spends against one
cap on a real MariaDB. The live walk found two defects, both fixed here: the run
console route dropped the budget it was handed, and the role refusal used a
plural verb over a one-item list.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 05:50:58 -05:00
9bc0bf5a3d feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:20 -05:00
6e73660b52 feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 16:10:16 -05:00
7b570c8ea1 feat(events): the minimal admin surface (Phase 3)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 5m26s
PR Checks / client-build (pull_request) Successful in 8m30s
Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.

Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.

Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 08:39:35 -05:00
2e964cfeee feat(events): the runner (Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 5m29s
`utils/eventRunner.js`, the eighth poller, wired into server.js beside
engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their
grace window into `missed`, advances each due run through its phases, and drains
that phase's steps in `seq` order. The three core actions from Phase 1 get real
bodies, so a published event started from the existing run route now announces,
waits and completes on its own.

No routes are added: a runner has no surface, and the live controls stay Phase
3's.

Four things the org lead settled (2026-09-02): a parked step is `running` with a
NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members
rather than special cases keyed on an action id; a run whose concurrency key is
held stays `scheduled` and lets its grace window decide; and `n` in §L's
`retry(n)` is a runner constant.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 06:32:24 -05:00
8e03497eb3 feat(events): schema, CRUD and the core action registry (Phase 1)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m24s
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.

**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.

Schema (`db/schema.sql`, append-only):
  event_series, event_definitions, event_versions, event_runs,
  event_run_steps, event_run_log. The four that need a writer —
  event_action_settings, event_run_budget, event_run_resources,
  event_run_participants — arrive with the phases that give them one.

Registry (`modules/registries.js` + `config/coreEventActions.js`):
  registerEventActions staging and commit, with its own id namespace, the
  closed risk and reversibility sets, revert() required iff and only iff
  reversible: 'ledger', a bounded budgetMs and a param shape whose every
  entry needs a type and an example. perform/revert/cost are stripped from
  everything the catalog serves. Core declares core.announce, core.wait and
  core.cue through the same staging area a module will use.

  It is reachable ONLY by registerCore(): loader.js builds its own api facade
  and has no method that delegates here, so no module can call it and
  MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.

Surface (13 routes under /api/v1/admin/events):
  Reads staff-wide; publish, archive and run creation admin-only from this
  phase per EVENTS.md §N2, even though the switchboard they will consult does
  not exist yet — a button that is admin-only later and open now is a gate
  nobody notices was missing. The live run controls and `verify` are absent
  rather than stubbed, because nothing is in flight yet.

Four things the build settled, all recorded in docs:
  - event_definitions gained a `spec` column. A draft's working copy cannot
    be an event_versions row: that table is immutable and a run pins one.
  - The spec validator must accept its own output. It added `actionVersion`
    and `dormant` and then refused them as unknown keys, which would have made
    the second save of any definition — and publish's re-validation —
    impossible. A test caught it; both are now accepted and recomputed.
  - A param's `example` is required, optional params included, matching
    registerEventTriggers. It is the authoring form's placeholder.
  - Two routes the §API-surface table did not name: GET /admin/events/:id and
    GET /admin/events/series.

Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.

`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.

Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).

Docs: RunicGateway/docs#209

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 23:29:07 -05:00
5779d15150 feat(engagement): retention — three sweeps and one recorded refusal
All checks were successful
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m23s
ENGAGEMENT.md Phase 14, the last phase of the workstream. Four engagement
tables grew on every fire and nothing had ever deleted from any of them.

Three of them now have a horizon, swept nightly by one worker
(utils/engagementRetentionPrune.js — setInterval + unref + stop(), batched
1000 x 50, each table's failure caught on its own so a lock timeout on one
does not leave the other two unbounded):

  engagement_sends      180 days   engagement_sends_retain_days      (7-3650)
  engagement_cooldowns   30 days   engagement_cooldowns_retain_days  (2-3650)
  engagement_outbox      30 days   engagement_outbox_retain_days     (2-3650)

The fourth, engagement_suppressions, does not expire, and that is the
recorded decision rather than an omission: a suppression is a standing
decision, and ageing out a hard bounce re-mails an address that already
bounced. The way out stays deliberate, and is now reachable per row.

Six decisions were settled by the org lead before any code. Two of them
widened the phase past what was offered:

  * the send-log horizon is admin-configurable, so retention got a SCREEN
    (Admin -> Engagement -> Retention) where team_activity and
    user_notifications keep theirs in invisible settings rows. The send-log
    horizon changes what an operator-facing page is able to show, so it has
    to be visible; the other two came with it, because "what does this
    deployment keep" is one question.
  * the suppression purge, which cost a Phase 9 decision. The list
    deliberately stripped address_hash from every row, so the only way out
    was a window.prompt asking the operator to retype an address the screen
    has never shown them. The row had no handle at all. The hash is now
    returned: this route is admin-only and an admin can already suppress and
    unsuppress any address they can name, so it grants no capability they
    lack. GET /sends still strips its own.

The outbox sweep is TERMINAL-ONLY and that is a correctness rule: a
scheduled row is a send this deployment still intends to make (delay_seconds
can put one a day out) and a sending row may be mid-flight.

One shipped defect had to be fixed for the sweep to be a bound at all.
reclaimStale returned every stale sending row to scheduled, and MAX_ATTEMPTS
is consulted only on a graceful retry outcome — so a send that killed the
process mid-flight cycled sending -> scheduled -> sending forever, never
terminal, therefore never eligible for any sweep. It now fails an exhausted
row BEFORE reclaiming the rest; the order is the fix.

Two indexes (idx_engo_sweep, idx_engs_sweep): every existing index on those
tables has created_at in second position, which serves a per-rule window and
is useless to a whole-table horizon.

Proved twice: engagementRetentionSql.test.js against a real MariaDB (7
tests, incl. the acceptance case and the wrong reclaim order run
deliberately), and the live stack, where a 90-day-old cancelled row was
swept and a 90-day-old scheduled row survived.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 15:40:53 -05:00
eec7dbf785 fix(engagement): claim the seed guard atomically, and let a rule name a digest body
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m29s
PR Checks / bot-tests (pull_request) Successful in 8m30s
Both defects came out of Phase 13's acceptance walk, and neither was visible to
any test.

**1. The one-shot rule-group guard was not a guard.** `seedRuleGroup` and
`coreRules.seedGroup` both read their settings stamp, inserted the whole group,
and wrote the stamp AFTER the loop. Two instances booting in the same moment both
read "absent" and both insert -- the walk ended up with 52 module rules where the
module ships 26. `docker compose up --scale app=2` and a rolling restart both
start two instances on purpose, so this is ordinary rather than exotic.

`settings.db` gains `claim(key, value)`: the same `INSERT IGNORE` as
`seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a
key. The atomicity is the PRIMARY KEY's -- no transaction, no lock, the same
bargain `engagementWorker`'s row claim already makes. Both seeders claim before
inserting.

The trade the code already documented is unchanged, only its order: a process
that dies mid-loop leaves the group stamped and partly seeded, and the missing
rules are an operator's visit to the "new rule" form. A duplicate rule is two
mails per event, for every rule in the group, which both functions' own comments
already call the worse outcome.

**Why no test caught it:** the stubs supplied `get` and `set` over a Map, which
cannot race, so they agreed with the bug -- Phase 4a's `foundRows` finding in a
different costume. The fake is now `claim`-shaped and decides without yielding,
exactly as the table does, and both suites gained a test that runs two seeders
with `Promise.all` and asserts one insert each. Verified by reverting the fix:
the module test then reports every rule inserted twice.

**2. A rule that names a `digest` body could not be saved.** `registries.js`
`checkSeedRule` permits `digest` in as many words -- it is the body
`teamDigestWorker` renders for a rule whose email channel an individual set to
digest mode, so it never appears in `channels` and never could -- and
MODULE_API.md 2.4 tells modules they may point `template_keys` at
`notify.digest`. `engagementRules.model.js` then rejected any key that was not
one of the rule's channels.

So every rule shipping a digest body answered an operator who opened it and
pressed Save with a 400 naming a key they had never typed: core's own Team and
news rules, and sixteen of module-uo's. The only way to save was to delete the
digest body, silently dropping digest support from that rule. The two validators
now agree; anything that is neither a channel nor `digest` is still refused, with
a test for each direction.

No MODULE_API bump: no member is added, removed or changed, and the documented
contract is what the code now honours rather than something new.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 14:29:21 -05:00
c8d45733b6 fix(engagement): two defects the Phase 11b live walk found in core
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Successful in 5m30s
Both are invisible to a fixture and loud on a real database, which is why the
walk is the phase's acceptance rather than a formality.

1. registerEngagementSeeds validated `max_sends_per_hour` and then dropped it
   from the normalized rule. The column is NOT NULL, so every one of the 25
   module-seeded rules failed to insert at boot. The registry test asserted the
   REJECTION of a bad ceiling and never that a good one survives; it now asserts
   the normalized rule against `engagementRules.db.insert`'s own column list, so
   the next field added is covered the day it is added.

2. The cooldown claim runs inside the engine's per-channel loop and its key was
   (rule, user, subject). So the first channel of a rule claimed the cooldown and
   every later one was reported as cooled -- and `inapp` is ranked first
   deliberately, so a rule naming email + in-app delivered the inbox item and
   silently never the mail. Core's own `news.post` rule has that shape. Phase
   11b's decision 8 requires the letter and the inbox item to fire together.

   `channel` joins the PRIMARY KEY (the org lead's decision 12: a cooldown is per
   delivery, not per occasion). Migrated in place behind an information_schema
   guard, because MariaDB has no conditional form of a key change and replaying
   schema.sql would otherwise fail on every boot after the first.

1551 core tests green; both new tests verified by reverting each fix in turn.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:09 -05:00
40ab1ce8d2 fix(modules): bump the CLIENT half of MODULE_API_VERSION to 1.9.0
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m0s
The two halves version ONE contract and a test asserts they agree
(client/test/moduleRegistry.test.js). I bumped server/src/modules/version.js and
not client/src/modules/version.js, so client-build went red — the job runs the
client suite before it builds.

Nothing on the client half changed: a seed is server-side data and core's seeders
write it on the boot path. It bumps for the reason its own header gives — a
module declares one `coreApi` range against both halves, and a client claiming
1.8.0 while the server answers 1.9.0 is two answers to one question.

327 client tests green; client build clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:22:00 -05:00
0a9149a04f fix(engagement): a trigger-bound template may reference the unsubscribe link
Some checks failed
PR Checks / client-build (pull_request) Failing after 23s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m6s
Found building module-uo's sixteen in-universe bodies, which are the first
trigger-bound templates in the system to carry an unsubscribe line of their own.

`emailChannel.deliver` computes an unsubscribe token per recipient and merges it
LAST over the projection, so `{{unsubscribeUrl}}` has always RENDERED correctly.
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 those bodies. Rendering right and then refusing the edit is the worst of
both.

Nothing had ever taken this path: core's generic `notify.event` declares
`unsubscribeUrl` in its own seed and is bound to no trigger, so `seedByKey`
supplied it there.

Adds DELIVERY_VARIABLES beside AMBIENT_VARIABLES — declared separately because
they apply to a different set of templates. Ambient facts are about the
deployment and reach every body; delivery facts are about the send and reach the
trigger-bound ones, which is exactly the set that is engagement mail.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:07:16 -05:00
cfd1cb3c3c feat(engagement): let a module ship its own templates and rules (Phase 11b)
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next 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 in them, and there was no
registerTemplates or registerRules anywhere in registries.js.

So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).

Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.

The two halves behave differently, deliberately:

  - Templates re-ensure on every boot, so a bumped seedVersion reaches every
    deployment except the ones where an operator edited that row.
  - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
    would resurrect a rule an operator deleted and reset one they enabled. This is
    11a's seed-key finding stated as an API rather than as a warning: a rule
    appended to an existing group reaches fresh installs only, and one that must
    reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.

Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).

16 new tests; 1549 core tests green; check:modules clean.

Refs docs#/ENGAGEMENT.md Phase 11b decision 7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 00:46:15 -05:00
1d4cd4adae feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m3s
Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:33:02 -05:00
c208543044 feat(engagement): deliverability — suppression, bounces and the verification gate
All checks were successful
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 5m12s
ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a
rule's audience does not get the mail, and they sit at deliberately different
points in the pipeline.

`engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a
rule's `delay_seconds` grace window and an address can bounce inside it, so the
only correct check is the one taken immediately before the transport call — which
is also what produces the `status='suppressed'` row with no transport call at all.

The Phase 1b verification gate is applied at ENQUEUE, through a new optional
`registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the
shared audience would have silenced the wrong sink: a rule spanning email and
in-app must still put an item in an unverified user's inbox. The excluded counts
reach `summary.ineligible` and the admin reach preview, which until now reported
an audience size that was never the number of people who would be mailed.

`bounceClassify.js` is the only thing that may write a `bounce` row, and it is
deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying
pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy
refusal, neither of which is a fact about the recipient. Reusing it would mean one
stale SMTP password suppressing every address the worker touched, silently. The
classifier reads the RFC 3463 enhanced status first, falls back to a phrase match
only past a veto list and only for 550/551/553, and does not suppress anything it
is unsure about.

Scope is engagement rules only: resets, invites, verification and the contact form
still attempt, matching the posture passwordReset.controller.js already stated.

Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard
bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a
status `engagement_sends` has carried since §4.5 — matched nothing and always
would have. It is now its own outcome; the outbox row stays `failed`, since that
ENUM has no `bounced` and a bounced row is one that finished unsuccessfully.

`address_masked` is this phase's one addition to §4.5's DDL. A hash-only table
cannot be operated — an operator cannot tell three typos from a whole domain
refusing mail — and the domain survives while the local part is destroyed, so the
column can never be read back as an address book.

- schema: `engagement_suppressions` (+ `address_masked`, `created_by`)
- `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement
  → Suppressions, the only way out of the list
- `sendNotification` returns `smtp: { code, responseCode, response }`
- 26 new tests; swagger, routes manifest and guards regenerated

Docs: RunicGateway/docs#191.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:47:34 -05:00
24a3cd85b3 feat(engagement): the in-app channel, core and web (engagement Phase 7)
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s
ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.

Four decisions settled by the org lead before any code:

1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
   device somebody is holding and email leaves the building, so both are asked
   for; an inbox item is a row on a page the user chose to open. Left `off` the
   channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
   screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
   preferences screen moves to `…/settings`. The plain word belongs to the
   content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
   a registered trigger, and writes when it does not.

Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
  to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
  no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
  first button → url, the rest → body) and inserts. `pushChannel.js` — a
  content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
  resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
  so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
  supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
  `settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
  `POST …/read-all`. Swagger + route manifest + four component schemas.

Web
- `NotificationBell` in all three headers, polling its badge once a minute and
  pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
  `/auth/me/notifications/channels` — a strict superset of the push-only stream
  list it replaces. The two legacy endpoints are untouched, so the shipped
  Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
  keeps them out of `/account`, so without this the inbox was unreachable for
  every non-player account. `lib/notificationPaths.js` is the one mapping.

Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.

Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:07:10 -05:00
065bec7ad8 feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.

Seven decisions settled by the org lead before any code:

  - email only moves; the push tickle and the Discord bridge stay direct calls
  - the EVENT carries its access-checked audience, and `members` resolves to it
  - the four Team rules are seeded DISABLED, with an admin banner and a note
  - team_notification_prefs stays, read by the engine as a scoped preference
  - the payload wins and a structural projection fills the gaps
  - the digest keeps computing at send time; only its state generalizes
  - an unsubscribe token turns off the channel it names, and nothing else

Three defects found while building it:

  - `email.button` never absolutized its href, while image and itemList both
    did. Every rule-driven CTA would have been a dead relative link, because a
    trigger's url variables are validated site-relative by construction.
  - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
    not to build. An outbox row snapshots the payload and so has none of the
    three properties the digest design exists for, including the security one.
  - the digest's send-log row carried no address_hash while the instant row
    beside it did, which would have made half the mail uncorrelatable in Phase 9.

Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.

Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:11:54 -05:00
3f90070566 feat(engagement): the template editor, the trigger catalog and the send log (engagement Phase 5b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could
change one. This is the screen that lets an operator change one without being able
to break the mail the system depends on — plus the two screens Q4 promised Phase 5:
Triggers (read-only, from the registries) and the Send Log, which closes G15.

The shape follows from one fact: a mail body is rendered by the SERVER, so the
preview is too, and framed rather than redrawn in React. A client-side renderer
would be a second implementation of the one artifact that matters, agreeing with
the send path on the day it was written and drifting from the first Outlook fix on.

Settled with the org lead before any code: a shipped default is edited IN PLACE
(`protected` blocks deletion and nothing else, `customized = 1` keeps the edit);
duplicate is the only way to a new template; `renderByKey` now requires
`published`; a test send is logged under a synthetic `core.admin.test-send`; and a
template a rule points at refuses deletion with a 409 naming the rules.

Three things the plan did not know, found by building it:

  - The undeclared-variable check cannot be a token scan. `email.itemList.variable`
    holds a BARE name, so a digest pointed at `itmes` would have saved clean and
    arrived empty. Blocks now declare `variables(props)`; the editor makes that
    field a select over the trigger's list variables so the typo is unavailable.
  - A duplicate that drops `seed_key` loses its variable palette, so duplicating
    `notify.event` would have been refused for the tokens it was copied with — the
    one action §4.6.2 offers, refusing itself. The copy inherits it; `customized`
    is what the seeder actually reads.
  - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first
    version tested it with `.length` — so block validation never ran at all.

Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a
rule's template key was checked against a pattern with no dot in it, so no rule
could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it
workflow was unreachable. Both models now read one pattern.

Verified against the running stack: real multipart mail into a mailpit catcher
including an unsaved draft, the draft/published arms both ways through the real
mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk.
Server 1428 tests green, client 324.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:13:57 -05:00
12ff201ed5 feat(engagement): templates — the email block family, renderer and seeded set (engagement Phase 5a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m38s
Every subject and body moves out of `mailer.js` into `engagement_templates` rows an
operator can edit. A relocation, not a regression: nothing that sends mail today
starts depending on an operator authoring something first.

- `email.*` block family in its own registry, sharing the page family's envelope
  walk and validate-then-sanitize order by binding rather than by copy.
- A server-side renderer producing both parts of a multipart message; the text
  part is byte-identical to the literals this commit deletes.
- Nine seeded templates, six of them wired now; the seeder's `customized = 0`
  guard lives in the UPDATE's own WHERE.
- `renderByKey` falls back to the shipped seed when a row is missing or unusable,
  so no failure of the table can stop a password reset.

Also fixes `check:hosts` reading the template key `auth.email-verify` as the
hostname `auth.email`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:39 -05:00
4b45eddb5d feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s
The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.

Four decisions settled by the org lead before any code:

  - segments get their OWN nav entry, "Audiences", not a tab of the rules screen
  - the on/off switch is its own PATCH route, not a full PUT
  - the reach preview is a count only, on demand
  - a rule can be hard-deleted; the send log survives it

The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.

The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.

Two defects found by walking it against a live server, both in Phase 4a's code:

  1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
     only whether the segment ROW existed. The other shape of the same failure
     is a segment sitting exactly where it was whose every audience belongs to
     an uninstalled module: same outcome, nothing deleted. Uninstalling a module
     under an enabled rule produced a rule the screen showed as on and firing.
     The expression walk now lives in engagement/segments.js as
     `missingAudiences` and both lists ask it.
  2. "1 rule still use this segment" - the delete refusal pluralised the noun
     and not the verb, in the sentence an operator reads when told no.

Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.

Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.

Companion docs PR: docs#184.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:04 -05:00
2079aaf667 feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m37s
Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction.
This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger
now produces outbox rows and send-log entries; Admin - Engagement - Rules and the
segment composition UI are 4b.

Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep
worker, audience resolution, condition evaluation, the grace window and its
cancellation, and the save-path validation 4b's form will call. engagementEmit's
Phase 2 log line becomes the engine call.

Two settled questions this phase was blocked on:

  Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the
  outbox claims each row with a compare-and-set into the 'sending' state the ENUM
  already carried. It makes the outbox safe for two instances, not the deployment.

  Q4 (admin surface) - its own top-level nav group, built in 4b.

Two defects in the plan's own section 4, both found by building it:

  The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and
  one event is one row per (rule, user, channel) - so a fifty-person audience
  would have had one row admitted and forty-nine silently ignored. Scoped.

  Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always
  passes against this codebase's pool: the mariadb connector defaults
  foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is
  two statements now, with the interval guard in a WHERE clause.

The second defect is why there is a second test file. The stubbed suite was green
against the broken claim, because a stub can only agree with whoever wrote it;
engagementEngineSql.test.js runs the raw statements against a real MariaDB and
skips when there is none.

Verification: 43 new tests green in engagementEngine.test.js, 12 more against
MariaDB 11.8, and the whole path exercised end to end against a live database -
per-subject cooldowns, conditions, the CAS claim, the send log's honest failure
detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF
failures in the generated-artifact tests are unchanged from clean edge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:27 -05:00
b13ffd584f feat(notifications): per-channel preferences and the delivery-channel registry (engagement Phase 3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 10m29s
`notification_subscriptions` answers one question — which streams a user wants
PUSHED — because that is the only question the shipped Android client can ask.
This adds the general one: which subscribable ids, on which channel, in which
mode. The old table becomes the push projection of the new one and keeps its
exact wire shape, so the shipped APK needs no update and no delivery path is
touched.

What lands:

- `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the
  declarative half only: id, label, `carriesContent`, `defaultMode`,
  `supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for
  the reason `transports/index.js` deferred this file at all. `coreChannels.js`
  declares push / email / inapp through the subsystem's one door.
- `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill,
  copying the `announce_jobs → announce_job_legs` precedent.
- `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the
  `(id, channel)` pairs named are written — deliberately unlike the two whole-set
  PUTs beside it. `off` is a mode rather than an omission, so this endpoint has
  no empty-array case and the kotlinx DTO gotcha cannot arise here.

Three decisions the org lead settled before any code, and one corrects the
phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`.
The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row
does mean notified — but stream subscriptions have never worked that way, so
`instant` would have projected the whole catalog into the legacy GET for every
existing user and switched every toggle on in the shipped app after an upgrade
nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user.

One thing not named by the phase, and it is a G24 consequence rather than scope
creep: a trigger ceilinged at `staff` can never reach a non-staff user, so
offering the toggle would be offering a dead control AND disclosing the event
exists — `uo.cheat.detected` would otherwise appear in every player's screen the
moment Phase 11 declared it. Filtered from the catalog and gated on write. That
gave the `staff` label its first consumer, now written down as
`ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not
`teamGrants.STAFF_ROLES`, which answers a different question).

15 new tests; swagger, route manifest and guards regenerated. No web or app
surface — those are Phases 7 and 8, where a preference governs something visible.

Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:08:17 -05:00
563199a096 feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m29s
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.

`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.

MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.

Three design decisions, approved by the org lead before any code:

ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.

Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.

THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.

`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.

Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 06:40:28 -05:00
fbb4b0bd91 feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s
Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 01:53:50 -05:00
6e61146678 refactor(api): collapse /admin/account and /player/account onto /auth/me/account
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 10m32s
Self-service account security had three URL surfaces onto one controller. All
three mounted the same `admin/account.controller.js` handlers; each of the three
router files carried a header comment apologising for the arrangement.

`/auth/me/account` was already a strict superset, which settles which to keep:

  /admin/account   6 routes  noindex, isLoggedIn, staffOnly
  /player/account  8 routes  noindex, requireAuth
  /auth/me/account 10 routes noindex, requireAuth

Neither of the deleted surfaces carried recovery codes, and /admin/account
carried no username or password change at all — so client.js already called
/auth/me/account/recovery-codes/* for two operations on a screen it otherwise
served from /admin/account. The split was leaking before this change.

Gating is equivalent where it overlapped: /player and /auth/me apply identical
`noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower
while buying nothing, since every handler is self-scoped to req.user.id. There
is no CSRF layer to differ.

  - 14 routes deleted, 0 added, no handler changed.
  - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the
    one router that still reaches it.
  - Web client: 14 call sites move onto a root-level api.myAccount /
    api.changeUsername / ... group, matching the /auth/me methods already there.
  - Android app: no change. MeApi.kt was already 100% /auth/me/account/*.
  - Two swagger tags, `Admin · Account` and `Player`, were declared only by the
    deleted routes and go with them. The orphaned `AccountStatus` schema goes
    too; `PlayerAccount` is re-described as the any-role /auth/me/account shape
    (the name is kept so existing $refs resolve).

Breaking to the published OpenAPI surface, accepted deliberately: both consumers
are in this org, and deprecate-then-delete would leave the next phase deciding
whether to add routes to surfaces already marked for removal.

Verification: routes.manifest.json shows exactly 14 deletions and 0 additions.
The OpenAPI spec loses the same 14 paths with zero surviving path definitions
changed; its large textual diff is pure reordering, because removing the
first-mounted router shifts every later path. 1203 server tests, 288 client
tests, 53 bot tests green; check:modules, check:hosts and routes:manifest
--check all pass.

Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of
engagement Phase 1b, which adds a self-service email field — written once here
rather than three times.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 00:49:25 -05:00
c4ab8b9b9d docs(email): SMTP setup, the three postures, and the upgrade note
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 2m32s
The operator-facing half of engagement Phase 1. README's stack table and
security section, plus both .env.example files, all pointed at the
removed Connect Gmail flow.

The env comments now name the three supported postures rather than one
provider — a relay as the recommendation, smtp.gmail.com:587 with an app
password as the shortest migration, an unauthenticated local MTA as the
third — and point at docs/website/UPGRADE_NOTES.md for the deployment
this actually happens to.

The OpenAPI spec is regenerated: two routes gone, three annotations
rewritten, and the dashboard's new warnings[] documented.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:54:04 -05:00
47c8b37d45 feat(email): remove Gmail OAuth2, put SMTP behind a transport registry
Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A
subtraction and a replacement in one commit, because leaving the OAuth2
flow half-wired across a release is worse than either end state.

Deleted, per the §1.2a inventory: GET /admin/email/connect/start and
/connect/callback, the connectStart/connectCallback controllers with the
email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing,
the https://mail.google.com/ scope, the borrowed `google` auth-providers
client, the OAuth2 nodemailer transport with its smtp.gmail.com:465
literals, the refresh-token decrypt in the model, and the client's
Connect Gmail button, redirect banner and six Gmail error strings.
`provider` and `refresh_token_enc` stay as columns under the
additive-only discipline, unread.

Added: a mail transport registry (server/src/engagement/transports) with
`smtp` as the sole registration. `credentialFields` is the single
declaration the admin form renders, the sanitizer filters against, and
the "is it secret" answer comes from, so adding a transport is a
registration rather than four edits. email_config gains transport /
credential_enc (one encrypted JSON blob, since the field list is the
transport's to declare) / reply_to.

All six call sites keep their exact failure contracts: the contact
form's mailto fallback, the invite's copyable link, the reset's generic
200, and sendTeamNotification's never-throws. One deliberate behaviour
change: `enabled` now gates every sender rather than only isConfigured()
— the connect flow used to set it as a side effect, and with a credential
form the toggle has to mean what it says.

Send-test becomes the real verification. Under OAuth2 the sender came
back from Google and was guaranteed to belong to the credential;
operator-typed, it can be refused, so failures name the sender and the
SPF/DMARC reason (§1.2a consequence 2).

G22, the silent degradation: an upgraded deployment backfills to smtp
with no credentials and every sink politely does nothing. The admin
dashboard now warns when the deprecated Gmail token is present and no
replacement credential is, so the one deployment this happens to is told.
A fresh install has never had mail and is not nagged.

Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own
self-test, wired into pr-checks before the install; routes.manifest and
routes.guards regenerated (-2 routes).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:52:55 -05:00
e25e7ade80 fix(swagger): hoist the one inline predicate that makes the generator run away
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 26s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m42s
`npm run swagger` cannot run on this tree. It dies with swagger-autogen's
"FATAL ERROR: invalid array length - Allocation failed", generating
nothing, and it reproduces on a pristine checkout under both Node 20 and
Node 24 — so the committed spec cannot be regenerated by anyone, and any
PR that adds or changes a route is unable to meet the standing obligation
to update it.

Bisected to one statement in `teams.router.js`:

  param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v))

Hoisting that arrow to a named const fixes it outright. Nothing else
changes and the regenerated spec is byte-identical to the committed one,
so this is a generator fix, not a spec change.

The diagnosis worth keeping, because the file's own comment recorded a
different one. Phase 8 shipped a bare regex LITERAL before `.test(` and
phase 9 hoisted the regex, blaming a per-file route limit measured at
twenty statements; the file has sat at nineteen ever since on the theory
that it was one under the edge. That theory is wrong. Probing every
router file individually, `teams.router.js` at nineteen statements dies
while a THREE-route file carrying only this one route also dies — so the
trigger is the inline arrow reaching `.test(`, not the count. Hoisting
the regex was half the fix; the predicate around it needed hoisting too.

The comments in `teams.router.js`, `teamsVoice.router.js` and
`admin/index.js` are corrected to say so, since all three currently tell
the next person to keep counting statements.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 20:47:25 -05:00
f72c92ffbe fix(teams): the two defects the phase 9 rig walk found
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m33s
Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.

1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
   and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
   column list added `i.team_id` beside it. The `mariadb` driver refuses a
   result set with a repeated field name outright, so the pass died at its
   first query with "Error in results, duplicate field name `team_id`" — on the
   one code path every unit test stubs.

   It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
   NULL for exactly the Teams that have no channel yet, which is the create
   case. The two queries that do not join `teams` now ask for it by name.

   The regression test checks the INTERPOLATED sql captured from a fake
   `query`, not the source text — in the source the shared list is still a
   `${COLUMNS}` placeholder, and a first attempt that read the file passed
   happily with the bug reintroduced.

2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
   settings with voice switched on asks for a pass. An operator who then
   presses Sync now — the obvious next thing — hit `running` and got back
   `ran: false, reason: "a pass is already running"`, which the panel renders
   as nothing having happened, while the pass they triggered was busy creating
   their channels. A pass in flight is now JOINED and its real outcome
   returned, the same choice `teamSync.reconcileNow` makes for the same reason.

Tests: 1162 server (+2), 53 bot, 284 client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:07:27 -05:00
61abb3ec89 feat(teams): phase 9 — one voice channel per Team, granted by a role
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.

Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.

Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:

  - "the staff role" — there is no staff-role concept anywhere. Now a list of
    role ids the admin designates; empty is a normal answer, since guild
    administrators bypass overwrites and what is really missing is a way to
    let NON-admin staff in.
  - the parent category — §7.3 said the bot creates it and gave the id nowhere
    to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
    the server stores the id in settings.
  - whether the bot can act at all — nothing has ever checked. The operator
    invites the bot by hand and no invite URL with a permission integer exists
    in the tree, so a deployment can be one unticked box from every call
    failing. A preflight is now a PRECONDITION to enabling (422), not a
    per-Team error discovered afterwards.

Two more, decided rather than asked:

  - the threshold counts every active member, not linked ones. §7.3 wrote
    `voice_min_linked_members`; the operator is judging whether a Team is real,
    and link state answers a different question.
  - hidden Teams are never provisioned. A channel name is a game-sourced string
    published outside the site, which is exactly §2.8's concern —
    reservedNames.js already names "and eventually a Discord channel name" as a
    surface it protects — so the screen that suppresses a Team's page suppresses
    its channel, and a Team that becomes hidden takes the grace window.

Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.

Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.

Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.

Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 23:49:28 -05:00
11b4368b57 feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §7.2 bridges an event only if "its
visibility is public, or its destination channel is configured for a
members-only Team context". The four team.* streams carry no visibility; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:30 -05:00
cecd72915f feat(teams): the slash-command seam, and the first command through it
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module
registers a command's DEFINITION and its HANDLER together, the bot pulls the
definitions over the internal listener and runs none of our code, and the
handler executes here — forced by the bot container having no `modules` volume,
and the right boundary anyway.

Registration validates what Discord would reject as a batch (names, description
lengths, the four option types, required-before-optional), because the bot
registers the whole set in one PUT and a single bad entry costs every command
including the bot's own. Commands are not namespaced under their owner — there
is no dot in Discord's name grammar — so collisions are first-come with the
holder named.

The dispatcher is the access boundary: `linked` has no Discord equivalent, so
the platform-side permission default can only ever be advertising. It resolves
the actor by `auth_providers.kind` rather than the id slug, treats a banned
account as unlinked, bounds a handler under the bot's own timeout, and keeps
`ok` outside the envelope so a handler cannot forge it.

Liveness is asked at both the pull and the dispatch. The registries have no
removal path, so a module an operator disables at runtime would otherwise keep
a live handler behind a command Discord still advertises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:34 -05:00
13312d7fc3 fix(teams): make "replace the whole set" actually replace it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 8m53s
Found walking the live rig, which is the only place it could be found: every unit
test and the settings screen itself send every row, so the bug was invisible to
both.

`PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It
did not — it wrote the entries it was given and left every other preference
standing. So `{"teams": []}` cleared nothing, which is precisely the body the route
requires the array for: the field is mandatory even when empty so that clearing
everything is expressible, and it was the one thing that did not work.

A Team the caller could have named and did not now returns to its defaults. RESET
rather than deleted, and the difference is `last_digest_at`: that column is the
digest worker's state and not a preference, so dropping the row with it would make
every visit to the settings screen re-open a day-wide digest window and mail
somebody a summary they had already read.

Walked again after the fix on the real database: the empty set clears, an entry
naming a Team the caller is not in is still dropped, and the digest stamp survives.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:01:59 -05:00
5fa88baa0a test(teams): the refusals, which is most of what a notification feature is
A notification feature is mostly things that correctly do NOT happen, and each of
these is invisible until it goes wrong in production: a departed member and a
revoked guest are not recipients; a mute subtracts per Team and leaves the user's
other Teams alone; the author of a post never receives the notification about it;
forums switched off silences the forum streams including the digest; a Team's
first roster wakes nobody; a failed send does not stamp `last_digest_at`.

Two real defects came out of writing them.

`Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived
`filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id
0, so it was harmless — which is exactly why it would never have been noticed.
Fixed in all three places that filter ids.

`recipientIds: db.recipientIds` in the model captured the function OBJECT at
require time, so the layer below could never be substituted. That is not only
untestable; it means the model was not really the seam it claimed to be. Wrapped
so `db.x` resolves at call time.

The registries catalog assertion is now an exact five-element list, so a
shard-content stream creeping back into core's registration fails here rather
than shipping.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:23 -05:00
2a56cbf22a feat(teams): fire the four events, and the routes that configure them
The roster sync tickles at most ONCE per stream per run, not once per member: a
tickle is content-free, so five people joining in one sweep is five identical
notifications and one piece of information. Suppressed on a Team's FIRST roster,
the same condition the activity feed uses and the half where it matters more —
importing a 155-member guild would otherwise wake every one of their phones.

Forum notifications fire from the CONTROLLER, not from the forum model. That file
takes an already-resolved access decision and reads no membership table by design;
the fan-out reads both to compute its recipients, so calling it from inside would
make the forum model transitively depend on exactly what its header says it must
not touch. The model returns a `notify` key the controller destructures out before
the response, so the API's answer to "did my post save" is unchanged.

`pageUrlTemplate` joins the team provider — the one thing phase 6 found that the
design of record had not anticipated. Phase 3 left core with no Team page and
therefore no way to LINK to one, so a notification email could name a Team and not
take you to it. It is data rather than a callback: a function would put a module
hook on the mail path to produce a string that never varies. Relative paths only,
and protocol-relative is refused with absolute.

The unsubscribe endpoint is the only write in the public tier and the only route
with no `siteMode` — the reader is in their mail client, and the mail went out
before the site went into maintenance. POST always answers 200, valid token or
forged: distinguishing them would be an oracle for which (user, Team) pairs exist.
GET redirects and acts on nothing, so a mail client's link scanner cannot mute
Teams nobody asked to leave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:54 -05:00
686a214979 feat(teams): email as the third sink, with a digest that keeps no queue
A web-only user on a deployment running neither the Android app nor Discord gets
no notification that someone replied to their own thread — which is most users on
most deployments, and a forum where replies are invisible is a forum nobody
returns to. Email is a third consumer of the recipient set the previous commit
builds, not a fourth pipeline.

Unlike a push tickle, an email carries content: a mailbox is a destination the
recipient chose, not an untrusted relay reached by an unguessable topic. It
carries a title and an excerpt, never a full post.

The digest COMPUTES AT SEND TIME and keeps no pending-items queue. The only state
is `last_digest_at`. Three properties fall out, and the third is why it was chosen:
a deployment down for two days sends one correct digest rather than replaying a
backlog; a post a moderator hid after it was written is simply not in the query;
and a user who lost forum access between the post and the send is no longer in
the recipient set, so they are not emailed content they can no longer read.

`last_digest_at` is stamped only on a SUCCESSFUL send — stamping first would
quietly eat a day of somebody's notifications every time the mail provider had a
bad minute.

One-click unsubscribe is a stateless HMAC rather than a token table. Every
property that makes a password-reset token a row is absent: the link sits in a
mailbox for months so it has no useful expiry, and clicking it twice must mean
what clicking it once meant. Its whole capability is setting `muted` for one
(user, Team) pair.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:38 -05:00
26c23bd603 feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express.

`pushDispatch.publish` answers "everyone subscribed to a stream" and "this one
owner". Team notifications need "these N users", because Team scoping cannot live
in a stream id: the catalog is a static registration validated at boot against a
namespaced pattern, so a stream per Team is unexpressible, and stream ids are
stored in `notification_subscriptions` rows that would need collecting every time
a Team archived. So there are FOUR fixed core streams and the Team lives entirely
in the recipient set.

`team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks
default opposite ways, and the asymmetry lives in the column defaults so no
condition anywhere has to remember it.

One recipient query serves all four streams, because §6.2's two populations are
the same set written twice: "active members with a user_id plus active grants" IS
"everyone with resolved forum access". Mutes are subtracted in SQL rather than by
the caller — there is no function here that returns an unfiltered set.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:34:24 -05:00