Commit Graph

6 Commits

Author SHA1 Message Date
c9e992588b feat(events): a trigger can start a run
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 12m45s
Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's
published version may carry `starts`: a trigger, a condition and a
cooldown. A matching firing creates a run of it, now.

- spec: `starts` validated by the phase gate's own function (same
  grammar, same dormant rule; a dormant start refuses publish), a
  cooldown from 0m to 7d (30m default), at most ten, event.* refused.
- events/starts.js: the emit path's third subscriber, beside dispatch
  and the gate observer. An unverified version starts nothing; one run
  at a time per definition and the cooldown are one conditional UPDATE
  on the new event_trigger_starts table; the occurrence key makes one
  firing reported twice one run. Ignored firings are counted by why.
- event_runs.origin: the firing (trigger, label, time, subject, matched
  condition, declared variables), also on the run.created audit line.
- Editor: a "Started by the game" panel using the gate's condition
  builder, with the ignored-firings line. Run console: "Started because
  ... fired".
- No module contract change and no MODULE_API bump.

Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a
real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the
dev database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 01:51:55 -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
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
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
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
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