58 Commits

Author SHA1 Message Date
720103e3d4 Merge branch 'main' into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 13m24s
2026-09-15 10:04:54 +00:00
f373f2e897 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)' (#201) from feat/asset-bridge-p2 into edge
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 13m28s
Reviewed-on: #201
2026-09-10 16:20:24 +00:00
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
655fbf3f69 Merge pull request 'feat(events): the Event System — core (Phase 16b cutover, 2 of 6)' (#199) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 1m32s
Build container images / build (push) Successful in 2m12s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Failing after 33m0s
Reviewed-on: #199
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:43:29 +00:00
baa4f7d5ba Merge pull request 'fix(events): midnight in an announcement is 12:00 am on the Node we ship' (#200) from fix/events-announce-midnight-hourcycle into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m32s
Reviewed-on: #200
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:27:58 +00: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
b92b85c3a9 Merge pull request 'fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)' (#198) from fix/events-p16a-walk into edge
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Failing after 8m56s
Reviewed-on: #198
2026-09-09 13:47:24 +00: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
af9f4e191c Merge pull request 'fix(events): carry a module's own account of a successful step' (#197) from fix/events-module-detail into edge
Reviewed-on: #197
2026-09-09 00:22:36 +00: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
2e9ed50e21 Merge pull request 'feat(events): the public calendar, event pages and participation history (Phase 14a)' (#196) from feature/events-p14a-public-surface into edge
Reviewed-on: #196
2026-09-08 17:04:49 +00: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
6e6c24065c Merge pull request 'feat(events): the authoring UI proper (Phase 13)' (#195) from feature/events-p13-authoring-ui into edge
Reviewed-on: #195
2026-09-07 22:12:44 +00: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
db8e01e868 Merge pull request 'feat(events): targeted leases, value sets and searchable sources (Phase 12b)' (#194) from feature/events-p12b-borrowed-and-oneshots into edge
Reviewed-on: #194
2026-09-07 16:22:43 +00: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
d0178c6419 Merge pull request 'fix(events): give a lease's ledger row a reconcile path (Phase 11b)' (#193) from feature/events-p11b-leases-participation into edge
Reviewed-on: #193
2026-09-05 04:10:49 +00: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
aba8d1e43a Merge pull request 'feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)' (#192) from feature/events-phase-10 into edge
Reviewed-on: #192
2026-09-04 18:24:44 +00: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
4a91d74085 Merge pull request 'feat(events): the resource ledger, leases and cleanup (Phase 8)' (#190) from feature/events-phase-8 into edge
Reviewed-on: #190
2026-09-04 05:12:19 +00: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
57d183e921 Merge pull request 'feat(events): open the event contract to modules (Phase 7)' (#189) from feature/events-phase-7 into edge
Reviewed-on: #189
2026-09-03 19:37:33 +00: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
429e657239 Merge pull request 'feat(events): enablement, per-run caps and mayInvoke (Phase 6)' (#188) from feature/events-phase-6 into edge
Reviewed-on: #188
2026-09-03 14:36:42 +00: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
4ac917c3a3 Merge pull request 'feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)' (#187) from feature/events-phase-5 into edge
Reviewed-on: #187
2026-09-03 03:26:39 +00: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
9c23c5fd0e Merge pull request 'feat(events): schedule, recurrence and the calendar (Phase 4)' (#186) from feature/events-phase-4 into edge
Reviewed-on: #186
2026-09-03 01:58:06 +00: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
a481248bc0 Merge pull request 'feat(events): the minimal admin surface (Phase 3)' (#185) from feat/events-phase-3 into edge
Reviewed-on: #185
2026-09-02 15:47:31 +00: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
2ba397eff7 Merge pull request 'feat(events): the runner (Phase 2)' (#184) from feat/events-phase-2 into edge
Reviewed-on: #184
2026-09-02 11:35:22 +00: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
d88906e43c Merge pull request 'feat(events): schema, CRUD and the core action registry (Phase 1)' (#183) from feat/events-phase-1 into edge
Reviewed-on: #183
2026-09-02 04:38:54 +00: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
6331b36c45 Merge pull request 'feat(engagement): retention — three sweeps and one recorded refusal (Phase 14)' (#182) from feature/engagement-retention into main
All checks were successful
sync-project-tree / sync (push) Successful in 24s
Build container images / build (push) Successful in 33s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Successful in 8m10s
Reviewed-on: #182
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 20:57:06 +00: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
e59a68c152 Merge pull request 'fix(engagement): claim the seed guard atomically, and let a rule name a digest body' (#181) from fix/engagement-seed-claim-and-digest into main
Some checks failed
sync-project-tree / sync (push) Successful in 10s
Build container images / build (push) Successful in 1m35s
Build container images / deploy (push) Successful in 44s
SonarQube / analysis (push) Failing after 9m25s
Reviewed-on: #181
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 19:39:58 +00: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
66bb3b9a3f Merge pull request 'feat(engagement): the engagement system — cutover 3 of 7 (edge → main)' (#180) from edge into main
All checks were successful
Build container images / build (push) Successful in 47s
sync-project-tree / sync (push) Successful in -50s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Successful in 9m27s
Reviewed-on: #180
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 13:56:53 +00:00
52eac24d17 Merge pull request 'fix(engagement): two defects the Phase 11b live walk found in core' (#179) from fix/engagement-live-walk-core into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m29s
Reviewed-on: #179
2026-09-01 12:32:29 +00: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
c3783f56f1 Merge pull request 'feat(engagement): let a module ship its own templates and rules (Phase 11b)' (#178) from feature/engagement-module-seeds into edge
Reviewed-on: #178
2026-09-01 06:35:20 +00: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
81e0338a69 Merge pull request 'feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)' (#177) from feature/engagement-uo-triggers into edge
Reviewed-on: #177
2026-09-01 05:05:56 +00: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
49a61fdafa Merge pull request 'feat(engagement): deliverability — suppression, bounces and the verification gate (Phase 9)' (#176) from feature/engagement-deliverability into edge
Reviewed-on: #176
2026-08-31 15:52:47 +00: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
87c4e71025 Merge pull request 'feat(engagement): the in-app channel, core and web (engagement Phase 7)' (#175) from feature/engagement-inapp-channel into edge
Reviewed-on: #175
2026-08-31 07:22:24 +00: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
5168446c53 Merge pull request 'feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)' (#174) from feature/engagement-email-channel into edge
Reviewed-on: #174
2026-08-31 06:07:14 +00: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
189 changed files with 48047 additions and 846 deletions

View File

@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Events from './routes/public/Events.jsx'
import EventPage from './routes/public/EventPage.jsx'
import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -47,6 +50,13 @@ import EngagementAudiences from './routes/admin/views/EngagementAudiences.jsx'
import EngagementTemplates from './routes/admin/views/EngagementTemplates.jsx'
import EngagementTriggers from './routes/admin/views/EngagementTriggers.jsx'
import EngagementSendLog from './routes/admin/views/EngagementSendLog.jsx'
import EngagementSuppressions from './routes/admin/views/EngagementSuppressions.jsx'
import EngagementRetention from './routes/admin/views/EngagementRetention.jsx'
import EventsAdmin from './routes/admin/views/EventsAdmin.jsx'
import EventsCalendar from './routes/admin/views/EventsCalendar.jsx'
import EventEditor from './routes/admin/views/EventEditor.jsx'
import EventRun from './routes/admin/views/EventRun.jsx'
import EventActions from './routes/admin/views/EventActions.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
@@ -64,8 +74,10 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
@@ -99,6 +111,17 @@ export default function App() {
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
although it could not be shadowed by it — two segments against
one. It stays above because the ranking surprise this feature
has already shipped once was exactly here: a static segment
outranks a dynamic one whatever the source order, which is what
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
Nothing static shares a segment with `:slug`, so nothing here
repeats it. */}
<Route path="/site/events" element={<Events />} />
<Route path="/site/events/series/:slug" element={<EventSeries />} />
<Route path="/site/events/:slug" element={<EventPage />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
@@ -189,6 +212,27 @@ export default function App() {
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
{/* Events (EVENTS.md §I, Phase 3). Staff-wide, unlike Engagement:
§K makes every read here `staff`, and the moderator's whole
power over this feature is the run console — cancelling a run
that is doing something wrong at 2am. The narrower gates are
applied per action instead: authoring is admin+editor, publish
and start are admin only (§N2), and each button follows the
route it calls. `runs/:runId` is declared before `:id` so the
literal segment is never read as a definition id. */}
<Route path="events" element={<EventsAdmin />} />
<Route path="events/calendar" element={<EventsCalendar />} />
{/* The switchboard (Phase 6). A literal segment, declared before
`events/:id` the way the router declares `/actions` before
`/:id` — the same collision, on the other side of the wire. */}
<Route path="events/actions" element={<EventActions />} />
<Route path="events/runs/:runId" element={<EventRun />} />
{/* ONE route, and `new` is a value of `:id` rather than a
path beside it. A static `events/new` outranks the dynamic
segment in React Router whatever the order, so the editor
was handed no `id` at all and asked the API for
`/admin/events/undefined`. */}
<Route path="events/:id" element={<EventEditor />} />
{/* Engagement (ENGAGEMENT.md Phases 4b and 5b). Admin-only, matching the
server: every route under /admin/engagement re-gates to `admin`
on top of the group's staff gate, because this is the group that
@@ -207,8 +251,26 @@ export default function App() {
<Route path="templates" element={<EngagementTemplates />} />
<Route path="triggers" element={<EngagementTriggers />} />
<Route path="sends" element={<EngagementSendLog />} />
<Route path="suppressions" element={<EngagementSuppressions />} />
<Route path="retention" element={<EngagementRetention />} />
</Route>
<Route path="account" element={<AccountAdmin />} />
{/* Staff have an inbox and channel preferences like anyone else —
`/auth/me/notifications` is behind requireAuth only — but
`RequirePlayer` sends them out of the player portal, so the two
screens are mounted here as well. Same components, same API,
two paths; `lib/notificationPaths.js` is the one mapping. */}
<Route path="notifications" element={<PlayerInbox />} />
<Route path="notifications/settings" element={<PlayerNotifications />} />
{/* And participation history, for the same reason and by the same
arrangement (Phase 14a): `/player/events/history` is behind
requireAuth alone, so a staff member has one — but
`RequirePlayer` sends them out of `/account`. Declared BEFORE
`events/:id`, though it need not be: a static segment outranks
a dynamic one whatever the order, which is the rule that made
`events/new` unreachable for seven phases. Written in the order
it resolves. */}
<Route path="events/mine" element={<PlayerEvents />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
@@ -252,7 +314,19 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
<Route path="/account/notifications" element={<PlayerNotifications />} />
{/* Participation history (Phase 14a). Under /account rather than
/player because it is role-agnostic self-service: staff are a
superset of players and an admin reading their own attendance
is as ordinary as anyone else doing it. */}
<Route path="/account/events" element={<PlayerEvents />} />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word
belongs to the one a person means when they say it — which is
also what the bell in the header opens. The server's routes
split at the same place. */}
<Route path="/account/notifications" element={<PlayerInbox />} />
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one

View File

@@ -230,6 +230,25 @@ export const api = {
// field, so clearing the last subscription must not become an absent key.
setNotificationSubscriptions: (streams) =>
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
// (id, channel) pairs sent are written, so a screen managing one channel need
// not know what the others hold. Shipped with no surface at all until Phase 7.
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
setNotificationChannelPrefs: (prefs) =>
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
// id of the last item on the previous page — not an offset: the list gains
// rows at the top while it is being read.
notifications: ({ limit, before, unread } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
if (unread) qs.set('unread', 'true')
return req(`/auth/me/notifications${withQs(qs.toString())}`)
},
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
setTeamNotificationPrefs: (teams) =>
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
@@ -237,6 +256,24 @@ export const api = {
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
//
// The anonymous surface. `from`/`to` are optional — the server defaults to now
// through a month out, so the calendar's first render need not compute a window
// before it can ask for anything.
publicEvents: ({ from, to, seriesId } = {}) => {
const qs = new URLSearchParams()
if (from) qs.set('from', from)
if (to) qs.set('to', to)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/public/events${withQs(qs.toString())}`)
},
// `run` is what an announcement's link carries, so a mail about last Friday's
// occurrence opens last Friday's results rather than next Friday's.
publicEvent: (slug, run = null) =>
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -415,6 +452,138 @@ export const api = {
return req(`/admin/engagement/sends${withQs(qs.toString())}`)
},
// Suppressions (Phase 9). `unsuppressAddress` sends the address in the BODY
// of a DELETE rather than in the path, and that is not style: a path
// parameter lands in the access log, the browser history and every proxy in
// front of the deployment, and this one is a real person's address.
//
// **Phase 14 added the second form, and it is the one the row uses.** The
// list now returns each row's `address_hash`, so the Lift button on a row
// needs no address at all — the operator is looking at a mask and has never
// been told the address. `unsuppressAddress` stays for the address the
// operator types, which is the only way to reach a row that is not on the
// page in front of them.
listEngagementSuppressions: ({ limit, offset, reason, channel, search } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (offset) qs.set('offset', String(offset))
if (reason) qs.set('reason', reason)
if (channel) qs.set('channel', channel)
if (search) qs.set('search', search)
return req(`/admin/engagement/suppressions${withQs(qs.toString())}`)
},
suppressAddress: (address, detail) =>
req('/admin/engagement/suppressions', { method: 'POST', body: { address, detail } }),
unsuppressAddress: (address, channel) =>
req('/admin/engagement/suppressions', { method: 'DELETE', body: { address, channel } }),
unsuppressByHash: (hash, channel) => {
const qs = new URLSearchParams()
if (channel) qs.set('channel', channel)
return req(`/admin/engagement/suppressions/by-hash/${hash}${withQs(qs.toString())}`, {
method: 'DELETE',
})
},
// Retention (Phase 14). Three horizons, one screen; `engagement_suppressions`
// is not among them because a suppression does not expire.
getEngagementRetention: () => req('/admin/engagement/retention'),
setEngagementRetention: (body) =>
req('/admin/engagement/retention', { method: 'PUT', body }),
// Events (docs/website/EVENTS.md, Phase 3). Reads are staff-wide; authoring
// is admin+editor, publish and start are admin ONLY, and the six live
// controls are admin+moderator — the one gate in this feature wider than
// admin, because stopping a run at 2am is incident response and starting
// one is not (§N2). The buttons follow the same split, and the server
// re-checks every one of them.
listEvents: (state) => req(`/admin/events${state ? `?state=${encodeURIComponent(state)}` : ''}`),
getEvent: (id) => req(`/admin/events/${id}`),
createEvent: (body) => req('/admin/events', { method: 'POST', body }),
updateEvent: (id, body) => req(`/admin/events/${id}`, { method: 'PUT', body }),
publishEvent: (id) => req(`/admin/events/${id}/publish`, { method: 'POST' }),
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
// Phase 7. The values behind a param's `source` — resolved by the module that
// registered the source, on a request of its own rather than inside the
// catalog, because a source can be slow or down and must not take the whole
// editor with it. A refusal comes back 200 with `ok: false`, so this never
// throws for the case the screen is meant to render: the field degrades to
// free text with the reason beside it.
// Phase 12b made a source SEARCHABLE and Phase 13 is what asks. `q` is
// ignored, never refused, by a source that does not declare itself
// searchable — so passing it is always safe and the field decides whether
// it is a typeahead by reading `searchable` off the answer.
eventOptions: (sourceId, q) => {
const qs = q ? `?${new URLSearchParams({ q }).toString()}` : ''
return req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}${qs}`)
},
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200
// — the request succeeded, the plan has problems.
verifyEvent: (id) => req(`/admin/events/${id}/verify`, { method: 'POST' }),
// Phase 13's live cap meter, and NOT a lighter dry run — it dispatches
// nothing, so it knows nothing a module knows. It takes the spec in the
// body rather than an id because the plan it prices is the one in the
// author's hands, which is unsaved between keystrokes, and it records
// nothing, which is what makes it safe to call on a debounce.
priceEvent: (body) => req('/admin/events/price', { method: 'POST', body }),
// The switchboard, admin only in BOTH directions: reading which actions a
// deployment permits is as much configuration as writing it (§K). One action
// per write rather than the whole board, so an action that appeared between
// the read and the write cannot be overwritten with a default.
eventActions: () => req('/admin/events/actions'),
saveEventAction: (body) => req('/admin/events/actions', { method: 'PUT', body }),
eventSeries: () => req('/admin/events/series'),
// Series writes are admin+editor rather than admin: naming an arc is
// authoring, and §N2's narrow gate is about committing the deployment to a
// run. The delete is a real delete and answers with how many definitions it
// detached — `series_id` is ON DELETE SET NULL, so nothing is destroyed.
createEventSeries: (body) => req('/admin/events/series', { method: 'POST', body }),
updateEventSeries: (id, body) => req(`/admin/events/series/${id}`, { method: 'PUT', body }),
deleteEventSeries: (id) => req(`/admin/events/series/${id}`, { method: 'DELETE' }),
// The calendar. `from`/`to` are UTC instants the caller computes from the
// month it is showing, in the READER's zone — the server never guesses it.
// A `status` or `scope` filter suppresses projections, which is why the
// month view sends neither.
eventCalendar: ({ from, to, status, scope, seriesId } = {}) => {
const qs = new URLSearchParams({ from, to })
if (status) qs.set('status', status)
if (scope) qs.set('scope', scope)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/admin/events/calendar?${qs.toString()}`)
},
startEventRun: (id, body) => req(`/admin/events/${id}/runs`, { method: 'POST', body }),
listEventRuns: ({ definitionId, status, limit } = {}) => {
const qs = new URLSearchParams()
if (definitionId) qs.set('definitionId', String(definitionId))
if (status) qs.set('status', status)
if (limit) qs.set('limit', String(limit))
const suffix = qs.toString()
return req(`/admin/events/runs${suffix ? `?${suffix}` : ''}`)
},
getEventRun: (runId) => req(`/admin/events/runs/${runId}`),
getEventRunLog: (runId, limit) =>
req(`/admin/events/runs/${runId}/log${limit ? `?limit=${Number(limit)}` : ''}`),
pauseEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/pause`, { method: 'POST', body: { reason } }),
resumeEventRun: (runId) => req(`/admin/events/runs/${runId}/resume`, { method: 'POST' }),
// `cleanup` defaults to true server-side and has to be asked out of: EVENTS.md
// §L makes cancelling WITHOUT cleanup the separate, admin-only, logged action,
// so an absent flag means "give back what this run took".
cancelEventRun: (runId, reason, cleanup = true) =>
req(`/admin/events/runs/${runId}/cancel`, { method: 'POST', body: { reason, cleanup } }),
cleanupEventRun: (runId) => req(`/admin/events/runs/${runId}/cleanup`, { method: 'POST' }),
advanceEventRun: (runId, reason) =>
req(`/admin/events/runs/${runId}/advance`, { method: 'POST', body: { reason } }),
confirmEventStep: (runId, stepId, note) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/confirm`, { method: 'POST', body: { note } }),
skipEventStep: (runId, stepId, reason) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/skip`, { method: 'POST', body: { reason } }),
retryEventStep: (runId, stepId) =>
req(`/admin/events/runs/${runId}/steps/${stepId}/retry`, { method: 'POST' }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
@@ -557,6 +726,18 @@ export const api = {
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
// ----- event participation (Phase 14a) -----
//
// Self-scoped on the session and nothing else — there is no id to pass.
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
// list gains a row every time the reader attends something.
eventHistory: ({ limit, before } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
return req(`/player/events/history${withQs(qs.toString())}`)
},
},
}

View File

@@ -0,0 +1,353 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
import { inboxPath } from '../lib/notificationPaths.js'
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
// unread badge, and a panel with the most recent items.
//
// **The badge is polled, not pushed**, and the reason is that there is nothing
// to push over. The site's two SSE streams are the shard's; neither is
// per-user, and adding a third authenticated stream to carry an integer would
// mean one open connection per signed-in tab for the rest of the deployment's
// life. A minute-granular badge on a page somebody is already looking at is the
// same answer for a fraction of that. The poll pauses while the tab is hidden —
// a background tab has nobody to show a badge to — and refreshes the moment it
// comes back, which is also the moment it would be most wrong.
//
// **The panel shows a handful and links out.** Paging belongs on the page; a
// dropdown that scrolls is a list in the wrong place.
//
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
// returns focus, an outside `mousedown` closes, navigating closes — because
// this sits beside it in the same header and two menus that dismiss differently
// is a bug nobody files.
const POLL_MS = 60_000
const PANEL_ITEMS = 6
function BellIcon({ size = 17 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
)
}
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
// about an inbox item's time is how fresh it is.
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'now'
if (secs < 3600) return `${Math.floor(secs / 60)}m`
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
return `${Math.floor(secs / 86400)}d`
}
export default function NotificationBell() {
const { user } = useAuth()
const [unread, setUnread] = useState(0)
const [items, setItems] = useState([])
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
const navigate = useNavigate()
// Every read here swallows its failure. A count that could not be fetched is
// a bell with no badge, which is what a bell with nothing to report looks
// like anyway — the alternative is an error banner in the site header for a
// number nobody asked for.
const refreshCount = useCallback(async () => {
if (!user) return
try {
const res = await api.notificationsUnreadCount()
setUnread(res.unread || 0)
} catch {
/* leave the badge as it was */
}
}, [user])
useEffect(() => {
if (!user) return undefined
refreshCount()
const timer = setInterval(() => {
if (document.visibilityState === 'visible') refreshCount()
}, POLL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') refreshCount()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [user, refreshCount])
// The panel's items are fetched when it opens, never kept warm: a list nobody
// has asked to see is a request per minute for content nobody is reading.
const load = useCallback(async () => {
setError('')
try {
const res = await api.notifications({ limit: PANEL_ITEMS })
setItems(res.items || [])
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load notifications')
}
}, [])
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
if (!user) return null
const toggle = () => {
const next = !open
setOpen(next)
if (next) load()
}
// Opening an item marks it read and then goes where it points. The mark is
// awaited rather than fired off, so the badge the next screen renders is the
// one this click produced; a failed mark still navigates, because the item's
// link is the thing the user asked for.
const openItem = async (item) => {
setOpen(false)
if (!item.read) {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
} catch {
/* the link still works */
}
}
navigate(item.url || inboxPath(user))
}
const markAll = async () => {
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => list.map((i) => ({ ...i, read: true })))
} catch (err) {
setError(err.message || 'Could not mark them read')
}
}
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
// The count is in the label, not only in the badge: a screen reader gets
// "Notifications, 3 unread" rather than "Notifications" and a number it
// has no way to relate to it.
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
onClick={toggle}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
position: 'relative',
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
}}
>
<BellIcon />
{unread > 0 && (
<span
aria-hidden="true"
className="sans"
style={{
minWidth: 17,
height: 17,
padding: '0 4px',
borderRadius: 9,
background: 'var(--accent)',
color: 'var(--bg-deep)',
fontSize: '0.68rem',
fontWeight: 700,
lineHeight: '17px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
role="menu"
aria-label="Notifications"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
right: 0,
width: 320,
maxWidth: 'calc(100vw - 24px)',
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '4px 8px 8px',
}}
>
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
Notifications
</strong>
{unread > 0 && (
<button
type="button"
onClick={markAll}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
}}
>
Mark all read
</button>
)}
</div>
{error && (
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
{error}
</p>
)}
{!error && items.length === 0 && (
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
Nothing here yet.
</p>
)}
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => openItem(item)}
className="sans"
style={{
display: 'block',
width: '100%',
textAlign: 'left',
padding: '8px 10px',
borderRadius: 'var(--radius-input)',
border: 'none',
cursor: 'pointer',
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<span
style={{
display: 'block',
fontSize: '0.85rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 400 : 600,
}}
>
{item.title}
</span>
{item.body && (
<span
className="dim"
style={{
fontSize: '0.78rem',
marginTop: 2,
// The body is stored and rendered as TEXT, never as markup —
// `white-space: pre-line` is what keeps the template's own
// line breaks without ever interpreting anything.
whiteSpace: 'pre-line',
// Two lines, then an ellipsis. `-webkit-box` is the only
// clamp with real support; it is also why there is no second
// `display: block` above it.
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.body}
</span>
)}
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
{ago(item.createdAt)}
</span>
</button>
))}
<Link
to={inboxPath(user)}
role="menuitem"
onClick={() => setOpen(false)}
className="sans"
style={{
display: 'block',
marginTop: 4,
padding: '8px 10px',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.8rem',
color: 'var(--accent)',
textDecoration: 'none',
}}
>
See all notifications →
</Link>
</div>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import NavDropdown from './NavDropdown.jsx'
import NotificationBell from './NotificationBell.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
@@ -28,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Events', to: '/site/events' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },
@@ -107,6 +109,10 @@ export default function SiteHeader() {
</NavLink>
),
)}
{/* Renders nothing when signed out, so the header keeps its shape for
a visitor. It is here rather than only in the portal because an
inbox item is worth seeing from the page you are already on. */}
{!loading && <NotificationBell />}
{!loading && (
<NavLink
to={account.to}

View File

@@ -0,0 +1,817 @@
// ── What the three Events screens say, and what they let staff press ───────
//
// EVENTS.md §I. None of this is a boundary. `events/spec.js` on the server
// decides what may be saved, and the six control statements decide what may
// happen to a run — every one of them is a compare-and-set that re-checks the
// status this file only *predicted*. What is here is the part that would be
// wrong silently: a form that drops an authored step, a params box that posts a
// string where the action declared an int, and above all a console that offers a
// button the server is going to refuse.
//
// **The controls are modelled here rather than inline in the console for one
// reason: they can be tested against the server's rules.** A button that 409s is
// not a bug the way a wrong write is, but it is the failure mode an operator
// meets at 2am while the thing they are trying to stop keeps running — so the
// guards are written twice on purpose and the copy is checked.
// **The condition builder is borrowed, not rebuilt.** §I says the step editor
// reuses "the condition builder, exactly" — and a phase's advance gate is
// literally the engagement grammar, validated on the server by
// `engagement/conditions.js`. Importing the row helpers is what keeps this screen
// from becoming a second opinion about a grammar core owns.
import { conditionRowsFrom, conditionsFromRows, coerceLiteral } from './engagementRules.js'
// A run that is over. Verbatim `eventRuns.db`'s TERMINAL.
export const TERMINAL_RUN_STATUSES = ['completed', 'cancelled', 'failed', 'missed']
export const isTerminalRun = (status) => TERMINAL_RUN_STATUSES.includes(status)
/** A step waiting on a human: `running`, with nothing holding it. */
export const isParked = (step) => Boolean(step && step.status === 'running' && step.parked)
/**
* The highest `seq` of a step in this phase that is not still `pending` — the
* furthest the phase has got — or null when none of it has been attempted.
*
* The same rule as the server's `lastStartedSeq`, over the step list the console
* already has, and used only to decide whether to OFFER retry. The near miss is
* worth keeping in view: "the lowest step that is not finished" looks like the
* same thing and is not, because the runner steps OVER a failed step. Under that
* rule a phase that carried on past an `on_failure: skip` failure and then paused
* at a later one would offer retry on the wrong step.
*/
export function lastStartedSeqOf(steps, phase) {
const started = (steps || [])
.filter((s) => s.phase === phase && s.status !== 'pending')
.map((s) => Number(s.seq))
return started.length ? Math.max(...started) : null
}
/**
* Which run-level controls to offer.
*
* `pause` is `starting`/`running` only: a `scheduled` occurrence that should not
* happen is cancelled, not paused. `cancel` is everything non-terminal — "this
* is not happening" is a decision made before a run starts as often as during
* one.
*
* **`advance` is offered only when the phase is genuinely waiting on its gate**,
* which is the same test the server makes and is stated here in the same words
* on purpose: this decides what is *offered*, the server decides what is
* *allowed*, and a button that is present and always refused is the "control
* that answers 409 and does nothing" this feature has refused twice. The gate
* must be open-and-unsatisfied AND no step of the phase may still be pending or
* running — a phase held by a step is held by the step, and skip is its control.
*/
export function runControlsFor(run, gates = [], steps = []) {
if (!run) return { pause: false, resume: false, cancel: false, advance: false }
const terminal = isTerminalRun(run.status)
const gate = (gates || []).find((g) => g.phase === run.currentPhase)
const stepOpen = (steps || []).some(
(s) => s.phase === run.currentPhase && ['pending', 'running'].includes(s.status),
)
return {
pause: ['starting', 'running'].includes(run.status),
resume: run.status === 'paused',
cancel: !terminal,
advance: run.status === 'running' && Boolean(gate) && !gate.satisfied && !stepOpen,
}
}
/**
* Which step-level controls to offer, for one step of one run.
*
* `retry` carries the guard worth restating: only while the run is PAUSED, only
* on a `failed` step of the phase the run is currently in, and only when that
* step is the furthest one the phase has reached. A failed step under an
* `on_failure` of `skip` is one the run has already moved past, and re-queueing
* it would put a pending row behind the runner's cursor, where it would sit for
* ever.
*/
export function stepControlsFor(run, step, steps) {
const none = { confirm: false, skip: false, retry: false }
if (!run || !step) return none
if (isTerminalRun(run.status)) return none
const parked = isParked(step)
const furthest = step.phase === run.currentPhase ? lastStartedSeqOf(steps, step.phase) : null
return {
confirm: parked,
skip: parked || step.status === 'pending',
retry:
run.status === 'paused' &&
step.status === 'failed' &&
step.phase === run.currentPhase &&
furthest !== null &&
Number(furthest) === Number(step.seq),
}
}
// ── The definition form ────────────────────────────────────────────────────
export const BLANK_PHASE_KEY = 'phase'
const nextPhaseKey = (phases) => {
const used = new Set((phases || []).map((p) => p.key))
for (let n = 1; n < 100; n++) {
const key = n === 1 ? BLANK_PHASE_KEY : `${BLANK_PHASE_KEY}-${n}`
if (!used.has(key)) return key
}
return `${BLANK_PHASE_KEY}-${Date.now()}`
}
/**
* A new step, with its params PREFILLED from the action's declared examples.
*
* Every param carries a required `example` — that requirement is the reason this
* works — so a fresh `core.announce` step arrives with the right keys and
* plausible values rather than empty. Phase 13 turned the box into a form and
* this stayed exactly as it was: a form whose fields start at the declared
* example is a step an author edits rather than one they compose.
*/
export function blankStep(action) {
const params = {}
for (const p of action?.params || []) {
if (p.required || p.example !== undefined) params[p.name] = p.example
}
return {
actionId: action?.id || '',
label: action?.label || '',
onFailure: '',
paramsText: JSON.stringify(params, null, 2),
}
}
export function blankPhase(phases) {
return { key: nextPhaseKey(phases), label: 'New phase', steps: [], advance: blankAdvance() }
}
/**
* The advance gate as the FORM holds it (Phase 5) — three fields that are
* always present and mostly empty, rather than a discriminated union the form
* has to rebuild every time the dropdown moves.
*
* `kind: ''` is "no condition", which is what nearly every phase is and what
* every phase was before this. The form keeps a half-typed `on` gate's trigger
* while the author looks at `after`, because a dropdown that discards what was
* typed under the other option is one an operator learns to be afraid of.
*/
export function blankAdvance() {
return { kind: '', after: '30m', on: '', count: 1, ...blankWhere() }
}
/**
* The `where` predicate as the BUILDER holds it (Phase 13).
*
* `whereText` survives beside the rows and is not vestigial: it is what a
* predicate the builder cannot render is shown as, and what is posted for one.
* See `whereFormFrom`.
*/
export function blankWhere() {
return { whereOp: 'and', whereRows: [], whereEditable: true, whereText: '' }
}
export const ADVANCE_KINDS = [
{ value: '', label: 'When its steps are done' },
{ value: 'after', label: 'After a fixed delay' },
{ value: 'on', label: 'When something happens in the game' },
]
/** The stored gate, as the form's fields. */
export function advanceFormFrom(advance) {
const blank = blankAdvance()
if (!advance) return blank
if (advance.after !== undefined) return { ...blank, kind: 'after', after: advance.after }
return {
...blank,
kind: 'on',
on: advance.on || '',
count: advance.count ?? 1,
...whereFormFrom(advance.where),
}
}
/**
* A stored `where` tree → the builder's flat rows (Phase 13).
*
* **This is `conditionRowsFrom` and it is deliberately the same function**, not a
* second one shaped like it. The grammar behind a phase gate is the engagement
* condition grammar — the server validates it with `engagement/conditions.js`
* and renders the diagnosis panel's sentence with the same labels — so an editor
* here that re-decided what a tree looks like would be the second implementation
* §I refuses on the read side for exactly this reason.
*
* A tree the flat editor cannot hold (`A and (B or C)`) comes back
* `whereEditable: false` and is SHOWN as its JSON rather than silently
* flattened: `A and B and C` fires on different events, and an author would have
* no way to know the save had done it to them.
*/
export function whereFormFrom(where) {
const blank = blankWhere()
if (!where) return blank
const rows = conditionRowsFrom(where)
return {
whereOp: rows.op,
whereRows: rows.rows,
whereEditable: rows.editable,
whereText: JSON.stringify(where, null, 2),
}
}
/** The editor's working state, from what `GET /admin/events/:id` returned. */
export function formFromDefinition(event) {
const spec = event?.spec || {}
return {
title: event?.title || '',
summary: event?.summary || '',
body: event?.body || '',
imageUrl: event?.imageUrl || '',
seriesId: event?.seriesId ? String(event.seriesId) : '',
seriesOrder: event?.seriesOrder ?? 0,
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
advance: advanceFormFrom(p.advance),
steps: (p.steps || []).map((s) => ({
actionId: s.actionId || '',
label: s.label || '',
onFailure: s.onFailure || '',
dormant: Boolean(s.dormant),
actionVersion: s.actionVersion,
paramsText: JSON.stringify(s.params || {}, null, 2),
})),
})),
}
}
/**
* One phase's advance gate, as the spec shape — or null when it has none.
*
* **Whether the predicate is VALID is still the server's answer.** The builder
* coerces each literal to the type the trigger DECLARED — which is not a second
* validator but the thing that makes the first one's error useful: every value
* in an HTML input is a string, and `{ cmp: 'gt', value: \"5\" }` against an `int`
* variable is refused by `engagement/conditions.js`, rightly, at which point the
* author is reading an error about JSON rather than about what they typed.
*
* A predicate the builder could not render round-trips through `whereText`
* unchanged. That is the point of keeping the text: the alternative to posting it
* back verbatim is dropping an author's tree because this screen could not draw
* it.
*/
export function advancePayload(advance, where, errors, variables = []) {
if (!advance || !advance.kind) return null
if (advance.kind === 'after') return { after: advance.after }
const out = { on: advance.on, count: Number(advance.count) || 1 }
if (advance.whereEditable === false) {
const text = String(advance.whereText || '').trim()
if (text) {
try {
out.where = JSON.parse(text)
} catch (err) {
errors.push(`${where}, advance condition: ${err.message}`)
}
}
return out
}
const built = conditionsFromRows(advance.whereOp || 'and', advance.whereRows || [], variables)
if (built) out.where = built
return out
}
/**
* The form, as a request body — or the list of everything wrong with it.
*
* Only the JSON parse is checked here, and only because a params box whose text
* is not JSON cannot be turned into a request at all. **Everything else is left
* to the server**: unknown params, wrong types, missing required ones, bad phase
* keys and duplicate keys all come back from `POST`/`PUT` as a list, and
* re-deciding any of them here would be a second validator drifting from the one
* that matters.
*
* `onFailure` is omitted when the author has not chosen one, so the server
* applies the action's risk-class default rather than being told a value the
* form invented.
*/
export function payloadFromForm(form, { triggersById = new Map() } = {}) {
const errors = []
const phases = (form.phases || []).map((phase, pi) => {
const where = advancePayload(
phase.advance,
`Phase ${pi + 1} "${phase.label || phase.key}"`,
errors,
// The declared types the builder coerces against. A trigger nothing
// registers has none, and every literal then stays the string it was typed
// as — which is right: the gate is dormant, the server carries its `where`
// through unvalidated, and inventing types for it here would edit a
// predicate nobody can currently check.
triggersById.get(phase.advance?.on)?.variables || [],
)
return {
key: phase.key,
label: phase.label,
// Omitted rather than sent as null when there is no gate, which is what
// `events/spec.js` stores for the same reason: a spec full of
// `"advance": null` makes the first phase to gain one look like an edit to
// every phase in the version diff.
...(where ? { advance: where } : {}),
steps: (phase.steps || []).map((step, si) => {
const out = { actionId: step.actionId }
if (step.label) out.label = step.label
if (step.onFailure) out.onFailure = step.onFailure
const parsed = parseParams(step.paramsText)
if (parsed.error) {
errors.push(`Phase ${pi + 1} "${phase.label || phase.key}", step ${si + 1}: ${parsed.error}`)
} else {
out.params = parsed.params
}
return out
}),
}
})
if (errors.length) return { ok: false, errors }
return {
ok: true,
payload: {
title: form.title,
summary: form.summary || null,
body: form.body || null,
imageUrl: form.imageUrl || null,
seriesId: form.seriesId ? Number(form.seriesId) : null,
seriesOrder: Number(form.seriesOrder) || 0,
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
},
}
}
// ── The schedule (Phase 4) ─────────────────────────────────────────────────
//
// The four closed shapes of §E, mirrored so the form can render one and the
// preview can describe it. `events/spec.js` and `events/recurrence.js` remain
// the deciders — this is what makes the form a form rather than a text box, and
// it is the whole reason the schedule is not a cron string: a closed set has a
// dropdown, and an operator can proofread a dropdown.
export const WEEKDAYS = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
]
export const SCHEDULE_KINDS = [
{ value: 'manual', label: 'Started by hand' },
{ value: 'once', label: 'Once, at a set time' },
{ value: 'weekly', label: 'Weekly, on chosen days' },
{ value: 'monthly', label: 'Monthly, on the nth weekday' },
]
// 1..4 and "last". There is no fifth: every month has a first through fourth of
// every weekday, and "last" is what a month with five Fridays makes different
// from "fourth" (org lead, 2026-09-02).
export const MONTHLY_NTHS = [
{ value: 1, label: 'First' },
{ value: 2, label: 'Second' },
{ value: 3, label: 'Third' },
{ value: 4, label: 'Fourth' },
{ value: -1, label: 'Last' },
]
const capitalise = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1)
/**
* A schedule in words, in the event's own zone.
*
* The server says the same thing in `events/recurrence.js#describe`, and the two
* are allowed to differ on wording but not on meaning — this one is what an
* author reads while they are still typing, before anything has been saved.
*/
export function describeSchedule(schedule, timezone = 'UTC') {
if (!schedule || typeof schedule !== 'object') return 'No schedule'
const nth = MONTHLY_NTHS.find((n) => n.value === Number(schedule.nth))
switch (schedule.kind) {
case 'manual':
return 'Started by hand — nothing happens until an admin presses Start'
case 'once': {
if (!schedule.at) return 'Once — no date chosen yet'
return `Once, on ${String(schedule.at).replace('T', ' at ')} (${timezone})`
}
case 'weekly': {
const days = (schedule.days || []).map(capitalise)
if (!days.length || !schedule.time) return 'Weekly — choose days and a time'
const list =
days.length === 1
? days[0]
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
return `Every ${list} at ${schedule.time} (${timezone})`
}
case 'monthly': {
if (!nth || !schedule.weekday || !schedule.time) {
return 'Monthly — choose a week, a weekday and a time'
}
return `The ${nth.label.toLowerCase()} ${capitalise(schedule.weekday)} of every month at ${schedule.time} (${timezone})`
}
default:
return 'No schedule'
}
}
/**
* The schedule half of the editor's working state.
*
* Every shape's fields are kept side by side rather than cleared when the kind
* changes, so an author who clicks Weekly, then Monthly, then back has not lost
* the days they picked. `scheduleFromForm` reads only the fields the chosen kind
* uses, which is what keeps the request body a clean single shape.
*/
export function scheduleFormFrom(schedule) {
const s = schedule || {}
return {
scheduleKind: s.kind || 'manual',
scheduleAt: s.kind === 'once' ? s.at || '' : '',
scheduleDays: s.kind === 'weekly' ? s.days || [] : [],
scheduleNth: s.kind === 'monthly' ? String(s.nth) : '1',
scheduleWeekday: s.kind === 'monthly' ? s.weekday || 'friday' : 'friday',
scheduleTime: s.kind === 'weekly' || s.kind === 'monthly' ? s.time || '20:00' : '20:00',
}
}
/** The schedule the form describes, as the spec object the server expects. */
export function scheduleFromForm(form) {
switch (form.scheduleKind) {
case 'once':
return { kind: 'once', at: form.scheduleAt }
case 'weekly':
return { kind: 'weekly', days: form.scheduleDays || [], time: form.scheduleTime }
case 'monthly':
return {
kind: 'monthly',
nth: Number(form.scheduleNth),
weekday: form.scheduleWeekday,
time: form.scheduleTime,
}
default:
return { kind: 'manual' }
}
}
/**
* What a calendar entry is, and therefore what may be done with it.
*
* A `run` is a row: it has a console and somebody can cancel it. A `projected`
* entry is arithmetic the runner has not reached yet — there is nothing to open
* and nothing to stop, and an operator who treats one as a booking has been
* misled by the UI rather than by the server.
*/
export const isProjected = (entry) => entry?.kind === 'projected'
/** An empty box is `{}`, not a parse error — a step may legitimately take none. */
export function parseParams(text) {
const raw = (text || '').trim()
if (!raw) return { params: {} }
let value
try {
value = JSON.parse(raw)
} catch (err) {
return { error: `the params are not valid JSON (${err.message})` }
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return { error: 'the params must be a JSON object' }
}
return { params: value }
}
// ── Step params, as a form (Phase 13) ─────────────────────────────
//
// §I: the step editor is *"the condition builder, exactly — core serves a
// catalog, the module declared the schema, core renders a form it does not
// understand"*. Phase 3 shipped the raw JSON box as an explicit placeholder for
// this, and everything the form needs was already in the catalog: a param's
// name, type, whether it is required, its description, its example, and the
// option source behind it.
//
// **The JSON stays as the storage and as the escape hatch, and both halves of
// that matter.** As storage, because `payloadFromForm` already builds a request
// out of it and a second representation would be two things to keep in step. As
// an escape hatch, because a form can only render what the declaration
// describes — and a step may legitimately hold something it does not.
//
// The rule for when the form gives way is the CONDITION BUILDER'S rule, which is
// the reason this reads as a port of it rather than as a new idea: a value the
// editor cannot round-trip is SHOWN rather than silently rewritten. Flattening
// `A and (B or C)` there and dropping an undeclared param here are the same
// mistake — a save that looks clean and means something else.
/** The two ways a step's params are edited. */
export const PARAM_FORM = 'form'
export const PARAM_JSON = 'json'
/**
* Can this step's params be rendered as a form without losing anything?
*
* `{ ok: true }`, or `{ ok: false, reason }` naming what the form cannot hold.
* Three things make one, and none of them is an error — each is a step that has
* to be edited as JSON:
*
* • **the action is dormant.** There is no declaration, so there are no fields.
* A form here would render nothing and look like a step with no params.
* • **a param the action does not declare.** The save refuses it by name, which
* is what the author needs to see — and a form that dropped it would post a
* step that saves cleanly having deleted something they typed.
* • **a value no single control can hold** — an object or an array against a
* scalar declaration.
*/
export function paramsRenderable(action, params) {
if (!action) return { ok: false, reason: 'the module that registered this action is not installed' }
const declared = new Map((action.params || []).map((p) => [p.name, p]))
for (const [name, value] of Object.entries(params || {})) {
if (!declared.has(name)) {
return { ok: false, reason: `this step carries "${name}", which ${action.id} does not declare` }
}
if (value !== null && typeof value === 'object') {
return { ok: false, reason: `"${name}" holds a ${Array.isArray(value) ? 'list' : 'structure'}, which no single field can hold` }
}
}
return { ok: true }
}
/**
* Which mode should this step open in?
*
* The author's own choice wins whenever the form COULD render the step — an
* author who switched to JSON stays in JSON. What they cannot do is stay in a
* form that would lose something, so an unrenderable step is forced to JSON
* whatever the choice was, and the reason is returned so the screen can say it.
*/
export function paramsMode(step, action) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return { mode: PARAM_JSON, forced: true, reason: parsed.error }
const renderable = paramsRenderable(action, parsed.params)
if (!renderable.ok) return { mode: PARAM_JSON, forced: true, reason: renderable.reason }
return { mode: step?.paramsMode === PARAM_JSON ? PARAM_JSON : PARAM_FORM, forced: false, reason: null }
}
/** One declared param's current value, as the control holds it. */
export function paramValue(step, name) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return undefined
return parsed.params[name]
}
/**
* Write one param, and give back the whole box.
*
* **An empty field REMOVES the key rather than posting an empty string**, and
* that is the server's own reading rather than a convenience: `checkParams`
* treats `undefined`, `null` and `''` alike — absent — so a required param left
* blank comes back as *"is required"*, which is the error the author needs,
* instead of as a type complaint about `""`.
*
* **A value that does not parse is passed through as typed.** `coerceLiteral` is
* the engagement builder's, unchanged, and its rule is the one that matters
* here too: half of `-` is not a number, and turning it into `NaN` or `0` while
* somebody is still typing would either post a value they never wrote or make
* the field impossible to type a negative into. The server's type check then
* names the param.
*
* Re-serialising the whole object rather than splicing text, for `pickParam`'s
* reason: a string edit that produced valid-looking JSON with a duplicate key
* would be a value the editor and the server read differently.
*/
export function setParam(step, name, raw, type) {
const parsed = parseParams(step?.paramsText)
if (parsed.error) return step?.paramsText || '{}'
const next = { ...parsed.params }
if (raw === '' || raw === undefined || raw === null) delete next[name]
else next[name] = coerceLiteral(type, raw)
return JSON.stringify(next, null, 2)
}
/**
* A stored `datetime` as a `datetime-local` input wants it, and back.
*
* The server normalises a datetime param to an ISO string (`conditions.js`
* `checkLiteral`), and the input needs `YYYY-MM-DDTHH:mm` with no zone. The
* slice is the whole conversion in one direction; in the other the input's own
* text is a moment `new Date()` parses, so it is posted as typed and the server
* does the normalising — one implementation of what a datetime is, and it is
* not this one.
*/
export const datetimeInputValue = (value) => (typeof value === 'string' ? value.slice(0, 16) : '')
/**
* Everything the meter needs out of the form, and nothing else.
*
* The price route takes a spec, not a definition: no title, no schedule, no
* series. Sending the whole payload would put a document in front of a route
* that reads two fields of it — and would fail the moment the rest of the form
* is mid-edit, which is exactly when the meter is being read.
*
* A step whose params do not parse is sent with none rather than dropped, so a
* half-typed JSON box costs its own step's draw and not the phase's.
*/
export function priceBodyFrom(form) {
return {
phases: (form?.phases || []).map((phase) => ({
key: phase.key || null,
steps: (phase.steps || []).map((step) => ({
actionId: step.actionId || '',
params: parseParams(step.paramsText).params || {},
})),
})),
}
}
/**
* Is this plan worth pricing at all?
*
* A meter that fires on an empty form asks the server what nothing costs, on
* every keystroke of the title field. One step with an action chosen is the
* threshold, because that is the first moment there is an answer.
*/
export const worthPricing = (form) =>
(form?.phases || []).some((p) => (p.steps || []).some((s) => s.actionId))
// ── Rendering what happened ────────────────────────────────────────────────
const STATUS_WORDS = {
scheduled: 'Scheduled',
starting: 'Starting',
running: 'Running',
paused: 'Paused',
ending: 'Winding down',
completed: 'Completed',
cancelled: 'Cancelled',
failed: 'Failed',
missed: 'Missed',
}
export const runStatusWord = (status) => STATUS_WORDS[status] || status || 'unknown'
const KIND_WORDS = {
'run.created': 'Occurrence created',
'run.status': 'Run status',
'run.health': 'Health',
'run.blocked': 'Held off',
'phase.entered': 'Phase entered',
'phase.completed': 'Phase completed',
'step.status': 'Step',
'step.retry': 'Step retried',
'step.parked': 'Waiting on a human',
'phase.gate': 'Advance condition set',
'condition.evaluated': 'Condition evaluated',
'phase.advanced': 'Phase advanced',
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
// scanning a stopped run needs to see that nothing is broken.
'step.refused': 'Refused',
'run.budget': 'Caps',
'version.verified': 'Dry run passed',
// Phase 15. "Reported" rather than "Detail": the line is the module talking
// about its own verb, and every other word here names something core did.
'step.detail': 'Step reported',
note: 'Note',
}
// How deep and how long a module's own `detail` value is allowed to render.
// The dispatcher already caps the whole object at 4KB, so this is about a line
// staying a line — an operator scanning a run's log should not have one row
// wrap eight times because a module answered with an array of forty names.
const DETAIL_LIST_SHOWN = 5
const DETAIL_TEXT_MAX = 80
/**
* One value out of a module's `detail`, as text.
*
* **Core does not interpret these keys and neither does this.** A module wrote
* the object; the console shows it. That is the whole reason the renderer is
* generic rather than a switch — a switch would be core learning a module's
* vocabulary, which is the thing the module system exists to prevent.
*/
function detailValue(value) {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
const shown = value.slice(0, DETAIL_LIST_SHOWN).map(detailValue).join(', ')
return value.length > DETAIL_LIST_SHOWN
? `${shown} and ${value.length - DETAIL_LIST_SHOWN} more`
: shown
}
if (typeof value === 'object') {
// A nested object is rendered by its keys rather than as JSON: an operator
// reading a log wants "granted: 8, missed: 4", not a brace.
return Object.entries(value)
.map(([k, v]) => `${k} ${detailValue(v)}`)
.join(', ')
}
const text = String(value)
return text.length > DETAIL_TEXT_MAX ? `${text.slice(0, DETAIL_TEXT_MAX - 1)}…` : text
}
export const logKindWord = (kind) => KIND_WORDS[kind] || kind
/**
* One log line as a sentence.
*
* The `detail` of a human control carries `control` and `by`, which is what
* separates "the runner paused this because a world write failed" from "somebody
* pressed pause" — the two are the same transition and the console has to be
* able to tell them apart at a glance.
*/
export function describeLogLine(line) {
const d = line?.detail || {}
const by = d.by ? ' by staff' : ''
switch (line?.kind) {
case 'run.status':
return d.control
? `${runStatusWord(d.to)}${by} — ${d.control}${d.reason ? `: ${d.reason}` : ''}`
: `${d.from ? `${runStatusWord(d.from)} → ` : ''}${runStatusWord(d.to)}${d.because ? ` (${d.because})` : ''}`
case 'run.health':
return `Health is now ${d.to}${d.because ? ` (${d.because})` : ''}`
case 'run.blocked':
return `Held: run ${d.heldBy} has the concurrency key "${d.concurrencyKey}"`
case 'phase.entered':
return `Entered ${line.phase} (${d.steps ?? '?'} steps)`
case 'phase.completed':
return `${line.phase} finished`
case 'step.parked':
return `${d.action} is waiting on a human`
case 'step.retry':
return `${d.action} failed, attempt ${d.attempt} of ${d.of}${d.error ? `: ${d.error}` : ''}`
case 'step.status':
return d.control
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`
: `${line.phase} advances on ${d.needed} × ${d.trigger}${d.where ? ` where ${d.where}` : ''}`
// Both outcomes are logged, and the near miss is the useful one: it is the
// difference between "the boss did spawn, in the wrong region" and "no boss
// has spawned", which look identical on every other line of this log.
case 'condition.evaluated':
return `${d.trigger} ${d.matched ? 'counted' : 'did not count'} — ${d.seen} of ${d.needed}${
d.satisfied ? ', condition met' : ''
}`
case 'phase.advanced':
return d.because === 'forced'
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
// reason an operator feels at 2am: a refusal is not a failure, and the line
// has to say which deployment rule stopped it -- the answer to "not enabled"
// is a switch, and the answer to "over the cap" is a number.
case 'step.refused':
return `${d.action} refused: ${d.error}`
case 'run.budget':
return (d.dimensions || [])
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
.join(', ') || 'no caps apply to this run'
case 'version.verified':
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
// Phase 15. The one line whose body core did not compose: a module may answer
// a successful step with a `detail` object, and this renders whatever keys it
// put there. `action` is core's own and is pulled out to lead the sentence;
// everything after it is the module's.
//
// **Without this case the row would render as the literal string
// "step.detail"**, because the default below is a kind word and not a
// sentence — which would be the reporting channel existing and showing
// nothing, the exact failure it was built to fix.
case 'step.detail': {
const { action, ...rest } = d
const body = Object.entries(rest)
.map(([key, value]) => `${key}: ${detailValue(value)}`)
.join(', ')
return body ? `${action || 'A step'} — ${body}` : `${action || 'A step'} reported nothing`
}
default:
return logKindWord(line?.kind)
}
}

View File

@@ -0,0 +1,99 @@
// Rendering an event's instant, shared by the public event screens.
//
// **The split these two functions make is EVENTS.md §I's, and it is the one
// thing about event times that is easy to get wrong.** The server returns UTC
// instants and never guesses the reader's zone. The client places them:
//
// • the DAY an entry is filed under is the reader's own — "what is on this
// month" is a question about the month the person reading is living in;
// • the TIME beside it is always the EVENT's zone, carried on the entry —
// because every listing this feature replaces is written in the shard's
// local zone, and "8pm" means the shard's evening to everyone reading it.
//
// Rendering the time in the reader's zone instead would be defensible and is
// wrong here: a player in Berlin told an American shard's event is at "02:00"
// has been told something true and useless, and told it in a way that makes the
// shard's own announcement look like a mistake.
/** The event's own wall clock, with the zone named so it misreads as nothing. */
export function eventTime(instant, timezone) {
try {
const time = new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
return `${time} ${shortZone(timezone)}`
} catch {
// An unknown IANA name throws rather than falling back, and an event whose
// timezone column holds a typo must still render. UTC off the instant is the
// honest answer when the zone cannot be honoured.
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
}
}
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
function shortZone(timezone) {
if (!timezone) return 'UTC'
const tail = String(timezone).split('/').pop()
return tail.replace(/_/g, ' ')
}
/** The reader's own day, for the heading an entry is filed under. */
export function readerDayLabel(instant) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
}).format(d)
}
/** The event's own day and time together, for a page that shows one occurrence. */
export function eventDateTime(instant, timezone) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
try {
return `${new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(d)} ${shortZone(timezone)}`
} catch {
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
}
}
// The word beside an entry, for the four public statuses.
//
// **`cancelled` needs the instant, and that is the whole reason this is a
// function rather than a lookup table.** The server publishes `failed` and
// `missed` as `cancelled` too — to a visitor those three are one event, and the
// difference between them is about the deployment — but the three do not share
// one English sentence. "Did not happen" is right for a past occurrence and a
// plain falsehood for a future one, and a run four days out that an operator has
// called off is exactly the common case: the calendar was saying *did not
// happen* about next Friday.
//
// So the tense follows the clock, not the status. A future call-off reads
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
// word for the failed and missed runs folded in with it.
const WORDS = {
live: 'Happening now',
scheduled: 'Scheduled',
completed: 'Finished',
}
export function statusWord(status, scheduledFor, now = Date.now()) {
if (WORDS[status]) return WORDS[status]
if (status !== 'cancelled') return status
const at = new Date(scheduledFor).getTime()
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
}

View File

@@ -0,0 +1,35 @@
// Where a given account's notification screens live.
//
// **Staff and players reach the same two screens at different paths, and that is
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
// the admin area, where staff manage their own account under `/admin/account`.
// So a bell that always pointed at `/account/notifications` would, for every
// staff member, point at a page that redirects.
//
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
// unreachable on the web. Two routes, one pair of components, one mapping here.
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
/** The inbox — what the bell opens. */
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
/**
* This account's own event participation (events Phase 14a).
*
* The third screen to need this mapping, and it needed it for exactly the reason
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
* self-scoped on `req.user.id` — a staff member has a participation history like
* anyone else, and the group's own header says staff are a superset of players.
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
* page. Found the same way the notifications pair was: signed in as an admin,
* the screen simply redirected.
*/
export const eventHistoryPath = (user) =>
isStaff(user) ? '/admin/events/mine' : '/account/events'

View File

@@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) {
{/* The one link off this control, because "mute" is a blunt answer to a
question the account screen asks properly — which streams, and whether
email is on at all. */}
<Link to="/account/notifications" className="dim">All notification settings</Link>
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
</div>
)
}

View File

@@ -11,6 +11,27 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
// Phase 7): a module may register event actions, budget dimensions, leases and
// param option sources. All four are server-side registrations and nothing on
// `window.__rg` changed — but what they produce is met on this half, in the step
// editor: an option source is what turns a param from a text box into a dropdown
// of real values, and a budget's label and unit are what the switchboard's cap
// box says beside its number. This file bumps for the reason at the top: the two
// halves state ONE version, and a module declares one `coreApi` range against
// both.
// 1.9.0 - a module may ship its own message bodies and rules:
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
// and core's seeders write it on the boot path - but the bodies it ships are
// edited through the template editor this half already renders, and an operator
// meets them there. This file bumps for the reason at the top: the two halves
// state ONE version, and a module declares one `coreApi` range against both.
// 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on
// this half changed: a ceiling is declared on the server's `api` and enforced
// there, and the admin screens that render one read the vocabulary from
// `GET /admin/engagement/triggers` rather than holding a copy. This file bumps
// anyway, for the reason at the top - the two halves state ONE version.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2). Nothing
// on this half changed: every member the version adds is on the server's `api`
// and `ctx` (registerEventTriggers, registerAudiences, ctx.events.emit,
@@ -53,4 +74,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.7.0'
export const MODULE_API_VERSION = '1.10.0'

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -43,6 +44,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
@@ -51,6 +53,7 @@ const IconList = () => <Icon><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4"
const IconTemplate = () => <Icon><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></Icon>
const IconSpark = () => <Icon><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" /><path d="M18 16l.9 2.1L21 19l-2.1.9L18 22l-.9-2.1L15 19l2.1-.9z" /></Icon>
const IconLog = () => <Icon><path d="M4 5h16v14H4z" /><path d="M8 9h8M8 12h8M8 15h5" /></Icon>
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /><circle cx="12" cy="15" r="1.4" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
@@ -96,9 +99,9 @@ export const NAV = [
},
{
// Its own top-level group (ENGAGEMENT.md §7.1 Q4), not a section of
// Settings. Settings is already one long page of sections, and these five
// screens are two editors, a catalog and a paged table, none of which is a
// settings section. Email Delivery stays under Settings: configuring a
// Settings. Settings is already one long page of sections, and these six
// screens are two editors, a catalog and two paged tables, none of which is
// a settings section. Email Delivery stays under Settings: configuring a
// transport is not the same job as deciding who gets mail.
title: 'Engagement',
items: [
@@ -107,6 +110,44 @@ export const NAV = [
{ to: '/admin/engagement/templates', label: 'Templates', icon: IconTemplate, roles: ['admin'] },
{ to: '/admin/engagement/triggers', label: 'Triggers', icon: IconSpark, roles: ['admin'] },
{ to: '/admin/engagement/sends', label: 'Send Log', icon: IconLog, roles: ['admin'] },
// Beside the Send Log rather than inside it (Phase 9): the log answers
// "did that message go out", and this answers "why is this person not
// getting any" - and it is the only screen that can lift a suppression.
{ to: '/admin/engagement/suppressions', label: 'Suppressions', icon: IconLog, roles: ['admin'] },
// Last in the group because it is the one screen nobody visits weekly, and
// beside Suppressions on purpose: it is where the reader is told that the
// fourth engagement table does NOT expire, which is otherwise a silence
// that reads as an oversight.
{ to: '/admin/engagement/retention', label: 'Retention', icon: IconGear, roles: ['admin'] },
],
},
{
// Its own top-level group rather than a row under Content, and staff-wide
// rather than admin-only. Both follow EVENTS.md §K: every read here is
// `staff`, and the moderator's entire power over this feature is the run
// console — the thing they open when an event is doing something wrong at
// 2am. Hiding it from them would leave the one role that exists for incident
// response unable to see the incident. The narrower gates live on the
// actions: authoring is admin+editor and publish/start are admin only, both
// enforced server-side and mirrored on the buttons.
title: 'Events',
items: [
{ to: '/admin/events', label: 'Events', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
// Phase 4. The same staff gate as the list beside it: a calendar is a read,
// and the arcs it manages are authoring gated on the buttons rather than
// on the row.
{ to: '/admin/events/calendar', label: 'Calendar', icon: IconCalendar, roles: ['admin', 'editor', 'moderator'] },
// Phase 6, and the one row in this group that is NOT staff-wide. §K puts
// the switchboard in the same row as the world-changing actions it
// governs: what a deployment permits at all is configuration, not a read,
// and the server gates both the GET and the PUT on `admin`.
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
// Phase 14a, and the one row here that is not about running the
// deployment: it is this staff member's OWN attendance, the same screen
// and the same route a player reads at /account/events. It has no `roles`
// because it needs none — every account has a participation history, and
// the server scopes it to the caller.
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
],
},
{
@@ -129,6 +170,11 @@ export const NAV = [
},
{
items: [
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
// marking this one exact would leave `/admin/notifications/settings`
// outside the allowlist and bounce a staff member off their own
// preferences screen. The row covering its sub-routes is the point.
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -177,11 +223,20 @@ const TITLES = {
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
'/admin/notifications': 'Notifications',
'/admin/notifications/settings': 'Notification settings',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates',
'/admin/engagement/triggers': 'Triggers',
'/admin/engagement/suppressions': 'Suppressions',
'/admin/engagement/sends': 'Send Log',
'/admin/engagement/retention': 'Retention',
'/admin/events': 'Events',
'/admin/events/calendar': 'Event calendar',
'/admin/events/actions': 'Event actions',
'/admin/events/mine': 'My participation',
'/admin/events/new': 'New event',
}
// An installed module's admin pages are not in TITLES and cannot be — core does
@@ -202,6 +257,10 @@ function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/users/')) return 'User'
if (pathname.startsWith('/admin/engagement')) return 'Engagement'
// /admin/events/:id and /admin/events/runs/:runId are both dynamic, and both
// belong to the same section as far as the page title is concerned.
if (pathname.startsWith('/admin/events/runs/')) return 'Event run'
if (pathname.startsWith('/admin/events/')) return 'Event'
return 'Admin'
}
@@ -443,6 +502,11 @@ export default function AdminLayout() {
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
is role-agnostic — and `RequirePlayer` keeps them out of the
player portal, so without this the one place they spend their
time is the one place the bell is missing. */}
<NotificationBell />
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site →
</a>

View File

@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Retention (ENGAGEMENT.md Phase 14).
//
// Three of the four engagement tables grew on every fire and nothing had ever
// deleted from any of them. This screen is the policy: how long the deployment
// keeps a cooldown row, a finished outbox row and a send-log entry.
//
// **Why it is a screen, when the other two retention workers in this codebase
// (`team_activity`, `user_notifications`) are invisible settings rows.** The
// send-log horizon changes what an operator-facing page is *able to show* — the
// Send Log is the only answer to "was this person told" — so an operator has to
// be able to see it and set it, not discover it by finding rows missing. Having
// made one visible, hiding the other two would be the worse split: "what does
// this deployment keep" is one question and deserves one answer.
//
// **The fourth table is on this page as prose, not as a control.** Suppressions
// do not expire (org lead, 2026-09-01), and saying so here is the point: an
// operator reading a retention screen that lists three tables would reasonably
// assume the fourth was an oversight.
const FIELDS = [
{
name: 'sends',
label: 'Send log',
table: 'engagement_sends',
// The one horizon the org lead asked to be pickable rather than typed —
// and `custom` stays, because a deployment with a compliance answer to
// give should not be limited to three numbers somebody chose.
presets: [90, 180, 365],
help:
'One row per delivery attempt. This is what Admin → Engagement → Send Log reads, so the '
+ 'horizon is also how far back "was this person told" can be answered. The per-rule hourly '
+ 'ceiling counts this table too, which is why it can never go below a week.',
},
{
name: 'cooldowns',
label: 'Cooldowns',
table: 'engagement_cooldowns',
presets: [7, 30, 90],
help:
'One row per rule, user, subject and channel, written on every fire. Deleting a row that '
+ 'is still in force makes the next fire count as a first fire — that is a duplicate '
+ 'message — so this must stay longer than the longest cooldown on any enabled rule.',
},
{
name: 'outbox',
label: 'Outbox',
table: 'engagement_outbox',
presets: [7, 30, 90],
help:
'Only finished rows are ever removed: sent, failed, cancelled and not-sent. A scheduled '
+ 'row is a message this deployment still intends to send and is never swept, however old '
+ 'the horizon.',
},
]
export default function EngagementRetention() {
const [policy, setPolicy] = useState(null)
const [limits, setLimits] = useState({})
const [warnings, setWarnings] = useState([])
const [longestCooldown, setLongestCooldown] = useState(0)
const [draft, setDraft] = useState({})
const [saving, setSaving] = useState(false)
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const apply = useCallback((result) => {
setPolicy(result.retention)
setDraft(result.retention)
setLimits(result.limits || {})
setWarnings(result.warnings || [])
setLongestCooldown(result.longestCooldownSeconds || 0)
}, [])
useEffect(() => {
let alive = true
;(async () => {
try {
const result = await api.admin.getEngagementRetention()
if (alive) apply(result)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => { alive = false }
}, [apply])
async function save() {
setSaving(true)
setNote(null)
try {
// The whole draft, not the changed field: this screen is the one place the
// three are set together, and a partial save would leave the warning line
// (which is computed from the cooldown horizon) describing a policy that is
// half saved. The route itself is sparse, so sending three is legal.
const result = await api.admin.setEngagementRetention(draft)
apply(result)
setNote('Saved.')
} catch (err) {
setNote(err.message)
} finally {
setSaving(false)
}
}
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
const dirty = policy && FIELDS.some((f) => Number(draft[f.name]) !== Number(policy[f.name]))
return (
<section>
<p className="sans dim" style={{ fontSize: '0.88rem', maxWidth: 720, marginTop: 0 }}>
How long this deployment keeps the engagement system&rsquo;s own records. A nightly sweep
removes anything older, in batches, and skips a table it cannot read rather than failing
the run.
</p>
{warnings.map((w) => (
<p
key={w}
className="sans"
style={{
fontSize: '0.85rem',
maxWidth: 720,
padding: '10px 12px',
borderLeft: '3px solid #d98b84',
background: 'rgba(217, 139, 132, 0.08)',
}}
>
{w}
</p>
))}
<div style={{ display: 'grid', gap: 22, maxWidth: 720, marginTop: 20 }}>
{FIELDS.map((f) => {
const spec = limits[f.name] || {}
const value = draft[f.name] ?? ''
const isPreset = f.presets.includes(Number(value))
return (
<div key={f.name}>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<span className="field-label" style={{ fontWeight: 600 }}>{f.label}</span>
<code className="dim" style={{ fontSize: '0.74rem' }}>{f.table}</code>
</div>
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '4px 0 8px' }}>
{f.help}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label>
<span className="field-label">Keep for</span>
<select
className="select"
value={isPreset ? String(value) : 'custom'}
onChange={(e) => {
const next = e.target.value
// Choosing "custom" must not blank the field — the number
// box below is what the operator is about to edit, and an
// empty one would post NaN.
if (next === 'custom') return
setDraft({ ...draft, [f.name]: Number(next) })
}}
>
{f.presets.map((d) => (
<option key={d} value={String(d)}>{d} days</option>
))}
<option value="custom">Custom…</option>
</select>
</label>
<label>
<span className="field-label">Days</span>
<input
className="input"
type="number"
min={spec.min ?? 2}
max={spec.max ?? 3650}
style={{ width: 110 }}
value={value}
onChange={(e) => setDraft({ ...draft, [f.name]: e.target.value === '' ? '' : Number(e.target.value) })}
/>
</label>
{spec.min !== undefined && (
<span className="sans dim" style={{ fontSize: '0.78rem', paddingBottom: 8 }}>
{spec.min}–{spec.max} days
</span>
)}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 24 }}>
<button type="button" className="pill" disabled={!dirty || saving} onClick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
{dirty && (
<button type="button" className="pill" disabled={saving} onClick={() => setDraft(policy)}>
Discard
</button>
)}
{note && <span className="sans" style={{ fontSize: '0.82rem' }}>{note}</span>}
</div>
<div style={{ maxWidth: 720, marginTop: 32 }}>
<h3 className="sans" style={{ fontSize: '0.95rem', marginBottom: 6 }}>
Suppressed addresses do not expire
</h3>
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>
A suppression is a standing decision, not a record of something that happened. Ageing one
out would re-mail an address that already hard-bounced or asked to be left alone, which is
how a sender loses a domain&rsquo;s reputation. The way out of that list stays a
deliberate act:{' '}
<strong>Lift</strong> on the row, in Admin → Engagement → Suppressions.
</p>
{longestCooldown > 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', marginBottom: 0 }}>
The longest cooldown on an enabled rule right now is {longestCooldown} seconds.
</p>
)}
</div>
</section>
)
}

View File

@@ -482,6 +482,60 @@ function RuleEditor({ catalog, segments, rule, onSaved, onCancel }) {
// ── The screen ─────────────────────────────────────────────────────────────
// ── The Phase 6 migration notice ───────────────────────────────────────────
//
// Team notifications used to be sent with no operator configuration at all;
// ENGAGEMENT.md Phase 6 moved them onto rules, and the org lead's decision was to
// seed those rules DISABLED rather than carve an exception into "nothing is on by
// default". The consequence is a deployment whose Team email has stopped and
// nobody has been told — which is G22's failure mode with a different cause — so
// the screen that can fix it says so.
//
// It reads the RULES rather than a flag, so it disappears the moment one is
// switched on and comes back if every one is switched off again. A deployment
// that deleted them all sees nothing, which is right: they made that choice.
//
// **Phase 11 added a second notice of exactly the same shape, for news**
// (ENGAGEMENT.md §7.1 Q9). Publishing a news post used to tickle every subscriber
// directly, and that call is now an emit through the engine, so news push stops
// on upgrade until the seeded `news.post` rule is switched on. Two notices rather
// than one generalised "some rules are off" banner, deliberately: each names a
// capability that USED to work without configuration and now does not, which is
// a different statement from "you have a disabled rule" — and a rule an operator
// created and disabled themselves must never produce a warning.
const TEAM_TRIGGERS = [
'team.forum.post',
'team.announcement',
'team.member.joined',
'team.leadership.changed',
]
const NEWS_TRIGGERS = ['news.post']
// One style for both notices, so the pair reads as one kind of message rather
// than two that happen to look alike.
const NOTICE_STYLE = {
fontSize: '0.85rem',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 16,
border: '1px solid #7a6440',
color: '#e0b070',
}
const triggerOf = (rule) => rule.triggerId || rule.trigger_id
// True only when rules for these triggers EXIST and every one of them is off.
// Zero matching rules means the operator deleted them, which is a choice, not a
// regression to warn about.
function allOff(rules, triggers) {
const group = rules.filter((r) => triggers.includes(triggerOf(r)))
return group.length > 0 && group.every((r) => !r.enabled)
}
const teamRulesAllOff = (rules) => allOff(rules, TEAM_TRIGGERS)
const newsRulesAllOff = (rules) => allOff(rules, NEWS_TRIGGERS)
export default function EngagementRules() {
const [catalog, setCatalog] = useState(null)
const [segments, setSegments] = useState([])
@@ -558,6 +612,25 @@ export default function EngagementRules() {
return (
<section>
{teamRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>Team notification emails are off.</strong> They used to be sent automatically; they
are now rules, and the four below arrived switched off so that nothing starts mailing on its
own. Switch on the ones this deployment wants — per-member preferences and per-Team mutes
still apply above them, and unsubscribe links in mail already sent still work.
</div>
)}
{newsRulesAllOff(rules) && (
<div className="sans" style={NOTICE_STYLE}>
<strong>News notifications are off.</strong> Publishing a news post used to send a push
notification to everyone subscribed to it. That is now the “News posts” rule below, and it
arrived switched off for the same reason the Team rules did. Switch it on to resume news
push — it also carries email and the in-app inbox, each still subject to each person’s own
preferences. The in-game town crier and the Discord announcement are unaffected either way.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 640 }}>
A rule turns an event into mail: which event, who hears about it, on which channels, and how

View File

@@ -43,6 +43,11 @@ export default function EngagementSendLog() {
const [offset, setOffset] = useState(0)
const [status, setStatus] = useState('')
const [testTrigger, setTestTrigger] = useState('')
// Phase 14. `total` is now a truncated number, and a screen that shows a total
// without saying so is quietly wrong about the deployment's own history — this
// is the fix for that, and the reason the horizon got an operator-facing
// control rather than the invisible settings row the other two sweeps use.
const [retainDays, setRetainDays] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -55,6 +60,15 @@ export default function EngagementSendLog() {
setRows(result.sends || [])
setTotal(result.total || 0)
setTestTrigger(result.testSendTrigger || '')
// Best-effort and non-blocking: the log is worth showing even if the policy
// cannot be read, so a failure here leaves the note off rather than the
// screen empty.
try {
const policy = await api.admin.getEngagementRetention()
setRetainDays(policy?.retention?.sends ?? null)
} catch {
setRetainDays(null)
}
}, [])
useEffect(() => {
@@ -153,6 +167,7 @@ export default function EngagementSendLog() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}–{to} of {total}
{retainDays ? ` · entries older than ${retainDays} days are removed automatically` : ''}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}

View File

@@ -0,0 +1,294 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Engagement → Suppressions (ENGAGEMENT.md §4.5 gap G16, Phase 9).
//
// **This screen is the only way out of the suppression list**, which is the whole
// reason it exists rather than the list living as a filter on the Send Log. A
// hard bounce is written by a background worker with no human in the loop, so
// without a lift button a mistyped-then-corrected mailbox is silenced for good
// and nobody ever finds out why that person stopped hearing from the deployment.
//
// **Addresses are shown masked, and the mask is deliberate on both ends.** The
// table holds a sha256 and an `address_masked` — `d***@example.com` — and the
// route never returns the hash, for the same reason the Send Log strips it: a
// digest of every address on the deployment, handed to a browser, is an offline
// dictionary attack waiting to be run. The domain survives because the signal an
// operator is actually hunting is domain-shaped ("everything to this company is
// bouncing" is a different problem from three people mistyping their own
// address), and the local part is destroyed rather than shortened so the list can
// never be read back as an address book.
//
// The consequence to keep in mind while reading this file: **lifting a
// suppression needs the WHOLE address typed in**, because the screen genuinely
// does not have it. That is not a rough edge to be smoothed later — it is the
// privacy design working, and the confirm dialog says so.
const REASON_LABEL = {
bounce: 'Hard bounce',
complaint: 'Marked as spam',
manual: 'Added by an admin',
unverified: 'Unverified',
}
const REASON_HELP = {
bounce: 'The receiving server said this mailbox does not exist.',
complaint: 'The recipient reported a message as spam.',
manual: 'Somebody here added it — usually a bounce reported another way.',
unverified: 'Reserved: the verification gate excludes these before a send is queued.',
}
const PAGE = 50
export default function EngagementSuppressions() {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [byReason, setByReason] = useState({})
const [offset, setOffset] = useState(0)
const [reason, setReason] = useState('')
const [search, setSearch] = useState('')
// Debounced separately from `search` so typing a domain does not fire a request
// per keystroke; `search` is what the input shows, `applied` is what was asked.
const [applied, setApplied] = useState('')
const [adding, setAdding] = useState('')
const [note, setNote] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (nextOffset, nextReason, nextSearch) => {
const result = await api.admin.listEngagementSuppressions({
limit: PAGE,
offset: nextOffset,
reason: nextReason || undefined,
search: nextSearch || undefined,
})
setRows(result.suppressions || [])
setTotal(result.total || 0)
setByReason(result.byReason || {})
}, [])
useEffect(() => {
const t = setTimeout(() => { setOffset(0); setApplied(search.trim()) }, 300)
return () => clearTimeout(t)
}, [search])
const refresh = useCallback(async () => {
setLoading(true)
try {
await load(offset, reason, applied)
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [load, offset, reason, applied])
useEffect(() => { refresh() }, [refresh])
async function addByHand(e) {
e.preventDefault()
const address = adding.trim()
if (!address) return
setNote(null)
try {
const result = await api.admin.suppressAddress(address)
// `created: false` is not a failure — the operator asked for the address to
// be suppressed and it is. Saying so plainly beats an error dialog for an
// outcome that is exactly what was wanted.
setNote(result.created
? `${result.address} will no longer be mailed.`
: `${result.address} was already suppressed.`)
setAdding('')
await refresh()
} catch (err) {
setNote(err.message)
}
}
async function lift() {
// The address cannot come from the row — the screen has only the mask. Asking
// for it in full is the cost of not storing it, and the prompt says why so it
// does not read as a missing feature.
const address = window.prompt(
'Type the full address to let it be mailed again.\n\n'
+ 'Suppressed addresses are stored one-way, so this screen never has the address itself.',
)
if (!address || !address.trim()) return
setNote(null)
try {
await api.admin.unsuppressAddress(address.trim())
setNote(`${address.trim()} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
/**
* The per-row Lift (Phase 14). No address is asked for and none is needed: the
* row carries its own `address_hash`, which is the only handle this screen has
* ever been able to have — the address itself is stored one-way.
*
* No confirm dialog, deliberately. Lifting is reversible in one click (the
* Suppress field above is right there), and a browser modal blocks the whole
* tab, which is the failure mode the automation notes in this repo warn about.
*/
async function liftRow(row) {
setNote(null)
try {
await api.admin.unsuppressByHash(row.address_hash, row.channel)
setNote(`${row.address_masked || 'That address'} can be mailed again.`)
await refresh()
} catch (err) {
setNote(err.message)
}
}
if (loading && rows.length === 0 && !applied && !reason) return <Loading />
if (error) return <ErrorState message={error} />
const to = Math.min(offset + PAGE, total)
const summary = Object.entries(byReason).filter(([, n]) => n > 0)
return (
<section>
<p className="sans" style={{ margin: '0 0 16px', fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Addresses this deployment has stopped mailing. Engagement rules skip them; password resets,
invites and verification mails still go out, because those are asked for by the person
themselves. Addresses are stored one-way and shown masked.
</p>
{summary.length > 0 && (
<div className="panel-flat" style={{ display: 'flex', gap: 24, flexWrap: 'wrap', padding: '12px 16px', marginBottom: 16 }}>
{summary.map(([r, n]) => (
<div key={r}>
<div className="sans" style={{ fontSize: '1.1rem', fontWeight: 600 }}>{n}</div>
<div className="sans dim" style={{ fontSize: '0.76rem' }} title={REASON_HELP[r] || ''}>
{REASON_LABEL[r] || r}
</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: 16 }}>
<label style={{ flex: '1 1 220px' }}>
<span className="field-label">Search</span>
<input
className="input"
value={search}
placeholder="a domain, or part of one"
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<label>
<span className="field-label">Reason</span>
<select className="select" value={reason} onChange={(e) => { setOffset(0); setReason(e.target.value) }}>
<option value="">Any</option>
{Object.keys(REASON_LABEL).map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</label>
<form onSubmit={addByHand} style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flex: '1 1 280px' }}>
<label style={{ flex: 1 }}>
<span className="field-label">Suppress an address</span>
<input
className="input"
type="email"
value={adding}
placeholder="someone@example.com"
onChange={(e) => setAdding(e.target.value)}
/>
</label>
<button type="submit" className="pill" style={{ fontSize: '0.74rem' }} disabled={!adding.trim()}>
Suppress
</button>
</form>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} onClick={lift}>
Lift a suppression
</button>
</div>
{note && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '0 0 14px' }}>{note}</p>
)}
{total === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{reason || applied ? 'Nothing matches that filter.' : 'No addresses are suppressed.'}
</p>
) : (
<>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Address</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Channel</th>
<th className="adm-th">Since</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.channel}:${r.address_masked}:${r.created_at}`}>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.address_masked
? <code style={{ fontSize: '0.8rem' }}>{r.address_masked}</code>
: <span className="dim">not recorded</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }} title={REASON_HELP[r.reason] || ''}>
{REASON_LABEL[r.reason] || r.reason}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{r.detail || ''}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{r.channel}</td>
<td className="adm-td" style={{ whiteSpace: 'nowrap', fontSize: '0.8rem' }}>
{new Date(r.created_at).toLocaleString()}
</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
<button
type="button"
className="pill"
style={{ fontSize: '0.72rem' }}
disabled={!r.address_hash}
title={r.address_hash
? 'Let this address be mailed again'
: 'This row has no handle to act on'}
onClick={() => liftRow(r)}
>
Lift
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<span className="sans dim" style={{ fontSize: '0.82rem' }}>
{offset + 1}–{to} of {total}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Newer
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }}
disabled={to >= total} onClick={() => setOffset(offset + PAGE)}>
Older
</button>
</div>
</div>
</>
)}
</section>
)
}

View File

@@ -24,6 +24,7 @@ const CEILING_NOTE = {
members: 'only members of the thing it is about',
subscribers: 'only people who opted in',
staff: 'only staff',
admin: 'only administrators',
authenticated: 'any signed-in account',
everyone: 'anyone',
}

View File

@@ -0,0 +1,280 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
//
// **This screen is the whole of the permission model beyond the role.** A module
// declaring `uo.creature.spawn` is code the operator installed; it is not a
// permission they granted. Enablement is the grant, and the cap is how much of
// it — so this is the one screen in the feature where an operator decides what
// the deployment *can do at all*, rather than what it is going to do tonight.
//
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
// must never start doing things, which is the posture a seeded engagement rule
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
// is `inspect` — arriving off would break every published event that waits.
//
// **A row with no stored setting is not "off".** It is "the default for its risk
// class", computed on the server by the same function the runner asks. The screen
// says which it is looking at, because "an admin turned this on" and "this has
// always been on" are different facts and only one of them is a decision.
//
// **Admin only in both directions**, including the read: §K puts the switchboard
// in the same row as the world-changing actions it governs, and knowing exactly
// what a deployment permits is not a staff-wide read.
const RISK_WORD = {
notify: 'Tells people something',
inspect: 'Reads the world',
change: 'Changes the world',
irreversible: 'Changes the world irreversibly',
}
const RISK_COLOR = {
notify: 'var(--muted)',
inspect: 'var(--muted)',
change: '#d9c184',
irreversible: '#d98b84',
}
const REVERSIBLE_WORD = {
none: 'nothing to undo',
self: 'undoes itself',
ledger: 'undone from the ledger at teardown',
override: 'restores a baseline',
}
export default function EventActions() {
const [actions, setActions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(null)
const [problem, setProblem] = useState(null)
const [notice, setNotice] = useState(null)
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
// A cap is a number somebody types digit by digit, and writing on every
// keystroke would put "3" in the database on the way to "30".
const [drafts, setDrafts] = useState({})
const load = useCallback(async () => {
const data = await api.admin.eventActions()
setActions(data.actions || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load()
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load])
/**
* Write one action's row.
*
* The whole row goes every time — the switch and every cap — because the route
* takes one action per request and a sparse write would have to decide what an
* omitted cap means. Here it can only mean one thing, so it is sent.
*/
const save = async (action, { enabled = action.enabled, caps } = {}) => {
setBusy(action.id)
setProblem(null)
setNotice(null)
const nextCaps = caps !== undefined ? caps : capsOf(action)
try {
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
await load()
setDrafts((d) => {
const next = { ...d }
for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
return next
})
setNotice(`Saved ${action.label}.`)
} catch (err) {
setProblem(err.message)
} finally {
setBusy(null)
}
}
/** The caps this row would save: the drafts on top of what is stored. */
const capsOf = (action) => {
const out = {}
for (const { id: dimension } of action.dimensions) {
const draft = drafts[`${action.id}:${dimension}`]
const value = draft !== undefined ? draft : action.caps[dimension]
if (value === '' || value === undefined || value === null) continue
out[dimension] = Number(value)
}
return out
}
const capValue = (action, dimension) => {
const draft = drafts[`${action.id}:${dimension}`]
if (draft !== undefined) return draft
const stored = action.caps[dimension]
return stored === undefined || stored === null ? '' : String(stored)
}
const dirty = (action) =>
action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
What this deployment permits an event to do, and how much of it per run. Anything that changes
the world arrives switched off — installing a module declares a verb, it does not grant
permission to use it. Caps are copied into a run when the run is created, so moving a switch
never changes what a run already in flight is allowed.
</p>
{problem && (
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
</div>
)}
{notice && (
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
</div>
)}
{actions.length === 0 && (
<div className="panel-flat" style={{ padding: 14 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
No module registers an event action. Core always declares its own three, so an empty list
here means the registry did not load.
</p>
</div>
)}
{actions.map((action) => (
<div
key={action.id}
className="panel-flat"
style={{
padding: 14,
marginBottom: 10,
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
opacity: action.enabled ? 1 : 0.75,
}}
>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
</div>
{action.description && (
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
)}
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
{' · '}
{REVERSIBLE_WORD[action.reversible] || action.reversible}
{/* Which of the two facts this is. A default is not a decision, and
an operator auditing their own deployment needs to see the
difference without reading the risk table in their head. */}
{' · '}
{action.configured
? `set by ${action.updatedBy || 'an administrator'}`
: 'never configured — showing the default for its risk class'}
</p>
</div>
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
<input
type="checkbox"
checked={action.enabled}
disabled={busy === action.id}
onChange={(e) => save(action, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
{action.dimensions.length > 0 && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
it. Where another enabled action spends the same thing, the tightest cap is the one a
run gets.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{action.dimensions.map((d) => (
<label key={d.id} className="sans" style={{ fontSize: '0.8rem' }}>
{/*
The LABEL, with the unit beside the box — both from the module's
`registerEventBudgets` declaration (Phase 7). Before it, this said
`uo.creatures` over an unlabelled number, which is ambiguous in exactly
the case that matters: 30 of what?
*/}
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>
{d.registered ? d.label : d.id}
</span>
<span style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<input
type="number"
min="0"
step="1"
style={{ width: 110 }}
value={capValue(action, d.id)}
disabled={busy === action.id || !d.registered}
onChange={(e) =>
setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
}
/>
{d.registered && d.unit && (
<span className="dim" style={{ fontSize: '0.75rem' }}>{d.unit}</span>
)}
</span>
{/*
A dimension nobody declares is SHOWN rather than hidden. The action is
refused when it is saved into a step and again if it is ever dispatched,
so the operator needs to be told which module is incomplete — hiding the
row would make a broken module look like a cheap one.
*/}
{!d.registered && (
<span
className="sans"
style={{ display: 'block', marginTop: 2, fontSize: '0.72rem', color: '#d98b84' }}
>
No module declares this as a budget, so a step using this action is
refused. It cannot be capped until one does.
</span>
)}
</label>
))}
<button
type="button"
className="btn"
disabled={busy === action.id || !dirty(action)}
onClick={() => save(action)}
>
Save caps
</button>
</div>
</div>
)}
</div>
))}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,722 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import {
runStatusWord,
isTerminalRun,
isParked,
runControlsFor,
stepControlsFor,
describeLogLine,
} from '../../../lib/eventAuthoring.js'
// Admin → Events → the run console (EVENTS.md §I, Phase 3).
//
// One run: where it is, what each of its steps did, what a human can still do
// about it, and the diagnostic log underneath. Staff-wide to read; the six
// controls are `admin` + `moderator`, and the server re-checks every one of them
// against the run's live status — this screen predicts, it does not decide.
//
// **It polls rather than streaming.** A run changes on the runner's tick, which
// is a fifteen-second clock, and a console watched for the length of an event is
// a tab left open for two hours: an SSE channel for that is a connection held
// per staff member for a screen that could not use the latency. The poll stops
// the moment the run reaches a terminal status, because a completed run has
// nothing further to say.
//
// **The parked step is the thing this screen exists to make impossible to
// miss.** A run waiting on a GM cue is `running` and healthy-looking, and it will
// stay that way for ever unless somebody presses confirm. It is called out above
// the step list rather than being one row in it.
//
// **Phase 5 gave it a second one of those, and the panel is this phase's real
// deliverable** (§ Observability): a phase whose steps have all finished and
// whose advance condition has not been met is also `running` and also
// healthy-looking. *"Why didn't phase 3 start?"* is answered here, above the
// steps, in the condition builder's own words — and the sentence is the
// SERVER'S. `gates[].where` arrives already rendered, because those labels are
// defined in the condition grammar and a second renderer in the browser would
// be a second opinion about what `gte` reads as.
//
// **Phase 8 gave it a third, and it is the one that outlives the event.** The
// resource ledger is what this run changed in the world and what became of it,
// and its unresolved rows are the reason a `completed` run can still need a
// person — EVENTS.md §L: a run reaches `completed` with `cleanup_status =
// 'incomplete'` rather than being held open, because a tidy `completed` row over
// a shard full of orphaned monsters is the failure that would end this feature's
// credibility on its first bad night. The panel is shown on finished runs for
// exactly that reason, and it is the only panel here whose empty state matters.
const POLL_MS = 5000
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
completed: '#8fc79a',
}
// The six ledger statuses, in the two groups that matter to a reader: green is
// resolved, amber wants a person. `orphaned` and `drifted` are amber rather than
// red because neither is a fault — one thing vanished, the other was taken by
// somebody with every right to take it — and red is reserved for "this did not
// come back and core kept asking".
const RESOURCE_COLOR = {
reverted: '#8fc79a',
confirmed: '#d9c184',
pending: '#d9c184',
reverting: '#d9c184',
drifted: '#d9c184',
orphaned: '#d9c184',
}
const RESOURCE_WORD = {
pending: 'recorded, unconfirmed',
confirmed: 'still out there',
reverting: 'being given back',
reverted: 'given back',
orphaned: 'gone',
drifted: 'someone else moved it',
}
const STEP_COLOR = {
done: '#8fc79a',
failed: '#d98b84',
refused: '#d9c184',
skipped: 'var(--muted)',
cancelled: 'var(--muted)',
}
const when = (v) => (v ? new Date(v).toLocaleString() : '—')
const clock = (v) => (v ? new Date(v).toLocaleTimeString() : '')
// How many participants the console renders before it stops and counts the rest.
// A run's participants are people and a busy event has hundreds; this panel is a
// check that the collection worked and that the ranking looks right, not the
// results page — that is Phase 14's, and it is public.
const PARTICIPANTS_SHOWN = 50
/**
* Seconds as an operator reads them — the same vocabulary the spec authors a
* gate in, so "28 min" on this screen and `after: '30m'` in the editor are
* obviously the same kind of thing.
*/
function elapsed(seconds) {
const s = Math.max(0, Number(seconds) || 0)
if (s < 60) return `${s} sec`
if (s < 3600) return `${Math.floor(s / 60)} min`
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return m ? `${h} hr ${m} min` : `${h} hr`
}
/**
* One phase gate, as the panel draws it.
*
* The satisfied ones are drawn too, and dimmed: "phase 2 waited 41 minutes and
* was released by the third boss" is the same question as the live one, asked
* after the fact, and it is the one an operator asks the morning after.
*/
function GateRow({ gate, current }) {
const colour = gate.satisfied ? 'var(--muted)' : gate.stalled ? '#d98b84' : '#d9c184'
return (
<div style={{ padding: '8px 0', borderTop: '1px solid var(--rule)' }}>
<div className="sans" style={{ fontSize: '0.86rem', color: colour }}>
Phase <strong>{gate.phase}</strong>
{current && !gate.satisfied ? ' has not started' : ''}
{gate.satisfied && ` — released ${gate.satisfiedBy === 'forced' ? 'by hand' : `on its ${gate.satisfiedBy === 'elapsed' ? 'deadline' : 'condition'}`}`}
{gate.stalled && ' — STALLED'}
</div>
<dl className="sans" style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px', margin: '6px 0 0', fontSize: '0.8rem' }}>
{gate.kind === 'after' ? (
<>
<dt className="dim">waiting for</dt>
<dd style={{ margin: 0 }}>{elapsed(gate.after)} from the start of the phase</dd>
<dt className="dim">until</dt>
<dd style={{ margin: 0 }}>{when(gate.dueAt)}</dd>
</>
) : (
<>
<dt className="dim">waiting on</dt>
<dd style={{ margin: 0 }}>
<code>{gate.waitingOn}</code>
{gate.where ? <> where <em>{gate.where}</em></> : <span className="dim"> — any firing</span>}
</dd>
<dt className="dim">seen so far</dt>
<dd style={{ margin: 0 }}>{gate.seen} of {gate.needed}</dd>
</>
)}
<dt className="dim">since</dt>
<dd style={{ margin: 0 }}>{when(gate.since)} ({elapsed(gate.elapsedSeconds)})</dd>
{gate.kind === 'on' && gate.lastEvent && (
<>
<dt className="dim">last related event</dt>
<dd style={{ margin: 0 }}>
<code>{gate.lastEvent.trigger}</code> at {clock(gate.lastEventAt)}
{' — '}
{/* The near miss is the valuable half: "the boss did spawn, in
Britain" and "no boss has spawned" are different answers and
look identical without this line. */}
{gate.lastEvent.matched ? 'counted' : 'did not count'}
{Object.keys(gate.lastEvent.variables || {}).length > 0 && (
<span className="dim">
{' ('}
{Object.entries(gate.lastEvent.variables).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}
{')'}
</span>
)}
</dd>
</>
)}
</dl>
</div>
)
}
export default function EventRun() {
const { runId } = useParams()
const [run, setRun] = useState(null)
const [steps, setSteps] = useState([])
const [counts, setCounts] = useState({})
const [gates, setGates] = useState([])
// The caps this run was given and what it has spent of them (Phase 6). Copied
// into the run when it was created, so this is what THIS run is allowed rather
// than what the switchboard says today.
const [budget, setBudget] = useState([])
// What this run created or borrowed, and what became of each (Phase 8).
const [resources, setResources] = useState([])
const [unresolved, setUnresolved] = useState(0)
// Who took part, best first (Phase 10). Present whether or not the results
// have been published; `run.resultsPublishedAt` is what says which.
const [participants, setParticipants] = useState([])
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState(null)
const [notes, setNotes] = useState({})
const [reason, setReason] = useState('')
const alive = useRef(true)
const load = useCallback(async () => {
const [detail, log] = await Promise.all([
api.admin.getEventRun(runId),
api.admin.getEventRunLog(runId, 200),
])
if (!alive.current) return
setRun(detail.run)
setSteps(detail.steps || [])
setCounts(detail.counts || {})
setGates(detail.gates || [])
setBudget(detail.budget || [])
setResources(detail.resources || [])
setUnresolved(detail.unresolvedResources || 0)
setParticipants(detail.participants || [])
setLines(log.log || [])
}, [runId])
useEffect(() => {
alive.current = true
;(async () => {
setLoading(true)
try {
await load()
setError(null)
} catch (err) {
if (alive.current) setError(err.message)
} finally {
if (alive.current) setLoading(false)
}
})()
return () => {
alive.current = false
}
}, [load])
// The poll, and its own off switch. A terminal run is not re-read: it cannot
// change, and a console left open on last night's completed event should not
// be a request every five seconds until the tab is closed.
useEffect(() => {
if (!run || isTerminalRun(run.status)) return undefined
const timer = setInterval(() => {
load().catch(() => {})
}, POLL_MS)
return () => clearInterval(timer)
}, [run, load])
/** Every control goes through here: press, reload, and surface a refusal. */
const act = async (fn) => {
setBusy(true)
setProblem(null)
try {
await fn()
await load()
} catch (err) {
// A 409 is the ordinary answer to a button pressed against a run that has
// moved on since the screen was drawn, so it is shown as a sentence rather
// than as an error state — and the reload above has already re-drawn the
// controls as they now stand.
setProblem(err.body?.errors?.[0] || err.message)
await load().catch(() => {})
} finally {
setBusy(false)
}
}
if (loading && !run) return <Loading />
if (error) return <ErrorState message={error} />
if (!run) return <ErrorState message="No such run." />
const controls = runControlsFor(run, gates, steps)
const waiting = gates.find((g) => g.phase === run.currentPhase && !g.satisfied)
const parked = steps.filter(isParked)
const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(' · ')
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
<div>
<h2 className="sans" style={{ margin: 0, fontSize: '1.05rem' }}>
<Link to={`/admin/events/${run.definitionId}`}>{run.definitionTitle}</Link>{' '}
<span className="dim" style={{ fontWeight: 400 }}>v{run.version}</span>
</h2>
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.8rem' }}>
Occurrence {when(run.scheduledFor)}
{run.scope ? ` · scope ${run.scope}` : ''}
{run.rehearsal ? ' · rehearsal' : ''}
{run.concurrencyKey ? ` · key ${run.concurrencyKey}` : ''}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<div className="sans" style={{ fontSize: '1rem', color: STATUS_COLOR[run.status] || undefined }}>
{runStatusWord(run.status)}
{run.currentPhase && <span className="dim" style={{ fontSize: '0.82rem' }}> · {run.currentPhase}</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem' }}>
{run.health !== 'ok' && <span style={{ color: '#d9c184' }}>{run.health} · </span>}
{summary || 'no steps'}
{!isTerminalRun(run.status) && <span> · refreshing</span>}
</div>
</div>
</div>
{/* Health is not status, which is the whole reason the two are separate
columns — but the sentence has to agree with the status it sits beside.
A degraded RUNNING run is the interesting case: still going, already in
trouble. A degraded PAUSED run is not "still running", and saying so on
the one screen an operator opens to find out what stopped it would be
the console contradicting itself. Found in the browser walk. */}
{run.health === 'degraded' && !isTerminalRun(run.status) && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d9c184', marginTop: 10 }}>
{run.status === 'paused' ? (
<>
Something in this run failed, and it is waiting for a person. Resuming carries the phase
past the failed step; <em>Retry &amp; resume</em> puts that step back in the queue first.
</>
) : (
<>
Something in this run has already had to be retried. It is still running — this is
what &ldquo;degraded&rdquo; means, and the log below says what happened.
</>
)}
</p>
)}
{run.lastError && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{run.lastError}</p>
)}
{problem && (
<p className="sans" style={{ fontSize: '0.82rem', color: '#d98b84', marginTop: 6 }}>{problem}</p>
)}
{/* ── The run controls ── */}
<div className="panel-flat" style={{ padding: '12px 14px', margin: '14px 0', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Reason (recorded with your name)</span>
<input className="input" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="optional" />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.pause}
onClick={() => act(() => api.admin.pauseEventRun(run.id, reason))}>
Pause
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.resume}
onClick={() => act(() => api.admin.resumeEventRun(run.id))}>
Resume
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.advance}
onClick={() => act(() => api.admin.advanceEventRun(run.id, reason))}>
Advance phase
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || !controls.cancel}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason))}>
Cancel run
</button>
{/* The separate, admin-only decision (§L). It is a second button rather
than a checkbox on the first because the two are not variants of one
action: one gives the world back, the other deliberately leaves it
changed. A checkbox next to Cancel is a thing an operator unticks by
accident at two in the morning. The server refuses this to a
moderator, and the refusal arrives as a sentence in `problem`. */}
{controls.cancel && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.cancelEventRun(run.id, reason, false))}>
Cancel, leave changes up
</button>
)}
</div>
{isTerminalRun(run.status) && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>
This run is over ({runStatusWord(run.status)} at {when(run.endedAt)}). Nothing can change it
— a run pins the version it started from so that it can still be explained later.
</p>
)}
{/* ── Why this phase has not started (Phase 5) ──
Above the step list for the same reason the parked cue is: a phase
waiting on a condition is `running` and looks completely healthy, and
the one screen an operator opens to find out why nothing is happening
must say so before they have to read a log. */}
{gates.length > 0 && (
<div
className="panel-flat"
style={{ padding: 14, marginBottom: 14, borderLeft: `3px solid ${waiting ? (waiting.stalled ? '#d98b84' : '#d9c184') : 'var(--rule)'}` }}
>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
{waiting ? 'Why this phase has not started' : 'Phase advance conditions'}
</h3>
<p className="sans dim" style={{ margin: '0 0 4px', fontSize: '0.8rem' }}>
{waiting ? (
<>
Every step of this phase has finished. It advances when the condition below is met —
nothing times out, and <em>Advance phase</em> is how a person overrides it.
{waiting.stalled && ' This one has been waiting long enough that the run is marked stalled.'}
</>
) : (
'What each phase of this run waited for, and what released it.'
)}
</p>
{gates.map((gate) => (
<GateRow key={gate.phase} gate={gate} current={gate.phase === run.currentPhase} />
))}
</div>
)}
{/* ── What this run is allowed, and what it has spent ──
A meter rather than a sentence: a cap is two numbers and a name, and
unlike a gate it needs no grammar rendered to be read. It is shown for
every run that has a budget at all, finished ones included — "how much
did last night's invasion actually spawn" is the same question asked
the morning after. */}
{budget.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{budget.map((b) => {
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
const full = b.cap !== null && b.consumed >= b.cap
return (
<tr key={b.dimension}>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
</td>
<td style={{ width: '100%', padding: '3px 0' }}>
{b.cap === null ? (
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
) : (
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
<span
style={{
display: 'block',
height: 6,
width: `${Math.round(spent * 100)}%`,
background: full ? '#d9c184' : '#8fc79a',
borderRadius: 3,
}}
/>
</span>
)}
</td>
{/* Which switch set the number, so an operator can trace a cap
back to a thing they can change rather than wondering
where 30 came from. */}
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{b.from || ''}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* ── What this run changed in the world (Phase 8) ──
The WHOLE ledger, reverted rows included: "how much did last night's
invasion actually spawn, and did all of it come back" is one question
with two halves, and a list of only the failures answers neither.
Shown on finished runs for the same reason the caps meter is. */}
{(resources.length > 0 || run.cleanupStatus === 'incomplete') && (
<div
className="panel-flat"
style={{
padding: 14,
marginBottom: 14,
borderLeft: `3px solid ${unresolved > 0 ? '#d9c184' : 'var(--rule)'}`,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
What this run changed
</h3>
{/* The manual retry. Offered only on a terminal run, because a run
still in flight has a ledger that is still growing and reverting a
resource the next step is about to use would be undoing an event
while it is happening. */}
{isTerminalRun(run.status) && unresolved > 0 && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.cleanupEventRun(run.id))}>
Try cleanup again
</button>
)}
</div>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
{unresolved > 0 ? (
<>
{unresolved} of these {unresolved === 1 ? 'is' : 'are'} still unresolved. The runner
gives them back on its own and stops asking after a few tries;{' '}
<em>Try cleanup again</em> clears that count and asks once more.
</>
) : (
'Everything this run created or borrowed has been given back.'
)}
</p>
{resources.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>
Nothing named — a step changed the world and its answer never arrived, so core kept the
record it wrote beforehand and will ask the module to undo it by key.
</p>
) : (
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{resources.map((r) => (
<tr key={r.id}>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{r.kind}</code>{' '}
<code className="dim" style={{ fontSize: '0.78rem' }}>{r.ref}</code>
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: RESOURCE_COLOR[r.status] }}>
{RESOURCE_WORD[r.status] || r.status}
</td>
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{r.module}
{r.leaseUntil ? ` · until ${clock(r.leaseUntil)}` : ''}
{r.revertAttempts > 0 ? ` · ${r.revertAttempts} attempt${r.revertAttempts === 1 ? '' : 's'}` : ''}
</td>
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
{r.lastError || ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── Who took part (Phase 10) ──
Shown whenever a module has reported anybody, published or not — and the
difference between the two is the whole point of the line under the
heading. A run whose participants are collected and unranked is a real
state, not an error: an author has not placed a `core.results.publish`
step, or has not run it yet. Saying "not published yet" is what stops
somebody reading this table as the final standings. */}
{participants.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>
Who took part
</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
{run.resultsPublishedAt ? (
<>Results published {clock(run.resultsPublishedAt)}. Ranked best first.</>
) : (
<>
{participants.length} recorded, and the results have not been published — nothing
outside this page shows them, and nobody has a rank yet. Publishing is a{' '}
<code style={{ fontSize: '0.78rem' }}>core.results.publish</code> step in the event
itself.
</>
)}
</p>
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{participants.slice(0, PARTICIPANTS_SHOWN).map((p) => (
<tr key={p.memberKey}>
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', width: 34, textAlign: 'right' }}>
{p.rank ?? ''}
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
<code style={{ fontSize: '0.78rem' }}>{p.memberKey}</code>
</td>
{/* A participant with no `userId` is not a defect: it is
somebody who turned up without a linked website account,
and the module is the only thing that could have known
otherwise. Saying so beats a blank cell. */}
<td className="dim" style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
{p.userId ? `account ${p.userId}` : 'no linked account'}
</td>
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>{p.score}</td>
<td className="dim" style={{ width: '100%', padding: '3px 0', fontSize: '0.78rem' }}>
{clock(p.joinedAt)}
</td>
</tr>
))}
</tbody>
</table>
{participants.length > PARTICIPANTS_SHOWN && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.78rem' }}>
and {participants.length - PARTICIPANTS_SHOWN} more.
</p>
)}
</div>
)}
{/* ── Waiting on a person ── */}
{parked.length > 0 && (
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Waiting on a person</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.8rem' }}>
Nothing else in this phase runs until each of these is confirmed. There is no timeout —
a cue posted on Friday is still waiting on Monday.
</p>
{parked.map((step) => (
<div key={step.id} style={{ marginBottom: 10 }}>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.86rem' }}>
{step.params?.instruction || step.actionId}
{step.params?.assignee && <span className="dim"> — for {step.params.assignee}</span>}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">What you did (optional)</span>
<input className="input" value={notes[step.id] || ''}
onChange={(e) => setNotes((n) => ({ ...n, [step.id]: e.target.value }))} />
</label>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.confirmEventStep(run.id, step.id, notes[step.id]))}>
Confirm — done
</button>
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, notes[step.id]))}>
Skip it
</button>
</div>
</div>
))}
</div>
)}
{/* ── The steps ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Steps</h3>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Phase</th>
<th className="adm-th">#</th>
<th className="adm-th">Action</th>
<th className="adm-th">Status</th>
<th className="adm-th">Attempts</th>
<th className="adm-th">Detail</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{steps.map((step) => {
const c = stepControlsFor(run, step, steps)
return (
<tr key={step.id}>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>
{step.phase}
{step.phase === run.currentPhase && <span className="dim"> ·now</span>}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>{step.seq + 1}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
<code style={{ fontSize: '0.78rem' }}>{step.actionId}</code>
<div className="dim" style={{ fontSize: '0.74rem', maxWidth: 320, overflowWrap: 'anywhere' }}>
{JSON.stringify(step.params)}
</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STEP_COLOR[step.status] || undefined }}>
{isParked(step) ? <span style={{ color: '#d9c184' }}>waiting</span> : step.status}
</td>
<td className="adm-td dim" style={{ fontSize: '0.8rem' }}>
{step.attempts}
{step.dueAt && new Date(step.dueAt) > new Date() && (
<div style={{ fontSize: '0.74rem' }}>due {clock(step.dueAt)}</div>
)}
</td>
<td className="adm-td" style={{ fontSize: '0.78rem', maxWidth: 280, overflowWrap: 'anywhere' }}>
{step.lastError || ''}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
{c.retry && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.retryEventStep(run.id, step.id))}>
Retry &amp; resume
</button>
)}
{c.skip && !isParked(step) && (
<button type="button" className="pill" style={{ fontSize: '0.7rem', marginLeft: 4 }} disabled={busy}
onClick={() => act(() => api.admin.skipEventStep(run.id, step.id, reason))}>
Skip
</button>
)}
</td>
</tr>
)
})}
{steps.length === 0 && (
<tr><td className="adm-td dim" colSpan={7}>No steps have been materialised yet.</td></tr>
)}
</tbody>
</table>
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', marginTop: 8 }}>
Steps run strictly in order within a phase, and the phase ends when every one of them has
finished. A failed step is not retried by the runner past its attempt limit — resuming a
paused run carries the phase past it, and <em>Retry &amp; resume</em> puts the step the run is
stopped at back in the queue.
</p>
{/* ── The log ── */}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>Log</h3>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
The run&rsquo;s own diagnostic record, newest first — this is what answers &ldquo;why didn&rsquo;t phase 3
start?&rdquo; without reading server logs. Who published or started what is recorded separately, in
the activity log.
</p>
<div className="panel-flat">
<table className="adm-table">
<tbody>
{lines.map((line) => (
<tr key={line.id}>
<td className="adm-td dim" style={{ fontSize: '0.76rem', whiteSpace: 'nowrap' }}>{clock(line.at)}</td>
<td className="adm-td dim" style={{ fontSize: '0.76rem' }}>{line.phase || ''}</td>
<td className="adm-td" style={{ fontSize: '0.8rem' }}>{describeLogLine(line)}</td>
</tr>
))}
{lines.length === 0 && <tr><td className="adm-td dim">Nothing logged yet.</td></tr>}
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js'
// Admin → Events (EVENTS.md §I, Phase 3).
//
// Two tables on one screen: the definitions an operator authors, and the runs
// those definitions have produced. They are together rather than on two nav rows
// because the question this screen exists to answer is one question — "what is
// scheduled, and what is happening right now" — and the second half of it is the
// one somebody opens at 8pm on a Friday.
//
// **The waiting badge is the whole reason the run table is here rather than
// buried a click away.** A run parked on a GM cue looks perfectly healthy: it is
// `running`, nothing has failed, and it will stay that way for ever because it
// is waiting for a person who does not know they are being waited for. The count
// comes from the run row itself (`waitingSteps`), so a run needs nobody to open
// it before it can say so.
//
// **The calendar is a separate screen, not a third table here.** It answers
// "when", this one answers "what" — and Phase 4, which built it, also made a
// definition able to carry a recurrence, so the two questions stopped having the
// same answer the moment an occurrence could exist before anybody pressed Start.
const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' }
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
}
const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' }
const when = (value) => (value ? new Date(value).toLocaleString() : '—')
export default function EventsAdmin() {
const { user } = useAuth()
const navigate = useNavigate()
const [events, setEvents] = useState([])
const [runs, setRuns] = useState([])
const [state, setState] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const isAdmin = user?.role === 'admin'
const mayAuthor = isAdmin || user?.role === 'editor'
const load = useCallback(async (nextState) => {
const [defs, runList] = await Promise.all([
api.admin.listEvents(nextState || undefined),
api.admin.listEventRuns({ limit: 50 }),
])
setEvents(defs.events || [])
setRuns(runList.runs || [])
}, [])
useEffect(() => {
let alive = true
;(async () => {
setLoading(true)
try {
await load(state)
if (alive) setError(null)
} catch (err) {
if (alive) setError(err.message)
} finally {
if (alive) setLoading(false)
}
})()
return () => {
alive = false
}
}, [load, state])
// "Start now" is an occurrence whose instant is the present, not a separate
// concept — the same route a scheduled occurrence will use in Phase 4. Admin
// only, deliberately (§N2): starting commits the deployment to everything the
// definition contains, unattended.
const startNow = async (event) => {
setBusy(true)
setNotice(null)
try {
const result = await api.admin.startEventRun(event.id, {})
navigate(`/admin/events/runs/${result.run.id}`)
} catch (err) {
setNotice(err.message)
} finally {
setBusy(false)
}
}
if (loading && !events.length && !runs.length) return <Loading />
if (error) return <ErrorState message={error} />
const live = runs.filter((r) => !isTerminalRun(r.status))
const waiting = live.filter((r) => r.waitingSteps > 0)
return (
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem', color: 'var(--muted)', maxWidth: 620 }}>
Scheduled, bounded, audited changes to the live world. A definition is authored as a draft,
published as an immutable version, and every occurrence of it runs against the version it
pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its
own timezone — and the <Link to="/admin/events/calendar">calendar</Link> is where those
occurrences are read.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<label>
<span className="field-label">Show</span>
<select className="select" value={state} onChange={(e) => setState(e.target.value)}>
<option value="">All definitions</option>
<option value="draft">Drafts</option>
<option value="ready">Ready</option>
<option value="archived">Archived</option>
</select>
</label>
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/calendar">
Calendar
</Link>
{mayAuthor && (
<Link className="pill" style={{ fontSize: '0.74rem' }} to="/admin/events/new">
New event
</Link>
)}
</div>
</div>
{notice && (
<p className="sans" style={{ fontSize: '0.84rem', color: '#d98b84', marginTop: 0 }}>{notice}</p>
)}
{waiting.length > 0 && (
<div className="panel-flat" style={{ padding: '12px 14px', marginBottom: 16, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.86rem' }}>
<strong>{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a
person.</strong>{' '}
<span className="dim">
A cue holds its phase until somebody confirms it was done in-client — nothing else will
move it.
</span>
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{waiting.map((r) => (
<Link key={r.id} className="pill" style={{ fontSize: '0.74rem' }} to={`/admin/events/runs/${r.id}`}>
{r.definitionTitle} · {r.waitingSteps} waiting
</Link>
))}
</div>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '0 0 8px' }}>Definitions</h3>
{events.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>
{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}
</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">State</th>
<th className="adm-th">Version</th>
<th className="adm-th">Series</th>
<th className="adm-th">Updated</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td className="adm-td" style={{ fontSize: '0.85rem' }}>
<Link to={`/admin/events/${e.id}`}>{e.title}</Link>
<div className="dim" style={{ fontSize: '0.76rem' }}>{e.slug}</div>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>{STATE_WORD[e.state] || e.state}</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.currentVersion ? `v${e.currentVersion}` : <span className="dim">unpublished</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{e.seriesName || <span className="dim">—</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>{when(e.updatedAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{/* Start is admin only and the button follows the route: an
editor sees the definition and cannot commit the
deployment to running it. */}
{isAdmin && e.state === 'ready' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={busy} onClick={() => startNow(e)}>
Start now
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<h3 className="sans" style={{ fontSize: '0.95rem', margin: '22px 0 8px' }}>
Recent runs
{live.length > 0 && <span className="dim" style={{ fontWeight: 400 }}> · {live.length} in flight</span>}
</h3>
{runs.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.85rem' }}>Nothing has run yet.</p>
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Occurrence</th>
<th className="adm-th">Event</th>
<th className="adm-th">Status</th>
<th className="adm-th">Phase</th>
<th className="adm-th">Health</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ fontSize: '0.8rem', whiteSpace: 'nowrap' }}>
<Link to={`/admin/events/runs/${r.id}`}>{when(r.scheduledFor)}</Link>
{r.rehearsal && <span className="dim" style={{ fontSize: '0.74rem' }}> · rehearsal</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.definitionTitle} <span className="dim">v{r.version}</span>
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: STATUS_COLOR[r.status] || undefined }}>
{runStatusWord(r.status)}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem' }}>
{r.currentPhase || <span className="dim">—</span>}
</td>
<td className="adm-td" style={{ fontSize: '0.82rem', color: HEALTH_COLOR[r.health] || undefined }}>
{r.health === 'ok' ? <span className="dim">ok</span> : r.health}
</td>
<td className="adm-td" style={{ textAlign: 'right', fontSize: '0.78rem' }}>
{r.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}>
waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,457 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import { runStatusWord, isProjected } from '../../../lib/eventAuthoring.js'
// Admin → Events → Calendar (EVENTS.md §I, Phase 4).
//
// **This screen is the deliverable.** What this feature replaces is a WordPress
// calendar plugin with no series field, no recurrence and no results — so a
// month grid that knows about arcs, repeats and local time is not decoration
// here, it is the point.
//
// **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*:
// a real row with a status, a pinned version and a console, and somebody can
// cancel it. A dashed one is a *projection*: arithmetic past the runner's
// fourteen-day horizon, with no row behind it, nothing committed and nothing to
// open. An operator who treats a forecast as a booking has been misled by the
// UI, not by the server, so the difference is drawn rather than merely stated —
// and the legend says which is which.
//
// **The grid's date axis is the READER's zone; each entry's time is the
// EVENT's.** §E gives the timezone to the event because every listing this
// replaces is written in the shard's local zone, but "what is happening this
// month" is a question about the month the person reading is living in. So the
// cell an event lands in is the reader's date, and the time beside it always
// carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing.
const DAY_MS = 86_400_000
const STATUS_COLOR = {
failed: '#d98b84',
missed: '#d98b84',
paused: '#d9c184',
cancelled: 'var(--muted)',
running: '#8fc79a',
}
/** The event's own wall clock, which is the only time worth showing beside it. */
function localTime(instant, timezone) {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
} catch {
return new Date(instant).toISOString().slice(11, 16)
}
}
/** The reader's own date key, which is what places an entry in a cell. */
const readerDayKey = (instant) => {
const d = new Date(instant)
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
/**
* The six-week grid a month view draws, Monday first.
*
* Always six weeks rather than however many the month needs: a grid that
* changes height as you page through it is a grid whose rows move under the
* cursor.
*/
function monthGrid(year, month) {
const first = new Date(year, month, 1)
const offset = (first.getDay() + 6) % 7
const start = new Date(year, month, 1 - offset)
return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS))
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export default function EventsCalendar() {
const { user } = useAuth()
const today = useMemo(() => new Date(), [])
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [view, setView] = useState('month')
const [seriesId, setSeriesId] = useState('')
const [series, setSeries] = useState([])
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [managingSeries, setManagingSeries] = useState(false)
const mayAuthor = user?.role === 'admin' || user?.role === 'editor'
// The window is the grid's own span, not the month's: an entry in the leading
// or trailing week of the grid belongs to a neighbouring month and still has
// to be fetched, or the first row of every month renders empty.
const grid = useMemo(() => monthGrid(year, month), [year, month])
const window = useMemo(() => {
if (view === 'month') {
return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) }
}
// The list view answers a different question — "what is coming" — so it runs
// forward from now rather than over a calendar month.
const from = new Date()
return { from, to: new Date(from.getTime() + 60 * DAY_MS) }
}, [view, grid])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [calendar, seriesList] = await Promise.all([
api.admin.eventCalendar({
from: window.from.toISOString(),
to: window.to.toISOString(),
seriesId: seriesId || undefined,
}),
api.admin.eventSeries(),
])
setData(calendar)
setSeries(seriesList.series || [])
} catch (err) {
setError(err)
} finally {
setLoading(false)
}
}, [window.from, window.to, seriesId])
useEffect(() => {
load()
}, [load])
const byDay = useMemo(() => {
const map = new Map()
for (const entry of data?.entries || []) {
const key = readerDayKey(entry.scheduledFor)
if (!map.has(key)) map.set(key, [])
map.get(key).push(entry)
}
return map
}, [data])
const step = (delta) => {
const next = new Date(year, month + delta, 1)
setYear(next.getFullYear())
setMonth(next.getMonth())
}
if (loading && !data) return <Loading />
if (error && !data) return <ErrorState error={error} onRetry={load} />
const horizon = data?.horizon ? new Date(data.horizon) : null
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/*
The stepper belongs to the MONTH view only. The list answers "what is
coming" and runs sixty days forward from now whatever month is
selected -- so paging it would be three controls that visibly do
nothing, which is the one thing this feature has refused since Phase
1. The heading says which question is being asked instead.
*/}
{view === 'month' && (
<>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
&larr;
</button>
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
{`${MONTH_NAMES[month]} ${year}`}
</strong>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
&rarr;
</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={year === today.getFullYear() && month === today.getMonth()}
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
Today
</button>
</>
)}
{view === 'list' && (
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
style={{ minWidth: 170 }}>
<option value="">Every series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button type="button" className="pill" aria-pressed={view === 'month'}
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
onClick={() => setView('month')}>
Month
</button>
<button type="button" className="pill" aria-pressed={view === 'list'}
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
onClick={() => setView('list')}>
List
</button>
{mayAuthor && (
<button type="button" className="pill" aria-pressed={managingSeries}
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
onClick={() => setManagingSeries((v) => !v)}>
Series
</button>
)}
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
</div>
</div>
{/* The legend is not optional. The whole screen rests on the reader
knowing that a dashed entry is not a booking. */}
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
a console — it can be opened, paused and cancelled.{' '}
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> is what the
recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is
committed yet and there is nothing to open.
{horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}
</p>
{managingSeries && <SeriesManager series={series} onChanged={load} />}
{data?.truncated && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
This window has more than the calendar will draw. Narrow it by series, or page to a
shorter span.
</p>
)}
{view === 'month' ? (
<div className="panel-flat" style={{ padding: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
{d}
</div>
))}
{grid.map((day) => {
const entries = byDay.get(readerDayKey(day)) || []
const outside = day.getMonth() !== month
const isToday = readerDayKey(day) === readerDayKey(today)
return (
<div key={day.toISOString()}
style={{
minHeight: 84,
padding: 4,
borderRadius: 4,
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
opacity: outside ? 0.4 : 1,
}}>
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
{day.getDate()}
</div>
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
</div>
)
})}
</div>
</div>
) : (
<div className="panel-flat" style={{ padding: 4 }}>
{(data?.entries || []).length === 0 ? (
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
Nothing is scheduled in the next sixty days.{' '}
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
</p>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Series</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{(data?.entries || []).map((entry) => (
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
<span className="dim">
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
</span>
</td>
<td className="sans" style={{ fontSize: '0.84rem' }}>
{entry.runId ? (
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
) : (
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
)}
{entry.adjusted === 'gap' && (
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
{' '}(clocks change)
</span>
)}
</td>
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
{entry.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
)
}
// A projection has no run id, so the definition and the instant are its
// identity — the same pair the server dedupes projections against.
const entryKey = (entry) =>
entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}`
const chip = {
display: 'inline-block',
padding: '0 5px',
borderRadius: 3,
borderWidth: 1,
border: '1px solid var(--muted)',
fontSize: '0.72rem',
}
/**
* The arcs, managed where they are used.
*
* A series is a label, not authored content — nothing pins one and no run
* references one — so this is a small inline panel rather than a screen of its
* own, and it lives on the calendar because the calendar is what makes an arc
* visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message
* From the Void" is continuity that exists nowhere in the tooling this replaces.*
*
* The delete is a real delete, and it says what it will detach before it
* happens: `series_id` is ON DELETE SET NULL, so the definitions survive without
* an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed,
* which is why this is the one delete in this feature that is not an archive.
*/
function SeriesManager({ series, onChanged }) {
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState(null)
const run = async (fn) => {
setBusy(true)
setProblem(null)
try {
await fn()
await onChanged()
} catch (err) {
setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work')
} finally {
setBusy(false)
}
}
return (
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
An arc several events form together. The order here is where a series sits among the
others; where an event sits <em>within</em> its arc is that event&rsquo;s own order, in the
editor.
</p>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
)}
{series.map((s) => (
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
onBlur={(e) => {
const next = e.target.value.trim()
if (next && next !== s.name) {
run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering }))
}
}} />
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
onBlur={(e) => {
const next = Number(e.target.value)
if (Number.isInteger(next) && next !== s.ordering) {
run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next }))
}
}} />
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
</span>
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
onClick={() => {
// The count is in the question, because the consequence of this
// delete is entirely about the rows it does not delete.
const ask = s.definitionCount
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
: `Delete "${s.name}"?`
// eslint-disable-next-line no-alert
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
}}>
Delete
</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<input className="input" value={name} placeholder="New series name" disabled={busy}
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
onClick={() => run(async () => {
await api.admin.createEventSeries({ name: name.trim() })
setName('')
})}>
Add
</button>
</div>
</div>
)
}
function EntryChip({ entry }) {
const projected = isProjected(entry)
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
return (
<Link
to={to}
className="sans"
title={`${entry.title} — ${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : ` — ${runStatusWord(entry.status)}`}`}
style={{
display: 'block',
fontSize: '0.7rem',
padding: '1px 4px',
marginBottom: 2,
borderRadius: 3,
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
opacity: projected ? 0.7 : 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}>
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> ●</span>}
</Link>
)
}

View File

@@ -0,0 +1,84 @@
// This account's event participation (EVENTS.md §J, Phase 14a).
//
// **The screen's one real design decision is what an unranked row says.** A run
// whose participants were collected but whose results have not been published
// has a score and no rank, and that is a real state rather than an error — it is
// the same state the admin run console has shown since Phase 10. Rendering "—"
// with nothing explaining it would read as a bug; the row says "not published",
// which is a fact about the event rather than about the reader.
//
// The list is keyset-paged on the participation row's own id, not offset-paged:
// it gains a row every time the reader attends something.
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventDateTime } from '../../lib/eventCalendar.js'
const PAGE = 25
export default function PlayerEvents() {
const [pages, setPages] = useState([])
const [more, setMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const load = useCallback(async () => {
const result = await api.player.eventHistory({ limit: PAGE })
setPages([result.entries || []])
setMore((result.entries || []).length === PAGE)
return result
}, [])
const { loading, error } = useAsync(load)
const entries = pages.flat()
const loadMore = async () => {
const last = entries[entries.length - 1]
if (!last) return
setLoadingMore(true)
try {
const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
setPages((p) => [...p, result.entries || []])
setMore((result.entries || []).length === PAGE)
} finally {
setLoadingMore(false)
}
}
if (error) return <ErrorState message="Could not load your event history." />
if (loading) return <Loading />
if (entries.length === 0) {
return <EmptyState>You have not taken part in an event yet.</EmptyState>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{entries.map((e) => (
<div key={e.id} className="panel" style={{ padding: '16px 20px', display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 240 }}>
<Link to={`/site/events/${e.slug}?run=${e.runId}`} style={{ fontSize: '1.05rem' }}>
{e.title}
</Link>
<div className="dim sans" style={{ fontSize: '0.82rem', marginTop: 4 }}>
{eventDateTime(e.scheduledFor, e.timezone)}
{e.seriesName && ` · ${e.seriesName}`}
</div>
</span>
<span style={{ textAlign: 'right', minWidth: 140 }}>
<div className="sans" style={{ color: 'var(--head)' }}>
{e.rank != null ? `Rank ${e.rank}` : <span className="dim">Results not published</span>}
</div>
<div className="dim sans" style={{ fontSize: '0.82rem' }}>Score {e.score}</div>
</span>
</div>
))}
{more && (
<button className="btn" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading…' : 'Show more'}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
//
// **It took that path from the preferences screen, which moved to
// `/account/notifications/settings`.** The two are different kinds of thing —
// one is content addressed to this person, the other is how they would like to
// be reached — and the word "notifications" belongs to the first: it is what a
// person means when they say it, and what the bell in the header opens. The
// server's routes make the same split at the same place.
//
// Everything a row can carry is TEXT. `body` is stored as the text part of the
// in-app template's blocks and rendered with `white-space: pre-line`, never as
// markup; `url` is site-relative by the time it is stored, checked against the
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
// here — there is nothing on this screen that could be markup.
const PAGE = 30
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now'
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
return new Date(iso).toLocaleDateString()
}
function Item({ item, onOpen, onMark }) {
const body = (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong
className="sans"
style={{
fontSize: '0.95rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 500 : 700,
}}
>
{item.title}
</strong>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
</div>
{item.body && (
<p
className="sans dim"
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
>
{item.body}
</p>
)}
</>
)
return (
<li
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '14px 16px',
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line-soft)',
// The one visual difference between read and unread, plus the weight
// above. A dot alone is easy to miss on a long list.
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{item.url ? (
<button
type="button"
onClick={() => onOpen(item)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
{body}
</button>
) : (
body
)}
</div>
{!item.read && (
<button
type="button"
onClick={() => onMark(item)}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
}}
>
Mark read
</button>
)}
</li>
)
}
export default function PlayerInbox() {
const [items, setItems] = useState([])
const [unread, setUnread] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [unreadOnly, setUnreadOnly] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const { user } = useAuth()
const load = useCallback(async (only) => {
setLoading(true)
setError('')
try {
const res = await api.notifications({ limit: PAGE, unread: only })
setItems(res.items || [])
setHasMore(!!res.hasMore)
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load your notifications')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
// The cursor is the last item's id, not a page number: the list gains rows at
// the top while it is being read, and an offset under those conditions repeats
// or skips items.
const more = async () => {
if (!items.length) return
setBusy(true)
try {
const res = await api.notifications({
limit: PAGE,
before: items[items.length - 1].id,
unread: unreadOnly,
})
setItems((list) => [...list, ...(res.items || [])])
setHasMore(!!res.hasMore)
} catch (err) {
setError(err.message || 'Could not load more')
} finally {
setBusy(false)
}
}
const mark = async (item) => {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
// Filtered to unread, a marked item leaves the list; unfiltered it stays
// and goes quiet. Either way the list matches what it says it is showing.
setItems((list) =>
unreadOnly
? list.filter((i) => i.id !== item.id)
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
)
} catch (err) {
setError(err.message || 'Could not mark it read')
}
}
const open = async (item) => {
if (!item.read) await mark(item)
if (item.url) navigate(item.url)
}
const markAll = async () => {
setBusy(true)
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
} catch (err) {
setError(err.message || 'Could not mark them read')
} finally {
setBusy(false)
}
}
if (loading) return <Loading label="Loading your notifications…" />
if (error && !items.length) return <ErrorState message={error} />
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
marginBottom: 18,
}}
>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
<Link to={notificationSettingsPath(user)} className="dim">
Notification settings
</Link>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="pill"
onClick={() => setUnreadOnly((v) => !v)}
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
>
{unreadOnly ? 'Showing unread' : 'Show unread only'}
</button>
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
Mark all read
</button>
</div>
</div>
{error && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
)}
{items.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
{unreadOnly
? 'Nothing unread.'
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item) => (
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
))}
</ul>
)}
{hasMore && (
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
{busy ? 'Loading…' : 'Load older'}
</button>
)}
</div>
)
}

View File

@@ -1,8 +1,15 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { inboxPath } from '../../lib/notificationPaths.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the
// per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7).
//
// **It moved to `/account/notifications/settings` in Phase 7**, because the
// inbox took the plain path. See `PlayerInbox.jsx`.
//
// **This screen did not exist before phase 6, and that was the phase's first
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
@@ -18,6 +25,14 @@ import { api } from '../../api/client.js'
// thing to be told about, then which Teams, then whether any of it should reach a
// mailbox.
// The three modes a per-channel preference can take, labelled for a person. The
// set a given channel actually offers comes from its `supportsDigest` flag.
const MODES = [
{ value: 'off', label: 'Off' },
{ value: 'instant', label: 'As it happens' },
{ value: 'digest', label: 'Daily digest' },
]
const EMAIL_MODES = [
{ value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' },
@@ -48,48 +63,125 @@ function Note({ msg, error }) {
)
}
// ── What to be told about ──────────────────────────────────────────────────
// ── What to be told about, and how ─────────────────────────────────────────
//
// **This replaced the push-only checkbox list, and it is a strict superset of
// it.** `GET /auth/me/notifications/channels` returns every subscribable id —
// every push stream and every event trigger, one namespace (§7.2) — with the
// EFFECTIVE mode on each channel that applies. A trigger with nothing
// registered to push it simply has no push cell; core does not have to explain
// which kind of id a row is, and neither does a reader.
//
// The old whole-set endpoints are untouched and are now this surface's push
// projection: the shipped Android app keeps its wire shape, and a `push` entry
// written here is mirrored back into `notification_subscriptions` server-side.
//
// The update is SPARSE: only the cells that changed are sent. That is what lets
// this screen manage three channels without a whole-set PUT that could clobber
// a preference a newer client set.
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
function Channels({ channels, items, onSave, busy, msg, error }) {
const [edits, setEdits] = useState({})
useEffect(() => setEdits({}), [items])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
const key = (id, channel) => `${id}|${channel}`
const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel]
const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode }))
// A channel that supports digest offers three modes; one that does not offers
// two. Read off the registry rather than hardcoded, so a channel added later
// shows the right options without touching this file.
const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest'))
const changed = Object.entries(edits).filter(([k, mode]) => {
const [id, channel] = k.split('|')
const item = items.find((i) => i.id === id)
return item && item.modes[channel] !== mode
})
const save = () =>
onSave(
changed.map(([k, mode]) => {
const [id, channel] = k.split('|')
return { id, channel, mode }
}),
)
if (items.length === 0) {
return (
<Section title="What to notify me about">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
There is nothing to configure yet.
</p>
</Section>
)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const team = items.filter((i) => isTeamStream(i.id))
const rest = items.filter((i) => !isTeamStream(i.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
const rows = (list) =>
list.map((item) => (
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{item.label}
{item.description && (
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
)}
</td>
{channels.map((c) => (
<td key={c.id} style={{ padding: '10px' }}>
{item.channels.includes(c.id) ? (
<select
className="input"
aria-label={`${item.label} — ${c.label}`}
value={modeOf(item, c.id)}
onChange={(e) => set(item.id, c.id, e.target.value)}
style={{ fontSize: '0.86rem' }}
>
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
) : (
// Not "off" — a dash. Nothing is registered to push this id, so
// there is no preference to hold, and an `off` select would invite
// somebody to switch on a channel that has no sender behind it.
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
)}
</td>
))}
</tr>
))
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Notification</th>
{channels.map((c) => (
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{rows(rest)}
{team.length > 0 && (
<tr>
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Teams — set site-wide here, then per team below
</td>
</tr>
)}
{rows(team)}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [channels, setChannels] = useState([])
const [items, setItems] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
// Two reads in parallel, where there used to be three: the per-channel
// surface already carries the catalog and this user's effective modes, so
// the streams+subscriptions pair it replaced is one request fewer as well
// as one concept fewer.
const [prefs, teamPrefs] = await Promise.all([
api.notificationChannelPrefs(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setChannels(prefs.channels || [])
setItems(prefs.items || [])
setTeams(teamPrefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
const saveChannels = useCallback(async (prefs) => {
if (prefs.length === 0) return
setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
// The endpoint echoes the FULL stored state back, not just what was sent —
// so an entry it dropped (an unknown id, a channel that does not apply, a
// mode that channel will not take) is visible here as a cell that did not
// move, rather than as a screen that claims a save it did not make.
const stored = await api.setNotificationChannelPrefs(prefs)
setChannels(stored.channels || [])
setItems(stored.items || [])
setNotes((n) => ({ ...n, channels: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
setSaving((s) => ({ ...s, channels: false }))
}
}, [])
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Choose what you are told about, and how. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
Choose what you are told about, and how. Email and push are off until you switch them on;
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
which you can turn off here per notification.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
<Channels
channels={channels}
items={items}
onSave={saveChannels}
busy={saving.channels}
msg={notes.channels}
error={notes.channelsError}
/>
<Teams
teams={teams}

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -36,6 +37,14 @@ function Icon({ children, size = 16 }) {
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
// Participation history (Phase 14a). A calendar rather than a trophy: the row
// is every event this account attended, ranked or not, and most of them will
// never have a result published against them at all.
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></Icon>
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,8 +56,10 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
{ to: '/account/events', label: 'Events', icon: IconCalendar },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -59,6 +70,7 @@ const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
'/account/notifications/settings': 'Notification settings',
}
function moduleTitle(baseNav, pathname) {
@@ -186,9 +198,12 @@ export default function PlayerPortalLayout() {
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
← Site
</a>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<NotificationBell />
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
← Site
</a>
</div>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>

View File

@@ -54,14 +54,14 @@ export default function Unsubscribe() {
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>.
<Link to="/account/notifications/settings">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
</p>
)}
</PublicLayout>

View File

@@ -0,0 +1,190 @@
// One event's public page (EVENTS.md § API surface, Phase 14a).
//
// The storyline, its arc, what is live, what is next, what happened recently,
// and a results table once an occurrence has published one.
//
// **`?run=` is read from the URL and passed straight through**, because that is
// what an announcement's link carries. The page lives at the definition's slug —
// one stable address, so a link posted in Discord survives a retitle — and the
// occurrence has to be in the query string or a mail about last Friday's
// invasion would open next Friday's.
//
// **The error is checked before the form.** Phase 13 found the inverse of this
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
// branch left a failed load spinning for ever with nothing on screen naming the
// problem. Order matters, and the order is error first.
import { useParams, useSearchParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
export default function EventPage() {
const { slug } = useParams()
const [params] = useSearchParams()
const run = params.get('run')
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
if (error) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<ErrorState message="That event could not be found." />
<p style={{ marginTop: 16 }}>
<Link to="/site/events">Back to the calendar</Link>
</p>
</div>
</PublicLayout>
)
}
if (loading || !data) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<Loading />
</div>
</PublicLayout>
)
}
const event = data.event
const headline = event.current || event.next
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow={event.series ? event.series.name : 'Event'}
title={event.title}
lead={event.summary || ''}
/>
{event.series && (
<p className="sans" style={{ marginTop: -12 }}>
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
</p>
)}
{/* The one fact a visitor came for, before the storyline rather than
after it: whether it is happening now, and if not, when it next is. */}
<div
className="panel"
style={{
padding: '18px 22px',
marginBottom: 24,
borderColor: event.live ? '#8fc79a' : undefined,
}}
>
{event.live ? (
<>
<div
className="sans"
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
>
Happening now
</div>
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
{/* The phase LABEL, and only while it is live. The plan behind
the event is never published. */}
{event.current.phase || 'Under way'}
</div>
</>
) : event.next ? (
<>
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
Next
</div>
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
</div>
</>
) : (
<div className="dim">Nothing scheduled at the moment.</div>
)}
</div>
{event.body && (
<article
className="panel"
style={{ padding: 28, marginBottom: 24 }}
// Sanitized on write, the treatment a wiki page and a forum post get.
dangerouslySetInnerHTML={{ __html: event.body }}
/>
)}
{event.results && (
<section style={{ marginBottom: 24 }}>
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
Results
</h2>
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
{eventDateTime(event.results.scheduledFor, event.timezone)}
</p>
{event.results.participants.length === 0 ? (
<EmptyState>Results were published with nobody recorded.</EmptyState>
) : (
<table className="table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 60 }}>#</th>
<th>Who</th>
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
</tr>
</thead>
<tbody>
{event.results.participants.map((p, i) => (
<tr key={`${p.name || 'anon'}-${i}`}>
<td>{p.rank ?? '—'}</td>
{/* A module supplies a display name in `meta` or it does
not; the member key is never published, so there is
genuinely nothing else to render. */}
<td>{p.name || <span className="dim">Unnamed</span>}</td>
<td style={{ textAlign: 'right' }}>{p.score}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)}
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
{!headline && event.past.length === 0 && (
<EmptyState>This event has not been scheduled yet.</EmptyState>
)}
</div>
</PublicLayout>
)
}
function Occurrences({ title, list, slug, timezone, past = false }) {
if (!list || list.length === 0) return null
return (
<section style={{ marginBottom: 24 }}>
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
{title}
</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((o) => (
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
{/* Only a past occurrence gets its own link, and only when it has
results: on any other, `?run=` would change nothing a reader
could see. */}
{past && o.resultsPublishedAt && (
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
Results
</Link>
)}
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,78 @@
// One arc (EVENTS.md §I, Phase 14a).
//
// **The arc is the thing the tooling this replaces could not express at all.**
// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
// Partner → Message From the Void" existed only in a GM's head and in whatever
// the forum post said. This page is that continuity, in the order an editor
// arranged it — which is why the events are numbered rather than dated: an arc
// has an order, and its parts may be months apart or run out of sequence.
import { useParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
export default function EventSeries() {
const { slug } = useParams()
const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
// Error first, then loading — the order Phase 13 had to fix on the admin
// editor, where a failed load sat behind a spinner that never stopped.
if (error) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<ErrorState message="That series could not be found." />
<p style={{ marginTop: 16 }}>
<Link to="/site/events">Back to the calendar</Link>
</p>
</div>
</PublicLayout>
)
}
if (loading || !data) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<Loading />
</div>
</PublicLayout>
)
}
const series = data.series
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader eyebrow="Series" title={series.name} lead={series.description || ''} />
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
{series.events.map((e, i) => (
<li key={e.slug}>
<Link to={`/site/events/${e.slug}`} style={{ textDecoration: 'none' }}>
<div className="panel" style={{ padding: '18px 22px', display: 'flex', gap: 18 }}>
<span
className="display"
style={{ color: 'var(--accent)', fontSize: '1.4rem', minWidth: 36, textAlign: 'right' }}
>
{i + 1}
</span>
<span>
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
{e.title}
</span>
{e.summary && <p style={{ margin: '6px 0 0', color: 'var(--text)' }}>{e.summary}</p>}
</span>
</div>
</Link>
</li>
))}
</ol>
<p className="sans" style={{ marginTop: 24 }}>
<Link to="/site/events">Back to the calendar</Link>
</p>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,131 @@
// The public event calendar (EVENTS.md §I, Phase 14a).
//
// **A list, not a month grid.** The admin calendar draws a grid because an
// operator's question is "what does this month look like" — coverage, clashes,
// the gap on the third weekend. A visitor's question is "what is on, and when is
// the next one", which a chronological list answers in one glance and a grid
// answers by making them count squares. Same data, different question.
//
// **A projection is drawn differently from a run, and the reason is the
// operator's reason one tier along.** Past the materialisation horizon there is
// no row: nothing is committed to, nothing can be cancelled, and a forecast
// rendered identically to a booking would be the page promising something the
// server has not. It is dashed and labelled "expected".
//
// The date heading is the READER's day and the time beside each entry is the
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
// to everyone reading it, but "this month" is the month the reader is living in.
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
export default function Events() {
const { loading, error, data } = useAsync(() => api.publicEvents())
const entries = data?.entries || []
// Grouped by the reader's own day, in order. The server already sorted by
// instant, so this preserves that order rather than re-sorting.
const days = []
for (const entry of entries) {
const label = readerDayLabel(entry.scheduledFor)
const last = days[days.length - 1]
if (last && last.label === label) last.entries.push(entry)
else days.push({ label, entries: [entry] })
}
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow="What's on"
title="Events"
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
/>
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
{loading && <Loading />}
{error && <ErrorState message="Could not load the calendar right now." />}
{!loading && !error && entries.length === 0 && (
<EmptyState>Nothing on the calendar just yet — check back soon.</EmptyState>
)}
{days.map((day) => (
<div key={day.label}>
<h2
className="sans"
style={{
margin: '0 0 12px',
fontSize: '0.74rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--muted)',
}}
>
{day.label}
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{day.entries.map((entry) => (
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
))}
</div>
</div>
))}
</section>
</div>
</PublicLayout>
)
}
function EventRow({ entry }) {
const projected = entry.kind === 'projected'
const body = (
<div
className="panel"
style={{
padding: '16px 20px',
display: 'flex',
alignItems: 'baseline',
gap: 16,
flexWrap: 'wrap',
// The whole visual difference between a booking and a forecast, and it
// is deliberately not subtle.
borderStyle: projected ? 'dashed' : undefined,
opacity: projected ? 0.72 : 1,
}}
>
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
{eventTime(entry.scheduledFor, entry.timezone)}
</span>
<span style={{ flex: 1, minWidth: 200 }}>
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
{entry.title}
</span>
{entry.seriesName && (
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
{entry.seriesName}
</span>
)}
</span>
<span
className="sans"
style={{
fontSize: '0.72rem',
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: entry.live ? '#8fc79a' : 'var(--muted)',
fontWeight: entry.live ? 700 : 400,
}}
>
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
</span>
</div>
)
// A projection has no page of its own worth linking to any differently — the
// event page IS the definition's — so both link to the same place. It is the
// OCCURRENCE that does not exist yet, not the event.
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
}

View File

@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})
// ── Public events (Phase 14a) ───────────────────────────────────────────
//
// The one shape worth pinning is `?run=`: it is what an announcement's link
// carries, and a client that dropped it would make a mail about last Friday's
// occurrence open next Friday's.
test('the public calendar asks for no window at all by default', async () => {
willReply({ body: { entries: [] } })
await api.publicEvents()
// The server defaults to now through a month out, so the first render need
// not compute two ISO instants before it can ask for anything.
assert.equal(calls[0].url, '/api/v1/public/events')
})
test('an event page carries the run when one was named, and not when it was not', async () => {
willReply({ body: { ok: true } })
await api.publicEvent('the-yew-invasion')
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
willReply({ body: { ok: true } })
await api.publicEvent('the-yew-invasion', 3692)
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
})
test('an event slug is URL-encoded on every public path', async () => {
willReply({ body: { ok: true } })
await api.publicEventSeries('a b/c')
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
})
test('participation history takes a keyset cursor, never an offset', async () => {
willReply({ body: { entries: [] } })
await api.player.eventHistory({ limit: 25, before: 900 })
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
})

View File

@@ -0,0 +1,910 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
runControlsFor,
stepControlsFor,
isParked,
lastStartedSeqOf,
formFromDefinition,
payloadFromForm,
parseParams,
blankStep,
blankPhase,
describeLogLine,
logKindWord,
runStatusWord,
describeSchedule,
scheduleFormFrom,
scheduleFromForm,
isProjected,
blankAdvance,
advanceFormFrom,
advancePayload,
WEEKDAYS,
MONTHLY_NTHS,
ADVANCE_KINDS,
blankWhere,
whereFormFrom,
paramsRenderable,
paramsMode,
paramValue,
setParam,
datetimeInputValue,
priceBodyFrom,
worthPricing,
PARAM_FORM,
PARAM_JSON,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
// staff press (EVENTS.md §I, Phase 3).
//
// None of this is a boundary: `events/spec.js` decides what may be saved and the
// six control statements decide what may happen to a run, each of them a
// compare-and-set that re-checks the status this file only predicted.
//
// **The controls get most of the tests, and the reason is worth stating.** A
// button offered that the server refuses is not a wrong write — but it is the
// failure an operator meets at 2am, on the screen they opened because something
// is already going wrong, about the run they are trying to stop. So the guards
// are deliberately written twice and this is where the copy is checked against
// the original.
const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
const step = (over = {}) => ({
id: 10,
phase: 'main',
seq: 0,
status: 'pending',
parked: false,
...over,
})
// ── The run controls ───────────────────────────────────────────────────────
test('pause is offered only for a run in flight', () => {
assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
// A scheduled occurrence that should not happen is cancelled, not paused:
// resuming one after its grace window would produce a `missed` from a button
// labelled resume.
assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
})
test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
}
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
}
})
test('resume is offered for exactly one status', () => {
assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
})
// ── The step controls ──────────────────────────────────────────────────────
test('a parked step is running with nothing holding it, and only that', () => {
assert.equal(isParked(step({ status: 'running', parked: true })), true)
assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
assert.equal(isParked(step({ status: 'pending', parked: true })), false)
})
test('confirm is offered for a parked cue and for nothing else', () => {
const r = run()
const parked = step({ status: 'running', parked: true })
assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
const dispatching = step({ status: 'running', parked: false })
assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
const pending = step()
assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
})
test('skip is offered for a pending step and a parked cue', () => {
const r = run()
const pending = step()
const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
const failed = step({ id: 13, seq: 3, status: 'failed' })
const steps = [pending, parked, dispatching, failed]
assert.equal(stepControlsFor(r, pending, steps).skip, true)
assert.equal(stepControlsFor(r, parked, steps).skip, true)
assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
// A failed step does not need skipping: the runner already steps over it, so
// resuming the run carries the phase past it.
assert.equal(stepControlsFor(r, failed, steps).skip, false)
})
test('retry is offered for the failed step a paused run is stopped at', () => {
const r = run({ status: 'paused' })
const done = step({ id: 1, seq: 0, status: 'done' })
const failed = step({ id: 2, seq: 1, status: 'failed' })
const pending = step({ id: 3, seq: 2, status: 'pending' })
const steps = [done, failed, pending]
assert.equal(stepControlsFor(r, failed, steps).retry, true)
assert.equal(stepControlsFor(r, done, steps).retry, false)
assert.equal(stepControlsFor(r, pending, steps).retry, false)
})
test('retry is NOT offered for a failed step the run has moved past', () => {
// The case the server guard exists for, and the one this copy of it has to
// agree about: a phase that carried on past an `on_failure: skip` failure and
// then paused at a later step. Offering retry on the first would re-queue a row
// behind the runner's own cursor, where it sits pending for ever.
const r = run({ status: 'paused' })
const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
const carriedOn = step({ id: 2, seq: 1, status: 'done' })
const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
const notYet = step({ id: 4, seq: 3, status: 'pending' })
const steps = [skippedOver, carriedOn, stoppedAt, notYet]
assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
})
test('retry is not offered while the run is still running, or in a phase it has left', () => {
const failed = step({ status: 'failed' })
assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
const old = step({ phase: 'one', status: 'failed' })
const r = run({ status: 'paused', currentPhase: 'two' })
assert.equal(stepControlsFor(r, old, [old]).retry, false)
})
test('no control is offered on a run that is over', () => {
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
const parked = step({ status: 'running', parked: true })
assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
confirm: false,
skip: false,
retry: false,
})
}
})
test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
const steps = [
step({ id: 1, seq: 0, status: 'failed' }),
step({ id: 2, seq: 1, status: 'done' }),
step({ id: 3, seq: 2, status: 'pending' }),
step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
]
assert.equal(lastStartedSeqOf(steps, 'main'), 1)
assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
})
// ── The definition form ────────────────────────────────────────────────────
const ANNOUNCE = {
id: 'core.announce',
label: 'Announce',
risk: 'notify',
params: [
{ name: 'leg', type: 'string', required: true, example: 'discord' },
{ name: 'title', type: 'string', required: false, example: 'The gates open' },
{ name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
],
}
test('a new step arrives prefilled from the action’s declared examples', () => {
const fresh = blankStep(ANNOUNCE)
assert.equal(fresh.actionId, 'core.announce')
assert.deepEqual(JSON.parse(fresh.paramsText), {
leg: 'discord',
title: 'The gates open',
body: 'A caravan was sighted.',
})
})
test('a new phase never collides with an existing key', () => {
// Two phases sharing a key would silently collapse at materialisation —
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
// steps would never exist. The server refuses it; the form must not propose it.
const first = blankPhase([])
const second = blankPhase([first])
const third = blankPhase([first, second])
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
})
test('the form round-trips a definition without losing a step', () => {
const event = {
title: 'Invasion',
graceSeconds: 600,
timezone: 'Europe/Berlin',
concurrencyKey: 'invasion:{region}',
spec: {
schedule: { kind: 'manual' },
phases: [
{
key: 'warn',
label: 'Warning',
steps: [
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
{ actionId: 'core.wait', params: { seconds: 300 } },
],
},
],
},
}
const built = payloadFromForm(formFromDefinition(event))
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.phases, [
{
key: 'warn',
label: 'Warning',
steps: [
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
{ actionId: 'core.wait', params: { seconds: 300 } },
],
},
])
assert.equal(built.payload.graceSeconds, 600)
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
})
test('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
// The trap this guards is `||` where `??` is meant. A definition an operator
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
// would put it back on the public calendar on the author's next save — a
// surprise event announced by a typo fix.
const unlisted = payloadFromForm(
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
)
assert.equal(unlisted.payload.listed, false)
const listed = payloadFromForm(
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
)
assert.equal(listed.payload.listed, true)
})
test('a new definition defaults to listed', () => {
// The column's own default, and the ordinary case: unlisting is the
// deliberate act, not listing.
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
assert.equal(fresh.payload.listed, true)
})
test('an unchosen onFailure is omitted rather than invented', () => {
// The server defaults it from the action's risk class, which is the whole
// reason `risk` is required at registration. A form that posted a value would
// silently override that — turning a `change` action's `pause` into a `skip`
// and advancing a run over a half-changed world.
const form = formFromDefinition({
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
})
const built = payloadFromForm(form)
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
})
test('a params box that is not JSON is refused with the step named', () => {
const form = formFromDefinition({
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
})
form.phases[0].steps[0].paramsText = '{ leg: discord }'
const built = payloadFromForm(form)
assert.equal(built.ok, false)
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
})
test('an empty params box is an empty object, not an error', () => {
assert.deepEqual(parseParams('').params, {})
assert.deepEqual(parseParams(' ').params, {})
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
assert.ok(parseParams('"leg"').error)
})
// ── Rendering what happened ────────────────────────────────────────────────
test('a human transition reads differently from the runner’s own', () => {
// Both are `run.status` rows. `detail.control` is the only thing that separates
// "the runner paused this because a world write failed" from "somebody pressed
// pause", and the console has to tell them apart at a glance.
const byRunner = describeLogLine({
kind: 'run.status',
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
})
const byPerson = describeLogLine({
kind: 'run.status',
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
})
assert.match(byRunner, /Running → Paused/)
assert.match(byRunner, /core\.spawn/)
assert.match(byPerson, /pause/)
assert.match(byPerson, /by staff/)
assert.match(byPerson, /shard is lagging/)
})
test('the log lines a run produces all render as something', () => {
const lines = [
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
{ kind: 'phase.completed', phase: 'warn', detail: {} },
{ kind: 'step.parked', detail: { action: 'core.cue' } },
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
{ kind: 'note', detail: {} },
]
for (const line of lines) {
const text = describeLogLine(line)
assert.equal(typeof text, 'string')
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
}
})
// ── The one log line core did not compose (Phase 15) ──────────────────────
test('a module detail line renders the module keys, not the kind id', () => {
// The failure this guards is subtle and total: `step.detail` falling to the
// default renders the literal string "step.detail", which is the reporting
// channel existing and showing nothing — exactly what it was built to fix.
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'uo.item.grant', granted: 8, missed: 4, why: ['bank full', 'offline'] },
})
assert.ok(!text.includes('step.detail'), `the kind id leaked into the sentence: ${text}`)
assert.match(text, /uo\.item\.grant/)
assert.match(text, /granted: 8/)
assert.match(text, /missed: 4/)
assert.match(text, /bank full/)
})
test('a module detail is rendered generically, whatever a module puts in it', () => {
// Core does not interpret these keys and neither does the renderer — a switch
// here would be the browser learning one module vocabulary, which is the thing
// the module system exists to prevent. So an unfamiliar shape still reads.
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'rust.wipe.announce', servers: { eu: 3, us: 1 }, dryRun: false, at: null },
})
assert.ok(!text.includes('undefined'), text)
assert.ok(!text.includes('[object Object]'), `a nested object rendered as a brace: ${text}`)
assert.match(text, /eu 3/)
assert.match(text, /dryRun: false/, 'false is a value, not an absence')
})
test('a long module detail stays one line', () => {
const many = Array.from({ length: 40 }, (_, i) => `player-${i}`)
const text = describeLogLine({
kind: 'step.detail',
detail: { action: 'uo.item.grant', missed: many, note: 'x'.repeat(500) },
})
assert.match(text, /and 35 more/)
assert.ok(text.length < 300, `one row should not wrap eight times: ${text.length} chars`)
})
test('a module detail with nothing in it still reads as a sentence', () => {
const text = describeLogLine({ kind: 'step.detail', detail: { action: 'uo.world.save' } })
assert.ok(text.length > 0)
assert.ok(!text.includes('undefined'), text)
})
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.ok(runStatusWord(s).length > 0)
}
assert.equal(runStatusWord('something-new'), 'something-new')
})
// ── The schedule form (Phase 4) ─────────────────────────────────────
//
// The form is the whole argument against cron: a closed set of four shapes has a
// dropdown, and a dropdown can be proofread. What is checked here is that the
// round trip through the form does not quietly change what the author wrote —
// the server would refuse a malformed schedule, but it cannot refuse a
// well-formed one that says something the author did not mean.
test('a schedule survives the round trip through the form unchanged', () => {
for (const schedule of [
{ kind: 'manual' },
{ kind: 'once', at: '2026-10-31T20:00' },
{ kind: 'weekly', days: ['monday', 'friday'], time: '20:00' },
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
]) {
const form = scheduleFormFrom(schedule)
assert.deepEqual(scheduleFromForm(form), schedule, JSON.stringify(schedule))
}
})
test('switching kind keeps the other shapes fields, and sends only the chosen one', () => {
// An author who clicks Weekly, then Monthly, then back must not find the days
// they picked gone — but the request body must still be a single clean shape,
// not a union of everything they touched.
const form = { ...scheduleFormFrom({ kind: 'weekly', days: ['friday'], time: '20:00' }), scheduleKind: 'monthly' }
const sent = scheduleFromForm(form)
assert.deepEqual(Object.keys(sent).sort(), ['kind', 'nth', 'time', 'weekday'])
assert.equal(form.scheduleDays.includes('friday'), true)
})
test('formFromDefinition carries the whole schedule, not only its kind', () => {
const form = formFromDefinition({
title: 'Fishing contest',
timezone: 'Europe/Berlin',
spec: {
schedule: { kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
phases: [{ key: 'main', label: 'Main', steps: [] }],
},
})
assert.equal(form.scheduleKind, 'monthly')
assert.equal(form.scheduleNth, '-1')
assert.equal(form.scheduleWeekday, 'friday')
assert.equal(form.scheduleTime, '19:30')
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.schedule, {
kind: 'monthly',
nth: -1,
weekday: 'friday',
time: '19:30',
})
})
test('a definition with no schedule at all reads as manual rather than as broken', () => {
const form = formFromDefinition({ title: 'x', spec: { phases: [] } })
assert.equal(form.scheduleKind, 'manual')
assert.deepEqual(scheduleFromForm(form), { kind: 'manual' })
})
test('every schedule describes as a sentence, and a half-built one says what is missing', () => {
assert.match(describeSchedule({ kind: 'manual' }), /by hand/)
assert.equal(
describeSchedule({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
)
assert.equal(
describeSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
'The last Friday of every month at 19:30 (Asia/Kolkata)',
)
// Half-built is the state the preview spends most of its life in — an author
// is typing. It must prompt, never render "undefined".
for (const partial of [
{ kind: 'weekly', days: [], time: '20:00' },
{ kind: 'weekly', days: ['friday'], time: '' },
{ kind: 'monthly', nth: 1, weekday: '', time: '19:00' },
{ kind: 'once', at: '' },
]) {
const text = describeSchedule(partial, 'UTC')
assert.ok(text.length > 0)
assert.ok(!text.includes('undefined'), `${JSON.stringify(partial)} rendered: ${text}`)
assert.match(text, /choose|no date/i)
}
})
test('the weekday and nth vocabularies match the server', () => {
// Verbatim `events/recurrence.js`. A client list that drifted would offer a
// value the server refuses, which is exactly the class of failure this file
// exists to catch.
assert.deepEqual(WEEKDAYS, [
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
])
assert.deepEqual(MONTHLY_NTHS.map((n) => n.value), [1, 2, 3, 4, -1])
})
test('a projection is told apart from a run, because only one of them can be acted on', () => {
assert.equal(isProjected({ kind: 'projected', runId: null }), true)
assert.equal(isProjected({ kind: 'run', runId: 12 }), false)
assert.equal(isProjected(null), false)
})
// -- The advance gate (Phase 5) ---------------------------------------------
//
// What this screen must get right is what it OFFERS. `advance` is the one
// control in this feature whose whole point is that it overrides the engine, so
// a button offered in a state the server refuses would be the "control that
// answers 409 and does nothing" this feature has refused twice.
test('advance is offered only when the phase is waiting on its gate', () => {
const gate = (over = {}) => [{ phase: 'boss', satisfied: false, ...over }]
const done = [{ phase: 'boss', status: 'done' }]
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), done).advance, true)
// A phase with an open step is held by the STEP, and skip is its control.
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [...done, { phase: 'boss', status: 'pending' }]).advance,
false,
)
assert.equal(
runControlsFor({ status: 'running', currentPhase: 'boss' }, gate(), [{ phase: 'boss', status: 'running' }]).advance,
false,
)
// A phase with no gate advances on its steps and always has.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, [], done).advance, false)
// A gate already satisfied is not waiting.
assert.equal(runControlsFor({ status: 'running', currentPhase: 'boss' }, gate({ satisfied: true }), done).advance, false)
// And a run that is not running is waiting on nothing.
for (const status of ['scheduled', 'starting', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.equal(runControlsFor({ status, currentPhase: 'boss' }, gate(), done).advance, false, status)
}
})
test('runControlsFor still answers with no gates or steps at all', () => {
// The three Phase 3 controls were called with one argument for two phases, and
// the calendar still calls it that way.
const controls = runControlsFor({ status: 'running', currentPhase: 'boss' })
assert.equal(controls.pause, true)
assert.equal(controls.advance, false)
})
test('a gate round-trips through the form without losing the other shape', () => {
assert.deepEqual(advanceFormFrom(null), blankAdvance())
assert.equal(advanceFormFrom({ after: '2h' }).kind, 'after')
assert.equal(advanceFormFrom({ after: '2h' }).after, '2h')
const on = advanceFormFrom({ on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 3 })
assert.equal(on.kind, 'on')
assert.equal(on.count, 3)
assert.deepEqual(JSON.parse(on.whereText), { variable: 'region', cmp: 'eq', value: 'Yew' })
// The dropdown's three options, and the empty one is what nearly every phase
// is — so it is first and it is not called "none".
assert.equal(ADVANCE_KINDS[0].value, '')
})
test('advancePayload sends one shape, built from the builder\u2019s rows', () => {
const errors = []
assert.equal(advancePayload({ kind: '' }, 'Phase 1', errors), null, 'no gate sends no key at all')
assert.deepEqual(advancePayload({ kind: 'after', after: '30m' }, 'Phase 1', errors), { after: '30m' })
assert.deepEqual(
advancePayload({ kind: 'on', on: 'uo.champ.boss_up', count: '2', ...blankWhere() }, 'Phase 1', errors),
{ on: 'uo.champ.boss_up', count: 2 },
'an empty predicate is omitted, not sent as an empty object',
)
assert.equal(errors.length, 0)
// Whether the predicate is VALID is still the server's answer, named variable
// and all \u2014 the builder only offers what the trigger declares, and a variable
// that has gone away comes back named from the save.
assert.deepEqual(
advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereRows: [{ variable: 'nope', cmp: 'eq', value: '1' }],
},
'Phase 1',
errors,
[{ name: 'nope', type: 'int' }],
),
{ on: 'x', count: 1, where: { variable: 'nope', cmp: 'eq', value: 1 } },
)
assert.equal(errors.length, 0)
})
test('the builder coerces each literal to the type the trigger declared', () => {
// The trap this closes: every value in an HTML input is a string, and
// `{ cmp: 'gt', value: "5" }` against an int variable is refused by
// engagement/conditions.js. Without this the author reads an error about JSON
// rather than about what they typed.
const built = advancePayload(
{
kind: 'on',
on: 'x',
count: 1,
...blankWhere(),
whereOp: 'or',
whereRows: [
{ variable: 'tier', cmp: 'gte', value: '3' },
{ variable: 'region', cmp: 'in', value: 'Yew, Britain' },
],
},
'Phase 1',
[],
[{ name: 'tier', type: 'int' }, { name: 'region', type: 'string' }],
)
assert.deepEqual(built.where, {
op: 'or',
nodes: [
{ variable: 'tier', cmp: 'gte', value: 3 },
{ variable: 'region', cmp: 'in', value: ['Yew', 'Britain'] },
],
})
})
test('a predicate the builder cannot render is posted back unchanged, not flattened', () => {
// `A and (B or C)` is not `A and B and C` \u2014 they fire on different events \u2014
// and an author would have no way to know the save had done it. The condition
// builder's own rule, and this is the same function.
const nested = {
op: 'and',
nodes: [
{ variable: 'region', cmp: 'eq', value: 'Yew' },
{ op: 'or', nodes: [{ variable: 'tier', cmp: 'eq', value: 1 }, { variable: 'tier', cmp: 'eq', value: 2 }] },
],
}
const form = whereFormFrom(nested)
assert.equal(form.whereEditable, false)
assert.deepEqual(form.whereRows, [])
const errors = []
const built = advancePayload({ kind: 'on', on: 'x', count: 1, ...form }, 'Phase 1', errors)
assert.deepEqual(built.where, nested, 'the tree survives a screen that cannot draw it')
assert.equal(errors.length, 0)
// And the text is still the thing that can fail to parse, which is the only
// reason this path keeps an error channel at all.
advancePayload(
{ kind: 'on', on: 'x', count: 1, whereEditable: false, whereText: '{ not json' },
'Phase 2 "Boss"',
errors,
)
assert.equal(errors.length, 1)
assert.match(errors[0], /Phase 2 "Boss", advance condition:/)
})
test('a phase with no gate sends no `advance` key', () => {
const form = formFromDefinition({
title: 'x',
spec: { schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }] },
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.equal('advance' in built.payload.spec.phases[0], false)
})
test('an authored gate survives the round trip through the form', () => {
const form = formFromDefinition({
title: 'x',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'boss', label: 'Boss', steps: [], advance: { on: 'uo.champ.boss_up', where: { variable: 'region', cmp: 'eq', value: 'Yew' }, count: 2 } },
{ key: 'loot', label: 'Loot', steps: [], advance: { after: '10m' } },
],
},
})
const built = payloadFromForm(form)
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.phases[0].advance, {
on: 'uo.champ.boss_up',
where: { variable: 'region', cmp: 'eq', value: 'Yew' },
count: 2,
})
assert.deepEqual(built.payload.spec.phases[1].advance, { after: '10m' })
})
test('the log renders Phase 5\'s three kinds, including the near miss', () => {
assert.match(
describeLogLine({ kind: 'phase.gate', phase: 'boss', detail: { kind: 'on', trigger: 'uo.champ.boss_up', needed: 2, where: 'region is "Yew"' } }),
/boss advances on 2 × uo\.champ\.boss_up where region is "Yew"/,
)
assert.match(describeLogLine({ kind: 'phase.gate', phase: 'loot', detail: { kind: 'after', after: '10m' } }), /loot advances 10m after it started/)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: false, seen: 0, needed: 2 } }),
/did not count — 0 of 2/,
)
assert.match(
describeLogLine({ kind: 'condition.evaluated', detail: { trigger: 'uo.champ.boss_up', matched: true, seen: 2, needed: 2, satisfied: true } }),
/counted — 2 of 2, condition met/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'boss', detail: { because: 'forced', waitedSeconds: 4080, reason: 'never spawned' } }),
/boss advanced by hand after 4080s: never spawned/,
)
assert.match(
describeLogLine({ kind: 'phase.advanced', phase: 'loot', detail: { because: 'elapsed', waitedSeconds: 600 } }),
/loot advanced on its deadline after 600s/,
)
})
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
// The distinction the whole kind exists for. An operator scanning a stopped run
// has to be able to see that nothing is broken — the deployment simply does not
// permit what the author asked for — and the answer differs by cause: a switch
// for "not enabled", a number for "over the cap".
assert.match(
describeLogLine({
kind: 'step.refused',
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
}),
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
)
assert.match(
describeLogLine({
kind: 'step.refused',
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
}),
/refused: "Spawn creatures" is not enabled/,
)
assert.equal(logKindWord('step.refused'), 'Refused')
// The caps a run was seeded with, and which switch set each — so a number on
// the meter can be traced back to something an operator can change.
assert.match(
describeLogLine({
kind: 'run.budget',
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
}),
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
)
assert.match(
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
/uo\.gate\.minutes capped at nothing/,
)
// A run with no capped dimension at all still gets a sentence rather than an
// empty line, because an empty log entry reads as a bug.
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
assert.match(
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
/Version 2 passed its dry run — scheduled occurrences may start/,
)
})
// ── Step params as a form (Phase 13) ──────────────────────────────
//
// The form is not a boundary either — `events/spec.js` still decides what may be
// saved. What is tested here is the thing that would be wrong SILENTLY: a form
// that drops a param it cannot draw, or writes a value the author never typed.
const spawn = {
id: 'test.spawn',
label: 'Spawn',
params: [
{ name: 'creature', type: 'string', required: true, example: 'orc', source: 'test.creatures' },
{ name: 'count', type: 'int', required: true, example: 8 },
{ name: 'tame', type: 'boolean', required: false, example: false },
{ name: 'at', type: 'datetime', required: false, example: '2026-09-07T20:00:00.000Z' },
],
}
const stepWith = (params, over = {}) => ({
actionId: 'test.spawn',
paramsText: JSON.stringify(params, null, 2),
...over,
})
test('a step whose params the form can hold opens as a form', () => {
const mode = paramsMode(stepWith({ creature: 'orc', count: 8 }), spawn)
assert.deepEqual(mode, { mode: PARAM_FORM, forced: false, reason: null })
})
test('an author who chose JSON stays in JSON', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }, { paramsMode: PARAM_JSON }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, false, 'their choice, so no reason is shown')
})
test('a param the action does not declare FORCES the JSON box and says which', () => {
// The form would render four fields and post four values, having deleted
// `radius` — a save that looks clean and means something else. The save path
// refuses it by name, which is what the author needs to see.
const mode = paramsMode(stepWith({ creature: 'orc', count: 8, radius: 12 }), spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /carries "radius", which test\.spawn does not declare/)
})
test('a value no single control can hold forces the JSON box', () => {
assert.match(paramsMode(stepWith({ creature: ['orc', 'troll'] }), spawn).reason, /holds a list/)
assert.match(paramsMode(stepWith({ creature: { id: 'orc' } }), spawn).reason, /holds a structure/)
})
test('a dormant step is edited as JSON, because there is no declaration to draw', () => {
const mode = paramsMode(stepWith({ creature: 'orc' }), undefined)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not installed/)
})
test('a params box that is not JSON opens as JSON with the parse error', () => {
const mode = paramsMode({ actionId: 'test.spawn', paramsText: '{ not json' }, spawn)
assert.equal(mode.mode, PARAM_JSON)
assert.equal(mode.forced, true)
assert.match(mode.reason, /not valid JSON/)
})
test('paramsRenderable accepts a step with nothing in it', () => {
// A brand-new step with an optional-only action, and the empty case a form
// needs to survive before anybody has typed.
assert.deepEqual(paramsRenderable(spawn, {}), { ok: true })
})
test('setParam writes the type the param declared, not the string the input held', () => {
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '12', 'int')), { creature: 'orc', count: 12 })
assert.deepEqual(JSON.parse(setParam(step, 'tame', 'true', 'boolean')), {
creature: 'orc',
count: 8,
tame: true,
})
})
test('a half-typed number is kept as typed rather than turned into NaN', () => {
// `coerceLiteral`'s rule, and the reason it is borrowed rather than rewritten:
// turning `-` into NaN while somebody types would either post a value they
// never wrote or make a negative impossible to enter. The server's type check
// then names the param.
const step = stepWith({ count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'count', '-', 'int')), { count: '-' })
})
test('clearing a field REMOVES the key rather than posting an empty string', () => {
// `checkParams` treats undefined, null and '' alike — absent — so a required
// param left blank comes back as "is required", which is the error the author
// needs, instead of a type complaint about "".
const step = stepWith({ creature: 'orc', count: 8 })
assert.deepEqual(JSON.parse(setParam(step, 'creature', '', 'string')), { count: 8 })
})
test('setParam leaves an unparseable box alone rather than overwriting it', () => {
// The only way to reach this is a race between the mode switch and a
// keystroke; silently replacing the text with `{ "count": 1 }` would destroy
// whatever the author was midway through writing.
const step = { actionId: 'test.spawn', paramsText: '{ not json' }
assert.equal(setParam(step, 'count', '1', 'int'), '{ not json')
})
test('paramValue reads one param, and answers nothing for a box that does not parse', () => {
assert.equal(paramValue(stepWith({ count: 8 }), 'count'), 8)
assert.equal(paramValue(stepWith({ count: 8 }), 'creature'), undefined)
assert.equal(paramValue({ paramsText: '{ not json' }, 'count'), undefined)
})
test('a datetime is sliced to what the input wants, and anything else is empty', () => {
assert.equal(datetimeInputValue('2026-09-07T20:00:00.000Z'), '2026-09-07T20:00')
assert.equal(datetimeInputValue(undefined), '')
assert.equal(datetimeInputValue(12), '')
})
// ── The meter's request (Phase 13) ────────────────────────────
test('the price body carries the plan and nothing else', () => {
const form = formFromDefinition({
title: 'Invasion',
spec: {
schedule: { kind: 'manual' },
phases: [
{ key: 'warn', label: 'Warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', label: 'Assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
},
})
assert.deepEqual(priceBodyFrom(form), {
phases: [
{ key: 'warn', steps: [{ actionId: 'core.announce', params: { trigger: 'x' } }] },
{ key: 'assault', steps: [{ actionId: 'test.spawn', params: { count: 8 } }] },
],
})
})
test('a step whose params do not parse is priced with none rather than dropped', () => {
// Dropping it would move every step after it up an ordinal, so the meter's
// "phase 2 step 3" would name a different step from the one on the screen.
const form = {
phases: [{ key: 'p', steps: [{ actionId: 'test.spawn', paramsText: '{ not json' }] }] ,
}
assert.deepEqual(priceBodyFrom(form).phases[0].steps, [{ actionId: 'test.spawn', params: {} }])
})
test('an empty plan is not worth pricing', () => {
// Otherwise the meter asks the server what nothing costs on every keystroke of
// the title field.
assert.equal(worthPricing({ phases: [] }), false)
assert.equal(worthPricing({ phases: [{ steps: [] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: '' }] }] }), false)
assert.equal(worthPricing({ phases: [{ steps: [{ actionId: 'test.spawn' }] }] }), true)
})

View File

@@ -0,0 +1,89 @@
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
//
// One property matters here and it is EVENTS.md §I's: **the time beside an
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
// helper that quietly rendered both in the reader's zone would pass any test
// that only ever looked at one of them, and would put an American shard's 8pm
// event at "02:00" for a player in Berlin — true, useless, and looking like the
// shard's own announcement was wrong.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
// whose DATE differs between the two zones, which is what makes the split
// observable at all.
const INSTANT = '2026-09-12T00:00:00.000Z'
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
})
test('the zone is named in a form a reader recognises', () => {
// `America/New_York` is a database identifier, not something to show a player.
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
})
test('an unknown zone falls back to UTC rather than throwing', () => {
// `Intl` rejects an unknown identifier, and an event whose timezone column
// holds a typo must still render.
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
})
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
assert.equal(readerDayLabel('not a date'), '')
assert.equal(eventDateTime('not a date', 'UTC'), '')
})
test('the day label is the reader’s own day, whatever the event’s zone', () => {
// Two entries at the same instant in different event zones are filed under one
// heading, which is what makes a chronological list group correctly.
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
const label = readerDayLabel(INSTANT)
assert.ok(label.length > 0)
// The instant's UTC date is the 12th and New York's is the 11th; the label
// must not carry a zone at all, because it is neither of theirs.
assert.equal(/UTC|New York/.test(label), false)
})
test('eventDateTime carries the day and the zone together', () => {
const text = eventDateTime(INSTANT, 'America/New_York')
assert.match(text, /New York$/)
assert.match(text, /20:00/)
})
// ── The status word ────────────────────────────────────────────────────────
//
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
// run four days out that an operator had cancelled. The server publishes
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
// event — but they do not share one English sentence, so the tense follows the
// clock rather than the status.
const NOW = Date.parse('2026-09-08T12:00:00Z')
test('a cancelled occurrence in the future reads "Cancelled"', () => {
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
})
test('a cancelled occurrence in the past reads "Did not happen"', () => {
// Which is also the honest word for the failed and missed runs folded into
// `cancelled` on the way out.
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
})
test('the other three words do not depend on the clock at all', () => {
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
assert.equal(statusWord('live', at, NOW), 'Happening now')
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
assert.equal(statusWord('completed', at, NOW), 'Finished')
}
})
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
})

View File

@@ -0,0 +1,42 @@
// ── Where each account's notification screens live ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
// defect they pin was invisible to every other check: `/auth/me/notifications`
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
// the server, the tests and the API all agreed a staff member had an inbox —
// and on the web they could not reach it, because `RequirePlayer` sends anyone
// who is not a player back out of `/account`. The bell pointed at a redirect.
//
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
// this file plus the two admin routes it maps onto.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
test('a player gets the portal paths', () => {
const user = { role: 'player' }
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
})
test('every non-player role gets the admin paths, not just admin', () => {
for (const role of ['admin', 'editor', 'moderator']) {
const user = { role }
assert.equal(isStaff(user), true, role)
assert.equal(inboxPath(user), '/admin/notifications', role)
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
}
})
// The bell renders nothing when signed out, so these are never asked for a null
// user in practice — but a default that guessed "staff" would send a signed-out
// visitor at the admin area the moment that changed.
test('no user, or a user with no role, falls back to the player paths', () => {
for (const user of [null, undefined, {}, { role: '' }]) {
assert.equal(isStaff(user), false)
assert.equal(inboxPath(user), '/account/notifications')
}
})

View File

@@ -1774,9 +1774,18 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
-- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not
-- per subject".
subject_key VARCHAR(190) NOT NULL DEFAULT '',
-- The CHANNEL the cooldown is about, added in Phase 11b after the live walk.
-- Without it a rule naming two channels delivers on exactly ONE of them: the
-- claim runs inside the engine's per-channel loop, `inapp` is ranked first on
-- purpose (so push can reference its inbox row), and every later channel is
-- then reported as cooled. Phase 11b's decision 8 requires the letter and the
-- inbox item to fire together, so the cooldown is per delivery, not per
-- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason:
-- the channel set is data a module can extend.
channel VARCHAR(32) NOT NULL DEFAULT '',
last_fired_at DATETIME NOT NULL,
fire_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (rule_id, user_id, subject_key),
PRIMARY KEY (rule_id, user_id, subject_key, channel),
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- So a prune worker can drop rows older than the longest configured cooldown.
@@ -1785,6 +1794,29 @@ CREATE TABLE IF NOT EXISTS engagement_cooldowns (
INDEX idx_engc_sweep (last_fired_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Widen the key on a deployment that already has the table. Two statements, and
-- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY
-- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already
-- carries the new one is an error, not a no-op, so replaying this file on every
-- boot would fail the whole schema after the first run. The guard reads the key
-- itself out of information_schema rather than the column's existence, because
-- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not.
--
-- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user,
-- subject) that expires on its own interval. That is the right trade against
-- deleting them: a cooldown that outlives its rewrite costs at most one delayed
-- notification, and dropping the table would let a bounce storm through.
ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT '';
SET @engc_key_has_channel := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns'
AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel'
);
SET @sql := IF(@engc_key_has_channel = 0,
'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)',
'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per
-- (rule, user, channel) occurrence of an event.
CREATE TABLE IF NOT EXISTS engagement_outbox (
@@ -1818,7 +1850,10 @@ CREATE TABLE IF NOT EXISTS engagement_outbox (
INDEX idx_engo_due (status, due_at),
-- What a RESOLVING event queries: a house repaired back to LikeNew cancels
-- every scheduled row for that (rule, user, house).
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status),
-- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and
-- `idx_engo_due` cannot serve it: its second column is `due_at`.
INDEX idx_engo_sweep (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- G15: the per-message record. Today "did user X get the mail?" is unanswerable.
@@ -1845,7 +1880,11 @@ CREATE TABLE IF NOT EXISTS engagement_sends (
INDEX idx_engs_user (user_id, created_at),
-- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be
-- an index range scan rather than a table scan: it runs once per rule per event.
INDEX idx_engs_rule_window (rule_id, created_at)
INDEX idx_engs_rule_window (rule_id, created_at),
-- Phase 14's retention sweep deletes by age alone, so it needs `created_at`
-- LEADING. Every index above has it in second position, which serves a
-- per-rule or per-user window and is useless to a whole-table horizon.
INDEX idx_engs_sweep (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored
@@ -1891,3 +1930,769 @@ CREATE TABLE IF NOT EXISTS engagement_templates (
INDEX idx_engt_trigger (trigger_id, channel, status),
INDEX idx_engt_seed (seed_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The email channel on the engine (ENGAGEMENT.md §4.2b — Phase 6) ─────────
-- The scope an event is ABOUT, opaque to core and distinct from `subject_key`.
--
-- They are two different things and Phase 6 is where that stopped being
-- theoretical. `subject_key` is what a COOLDOWN is keyed on and comes from the
-- trigger's declared `subjectKey` — for the four Team triggers that is `teamName`,
-- a display string. `scope_key` is what a PREFERENCE and an UNSUBSCRIBE are keyed
-- on, and it has to be a stable identifier: `team:12` survives a rename, and a
-- Team renamed between the mail and the click must not orphan the unsubscribe
-- link in it. Same vocabulary as engagement_digest_state.scope_key below.
ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL;
-- Phase 14 (retention). The two sweep indexes, for deployments whose tables
-- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used
-- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the
-- cooldown primary-key change did -- that one needed one because MariaDB has no
-- conditional form of a PRIMARY KEY change, not because indexes lack one.
ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at);
ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at);
-- §4.2b: digest state, and DELIBERATELY not a digest queue.
--
-- The generic engine enqueues an outbox row per (rule, user, channel) at emit
-- time, carrying a snapshot of the payload. That is right for an instant send and
-- wrong for a digest, and `teamDigestWorker`'s header says why in three
-- properties: a deployment down for two days sends ONE digest rather than two
-- days of replay; a post a moderator hid after it was written is not in the
-- query so it is not in the mail; and a user who lost forum access between the
-- post and the send is no longer in the recipient set. The third is a security
-- property, and all three are properties of RE-DERIVING the content at send time.
-- A snapshot taken at emit time has none of them.
--
-- So a digest-mode recipient gets NO outbox row (see engine.js), and what
-- generalizes is this: the state the worker keeps, lifted out of
-- team_notification_prefs.last_digest_at so that a second digest — on another
-- channel, or over another scope — needs no second column on somebody's
-- preferences table.
CREATE TABLE IF NOT EXISTS engagement_digest_state (
user_id INT NOT NULL,
channel VARCHAR(32) NOT NULL,
-- '' is deployment-wide; 'team:12' is one Team. NOT NULL with a '' default
-- because this is a PRIMARY KEY column and MariaDB coerces a nullable one
-- anyway — the same workaround team_integration_config and teams.active_key
-- both carry, and the trap Part 4's preamble flags.
scope_key VARCHAR(190) NOT NULL DEFAULT '',
last_digest_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, channel, scope_key),
CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- The worker's driving question is "whose email digest is due?", which is a
-- range scan of this index rather than of every digest ever sent.
INDEX idx_engd_due (channel, last_digest_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Carry the Team digest windows across, once. Replay-safe by construction: an
-- INSERT IGNORE against the primary key, so the second and every later boot
-- writes nothing, and a window the new worker has since MOVED is not dragged
-- backwards by the next restart.
--
-- Rows with a NULL last_digest_at are copied too, and that is deliberate rather
-- than incidental: `clampSince` treats a missing row and a NULL stamp the same
-- way (reach back one interval, not to the floor), so the copy is faithful — and
-- copying only the stamped rows would make the backfill's own idempotence depend
-- on which rows happened to have fired.
INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at
FROM team_notification_prefs;
-- ── The in-app channel (ENGAGEMENT.md §4.5 G17 — Phase 7) ──────────────────
-- The inbox. Core, game-agnostic, and the first sink core owns that CARRIES its
-- content: a push tickle deliberately holds none and an email leaves the
-- building, so this is the one place a message both belongs to this deployment
-- and can be read without a mailbox.
--
-- `dedupe_key` is the acceptance criterion, expressed as an index rather than as
-- a check the writer has to remember: a replayed event, a retried outbox row and
-- a module calling `ctx.inbox.push` twice all reduce to the same INSERT IGNORE.
-- It is scoped to the USER (not to the rule and channel the outbox scopes by),
-- because one event may legitimately be two outbox rows for one person — a rule
-- spanning channels — and two inbox rows for it is one item shown twice.
-- Multiple NULLs are permitted by a UNIQUE index, which is what "this item does
-- not dedupe" means.
--
-- `url` is stored RELATIVE only, validated with the character class
-- `pageUrlTemplate` and the engine's `url` variables already use: it ends up in
-- an href on a page a signed-in user is looking at, and `//evil.test/x` passes
-- every "is it rooted" check anyone writes by hand.
CREATE TABLE IF NOT EXISTS user_notifications (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
trigger_id VARCHAR(96) NOT NULL,
title VARCHAR(300) NOT NULL,
body TEXT NULL, -- rendered by the inapp template, sanitized on write
url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate
dedupe_key VARCHAR(190) NULL,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
-- Both of the two questions this table is asked: "what is in my inbox" (the
-- list, newest first) and "how many are unread" (the badge, on every page
-- load). A single index answers both because `read_at` is IS NULL in one and
-- unconstrained in the other, and `created_at` orders what is left.
INDEX idx_un_unread (user_id, read_at, created_at),
-- What the prune sweep queries. Without it the sweep is a table scan of every
-- notification this deployment has ever written.
INDEX idx_un_prune (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ──
-- The addresses this deployment has stopped mailing, and why.
--
-- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a
-- deliberate choice rather than a workaround for a missing unique index. Two
-- accounts can no longer share an address, but a bounce arrives as an ADDRESS —
-- it does not know which account was behind it, and it stays true after the
-- account that held it changed its address or was deleted. Keying on the user
-- would forget a dead mailbox the moment anybody moved.
--
-- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because
-- the hash-only table cannot be operated. An operator looking at a screen of
-- sha256 digests cannot tell whether the list is three typos or a whole domain
-- refusing mail, and un-suppressing somebody who fixed their mailbox is the one
-- action this table has to support. `d***@example.com` is enough to act on and to
-- see a domain-wide pattern in, and — the reason it is safe — the local part is
-- destroyed rather than shortened, so the column is not an address book and
-- cannot be turned back into one. It is NULLable because a row written from a
-- correlation that only ever held a hash has nothing to mask.
--
-- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES`
-- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554
-- — an authentication failure and a relay-wide policy refusal, neither of which
-- is a fact about the recipient. Writing a suppression on every terminal failure
-- would mean one wrong SMTP password suppresses every address the worker touches
-- before anyone notices. Only recipient-scoped evidence reaches this table; see
-- `src/engagement/bounceClassify.js`.
CREATE TABLE IF NOT EXISTS engagement_suppressions (
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
channel VARCHAR(32) NOT NULL DEFAULT 'email',
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
detail VARCHAR(500) NULL,
created_by INT NULL, -- the admin, for a manual row; NULL for automatic
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
-- The screen's two orderings: newest first, and filtered by reason.
INDEX idx_engsup_created (created_at),
INDEX idx_engsup_reason (reason, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
-- Six of the eleven core tables land here: the ones that do not depend on the
-- module contract. The rest arrive with the phases that give them a writer
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
-- P8 (below) and `event_run_participants` in P10.
--
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
-- action id, a scope, a resource kind and a budget dimension are all opaque
-- strings core stores and never interprets (§C).
-- The arc. Definitions optionally belong to one, and the series is what carries
-- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From
-- the Void" is a thing the tooling this replaces cannot express at all.
--
-- The table lands in Phase 1 because `event_definitions.series_id` points at it;
-- the routes that create and order one are Phase 4's, where the calendar makes
-- an arc visible.
CREATE TABLE IF NOT EXISTS event_series (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(160) NOT NULL,
slug VARCHAR(160) NOT NULL,
description TEXT NULL,
-- Where this series sits among the others on the calendar. Not a position
-- WITHIN the series: a definition's place in its arc is `event_definitions`'
-- own `series_order` below, because that is the column an editor drags.
ordering INT NOT NULL DEFAULT 0,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evser_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The thing that is listed, searched, scheduled and audited.
--
-- **Three states, not five** (§E). There is no `submitted` and no `approved`: an
-- admin publishes their own work, so there is nobody to submit it to, and a
-- review state nobody uses is a state every query has to remember anyway.
--
-- `owner_module` is NULLable and is the module that SHIPPED this definition as
-- content, not the module whose actions its steps call — a definition may call
-- three modules' verbs and belong to none of them. NULL means an operator
-- authored it here, which is the ordinary case.
--
-- `concurrency_key` is stored as the TEMPLATE, not as the rendered value
-- (`invasion:{region}`), because it is rendered from a run's own params at
-- materialisation (§E). A flat definition-id key would wrongly stop one
-- definition running in two regions at once.
--
-- `current_version_id` carries NO foreign key, deliberately, and it is the one
-- column in this group without one: `event_versions.definition_id` already
-- points back here, and a second FK in the other direction makes the pair a
-- chicken and an egg on insert.
CREATE TABLE IF NOT EXISTS event_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL,
summary VARCHAR(500) NULL,
-- The storyline. Sanitized HTML, the same treatment a wiki page gets.
body MEDIUMTEXT NULL,
image_url VARCHAR(500) NULL,
owner_module VARCHAR(64) NULL,
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
current_version_id INT NULL,
-- The WORKING COPY of the spec - phases and their steps - as the author last
-- saved it. §D's column list does not name it because §D describes what a
-- PUBLISHED event is made of, and a version row is where a spec ends up. But
-- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning")
-- has to mean the draft lives somewhere, and it cannot be an `event_versions`
-- row: that table is immutable and a run pins one, so a mutable unpublished
-- row in it would be the exact thing versioning exists to prevent. Publishing
-- copies this column into a version and leaves it here as the next draft.
spec JSON NOT NULL,
series_id INT NULL,
series_order INT NOT NULL DEFAULT 0,
concurrency_key VARCHAR(190) NULL,
-- The grace window (§E). A schedule that passed this many seconds ago while the
-- process was down is `missed`, never a late silent start.
grace_seconds INT NOT NULL DEFAULT 900,
-- IANA, and it belongs to the EVENT rather than to the viewer: every listing
-- this replaces is written in the shard's local zone, and a recurrence computed
-- in UTC puts a Friday-8pm event at 7pm for half the year.
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
created_by INT NULL,
updated_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL,
CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evdef_slug (slug),
-- The admin list's default ordering, and the public calendar's filter.
INDEX idx_evdef_state (state, updated_at),
INDEX idx_evdef_series (series_id, series_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- An immutable snapshot of a whole definition spec: phases, steps, schedule,
-- conditions, announcements. A run pins one, and that pin is the entire reason
-- this table exists — it is what makes a run reproducible, and an audit
-- answerable, after the definition has been edited underneath it.
--
-- Nothing updates a row here. Editing a `ready` definition creates the NEXT
-- version on publish; a live run keeps the version it pinned and is unaffected
-- (EVENTS.md "Versioning, and editing a live event").
CREATE TABLE IF NOT EXISTS event_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version INT NOT NULL,
spec JSON NOT NULL,
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_by INT NULL,
CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
-- Two publishes racing for version 4 is one 1062, not two rows called 4.
UNIQUE KEY uq_evver_def_version (definition_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One occurrence of one definition, in one scope.
--
-- The UNIQUE key below — not the claim — is what makes "one run per occurrence
-- per scope" true (§E). The claim decides WHO advances an occurrence; this index
-- is what stops two of them existing. `scope` is inside the key so a worldwide
-- event fans out to many servers without colliding with itself, and it is
-- module-opaque: core stores the string and never parses it.
--
-- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was
-- COMPUTED in (Phase 4); what is stored is the instant.
--
-- `health` is a separate column from `status` because a run can be genuinely
-- running and degraded at once — announcements landing, world writes parked —
-- and one column cannot say both. `cleanup_status` is separate for the mirror
-- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather
-- than being held open, and stays on the admin screen until a human resolves it.
--
-- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run
-- whose pinned spec had been deleted could not be explained afterwards, which is
-- the one thing this table is for.
CREATE TABLE IF NOT EXISTS event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version_id INT NOT NULL,
-- Module-opaque, and '' rather than NULL for the single-scope case: it is part
-- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL
-- scope would silently permit two runs of one occurrence.
scope VARCHAR(190) NOT NULL DEFAULT '',
status ENUM('scheduled','starting','running','paused','ending',
'completed','cancelled','failed','missed')
NOT NULL DEFAULT 'scheduled',
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
cleanup_status ENUM('not_required','pending','complete','incomplete')
NOT NULL DEFAULT 'not_required',
current_phase VARCHAR(64) NULL,
scheduled_for DATETIME NOT NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
concurrency_key VARCHAR(190) NULL, -- rendered from this run's params
params JSON NULL,
-- A rehearsal dispatches for real but is excluded from the public calendar and
-- from participation history. Declared here in Phase 1 so the column exists
-- before anything can create a run without it.
rehearsal TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NULL,
ended_at DATETIME NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
started_by INT NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE,
CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id),
CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for),
-- The runner's materialise/advance scan: due runs by status.
INDEX idx_evrun_due (status, scheduled_for),
-- The admin run list, newest first, and the per-definition history.
INDEX idx_evrun_def (definition_id, scheduled_for),
-- Phase 2's overlap check. NULL keys are skipped by the index, which is right:
-- a definition with no key never contends.
INDEX idx_evrun_concurrency (concurrency_key, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The work queue: one action invocation, claimed with the outbox's
-- compare-and-set. This is the shape `engagement_outbox` already proved, and
-- retries, timeouts, duplicate execution and resumption are all properties of
-- this row rather than of a scheduler's memory.
--
-- `idempotency_key` is minted ONCE at materialisation and does not vary by
-- attempt (§E) — a retry re-sends the same key so the game side can recognise
-- the repeat. It is generated by core rather than by the module because core is
-- what guarantees its stability.
--
-- `action_version` records what the step was AUTHORED against. A module that
-- bumps its action makes the step render a warning in the editor rather than
-- dispatch a mistyped parameter.
--
-- `refused` is in the status set and is deliberately not `failed`: a cap breach
-- means nothing is wrong with the system, and an author asked for more than this
-- deployment allows.
CREATE TABLE IF NOT EXISTS event_run_steps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
phase VARCHAR(64) NOT NULL,
seq INT NOT NULL,
action_id VARCHAR(96) NOT NULL,
params JSON NULL,
action_version INT NOT NULL DEFAULT 1,
status ENUM('pending','running','done','failed','skipped','refused','cancelled')
NOT NULL DEFAULT 'pending',
due_at DATETIME NULL,
attempts INT NOT NULL DEFAULT 0,
-- Defaulted from the action's risk class at materialisation (§L): retry->skip
-- for notify, retry->pause for change, retry->abort_run for irreversible.
on_failure VARCHAR(32) NOT NULL DEFAULT 'skip',
idempotency_key CHAR(40) NOT NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
last_error VARCHAR(500) NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
-- The drain scan, exactly as written: due, pending, oldest first.
INDEX idx_evstep_due (status, due_at),
-- The run console: every step of one run in authored order.
INDEX idx_evstep_run (run_id, phase, seq),
-- Materialisation is INSERT IGNORE against this, so a tick that overruns into
-- the next one cannot double-materialise a phase.
UNIQUE KEY uq_evstep_slot (run_id, phase, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- "Why didn't phase 3 start?" must be a query.
--
-- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this
-- table exists rather than the audit log being reused: an operator diagnosing a
-- stalled phase needs to filter by kind and read structured detail, and an
-- administrative audit of WHO published WHAT is a different question with a
-- different retention. Both are written — the audit to `activity_log`, the
-- diagnosis here.
--
-- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM,
-- because the set grows with almost every later phase and an ENUM change is a
-- table alter this project has no migration system for.
--
-- The log is high-cardinality and grows per event, so it needs a retention sweep
-- from the start — `engagementRetentionPrune` is the pattern, and the rule that
-- work learned is that only TERMINAL rows are eligible. The sweep itself lands
-- with the runner in Phase 2; the index it needs is here from the beginning.
CREATE TABLE IF NOT EXISTS event_run_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
step_id BIGINT NULL,
kind VARCHAR(48) NOT NULL,
phase VARCHAR(64) NULL,
detail JSON NULL,
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
-- The run console reads this whole index and nothing else.
INDEX idx_evlog_run (run_id, at),
-- What the Phase 2 retention sweep queries. Without it the sweep is a table
-- scan of every line this deployment has ever logged.
INDEX idx_evlog_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What a phase is waiting for, and how far it has got (§E, Phase 5).
--
-- A phase used to advance on one fact — every step terminal — and that fact
-- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is
-- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up',
-- count: 3 }` is a tally of things that happened between one tick and the next,
-- and the runner is not running when they happen. This table is where a firing
-- is counted at the moment it fires.
--
-- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the
-- same idempotence `materialisePhase` has and for the same reason: a process
-- that died between entering a phase and writing this must not open a second
-- gate on the next tick.
--
-- **The tally is incremented by one statement with the threshold in it**, never
-- read-then-written — the argument `event_run_budget`'s conditional increment
-- makes, one phase early. Two emits arriving together each add one, and exactly
-- one of them crosses `needed`.
--
-- `last_event` holds ONLY the variables the condition names, not the payload.
-- It exists to answer "what did the last one look like, and why did it not
-- count", and a copy of a whole game event's data is a second copy of exactly
-- the content `engagement_sends` is careful not to keep.
--
-- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s
-- reason: the set can grow (an authored timeout was considered and declined for
-- Phase 5) and this project has no migration system for a column alter. `kind`
-- IS an ENUM, because §E closes it at two shapes.
CREATE TABLE IF NOT EXISTS event_run_phase_gates (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
phase VARCHAR(64) NOT NULL,
kind ENUM('after','on') NOT NULL,
-- kind='after': normalised to seconds at save, so the runner never parses a
-- duration string. `due_at` is entered_at + this, computed once at entry.
after_seconds INT NULL,
-- kind='on': the trigger being waited on and the predicate over its declared
-- variables. `conditions` is NULL for "any firing of this trigger".
trigger_id VARCHAR(96) NULL,
conditions JSON NULL,
needed INT NOT NULL DEFAULT 1,
tally INT NOT NULL DEFAULT 0,
entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_at DATETIME NULL,
last_event JSON NULL,
last_event_at DATETIME NULL,
satisfied_at DATETIME NULL,
satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced'
forced_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL,
-- Entry is INSERT IGNORE against this.
UNIQUE KEY uq_evgate_phase (run_id, phase),
-- The emit path's only query: every open gate waiting on this trigger. It runs
-- on every game event of every trigger anything waits on, so it is the one
-- index in this feature that is on a hot path rather than an admin screen.
INDEX idx_evgate_open (trigger_id, satisfied_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ───────
-- The deployment's switchboard, and the whole of the permission model beyond the
-- role (§K layer 2).
--
-- **Not a grant table.** Nobody is named in it, because the role check already
-- answered who; this table answers *what this deployment permits at all*, and
-- how much of it per run. That is the distinction §K draws between a capability
-- and permission to invoke it: a module declaring `uo.creature.spawn` is code the
-- operator installed, not a permission they granted.
--
-- **A missing row is not "disabled" — it is "the default for its risk class".**
-- Rows are written when an admin changes something, never seeded at boot, for a
-- reason that is structural rather than tidy: the registry is assembled in
-- `registerCore()` and by module `register()`, both of which run under
-- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2).
-- A boot-time seed from the registry would be exactly the database write those
-- two forbid. Reading a default from the risk class costs one branch and means a
-- deployment that never opens this screen behaves correctly.
--
-- The row survives its action: uninstalling a module leaves the settings behind,
-- so re-installing it restores the caps the operator chose rather than silently
-- resetting them. The switchboard only lists what is registered *now*, so a
-- stranded row is invisible until its action comes back.
--
-- `action_id` is the primary key rather than an id column: there is exactly one
-- row per action and every read is by that id.
CREATE TABLE IF NOT EXISTS event_action_settings (
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
enabled TINYINT(1) NOT NULL DEFAULT 0,
-- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by
-- this action; an empty object is an action that declares no cost at all.
caps JSON NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What one run has spent, and the most it may.
--
-- **The cap is COPIED here at run start, not read live.** A run is already
-- reproducible in every other respect — it pins a version, and the version is
-- immutable — and a cap read live would be the one input to a run's behaviour
-- that an admin could change underneath it at three in the morning. Copying also
-- makes the console's meter answer the right question afterwards: "what was this
-- run allowed", not "what is allowed now".
--
-- **One row per dimension, so the cap is the tightest of the actions the run's
-- version names** (org lead, 2026-09-03). Two actions that both spend
-- `uo.creatures` share this row, which is what makes a dimension a bound on the
-- run's total effect rather than a per-verb allowance. `effective_from` records
-- which action's cap won, so the console can say so.
--
-- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two
-- steps drawing on one dimension in the same tick cannot both see 28/30 and both
-- spend, and no transaction is needed to say so. Same shape as the outbox claim
-- and the gate's conditional increment, and the same reason.
CREATE TABLE IF NOT EXISTS event_run_budget (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
dimension VARCHAR(96) NOT NULL,
consumed INT NOT NULL DEFAULT 0,
-- **NULL is uncapped, and it is a row rather than an absent one.** A dimension
-- every action naming it left uncapped still accumulates here, so the console's
-- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one
-- unambiguous meaning: a step spending a dimension its own run's version never
-- priced, which `spend()` refuses.
cap INT NULL,
-- The action whose cap was the minimum. Documentary: it is what lets the run
-- console say "30, from uo.creature.spawn" rather than showing a number the
-- operator cannot trace back to a switch they set.
effective_from VARCHAR(96) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
-- Seeding is INSERT IGNORE against this, so a tick that overruns into the next
-- one cannot double-seed a run's budget.
UNIQUE KEY uq_evbud_dim (run_id, dimension)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The cleanup ledger: everything one run created or leased, and what became of
-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
--
-- **It holds both kinds of thing an event owns.** An OBJECT it created is
-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
-- `payload` carrying the baseline and what was applied, restored by the lease's
-- own `restore()`. One table, because cleanup asks both the same question --
-- what is still out there, and did putting it back work.
--
-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
-- does not exist until the module answers, so what is written before the dispatch
-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
-- '@step', `ref` = that key). On the answer the reported resources are inserted
-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
-- takes the key at all. Recording afterwards instead would make every object
-- whose ack was lost invisible to cleanup for ever.
--
-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
-- that never reverts stays visible -- the run reaches `completed` with
-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
-- tidy `completed` over a shard full of orphaned monsters is the failure that
-- would end this feature's credibility on its first bad night.
--
-- **The unique key is what stops two events leasing one target**, and it must
-- hold among LIVE rows only: last week's finished event must not keep this
-- week's from leasing the same rate. MariaDB has no partial index, so the
-- encoding is a STORED generated column that is NULL once the row is no longer
-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
-- foreign key whose column is a base column of a stored generated column
-- (error 1901), so `step_id` must not appear in the expression.
--
-- **The key is held by the three statuses that mean "core still believes this is
-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
-- mean it is not. §D says "among non-reverted rows", which was written before the
-- six statuses had their meanings; taken literally it makes `drifted` and
-- `orphaned` hold a target for ever, so one bad night would disable a lease
-- permanently with no control able to clear it. `drifted` means somebody else has
-- hold of the value and this run has deliberately let go of it; `orphaned` means
-- it vanished. Neither is a claim on the target, and both stay LOUD by another
-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
CREATE TABLE IF NOT EXISTS event_run_resources (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
-- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
-- the row records the module and the opaque names, and the step records the
-- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
-- of what was changed in the world must outlive the row that scheduled it.
step_id BIGINT NULL,
-- The registering module, copied at record time rather than derived from the
-- action id, so an uninstalled module still names itself on the console.
owner_module VARCHAR(64) NOT NULL,
-- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
-- treatment. '@step' is the one reserved `kind` and core owns it.
kind VARCHAR(64) NOT NULL,
ref VARCHAR(190) NOT NULL,
payload JSON NULL,
-- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
-- well: the game side restores baseline when it passes, without being asked
-- again, which is the fail-safe that makes an unattended world change
-- defensible. This column is core's copy of that promise, for the console and
-- for the boot-time check.
lease_until DATETIME NULL,
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
NOT NULL DEFAULT 'pending',
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
-- never succeed must become visible rather than cycling for ever. Engagement
-- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
-- makes an unbounded counter a row nothing can ever sweep.
revert_attempts INT NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
-- Optional, and module-opaque like the rest: who received it, for a granted
-- reward that results should be able to name. `event_run_participants` joins on
-- the same key in Phase 10.
member_key VARCHAR(190) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 1 while core still believes this resource is this run's, NULL once it is not.
-- See the unique key below; derived from `status` alone, deliberately.
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
-- "Two events cannot hold a lease on one target", among non-reverted rows.
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
-- The run console, and the cleanup sweep's read: one run's ledger in order.
INDEX idx_evres_run (run_id, status),
-- The cleanup leg's scan across runs, and the boot-time lease self-check.
INDEX idx_evres_live (status, lease_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- §K's last bound: "a scheduled definition that has never been verified is the
-- case worth refusing to start". A version is immutable, so a dry run that passed
-- against it stays true — which is what makes the pass a property of the VERSION
-- rather than of the definition, and what makes recording it two columns rather
-- than a table.
--
-- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing
-- start is watching, and that human is the review the gate exists to require.
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
--
-- **Not a second answer to the question `state` answers**, which is the trap the
-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
-- whether an event is SCHEDULABLE, and this says whether it is ANNOUNCED. The
-- two came apart the moment there was a public surface at all, because
-- publishing is what makes a definition runnable -- so without this column a
-- surprise invasion would have to be advertised a fortnight in advance in order
-- to be allowed to happen.
--
-- Default 1, so every definition that exists keeps the behaviour it had while
-- the only reader was staff, and unlisting is the deliberate act.
--
-- It hides the definition, its runs and its projections from the public
-- surfaces and from a participant's own history. It hides nothing from staff:
-- the admin calendar is the operational view, and an event nobody outside can
-- see is still an event the team is running.
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
-- ── Integrations: participants, results and the run's announcements
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────
-- Who took part, and how well. The eleventh and last of §D's core tables.
--
-- **Core writes this table and never sources it.** A `member_key` is
-- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of
-- Britain" onto a user row and must not try, because the mapping is one game's
-- (`shard_links`, for module-uo) and would be compiled into core the moment it
-- guessed. A module that knows both halves supplies both — `memberKey` always,
-- `userId` when its own link table has one — and core stores what it is told.
--
-- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what
-- happened at an event has to survive the deletion of an account that attended
-- it, or the results of last year's invasion silently rewrite themselves.
--
-- **`rank` is NULL until results are published** and is computed then, by
-- `core.results.publish`, over `score DESC`. It is a stored column rather than a
-- window function in the read because a published result is a fact about a
-- moment: a participant added afterwards (a late correction, a module's second
-- collect step) must not silently renumber a table people have already read.
CREATE TABLE IF NOT EXISTS event_run_participants (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
run_id BIGINT NOT NULL,
-- Module-opaque and NOT NULL: it is the identity the module knows, and the
-- half of the unique key that makes a repeated collect idempotent. A run whose
-- module cannot name its participants has no rows here at all.
member_key VARCHAR(190) NOT NULL,
user_id INT NULL,
-- Signed, because a game may score downward as readily as upward, and DECIMAL
-- rather than a float so two equal scores compare equal and a rank is stable.
score DECIMAL(18,4) NOT NULL DEFAULT 0,
rank_at INT NULL,
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Module-opaque. Whatever the module wants results to be able to display
-- beside a name -- a class, a city, a kill count -- with no core vocabulary in
-- it and nothing core ever reads.
meta JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
-- One row per participant per run. What makes a module reporting the same set
-- twice -- a retried collect step, a second attempt after a timeout -- an
-- upsert rather than a duplicated leaderboard.
UNIQUE KEY uq_evpart_member (run_id, member_key),
-- The results table: one run, best first.
INDEX idx_evpart_score (run_id, score),
-- Profile history (`GET /player/events/history`, Phase 14), newest first.
INDEX idx_evpart_user (user_id, joined_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- When the results table was published, and by which run of the publish action.
--
-- A stamp rather than a status: a run either has published results or it has
-- not, and the two questions a surface asks -- "may I show this table" and "when
-- was it settled" -- are the same column. `core.results.publish` is idempotent
-- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an
-- ordinary retried step.
ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL;
-- The run an announce job belongs to, when it belongs to one.
--
-- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are
-- not an event's, and nothing about how they are enqueued, retried or rolled up
-- changes. What this buys is that `core.announce.post` may enqueue a SECOND job
-- for a post that has already been announced -- the common case, since the post
-- an event announces is very often the news post that announced it -- without
-- either colliding with the first or overwriting `posts.announce_job_id`, which
-- is the back-pointer the post admin panel's retry button reads.
--
-- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce
-- job that went out is a delivery record and must outlive whatever asked for it,
-- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a
-- constraint here would be the one statement in this file that cannot replay.
-- The column is read only to answer "which run announced this", and a run id
-- that no longer resolves answers that honestly.
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);

View File

@@ -5,6 +5,7 @@ const wikiDb = require('../src/model/wiki/wiki.db')
const users = require('../src/model/users/users.model')
const { ensureSchema, close } = require('../src/utils/db')
const { seedTemplates } = require('../src/engagement/templates')
const { seedCoreRules } = require('../src/engagement/coreRules')
const brand = require('../src/config/brand')
const log = require('../src/utils/logger')('seed')
@@ -81,6 +82,13 @@ async function seedDefaults() {
// failed to seed costs the shipped default, which `renderByKey` falls back to
// anyway, and must not stop a boot.
await seedTemplates()
// Core's five rules — the four Team ones (Phase 6) and news (Phase 11) —
// seeded ONCE and all disabled. Each GROUP carries its own settings-key guard
// rather than re-ensured, so a rule an operator deleted stays deleted and one
// they enabled stays enabled; and so the news rule reaches the deployments that
// were already stamped for Teams, which are exactly the ones that lose their
// raw news push to the engine (ENGAGEMENT.md §7.1 Q9).
await seedCoreRules()
log.info('settings and wiki defaults ensured')
}

View File

@@ -1,7 +1,483 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.7.0",
"moduleApiVersion": "1.10.0",
"triggers": [
{
"id": "event.phase.changed",
"owner": "core",
"label": "Event — a new phase",
"description": "An event that is under way has moved on to its next stage.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "phase",
"type": "string",
"required": true,
"example": "assault",
"description": "The phase key just entered, as authored in the spec."
},
{
"name": "phaseLabel",
"type": "string",
"required": false,
"example": "The assault",
"description": "The phase label, when the spec gave it one. Falls back to the key."
},
{
"name": "phaseIndex",
"type": "int",
"required": true,
"example": 2,
"description": "Which phase this is, counting from 1."
},
{
"name": "phaseCount",
"type": "int",
"required": true,
"example": 4,
"description": "How many phases the pinned version has in total."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "event.run.cancelled",
"owner": "core",
"label": "Event — cancelled",
"description": "A scheduled event was cancelled by a member of staff.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "reason",
"type": "string",
"required": false,
"example": "The shard is down for an emergency patch.",
"description": "What the staff member gave as the reason, when they gave one."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "event.run.completed",
"owner": "core",
"label": "Event — finished",
"description": "An event has finished.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "participantCount",
"type": "int",
"required": true,
"example": 47,
"description": "How many participants the run recorded. Zero when nothing collected any."
},
{
"name": "durationMinutes",
"type": "int",
"required": true,
"example": 95,
"description": "How long the run took, start to end, in whole minutes."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "event.run.ending",
"owner": "core",
"label": "Event — winding down",
"description": "An event is drawing to a close.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "event.run.failed",
"owner": "core",
"label": "Event — run failed",
"description": "An event stopped before it finished.",
"kind": "event",
"subjectKey": "runId",
"audience": "admin",
"ceiling": "admin",
"version": 1,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "phase",
"type": "string",
"required": false,
"example": "assault",
"description": "The phase it failed in, when it had entered one."
},
{
"name": "error",
"type": "string",
"required": false,
"example": "sidecar responded 503",
"description": "The run’s last error, verbatim from the run row."
},
{
"name": "runUrl",
"type": "url",
"required": true,
"example": "/admin/events/runs/3692",
"description": "Site-relative path to the run console."
}
]
},
{
"id": "event.run.scheduled",
"owner": "core",
"label": "Event — scheduled",
"description": "A new event has been added to the calendar.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "startsAt",
"type": "datetime",
"required": true,
"example": "2026-09-12T20:00:00.000Z",
"description": "When the occurrence is due to start, UTC."
},
{
"name": "startsAtLabel",
"type": "string",
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "event.run.started",
"owner": "core",
"label": "Event — starting now",
"description": "A scheduled event has begun.",
"kind": "event",
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 2,
"variables": [
{
"name": "runId",
"type": "string",
"required": true,
"example": "3692",
"description": "The run this is about. Also the cooldown subject."
},
{
"name": "title",
"type": "string",
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "summary",
"type": "string",
"required": false,
"example": "Orcish warbands are massing north of Yew.",
"description": "The event’s one-line summary, when it has one."
},
{
"name": "seriesName",
"type": "string",
"required": false,
"example": "The Yew Campaign",
"description": "The arc this event belongs to, when it belongs to one."
},
{
"name": "timezone",
"type": "string",
"required": false,
"example": "America/New_York",
"description": "The zone the run was computed in — what a time in the body should be read as."
},
{
"name": "startsAt",
"type": "datetime",
"required": true,
"example": "2026-09-12T20:00:00.000Z",
"description": "When it actually started, UTC."
},
{
"name": "startsAtLabel",
"type": "string",
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
{
"id": "news.post",
"owner": "core",
@@ -38,8 +514,8 @@
"name": "postUrl",
"type": "url",
"required": true,
"example": "/news/five-on-friday-yew-invasion",
"description": "Site-relative path to the post."
"example": "/site/news",
"description": "Site-relative path to the post. The news list today — the site has no per-post route."
}
]
},

View File

@@ -194,6 +194,24 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/retention",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/retention",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules",
@@ -293,6 +311,42 @@
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates",
@@ -365,6 +419,276 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/events/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/:id",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/events/:id",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/publish",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/runs",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/verify",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/:id/versions",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/actions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/events/actions",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/calendar",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/catalog",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/price",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/advance",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cancel",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cleanup",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/pause",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/resume",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/events/series",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/events/series",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/events/series/:seriesId",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "PUT",
"path": "/api/v1/admin/events/series/:seriesId",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/invites",
@@ -1614,6 +1938,28 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/channels",
@@ -1634,6 +1980,15 @@
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams",
@@ -1683,6 +2038,15 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions",
@@ -1903,6 +2267,15 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/events/history",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",
@@ -2068,6 +2441,42 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/public/engagement/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token",
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/events",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/events/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/events/series/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/modules",

View File

@@ -85,6 +85,14 @@
"method": "GET",
"path": "/api/v1/admin/engagement/channels"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/retention"
},
{
"method": "PUT",
"path": "/api/v1/admin/engagement/retention"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/rules"
@@ -129,6 +137,22 @@
"method": "GET",
"path": "/api/v1/admin/engagement/sends"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "POST",
"path": "/api/v1/admin/engagement/suppressions"
},
{
"method": "DELETE",
"path": "/api/v1/admin/engagement/suppressions/by-hash/:hash"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/templates"
@@ -161,6 +185,126 @@
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/events"
},
{
"method": "POST",
"path": "/api/v1/admin/events"
},
{
"method": "DELETE",
"path": "/api/v1/admin/events/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/events/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/events/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/publish"
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/runs"
},
{
"method": "POST",
"path": "/api/v1/admin/events/:id/verify"
},
{
"method": "GET",
"path": "/api/v1/admin/events/:id/versions"
},
{
"method": "GET",
"path": "/api/v1/admin/events/actions"
},
{
"method": "PUT",
"path": "/api/v1/admin/events/actions"
},
{
"method": "GET",
"path": "/api/v1/admin/events/calendar"
},
{
"method": "GET",
"path": "/api/v1/admin/events/catalog"
},
{
"method": "GET",
"path": "/api/v1/admin/events/catalog/options/:sourceId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/price"
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs"
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/advance"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cancel"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/cleanup"
},
{
"method": "GET",
"path": "/api/v1/admin/events/runs/:runId/log"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/pause"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/resume"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/confirm"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/retry"
},
{
"method": "POST",
"path": "/api/v1/admin/events/runs/:runId/steps/:stepId/skip"
},
{
"method": "GET",
"path": "/api/v1/admin/events/series"
},
{
"method": "POST",
"path": "/api/v1/admin/events/series"
},
{
"method": "DELETE",
"path": "/api/v1/admin/events/series/:seriesId"
},
{
"method": "PUT",
"path": "/api/v1/admin/events/series/:seriesId"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
@@ -649,6 +793,14 @@
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications"
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/:id/read"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/channels"
@@ -657,6 +809,10 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/channels"
},
{
"method": "POST",
"path": "/api/v1/auth/me/notifications/read-all"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams"
@@ -677,6 +833,10 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/unread-count"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
@@ -773,6 +933,10 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "GET",
"path": "/api/v1/player/events/history"
},
{
"method": "GET",
"path": "/api/v1/player/teams"
@@ -837,6 +1001,26 @@
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
{
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
{
"method": "GET",
"path": "/api/v1/public/events"
},
{
"method": "GET",
"path": "/api/v1/public/events/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/events/series/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/modules"

View File

@@ -108,7 +108,21 @@ function main() {
return
}
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
// **Line endings are normalised before the comparison**, exactly as
// `routeManifest.js` does one file along, and for a reason that is not
// cosmetic: this repo is developed on Windows under `core.autocrlf=true`, so
// git checks a committed LF blob out as CRLF and a byte comparison then calls
// an unchanged manifest stale. That failure is worse than useless — it fires on
// every Windows checkout, says "a trigger declaration changed", and is fixed by
// regenerating a file whose CONTENT was already correct, which teaches a
// developer to ignore the one check that exists to be believed.
//
// 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.
const current = fs.existsSync(MANIFEST_PATH)
? fs.readFileSync(MANIFEST_PATH, 'utf8').replace(/\r\n/g, '\n')
: ''
if (current === next) {
process.stdout.write('engagement-triggers.json is current\n')
return

View File

@@ -0,0 +1,755 @@
// ── Core's own event actions ───────────────────────────────────────────────
//
// EVENTS.md §F, and Phase 1 of EVENTS_PLAN.md. The twin of config/coreTriggers.js
// and registered through the same staging area a module will use in Phase 7 —
// which is the entire reason these three exist this early. A registry whose first
// real registrant is a module is a registry that has already drifted, and §F's
// claim that core is "an event engine that can announce, wait, cue a human and
// publish results" with NO module installed is only true if core declares the
// verbs that do it.
//
// **Three actions, and between them they cover the three things an event can do
// that name no game noun at all**: tell people something, let time pass, and ask
// a human to go and do something. A deployment with no game module installed has
// a working event system made of exactly these.
//
// **Phase 8 added a fourth, and it is the odd one out on purpose.** `core.lease`
// names no game noun either — it borrows a value some module declared — but
// unlike the other three it genuinely changes the world, so it is `risk: 'change'`
// and therefore default-off, admin-only and cap-checked like any module verb.
// It is CORE's rather than each module's because §F puts the duration bound and
// the two-events-one-target conflict check on core's side of the seam: a lease
// verb per module would be that bound re-implemented once per module, advisory
// everywhere, and wrong in the first one that forgot it.
//
// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
// post an editor already wrote and queues it through `announce_jobs`, so the
// town crier and Discord arrive as already-registered legs with their retry and
// their classification rather than as a second delivery pipeline. And
// `core.results.publish` is what makes §F's "publish results" literal: it ranks
// the run's participants and stamps the table published. Both name a game noun
// nowhere, which is why they are core's; six actions is now the whole of what an
// event can do on a deployment with no game module installed at all.
//
// **Phase 2 gave all three real bodies**, and between them they exercise every
// shape §F's envelope can take: `core.announce` does work and finishes,
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
// without finishing at all. The runner learns nothing about any of them by id —
// each says what it needs in the envelope, through the same two members Phase 7
// hands to a module.
//
// **This file must not touch the database.** It is required from `registerCore()`,
// which runs under `routeManifest.js` and `swagger.js` against a dead pool
// (MODULE_API.md §2.2). Nothing below runs at require time; the announce leg is
// looked up inside `perform()`, per call, which is also what makes a leg
// registered by a module that booted later reachable at all. `core.lease` is the
// one action here that reaches a table, and it requires the model INSIDE
// `perform()` for the same reason — a top-level require would make this file
// build a pool during route-manifest generation.
const registries = require('../modules/registries')
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
// from the lease id and the target, so this is the one place a caller can push a
// ref past the column — and the ledger's own rule applies: refuse, never
// truncate, because a truncated ref is a restore pointed at another object.
const MAX_LEASE_REF = 190
/**
* Turn the `value` param's text into whatever the named lease says it holds.
*
* The range check is here too, and it is REQUIRED on the numeric types for the
* reason §F gives: unlike a cap, a bad lease value is in force the moment it is
* applied, so "0.5 to 5" is not advice.
*/
function coerceLeaseValue(lease, raw) {
const text = String(raw === undefined || raw === null ? '' : raw).trim()
if (lease.type === 'string') {
// A string lease with a declared value set is bounded here, at authoring
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
// only check on the value is the game side's, and that refusal arrives
// unattended, mid-run, from a step nobody is watching.
if (lease.values && !lease.values.includes(text)) {
return {
ok: false,
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
}
}
return { ok: true, value: text }
}
if (lease.type === 'bool') {
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
return { ok: false, error: `"${raw}" is not a yes or no value for ${lease.label}` }
}
const n = Number(text)
if (text === '' || !Number.isFinite(n)) {
return { ok: false, error: `"${raw}" is not a number, and ${lease.label} holds one` }
}
if (lease.type === 'int' && !Number.isInteger(n)) {
return { ok: false, error: `${lease.label} holds a whole number, and "${raw}" is not one` }
}
if (n < lease.min || n > lease.max) {
return { ok: false, error: `${lease.label} accepts ${lease.min} to ${lease.max}, and "${raw}" is outside that` }
}
return { ok: true, value: n }
}
const ACTIONS = [
{
id: 'core.announce',
label: 'Announce',
description:
'Publish a line of text to an announce leg — Discord, the in-game town crier, or any leg a module has registered.',
// Nothing in the world changes and nothing is created: a message goes out.
// That is what makes the default `on_failure` for this step `retry -> skip`
// (§L) rather than `pause`, and it is the honest class even though the
// message itself cannot be unsent.
risk: 'notify',
// A sent announcement is gone. `none` rather than `ledger` is not an
// omission — there is no undo to write, and declaring `ledger` would put a
// row in the cleanup ledger that teardown could never resolve.
reversible: 'none',
version: 1,
params: [
{
// A leg id, checked against the announce-leg registry at dispatch rather
// than here: legs are registered by modules, and this file is evaluated
// before any module has registered anything.
//
// **`source` is what moves that check earlier** (Phase 7). The dispatch
// check stays — a module can boot between authoring and the run — but
// until now a typo here was caught mid-run and nowhere else, which is the
// defect Phase 6's walk hit: an announce leg "site" no module registers,
// found by a dry run rather than by the form that accepted it.
name: 'leg',
type: 'string',
required: true,
example: 'discord',
source: 'core.options.legs',
description: 'The announce leg to publish on. Registered legs only.',
},
{
name: 'title',
type: 'string',
required: false,
example: 'The gates of Britain open at dusk',
description: 'Optional heading, for legs that render one.',
},
{
name: 'body',
type: 'string',
required: true,
example: 'A caravan has been sighted on the road east of Cove.',
description: 'The announcement itself. Plain text.',
},
],
/**
* Publish through the announce leg the step names.
*
* **The legs are reused rather than reimplemented** (§J, "reuse the legs"):
* `discord` is core's and `towncrier` is module-uo's, both already registered,
* both already carrying a `classify()` that knows what their transport's
* failures mean. An event announcement that went out by some other path would
* be a second delivery mechanism with its own bugs.
*
* A leg's `dispatch()` takes a POST — that is the shape the news path gave it
* — so an event announcement is presented as one. `excerpt` is the body
* because it is the field every leg renders as prose, and `image_url` is null
* because an event announcement has no article behind it to illustrate.
* Widening the leg contract to carry a second payload shape is a
* MODULE_API change, and Phase 7 is where those are made.
*
* The leg id is checked HERE rather than at authoring time, and that is not
* laxness: legs are registered by modules, and a spec is validated in a
* process that may have booted before the module that owns the leg.
*/
async perform({ params, verify }) {
const registered = registries.announceLeg(params.leg)
if (!registered) {
// Terminal, not transient. A leg nobody registers will not appear
// between two attempts sixty seconds apart, and the honest cause — a
// module removed, or a typo the authoring form could not catch — is a
// thing a human fixes.
return { ok: false, retry: false, error: `no module registers the announce leg "${params.leg}"` }
}
// A dry run reports what it WOULD do and sends nothing (§I). Answering
// before the dispatch rather than inside the leg is what keeps that true
// for legs written by people who never read this file.
if (verify) return { ok: true }
const result = await registered.dispatch({
title: params.title || null,
excerpt: params.body,
image_url: null,
})
// The leg's own classification, not a second opinion. `retry` vs
// `terminal` for a Discord webhook is a judgement `discordAnnounce.classify`
// already makes, and making it twice is how the two drift.
const { outcome, error } = registered.classify(result)
if (outcome === 'done') return { ok: true }
return { ok: false, retry: outcome === 'retry', error: error || `announce leg "${params.leg}" refused` }
},
},
{
id: 'core.wait',
label: 'Wait',
description: 'Let a fixed amount of time pass before the next step of this phase runs.',
// `inspect` rather than `notify`: nothing is sent and nobody is told. It is
// the weakest class the closed set has for an action that is not a broadcast.
risk: 'inspect',
reversible: 'none',
version: 1,
params: [
{
name: 'seconds',
type: 'int',
required: true,
example: 300,
description: 'How long to wait. The runner sets the next step due_at from this.',
},
],
// A wait is a genuine no-op at dispatch, and it stayed one: the delay is the
// NEXT step's `due_at`, which the runner owns, not something this function
// sleeps through. A `perform` that slept would hold a step's claim for the
// duration and turn a five-minute pause into a five-minute lease — and the
// reclaim would then re-dispatch it, so a long enough wait would never end.
//
// `holdFor` is an ordinary envelope member (org lead, 2026-09-02), which is
// why the runner can honour this without knowing what `core.wait` is.
async perform({ params, verify }) {
if (verify) return { ok: true }
return { ok: true, holdFor: params.seconds }
},
},
{
id: 'core.cue',
label: 'Cue a human',
description:
'Post an instruction for staff and wait for someone to confirm it was done before the run advances.',
// The action itself only posts an instruction. Whatever the human then does
// is outside this system entirely, which is precisely why the cue exists:
// it is how an event uses a capability no module has automated.
risk: 'notify',
reversible: 'none',
version: 1,
params: [
{
name: 'instruction',
type: 'string',
required: true,
example: 'Open the north gate and read the herald script in Britain bank.',
description: 'What the staff member is being asked to do.',
},
{
name: 'assignee',
type: 'string',
required: false,
example: 'Event Team',
description: 'Who the cue is addressed to. A label, not an account.',
},
],
/**
* Post the instruction and PARK. The step does not complete here.
*
* `await: 'human'` is the envelope member that says so (org lead,
* 2026-09-02), and the runner's answer to it is to leave the step `running`
* with a NULL lease — genuinely in flight, nothing holding it, so the stale
* reclaim passes it by and a cue posted on Friday is still waiting on Monday.
* The step ends when someone presses confirm, which is Phase 3's control.
*
* **Nothing is delivered from here in Phase 2, and that is visible rather
* than pretended.** The instruction is carried by the step's own params and
* shown on the run console; routing it to Discord or to a staff inbox is
* Phase 10's integration work, through the engagement triggers that own every
* other notification on this platform. An action that grew its own delivery
* path would be the second one.
*/
async perform({ verify }) {
if (verify) return { ok: true }
return { ok: true, await: 'human' }
},
},
{
id: 'core.lease',
label: 'Borrow a value',
description:
'Hold a module-declared value at a new setting for a bounded time, and put the old one back at teardown.',
// The world changes and it changes back, so `change` rather than
// `irreversible` — and `change`'s default `on_failure` is `pause`, which is
// the right stop for a run that failed halfway through altering the world.
risk: 'change',
// The one action core ships in this class. `override` is what tells the
// cleanup sweep to restore through the LEASE registry rather than through an
// action's `revert()`, which is why this action needs no `revert()` of its own
// and why the registry refuses one on it.
reversible: 'override',
version: 1,
params: [
{
name: 'lease',
type: 'string',
required: true,
example: 'uo.rate.skillgain',
source: 'core.options.leases',
description: 'Which declared value to borrow.',
},
{
// **A string, and the coercion is here rather than in the type system.**
// A param declares ONE type; a lease declares its own, and they are four
// different ones. Typing this `float` would make a boolean lease
// unauthorable and a string lease nonsense, so the field takes text and
// this action turns it into whatever the named lease said it holds — the
// one place that knows both halves.
name: 'value',
type: 'string',
required: true,
example: '3.0',
description: 'What to hold it at, in whatever type the lease declares.',
},
{
// **Optional here, required by the LEASE** (Phase 12b), and the two are
// not the same statement. A param's `required` is a property of the
// action, and this action serves both a config key (which has no target)
// and an object property (which cannot be named without one) — so the
// field is declared optional and `perform` refuses a targeted lease with
// nothing in it, in the lease's own words.
//
// It carries no `source` for the same reason: the values behind it are
// the chosen LEASE's, and a param declares one source for all time. The
// lease's own `target.source` is what the authoring form reads once the
// author has picked a lease, which is the only moment the right list is
// knowable.
name: 'target',
type: 'string',
required: false,
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
},
{
name: 'minutes',
type: 'int',
required: true,
example: 120,
description: 'How long to hold it. Core refuses more than the lease allows.',
},
],
// What a lease costs is the LEASE's business to bound, not a budget's:
// `maxDurationMs` and the numeric range are declared beside the callables and
// enforced below. A cap dimension here would be core inventing an accounting
// unit for something a module already bounds — and `registerEventBudgets`
// refuses a dimension nobody declared, which is exactly the rule that would
// then bite core's own action.
/**
* Read the baseline, reserve the target, apply the value.
*
* **This is rule 1 in its strongest form.** Unlike a spawn, a lease's target
* is knowable before the dispatch — it is the lease id the step names — so
* the ledger row is written with its real `kind` and `ref` BEFORE anything
* touches the world, and the two-events-one-target refusal comes from the
* unique index at that moment rather than from a check that read and then
* wrote. A second run asking for a lease another run holds comes back
* `refused`, in the same words a cap breach uses and for the same reason:
* nothing is broken, the deployment already has that value spoken for.
*
* The order is read then reserve then apply, and a failure at each stage
* undoes the one before it: a reservation whose `apply` refuses is released
* here rather than left for the sweep, because there is nothing out there to
* give back and a shard that is merely down must not lock a lease out for the
* length of a retry cycle.
*/
async perform({ runId, stepId, params, verify }) {
// eslint-disable-next-line global-require
const resourcesDb = require('../model/events/eventRunResources.db')
const lease = registries.eventLease(params.lease)
if (!lease) {
return { ok: false, retry: false, error: `no module registers the lease "${params.lease}"` }
}
const coerced = coerceLeaseValue(lease, params.value)
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
// **The target is checked before anything else about the world is read**
// (Phase 12b), because both of its failures are authoring mistakes rather
// than outages: a targeted lease with no target names nothing, and a target
// on a lease that has none is an author who has confused two fields. Both
// are `retry: false` — the second attempt has the same params.
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
if (lease.target && !targetRaw) {
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
}
if (!lease.target && targetRaw) {
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
}
const target = lease.target ? targetRaw : null
const ref = registries.leaseRef(lease.id, target)
// Refused rather than truncated, on the ledger's own rule for a resource
// ref: a truncated ref is a restore pointed at the wrong object.
if (ref.length > MAX_LEASE_REF) {
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
}
const minutes = Number(params.minutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
}
const ms = Math.round(minutes * 60_000)
if (ms > lease.maxDurationMs) {
return {
ok: false,
retry: false,
error: `${lease.label} may be held for at most ${Math.floor(lease.maxDurationMs / 60_000)} minutes, not ${minutes}`,
}
}
// **The dry run stops here, and it has still checked everything worth
// checking**: the lease exists, the value is in range and the duration is
// allowed. What it deliberately does not do is reserve the target — a
// verify that took a lease would be a dry run that changed something, and
// it would then refuse the real run that followed it.
//
// It also does not check that the TARGET exists, and that is the same
// rule rather than an exception: asking the game side whether a spawner is
// there is a live read the shard may be down for, and a dry run that fails
// because a shard is restarting would make `verified_at` a property of the
// moment rather than of the version (§K).
if (verify) return { ok: true }
const baseline = await lease.read({ target })
if (!baseline || baseline.ok !== true) {
return {
ok: false,
error: baseline && baseline.error
? String(baseline.error)
: `could not read the current value of ${lease.label}`,
}
}
const until = new Date(Date.now() + ms)
const reserved = await resourcesDb.reserve({
runId,
stepId,
owner: lease.owner || 'core',
kind: 'override',
// **The ref carries the target, and that is what makes the unique index
// right rather than merely present.** Reserved under the lease id alone,
// an event turning up one spawner would lock every other run out of every
// other spawner — a conflict check that refuses correct work is as wrong
// as one that permits a collision, and on a shard with 6,707 spawners it
// is the failure an operator would actually meet.
ref,
payload: {
target: ref,
leaseTarget: target,
baseline: baseline.value,
applied: coerced.value,
until: until.toISOString(),
},
leaseUntil: until,
})
if (!reserved.ok) {
const heldBy = reserved.holder ? ` (run ${reserved.holder.run_id})` : ''
return {
ok: false,
retry: false,
error: `${lease.label} is already leased by another run${heldBy}`,
}
}
// **`until` goes down the wire** (§F). The module passes it to its sidecar
// and the game side restores baseline when it passes, without being asked
// again — the fail-safe that makes an unattended, scheduled world change
// defensible, because the worst case is a world back at baseline early
// rather than one stuck changed indefinitely.
let applied
try {
applied = await lease.apply(coerced.value, until, { target })
} catch (err) {
applied = { ok: false, error: err.message }
}
if (!applied || applied.ok !== true) {
await resourcesDb.markReverted(reserved.id)
return { ok: false, error: applied && applied.error ? String(applied.error) : `${lease.label} refused the new value` }
}
await resourcesDb.confirm(reserved.id)
// **The run now owes the world something, and something has to say so.**
// The generic path marks a run dirty when it records a module's reported
// resources; this action reserves its own row and never goes through it, so
// a run whose only resource was a lease would have kept `cleanup_status =
// 'not_required'` and never been swept. Found by the live walk, and the
// cleanup leg's own scan was widened to make the class impossible rather
// than only this instance.
// eslint-disable-next-line global-require
await require('../events/ledger').markRunDirty(runId)
return { ok: true }
},
/**
* Which of this run's leases the game side still has a record of (Phase 11b).
*
* **A lease row had no reconcile path at all until this existed**, 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 restart reverts every config lease, by design) stayed in the
* ledger as live until teardown went looking for a baseline nobody was holding.
*
* The question asked is deliberately NOT "is the value still what we applied".
* That is drift, and drift is teardown's verdict to deliver through `restore`
* so the row lands as `drifted` with the current value beside it. A reconcile
* that inferred absence from a changed value would orphan the row first and
* throw that away — the operator would be told the lease vanished rather than
* that somebody moved it.
*
* A lease with no `inForce()` is reported in force, which is core's posture
* everywhere else in this file: "I could not ask" must never be recorded as
* "it is gone".
*/
async reconcile({ resources }) {
const inForce = []
for (const row of resources || []) {
if (row.kind !== 'override') continue
// Resolved through the ref parser rather than by a bare map lookup: a
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
// which would silently report every property lease still in force.
const found = registries.eventLeaseForRef(row.ref)
const lease = found && found.lease
if (!lease || typeof lease.inForce !== 'function') {
inForce.push(row.ref)
continue
}
let answer
try {
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
} catch (err) {
answer = null
}
// Only an explicit `held: false` takes a row out. A module that threw, timed
// out, or answered something unrecognisable has not said the lease is gone.
if (answer && answer.ok === true && answer.held === false) continue
inForce.push(row.ref)
}
return { ok: true, inForce }
},
},
{
id: 'core.announce.post',
label: 'Announce a post',
description:
'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
// Nothing in the world changes and nothing is created; a message goes out.
// Same class as `core.announce` and for the same reason.
risk: 'notify',
// The job is queued, the legs deliver, and none of it can be unsent. A
// `ledger` here would put a row in the cleanup ledger that teardown could
// never resolve.
reversible: 'none',
version: 1,
// **`core.announce` sends a line; this sends an ARTICLE**, and that is the
// whole difference between them (EVENTS.md §J, "News"). Events does not
// write posts — `ctx.posts` is read-only to modules and the CMS is core's —
// so an event that wants prose, an image and a permanent page links a post
// an editor already wrote. What this action adds over `core.announce` is
// therefore not a second transport but a second SHAPE: every leg's
// `dispatch()` takes a post, and this is the one that hands it a real one.
params: [
{
name: 'postId',
type: 'int',
required: true,
example: 412,
source: 'core.options.posts',
description: 'The published post to announce. Any category.',
},
],
/**
* Queue the post on every registered leg, as this run's announcement.
*
* **The refusals are all `retry: false`**, and each is a thing a human has to
* fix: a post id that names nothing, or a draft. Neither will have changed
* sixty seconds later, and retrying would spend two more attempts before
* saying the same thing.
*
* **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
* and the legs and returns; `announceWorker` drains them on its own tick with
* its own backoff. So this step is `done` when the announcement is queued,
* not when Discord has it — which is honest, because a leg that fails after
* six attempts over two hours is not something a step could usefully have
* stayed open for, and the post admin panel is where that failure is already
* surfaced.
*/
async perform({ runId, params, verify }) {
/* eslint-disable global-require */
const posts = require('../model/posts/posts.model')
const announceJobs = require('../model/announceJobs/announceJobs.model')
/* eslint-enable global-require */
const postId = Number(params.postId)
if (!Number.isInteger(postId) || postId < 1) {
return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
}
const post = await posts.getById(postId)
if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
if (!post.published) {
// A draft has no public page for a town-crier line to point at, and
// announcing one would publish its title to a shard before an editor
// meant to. Refused rather than published on the author's behalf:
// publishing is the CMS's decision and this action is not it.
return { ok: false, retry: false, error: `"${post.title}" is not published` }
}
// The dry run has now checked everything worth checking — the post exists
// and is published — and queues nothing. Checked BEFORE the legs are read,
// because a deployment with no leg registered is a real state and a verify
// that reported it as a failure would refuse a plan that is fine.
if (verify) return { ok: true }
await announceJobs.enqueueForRun(postId, runId)
return { ok: true }
},
},
{
id: 'core.results.publish',
label: 'Publish the results',
description:
'Rank this run\'s participants by score and publish the results table.',
// Nothing in the game world changes and nobody is messaged: a table core
// already holds becomes readable. `inspect` is the weakest class the closed
// set has and it is the honest one — which also means this action is
// default-ON like `core.wait`, and an author can place it without an admin
// first visiting the switchboard.
risk: 'inspect',
// **`none`, and it is worth saying why a publication is not reversible.**
// Nothing is created that core would have to come back for; un-publishing is
// an admin decision about a table, not a teardown obligation, and a `ledger`
// row here would make every completed event carry an outstanding resource
// for ever.
reversible: 'none',
version: 1,
// No params. What is published is this run's participants, which is the only
// set there is — a param naming which run would be a way to publish someone
// else's results from inside your own event.
params: [],
/**
* Rank, stamp, and say how many.
*
* **Idempotent by construction**, which is what makes it safe as an ordinary
* retried step: ranking is a total order over `(score, joined_at, id)`, so
* running it twice over an unchanged table writes the same numbers, and the
* stamp simply moves. A late participant added by a second collect step and
* a re-publish afterwards renumbers deliberately — that is the operator
* asking for exactly that.
*
* **A run with no participants publishes an empty table rather than
* failing.** "Nobody was recorded" is a true and renderable result, and it is
* the state of every run until a module can source attendance at all (Phase
* 12). Failing here would make an event whose module reports nothing look
* broken on the console for a reason that has nothing to do with the event.
*/
async perform({ runId, verify }) {
/* eslint-disable global-require */
const participantsDb = require('../model/events/eventRunParticipants.db')
const runsDb = require('../model/events/eventRuns.db')
/* eslint-enable global-require */
if (verify) return { ok: true }
await participantsDb.rankRun(runId)
await runsDb.markResultsPublished(runId)
return { ok: true }
},
},
]
// ── Core's own param option sources (§F, Phase 7) ──────────────────
//
// One, and it is core's half of the seam it hands a module on the same boot: a
// param's `source` names a registered option source, core asks it for values, and
// the authoring form renders a dropdown instead of a text box.
//
// **The legs are already a registry with labels in it**, so this costs nothing
// new — which is what makes it the right first exercise. `resolve()` is called
// per request rather than read once, for the same reason `core.announce` looks a
// leg up inside `perform()`: a leg registered by a module that booted after this
// file was evaluated must still appear, and a module uninstalled since must stop
// appearing.
const OPTION_SOURCES = [
{
id: 'core.options.legs',
label: 'Announce legs',
description: 'Every delivery leg registered on this deployment right now.',
async resolve() {
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
},
},
{
id: 'core.options.leases',
label: 'Borrowable values',
description: 'Every value a module has declared this deployment may lease.',
async resolve() {
return registries
.allEventLeases()
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
},
},
{
id: 'core.options.posts',
label: 'Published posts',
description: 'Every published post an event may announce, newest first.',
/**
* **The one option source in core that reaches a table**, and the reason it
* is allowed to is the rule §F draws about WHEN: a source resolves on its own
* request (`GET /admin/events/catalog/options/:sourceId`), which is a live
* request on a booted server, not at `register()` time under a dead pool.
*
* Grouped by category so the dropdown separates news from the newsletter
* rather than presenting one long list in which the two are indistinguishable
* — a `group` is what the form renders as an optgroup, and it costs a column
* that is already selected.
*/
async resolve() {
// eslint-disable-next-line global-require
const postsDb = require('../model/posts/posts.db')
const rows = await postsDb.listPublishedForOptions(200)
return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
},
},
]
module.exports = { ACTIONS, OPTION_SOURCES }

View File

@@ -75,6 +75,38 @@ const STREAMS = [
personal: false,
requiresLinkedAccount: false,
},
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
//
// **One of the seven `event.` triggers is also a stream, and that is a
// decision rather than an oversight** (org lead, 2026-09-04). A stream is a
// PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
// for ids registered here, `publishToUsers` joins `notification_subscriptions`,
// and that table is only ever written for a channel a user could switch on. So
// a trigger that is not also a stream can be mailed and put in the inbox, and
// its push is dead — a tickle published to nobody, which the send log
// nonetheless records as sent. Found on the live rig; the seeded rule named
// `push` before this line existed.
//
// **`run.started` alone, because push is the channel that says "now".** It is
// the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
// *"come back for X"* — and the other six are things a player reads when they
// next look. Six more toggles would put a wall of switches on the preferences
// screen for one feature, and `event.phase.changed` is the one most likely to
// buzz a phone four times in an evening.
//
// Same id as the trigger, which is §7.2's one namespace and the same-owner
// upgrade `news.post` already is: one id, one owner, two facets.
{
id: 'event.run.started',
label: 'Events — starting now',
description: 'A scheduled event is beginning.',
// Not owner-keyed: this is a public event happening in public, not a fact
// about one account's own property. Same as `news.post`.
personal: false,
// A player with no linked game account can still want to know an event is on.
requiresLinkedAccount: false,
},
]
module.exports = { STREAMS }

View File

@@ -29,6 +29,29 @@
// test-send without a live game event, which is the reason template systems go
// untested.
/**
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
* once because they are spread into all seven.
*
* `baseFor()` has always computed them and nothing declared them, so
* `engagementEmit.validatePayload` dropped all three before a template could see
* one — they were absent from the variable list an author picks from, and every
* single event emit logged `emit carried undeclared variables`. Found by the
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
*
* All three are optional, and each for its own reason rather than by default: an
* event need not carry a summary, most events belong to no series, and a run
* whose definition has been deleted resolves no zone.
*/
const EVENT_AMBIENT = [
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event’s one-line summary, when it has one.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The zone the run was computed in — what a time in the body should be read as.' },
]
const TRIGGERS = [
{
id: 'news.post',
@@ -49,8 +72,15 @@ const TRIGGERS = [
description: 'A plain-text summary, already stripped of markup.' },
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
description: 'The post category, when it has one.' },
{ name: 'postUrl', type: 'url', required: true, example: '/news/five-on-friday-yew-invasion',
description: 'Site-relative path to the post.' },
// **`/site/news`, the LIST, and not a per-post path.** The example said
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
// and nothing under it, which is why `announceJobs.logic.js` links the list
// from the Discord and town-crier announcements too. An `example` is what
// the template editor previews and test-sends with (§4.3 property 3), so an
// example naming a 404 is a preview that looks right and a mail that is not.
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
],
},
@@ -142,6 +172,269 @@ const TRIGGERS = [
description: 'Site-relative path to the announcement.' },
],
},
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
//
// **Seven triggers, one per moment a run passes through that somebody outside
// the run console might want to hear about — and Events owns none of the
// delivery.** A run emits; an operator's rule decides who is told, on what,
// and how often. That is the whole of §J's "clean fit" row, and it is why
// there is no announcement machinery anywhere in `utils/eventRunner.js`
// beyond a call to `emit`.
//
// **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
// lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
// attempts, the world may be half-changed — and a rule that mailed it to
// every subscriber would publish the deployment's incidents. The other six
// describe a public event happening in public, so they sit exactly where
// `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
// which is "people who asked to be told" rather than the whole user table.
//
// **Every `description` here is read by two audiences**, and the second one is
// easy to forget: the rule editor's catalog, and — through
// `projection.project`'s `intro` fallback — every recipient of an unauthored
// render through `notify.event` or `inapp.event`. So each is prose a player
// can read rather than a note to the operator. The live rig caught the
// original `run.failed` line, which ended "Staff-facing." and put those words
// in an administrator's own inbox item. Who a trigger is for is said by its
// CEILING, which is the only place that can enforce it anyway.
//
// **`subjectKey: 'runId'` on every one of them**, and it is the one place
// these differ from `news.post`. A cooldown keyed on the user would make
// `phase.changed` mean "at most one phase of at most one event an hour",
// silently swallowing the second wave of an invasion because the first wave's
// mail went out forty minutes ago. Keyed on the run it means "at most one
// line an hour ABOUT THIS RUN", which is the useful sentence — and across
// runs of the same definition the ids differ, so a weekly event is not
// throttled by last week's.
//
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
// that legal.** Until it there was no public event page at all — `App.jsx`
// mounted nothing under `/site/events` — and `news.post` had already paid for
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
// did not exist, so the template editor previewed a link that was dead in
// every mail it sent. The variable arrived with the page it points at, which
// is what makes this a version bump (1 -> 2) rather than a correction.
//
// **It carries `?run=`, and the query string is the whole reason it is a run
// url and not an event url.** The page lives at the DEFINITION's slug, so a
// weekly event has one stable address — but every one of these triggers is
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
// opened next Friday's would answer a different question from the one the
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
// and gains nothing here: an admin reading about broken machinery wants the
// console, not the storyline.
{
id: 'event.run.scheduled',
label: 'Event — scheduled',
description: 'A new event has been added to the calendar.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When the occurrence is due to start, UTC.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
// logic with which to format it. So the formatting happens at the emitter,
// in the shard-local zone, and arrives as a variable whose `example` shows
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.run.started',
label: 'Event — starting now',
description: 'A scheduled event has begun.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When it actually started, UTC.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
// logic with which to format it. So the formatting happens at the emitter,
// in the shard-local zone, and arrives as a variable whose `example` shows
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.phase.changed',
label: 'Event — a new phase',
description: 'An event that is under way has moved on to its next stage.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: true, example: 'assault',
description: 'The phase key just entered, as authored in the spec.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
description: 'The phase label, when the spec gave it one. Falls back to the key.' },
{ name: 'phaseIndex', type: 'int', required: true, example: 2,
description: 'Which phase this is, counting from 1.' },
{ name: 'phaseCount', type: 'int', required: true, example: 4,
description: 'How many phases the pinned version has in total.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.run.ending',
label: 'Event — winding down',
description: 'An event is drawing to a close.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.run.completed',
label: 'Event — finished',
description: 'An event has finished.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// Counted from `event_run_participants` at emit. Zero on a run whose
// module reported nobody, which is every run until a module collects —
// a template that says "47 took part" needs a number that is never
// missing, and "0" is the honest one.
{ name: 'participantCount', type: 'int', required: true, example: 47,
description: 'How many participants the run recorded. Zero when nothing collected any.' },
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
description: 'How long the run took, start to end, in whole minutes.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.run.cancelled',
label: 'Event — cancelled',
description: 'A scheduled event was cancelled by a member of staff.',
kind: 'event',
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// **The operator's reason, and not the run's `last_error`.** `cancel`
// takes a `{ reason }` a human typed for other humans; a diagnostic
// string is for the run console and would read as gibberish in a mail.
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
description: 'What the staff member gave as the reason, when they gave one.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
id: 'event.run.failed',
label: 'Event — run failed',
description: 'An event stopped before it finished.',
kind: 'event',
subjectKey: 'runId',
// **`admin`, and both halves of that.** The ceiling is the security
// boundary (§J, G24): no rule may ever widen this past admins, because a
// failure names the deployment's own broken machinery. The default audience
// matches, so a rule created from this trigger starts where it must end.
audience: 'admin',
ceiling: 'admin',
version: 1,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: false, example: 'assault',
description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
description: 'The run’s last error, verbatim from the run row.' },
// The admin console, not the public page — and this trigger gains no
// `eventUrl` at all. An admin reading that the machinery broke wants the
// steps and the errors, not the storyline. See the note above the six.
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
description: 'Site-relative path to the run console.' },
],
},
]
module.exports = { TRIGGERS }

View File

@@ -11,6 +11,25 @@
//
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
// would be a v2 mount); `server` is the informational package version.
//
// ── `capabilities` (Phase 14a) ──
//
// Opaque strings naming what CORE serves beyond the surface every backend has —
// the same idea as a module's `capabilities` on GET /public/modules, and
// deliberately the same word, so a client feature-detects one way rather than
// two. They are a different LIST because core is not a module: publishing core
// as a pseudo-module would leave a client unable to tell "this backend has
// events" from "a module called core happens to be installed", which is exactly
// the distinction the loader exists to make.
//
// The value is in what is ABSENT. A backend released before Events answers this
// object with no `capabilities` key at all, so a client can tell an older site
// from one that simply has nothing on its calendar — which it could not do by
// probing /public/events, where "not built" and "temporarily down" look alike.
//
// Static, because these are compiled-in features rather than installed ones:
// a core that has these routes always has them. An unknown string is to be
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
const pkg = require('../../package.json')
@@ -18,4 +37,6 @@ module.exports = {
service: 'runic-gateway', // stable backend identifier for first-run detection
api: 'v1', // API contract version (matches the /api/v1 mount)
server: pkg.version || '0.0.0', // server package version (informational)
// What core serves beyond the baseline. See the note above.
capabilities: ['events'],
}

View File

@@ -52,7 +52,15 @@ registerEmailBlock({
// still renders, inert, because dropping it silently would hide from the
// reader that the mail was meant to offer them something.
if (ctx.t(props.url).trim() === '') return ''
const href = ctx.safeHref(props.url)
// ABSOLUTIZED, like `email.image` and `email.itemList` already do, and this
// was a real defect until Phase 6 put a rule-driven variable in here. A
// trigger's `url` variables are validated site-RELATIVE by construction
// (`engagementEmit.RELATIVE_URL`), so `{{actionUrl}}` interpolates to
// `/guilds/the-silver-anvil` and a mail client has no origin to resolve that
// against: the button rendered a dead link. `absolute()` returns null for a
// relative path when no base is configured, which falls into the inert-label
// branch below rather than shipping the broken href.
const href = ctx.absolute(ctx.safeHref(props.url))
const label = ctx.h(props.label)
if (!href) {
return (
@@ -78,8 +86,12 @@ registerEmailBlock({
)
},
toText(props, ctx) {
const url = ctx.t(props.url).trim()
if (url === '') return '' // see toHtml: no url, no block, in either part
const raw = ctx.t(props.url).trim()
if (raw === '') return '' // see toHtml: no url, no block, in either part
// The text part shows the same absolute URL the button links to. Falls back
// to the raw value rather than dropping the block: a reader who can see a
// relative path can still find the site, and `itemList` makes the same trade.
const url = ctx.absolute(raw) || raw
const lead = props.textLead ? ctx.t(props.textLead).trim() : ''
return lead ? `${lead}\n${url}` : url
},

View File

@@ -1,7 +1,7 @@
// ── Resolving a rule's audience to recipients ──────────────────────────────
//
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
// an **`audience_segment_id`** pointing at an operator-composed tree of
// module-declared audiences (segments.js). This file turns either into user ids.
@@ -16,11 +16,11 @@
// The caller must not send. Falling back to the rule's plain `audience`
// column would reach a different population than the one composed (§5.1a
// rule 4), which is the failure mode this whole design exists to avoid.
// 3. `members` as a PLAIN audience resolves to nobody. It is the ceiling for
// "a module-declared list", and without a segment there is no list - core
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
// (from Phase 6) an event carrying its own access-checked recipient set. Core
// knows no game vocabulary and cannot guess which members were meant. A rule
// saved that way is inert and visible as such, rather than quietly falling
// back to something wider.
// with neither is inert and visible as such, rather than quietly falling back
// to something wider.
const registries = require('../modules/registries')
const channels = require('./channels')
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
}
}
case 'staff':
case 'admin':
// Both role-gated, and resolved through the ONE query rather than two.
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
// third is added the resolver does not need a third case — and, more to
// the point, cannot get one of them wrong while the others stay right.
return {
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
ceiling: 'staff',
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
ceiling: rule.audience,
dormant: false,
reason: null,
}
@@ -104,13 +109,36 @@ async function resolveForRule(rule, event) {
case 'authenticated':
case 'everyone':
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
case 'members':
case 'members': {
// **The event may name its own list, and Phase 6 is why that exists.**
// `members` is the ceiling for "a module-declared list", and until this
// phase the only way to name one was a segment — an operator-composed tree
// over audiences with CONSTANT params. That cannot express "the members of
// the Team this particular post was in": the list is different for every
// firing, and nothing in a saved segment reads the event.
//
// So an emitter that has already computed an access-checked recipient set
// hands it over on the envelope, and this is where it is used. It is not a
// bypass of anything: the set is still filtered through `users.status`
// below, and the ceiling returned is still `members`, so the G24 re-check
// in the engine still refuses a rule whose trigger has since narrowed.
// What it removes is core having to guess a game's membership vocabulary —
// the thing this case's original comment said it could not do.
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
return {
userIds: await recipients.filterActive(event.recipientUserIds),
ceiling: 'members',
dormant: false,
reason: null,
}
}
return {
userIds: [],
ceiling: 'members',
dormant: false,
reason: 'a "members" audience needs a segment naming which list',
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
}
}
default:
// Fails closed on an audience name the lattice does not know - the same
// posture `ceilings.permits` takes, and for the same reason.
@@ -128,11 +156,27 @@ async function resolveForRule(rule, event) {
* same id more tightly. That is precisely the case where a stale rule would
* otherwise mail a population the current declaration forbids, which is what
* makes this the security boundary rather than a duplicate check.
*
* **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
* carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
* effective bound is the MEET of the two, so a firing can only ever narrow what
* the declaration allows. Two incomparable ceilings meet to null and the gate
* refuses: `owner` and `staff` have no common descendant, and picking one would
* be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
* cannot get here — `emit` validates it against the same lattice — but the null
* is handled anyway, because this is the boundary and a boundary that trusts its
* caller is not one.
*
* @param {string} triggerId
* @param {string} ceiling the audience the rule resolved to
* @param {string|null} [emitted] a narrowing ceiling this firing carries
*/
function permitted(triggerId, ceiling) {
function permitted(triggerId, ceiling, emitted = null) {
const declaration = registries.eventTrigger(triggerId)
if (!declaration) return false
return ceilings.permits(declaration.ceiling, ceiling)
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
if (!bound) return false
return ceilings.permits(bound, ceiling)
}
module.exports = { resolveForRule, permitted, defaultOnChannels }

View File

@@ -0,0 +1,216 @@
// ── Which send failures are facts about the RECIPIENT ───────────────────────
//
// ENGAGEMENT.md Phase 9. The suppression list's whole value is that an address on
// it is genuinely undeliverable; the moment it fills with addresses that were
// fine, an operator learns to ignore it and it may as well not exist. This file
// is the one place that judgement is made.
//
// **It is deliberately NOT `mailer.PERMANENT_CODES`, and reusing that set would
// have been a mass-suppression bug.** That set answers "is retrying pointless?"
// and holds `EAUTH` and `554` alongside `550` — an authentication failure and a
// relay-wide policy refusal. Both are permanent and neither says anything about
// the person: one wrong SMTP password would suppress every address the outbox
// worker touched before anybody noticed the mail had stopped. "Do not retry" and
// "this mailbox does not exist" are different questions, and this file only
// answers the second.
//
// **The primary signal is the enhanced status code (RFC 3463), not the reply
// code.** `550` alone is the catch-all every refusal arrives as; `5.1.1` means
// one specific thing — no such mailbox. Every relay worth configuring emits an
// enhanced code, so it is read first and, when present, decides on its own.
//
// **The fallback is narrow on purpose.** Without an enhanced code a phrase match
// is all that is left, and phrase matching is how a classifier quietly starts
// suppressing everything. So it applies only after the reply code has already
// narrowed the failure to the recipient address — 550, 551 and 553 are RFC 5321's
// recipient-address codes — and only for phrases that cannot mean anything else,
// with a veto list checked first. `552` (storage exceeded) and `554` (transaction
// failed) are excluded from even that: a full mailbox gets emptied, and a generic
// transaction failure is generic.
//
// Anything this file is unsure about is NOT suppressed. The cost of a false
// negative is mailing a dead address again next month; the cost of a false
// positive is a person who silently stops hearing from the deployment and has no
// way to find out.
// RFC 3463 subject.detail pairs that mean "this address will not accept mail,
// today or ever". Kept as strings because `5.1.10` and `5.1.1` are different
// codes and numeric parsing loses that.
const PERMANENT_RECIPIENT = new Set([
'1.1', // bad destination mailbox address — no such user
'1.2', // bad destination system address — the domain does not take mail
'1.3', // bad destination mailbox address syntax
'1.6', // mailbox has moved, no forwarding address
'1.10', // recipient address has a null MX (RFC 7505)
'2.1', // mailbox disabled, not accepting messages
])
// Enhanced subjects that are permanent but are NOT about the recipient. Listed
// rather than merely omitted, because each is a plausible-looking 5.x.y that a
// later edit would otherwise be tempted to add:
// 2.2 — mailbox full. Permanent-coded by some relays, emptied by every user.
// 7.x — policy. Our sending reputation, our SPF, our content; the recipient is
// the one party it is not about.
// 3.x — the destination MAIL SYSTEM is full or refusing. Not the mailbox.
// 5.x — protocol failure. A bug at one end or the other.
const NEVER_RECIPIENT_SUBJECTS = new Set(['3', '5', '7'])
// RFC 5321 reply codes that name the recipient address specifically. 554 is
// absent deliberately: "transaction failed" is what a relay reaches for when it
// does not want to say why, and it is the commonest shape of a content or policy
// rejection.
const RECIPIENT_REPLY_CODES = new Set([550, 551, 553])
// Phrases that only ever mean "no such mailbox", checked only once a reply code
// above has established the failure is about the address. Each is a substring of
// a real refusal from a widely deployed MTA (Postfix, Exim, Exchange, Google,
// Microsoft 365).
const NO_SUCH_MAILBOX = [
'user unknown',
'unknown user',
'no such user',
'no such recipient',
'unknown recipient',
'invalid recipient',
'recipient address rejected',
'recipient not found',
'address does not exist',
'does not exist',
'mailbox unavailable',
'mailbox not found',
'no mailbox',
'user does not exist',
'address rejected',
]
// Phrases that appear alongside the ones above and mean the opposite, checked
// FIRST. "Mailbox unavailable" is a substring of the sentence a relay sends when
// a mailbox is merely full, so a substring match with no veto list would read a
// temporary condition as a dead address.
const NOT_A_DEAD_MAILBOX = [
'full',
'quota',
'storage',
'temporar',
'try again',
'greylist',
'rate limit',
'too many',
'spam',
'blocked',
'blacklist',
'blocklist',
'reputation',
'policy',
'authentication',
'not authorized',
]
/**
* The enhanced status code in an SMTP response, as `{ class, subject, detail }`,
* or null.
*
* Anchored to the start of the line rather than searched for anywhere in it: a
* bounce that quotes another server's answer ("...said: 550 5.1.1...") contains
* two, and the one that matters is the one this relay just gave us. A free search
* finds whichever comes first, which is not the same thing.
*/
function parseEnhanced(response) {
if (!response) return null
const m = /^\s*(\d{3})[\s-]+(\d)\.(\d{1,3})\.(\d{1,3})\b/.exec(String(response))
if (!m) return null
return { class: m[2], subject: m[3], detail: m[4] }
}
/** The three-digit reply code, off the error object or out of the response text. */
function replyCode(err) {
const direct = Number(err && err.responseCode)
if (Number.isInteger(direct) && direct >= 400 && direct <= 599) return direct
const m = /^\s*(\d{3})\b/.exec(String((err && err.response) || ''))
return m ? Number(m[1]) : null
}
const lower = (s) => String(s || '').toLowerCase()
/**
* Should this send failure suppress the address?
*
* @param {object} err the error a transport's send threw, or an object carrying
* the `responseCode` / `response` / `code` lifted off one
* @returns {{ suppress: boolean, reason: string, evidence: string|null }}
*
* `reason` is populated on a refusal too, and that is not decoration: it becomes
* the send log's `detail`, so "not suppressed: 554 does not name the recipient
* address" is the line that stops somebody re-deriving this decision from an
* unexplained non-event six months from now.
*/
function classify(err) {
const e = err || {}
const response = e.response || e.message || ''
const enhanced = parseEnhanced(response)
const code = replyCode(e)
// No reply code at all means the failure happened before or outside the SMTP
// transaction: the connection, the credentials, the socket. Never the
// recipient. `EAUTH` lands here, which is the whole reason this file exists.
if (!code) {
return {
suppress: false,
reason: `no SMTP reply code (${e.code || 'transport failure'}); not a recipient failure`,
evidence: null,
}
}
if (code < 500) {
return { suppress: false, reason: `${code} is a temporary failure`, evidence: null }
}
if (enhanced) {
const pair = `${enhanced.subject}.${enhanced.detail}`
if (enhanced.class !== '5') {
return { suppress: false, reason: `enhanced status ${enhanced.class}.${pair} is not permanent`, evidence: null }
}
if (PERMANENT_RECIPIENT.has(pair)) {
return { suppress: true, reason: 'bounce', evidence: `5.${pair}` }
}
if (NEVER_RECIPIENT_SUBJECTS.has(enhanced.subject)) {
return {
suppress: false,
reason: `5.${pair} is about the server or our standing with it, not the address`,
evidence: `5.${pair}`,
}
}
// A permanent 5.x.y this file has no opinion on. Unknown means no.
return {
suppress: false,
reason: `5.${pair} is not a known recipient failure`,
evidence: `5.${pair}`,
}
}
// No enhanced code: the narrow fallback.
if (!RECIPIENT_REPLY_CODES.has(code)) {
return { suppress: false, reason: `${code} does not name the recipient address`, evidence: null }
}
const text = lower(response)
const veto = NOT_A_DEAD_MAILBOX.find((p) => text.includes(p))
if (veto) {
return { suppress: false, reason: `${code}, but the response says "${veto}"`, evidence: null }
}
const hit = NO_SUCH_MAILBOX.find((p) => text.includes(p))
if (hit) {
return { suppress: true, reason: 'bounce', evidence: `${code} "${hit}"` }
}
return { suppress: false, reason: `${code} with no enhanced status and no recognised reason`, evidence: null }
}
module.exports = {
classify,
parseEnhanced,
replyCode,
PERMANENT_RECIPIENT,
NEVER_RECIPIENT_SUBJECTS,
RECIPIENT_REPLY_CODES,
NO_SUCH_MAILBOX,
NOT_A_DEAD_MAILBOX,
}

View File

@@ -44,6 +44,33 @@ const isMode = (value) => MODES.includes(value)
* @param {string} def.defaultMode the mode that applies with no stored row
* @param {boolean} def.supportsDigest may a preference for this channel be 'digest'
* @param {string} [def.description] one line for the preferences screen
* @param {(userId: number) => Promise<{address: string}|null>} [def.addressFor]
* where this channel would send to, or null when it cannot reach the user
* @param {(row: object) => Promise<{ok?: boolean, retry?: boolean, transport?: string, detail?: string, addressHash?: string}>}
* [def.deliver] deliver one claimed outbox row. **Must not throw** — the
* worker treats a throw as a transient failure, which is the right guess
* and a worse answer than the channel's own classification. A channel
* without one is declared but not yet deliverable, which is exactly what
* `inapp` is until Phase 7; the worker finishes such a row `failed` and
* says so in the send log rather than pretending it was sent.
* @param {(userIds: number[]) => Promise<{userIds: number[], excluded: object}>} [def.eligible]
* Phase 9. Narrow an already-resolved audience to the users this channel
* may write an outbox row for, and say how many it dropped and why.
*
* **It exists so the engine can stay channel-agnostic.** The verification
* gate is an email fact — an unverified address is a reason not to mail
* somebody and no reason at all not to put an item in their inbox — and a
* rule may name both channels. Filtering the shared audience before the
* per-channel loop would have silenced the wrong sink; an `if (channel ===
* 'email')` in `engine.js` would have put a channel's rule inside the
* generic engine. This is the seam that is neither.
*
* Distinct from `deliver`'s refusals on purpose: this runs at ENQUEUE and
* is for standing properties of a user (is this address verified), which
* are stable across a delay window and are worth not writing a row for.
* A suppression is not one of those — it can appear between the enqueue
* and the send — so it is checked in `deliver`, where it produces a
* `suppressed` row in the send log the acceptance criterion asks for.
*/
function registerDeliveryChannel(def) {
if (!def || typeof def !== 'object') throw new Error('registerDeliveryChannel: definition required')
@@ -67,6 +94,16 @@ function registerDeliveryChannel(def) {
if (defaultMode === 'digest' && !supportsDigest) {
throw new Error(`registerDeliveryChannel(${id}): defaultMode 'digest' needs supportsDigest`)
}
// Optional, but not optionally-typed. A channel registering `deliver: true` or
// a stale import that resolved to undefined would otherwise be a channel that
// silently never delivers — the failure Phase 3 deferred the whole behavioural
// half to avoid freezing, and the one the worker's "no delivery implementation
// yet" branch would report as if it were by design.
for (const fn of ['addressFor', 'deliver', 'eligible']) {
if (def[fn] !== undefined && typeof def[fn] !== 'function') {
throw new Error(`registerDeliveryChannel(${id}): ${fn} must be a function`)
}
}
channels.set(id, {
id,
@@ -75,12 +112,24 @@ function registerDeliveryChannel(def) {
carriesContent,
defaultMode,
supportsDigest,
addressFor: def.addressFor,
deliver: def.deliver,
eligible: def.eligible,
})
return id
}
/** Every channel, in registration order. The preferences screen's column set. */
const all = () => [...channels.values()].map((c) => ({ ...c }))
/**
* Every channel, in registration order. The preferences screen's column set.
*
* **Declarative fields only** — `addressFor`, `deliver` and `eligible` are
* stripped. This is what a route serializes, and a function on an object bound
* for `res.json` is a key that silently disappears rather than an error; keeping
* the boundary here means the API shape is decided in one place instead of by
* JSON.stringify.
*/
const all = () =>
[...channels.values()].map(({ addressFor, deliver, eligible, ...declared }) => ({ ...declared }))
/** Just the ids. */
const ids = () => [...channels.keys()]
@@ -108,6 +157,25 @@ const modesFor = (id) => {
/** Is `mode` a mode this channel accepts? The gate on every preference write. */
const acceptsMode = (id, mode) => modesFor(id).includes(mode)
/**
* Narrow an audience to the users this channel may enqueue for (Phase 9).
*
* The default for a channel that declares no `eligible` is "everyone the
* audience resolved to", which is what every channel but email does. It lives
* here rather than at each call site so the two consumers — the engine and the
* admin reach preview — cannot answer the question differently, which is exactly
* how a preview comes to promise a number the engine will not deliver.
*/
async function eligibleFor(id, userIds) {
const c = channels.get(id)
if (!c || typeof c.eligible !== 'function') return { userIds: userIds.slice(), excluded: {} }
const result = await c.eligible(userIds)
return {
userIds: (result && result.userIds) || [],
excluded: (result && result.excluded) || {},
}
}
// Test-only: the registry is module-level state.
function _reset() {
channels.clear()
@@ -124,5 +192,6 @@ module.exports = {
defaultMode,
modesFor,
acceptsMode,
eligibleFor,
_reset,
}

View File

@@ -248,4 +248,18 @@ const vocabulary = () =>
/** Convenience for a caller holding only a trigger id. */
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }
// `checkLiteral` is exported for the event system's step-param validator
// (EVENTS.md §F, Phase 1), which checks an authored param value against an
// action's declared param type — the same six types over the same coercion. A
// second copy of this switch would be a second answer to "is this a datetime",
// and the two would drift on the first zone-suffixed string somebody typed.
module.exports = {
validate,
validateFor,
evaluate,
vocabulary,
checkLiteral,
OPERATORS,
MAX_LIST,
MAX_DEPTH,
}

View File

@@ -16,6 +16,9 @@
// there is nowhere to express that generically". This is that place.
const { registerDeliveryChannel } = require('./channels')
const emailChannel = require('./emailChannel')
const pushChannel = require('./pushChannel')
const inappChannel = require('./inappChannel')
const CHANNELS = [
{
@@ -43,6 +46,10 @@ const CHANNELS = [
// there is nothing to roll up. Ten events are ten wakeups or one; either way
// the app pulls the same inbox.
supportsDigest: false,
// Phase 7: the oldest sink is the last to get a `deliver`, because until the
// inbox existed there was nothing for a content-free tickle to point at.
addressFor: pushChannel.addressFor,
deliver: pushChannel.deliver,
},
{
id: 'email',
@@ -53,19 +60,41 @@ const CHANNELS = [
// `team_notification_prefs.email_mode` already takes ('off' by default).
defaultMode: 'off',
supportsDigest: true,
// Phase 6: the first channel with a body. `supportsDigest` above is now load-
// bearing rather than aspirational — a 'digest' preference means the engine
// writes NO outbox row and the digest worker re-derives the content at send
// time (§4.2b), which is a different delivery path rather than a batched one.
addressFor: emailChannel.addressFor,
deliver: emailChannel.deliver,
// Phase 9: the only channel that declares one. The Phase 1b verification
// gate is an email fact, and this is the seam that keeps it out of the
// generic engine - see channels.js's `eligible` docs.
eligible: emailChannel.eligible,
},
{
id: 'inapp',
label: 'On the site',
description: 'An item in your notification inbox on the website and in the app.',
carriesContent: true,
// Opt-IN like the other two, and for a reason particular to this channel: the
// inbox does not exist until Phase 7. A default of 'instant' would mean every
// user is opted into a surface that has no rows and no screen, and the first
// thing Phase 7 shipped would be a backlog. Whether the inbox is opt-out once
// it is real is a Phase 7 decision with a live surface to look at.
defaultMode: 'off',
// **Opt-OUT, and the only one of the three that is** — settled by the org
// lead 2026-08-31, which is the Phase 7 decision this comment used to defer.
//
// The argument against a live default was never about in-app: it was that
// push wakes a device the user is holding and email leaves the building, so
// both must be asked for. An inbox item does neither. It is a row on a page
// the user chose to open, on this deployment, costing them one glance — and
// left at 'off' the surface would ship dead, because no rule could reach
// anyone until every user found a toggle for a channel they had never seen
// deliver anything. The backlog Phase 3 worried about cannot happen either:
// the table is empty at cutover, rules default to `enabled = 0`, and every
// rule carries a per-hour ceiling.
defaultMode: 'instant',
// Instant-only, and unlike push the reason is not that batching is
// meaningless — it is that the inbox IS the batch. A digest of inbox items
// is a list of things already sitting in a list.
supportsDigest: false,
addressFor: inappChannel.addressFor,
deliver: inappChannel.deliver,
},
]

View File

@@ -0,0 +1,319 @@
// ── The seven rules core ships, all of them OFF ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
// and about what, and the only knobs were per-user. Phase 6 moves that decision
// onto rules — which default `enabled = 0`, and of which core seeds none.
//
// **So a straight migration would have stopped Team email on every existing
// deployment, silently.** The org lead's decision was to honour the invariant
// rather than carve an exception into it: the rules are seeded, and they are
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
// Rules and switches one on, and until then the admin screen says so in as many
// words (`EngagementRules.jsx`). The release note names it.
//
// The alternative — seeding them enabled so nothing changes for anybody — was
// considered and refused. "Nothing is seeded, nothing is on by default" is what
// makes a rules table safe to restore, import or replicate, and an exception
// carved for the one pipeline that predates the engine is an exception that has
// to be re-argued every time somebody reads the invariant.
//
// **Seeded once, not ensured on every boot**, and the difference matters: an
// operator who deletes a rule must not find it back after a restart. The guard is
// a settings key, the same mechanism a one-shot migration uses, so a deployment
// that has seen this seed never sees it again — deleted rules stay deleted, and
// an enabled rule stays enabled rather than being reset to off.
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
// rather than an entry in the list above.** The Team key is already stamped on
// every deployment that has booted since Phase 6, and the guard reads its
// presence — so appending to `RULES` would have seeded the news rule on fresh
// installs only, and on exactly the upgrades that need it, never. Those are the
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
//
// **EVENTS.md Phase 10 added two more, and a third key**, for the event
// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
// application — a deployment that has already stamped the news key must still
// see these.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const log = require('../utils/logger')('engagement')
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
// the settings table can tell when it ran; only its presence is read.
const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
// EVENTS.md Phase 10's, third group, third key.
const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
name: 'Team forum posts',
// `members`, which resolves to the recipient set the event carries — the
// access-checked list `teamNotify` has always computed. Not `authenticated`,
// and the trigger's own ceiling would refuse that anyway: a private Team's
// forum excerpt reaching the whole site is the failure G24 exists for.
audience: 'members',
channels: ['email'],
// `email` is the instant body; `digest` is what the digest worker renders.
// Two keys because they are two different messages — a template written for
// one post renders a day of them as a single missing variable.
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
// the digest option are for, and a cooldown here would silently drop the
// second reply of a conversation rather than batching it.
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.announcement',
name: 'Team announcements',
audience: 'members',
channels: ['email'],
// **The generic body, not `notify.team-post`, and the reason is a naming
// inconsistency in the Phase 2 declarations rather than a design choice
// here.** The two triggers describe the same underlying thing — a thread in a
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
// `team.announcement` declares it as `title`. A template can only name one of
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
// announcement. `notify.event` + the structural projection gets it right
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
// reconciling the two declarations is a version bump this phase did not take
// on its own authority.
template_keys: { email: 'notify.event', digest: 'notify.digest' },
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'team.member.joined',
name: 'Team — new member',
audience: 'members',
channels: ['email'],
// The generic body: `notify.event` plus the structural projection renders it
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
// duplicates the template and points this rule at the copy.
template_keys: { email: 'notify.event' },
// An hour, per user per Team. This is the rule §6.4 argued should not exist
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
// and the cooldown is what makes it survivable for the operator who wants it
// anyway: a guild recruiting ten people in an afternoon sends one mail.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
{
trigger_id: 'team.leadership.changed',
name: 'Team — leadership change',
audience: 'members',
channels: ['email'],
template_keys: { email: 'notify.event' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
]
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
const NEWS_RULES = [
{
trigger_id: 'news.post',
name: 'News posts',
// `subscribers`, which is the trigger's declared default and the population
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
// opted into this id on at least one channel. Not `authenticated`, even
// though the trigger's ceiling permits it — a news post is worth telling
// people who asked to be told, and mailing the whole user table on every
// publish is how a notification feature earns a spam complaint.
audience: 'subscribers',
// **All three channels, unlike the Team rules' `email` alone**, and that is
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
// is what the raw tickle did; leaving it off would mean an operator who
// enabled the rule to restore news push got mail instead. In-app rides along
// because the inbox is the surface a tickle deep-links into (Phase 7).
channels: ['email', 'inapp', 'push'],
// The generic body plus the structural projection (§4.6.1 property 1):
// `news.post` declares its own `title` and `postUrl`, which the projection
// leaves exactly as emitted, so an unauthored mail already names the post and
// links it. `inapp.event` is the in-app renderer's; push carries no content
// by construction and needs no template.
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
// subject is the recipient. "Do not tell me about news more than once an
// hour" is the useful rule; keying it per post would make it a no-op, since
// every post is a new subject.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
]
// Phase 10's two, in their own list under their own one-shot key — the rule
// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
// would seed these on fresh installs only and on exactly the upgrades that want
// them, never.
//
// **Two rules for seven triggers, and that is the whole decision** (org lead,
// 2026-09-04). Every one of the seven is declared, so an operator can write a
// rule against any of them from the rules screen; what is SEEDED is the pair
// somebody would otherwise have to build from scratch on the first day — the
// player-facing "it is starting" and the staff-facing "it broke". Seeding all
// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
// asked for, and `event.phase.changed` is the one most likely to be switched on
// by accident and then mail a player four times in one evening.
const EVENT_RULES = [
{
trigger_id: 'event.run.started',
name: 'Events — starting now',
// `subscribers`, the trigger's own default: people who opted into this id on
// at least one channel. Not `authenticated`, even though the ceiling permits
// it — an event is worth telling people who asked to be told about events,
// and mailing the whole user table every Saturday night is how a feature
// earns a spam complaint. An operator who wants the whole site can widen it;
// the ceiling is what stops them widening it past that.
audience: 'subscribers',
// All three, like the news rule and for the same reason: push is the channel
// that gets somebody to log in *now*, which is the entire point of a
// "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
// the surface a content-free tickle deep-links into.
channels: ['email', 'inapp', 'push'],
// The one bespoke body this phase seeds; see `templateSeeds.js` for why it
// is one and not seven. `inapp.event` is the in-app renderer's generic, and
// push carries no content by construction and needs no template.
template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per user PER RUN — `event.run.started` declares `subjectKey:
// 'runId'`, so the cooldown subject is the run and not the recipient. It is
// near-redundant on a trigger that fires once per run, which is the point:
// it costs nothing and it is the guard if a run is ever restarted.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
{
trigger_id: 'event.run.failed',
name: 'Events — a run failed',
// `admin`, which is both the trigger's default and its ceiling. A failed run
// names the deployment's own broken machinery — a sidecar that did not
// answer, a step that ran out of attempts — and there is no widening of this
// that is not a disclosure.
audience: 'admin',
// No push. An admin's phone buzzing at four in the morning for a step that
// will still be failed at breakfast is a notification people switch off
// wholesale, and switching it off wholesale is how the one that mattered is
// missed. Mail and the inbox both wait.
channels: ['email', 'inapp'],
// The generic body plus the structural projection: `event.run.failed`
// declares its own `title` and a `runUrl`, so an unauthored mail is already
// headed with the event's name and buttoned through to the run console —
// §4.6.1 property 1, working exactly as it promises.
template_keys: { email: 'notify.event', inapp: 'inapp.event' },
// **No cooldown, and this is the one rule in the file that must not have
// one.** The subject is the run, so a cooldown would only ever suppress a
// second failure of the SAME run — which is precisely the run an
// administrator most needs the second line about.
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
]
/**
* Seed one group of rules, once, under its own guard key.
*
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
* failed to seed costs an operator one visit to the "new rule" form, not a
* deployment.
*
* @param {string} key the one-shot settings guard for THIS group
* @param {object[]} rules
* @param {string} note what the boot log should say when it inserts
*/
async function seedGroup(key, rules, note) {
const summary = { inserted: 0, skipped: 0 }
try {
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
// `get()` here with a `set()` after the inserts is not a guard when two
// instances boot together — both read "absent", both seed — and a duplicate
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
// own `affectedRows`, so exactly one caller proceeds. See the note below on
// what a partial run costs: that trade is unchanged, only its ordering.
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
return { ...summary, skipped: rules.length }
}
for (const rule of rules) {
try {
await rulesDb.insert({
audience_segment_id: null,
conditions: null,
// No delay and nothing cancels these. `delay_seconds` is the grace
// window a cancelling event needs, and nothing cancels "someone
// posted" — the post happened.
delay_seconds: 0,
cancel_on: [],
...rule,
enabled: 0,
updated_by: null,
})
summary.inserted += 1
} catch (err) {
log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
}
}
// Stamped even on a partial run — the claim above is the stamp. Re-running
// would duplicate the rules that did insert, and a duplicate rule is two
// mails per event, a worse outcome than the one missing rule an operator can
// add from the screen.
if (summary.inserted) {
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
}
} catch (err) {
log.error('rule seeding failed', { key, message: err.message })
}
return summary
}
/** The four Team rules (Phase 6). */
const seedTeamRules = () =>
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
/** The one news rule (Phase 11). */
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
/** The two event-lifecycle rules (EVENTS.md Phase 10). */
const seedEventRules = () =>
seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
/**
* Both groups, which is what the boot path calls.
*
* Sequential rather than concurrent, and not for correctness — each group has its
* own guard key and its own rows — but so the boot log reads in a fixed order and
* a failure names one group rather than an interleaving of two.
*/
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
const events = await seedEventRules()
return {
inserted: team.inserted + news.inserted + events.inserted,
skipped: team.skipped + news.skipped + events.skipped,
}
}
module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
seedEventRules,
RULES,
NEWS_RULES,
EVENT_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
EVENT_SEEDED_KEY,
}

View File

@@ -0,0 +1,64 @@
// ── Core's own scope-preference provider: Teams ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `team_notification_prefs` stays exactly
// where it is and keeps exactly the meaning it has had since Teams shipped; this
// is the adapter that lets the generic engine read it without knowing what a Team
// is. Registered here rather than at the bottom of `scopedPrefs.js` for the same
// reason `coreChannels` and `transports/smtp` are: requiring a registry must not
// have the side effect of populating it.
//
// **The two columns say different things and the mapping is not symmetric.**
//
// - `muted` is the Team's master switch and it silences EVERY channel. That is
// what the toggle has always meant on the account screen ("mute this Team"),
// and narrowing it to email would be a behaviour change nobody asked for. Note
// this is belt-and-braces on the live path — `teamNotify.recipientIds` already
// excludes muted users before the event is emitted — and it is here anyway so
// the meaning survives an emitter that stops filtering.
// - `email_mode` says nothing about any other channel, so on push or in-app this
// provider returns no opinion and the stream-level preference decides.
//
// **Absence of a row means 'off' for email, and that is the whole reason this
// provider answers for every user rather than only for the rows it finds.** The
// column defaults to `'off'` and both recipient queries COALESCE to it: no row
// has always meant "this person has not asked for Team email". Deferring to the
// stream-level preference instead would mean a user who once switched on
// `team.forum.post` email in the channels screen starts receiving mail from every
// Team on the deployment — a widening, produced by a migration, of a preference
// they expressed about something else.
const { registerScopePreference } = require('./scopedPrefs')
const teamNotify = require('../model/teams/teamNotify.model')
// team_notification_prefs.email_mode → the three modes the engine speaks. The
// vocabularies differ by one word and only one word: 'immediate' predates
// `notification_channel_prefs`, whose ENUM says 'instant'.
const EMAIL_MODE = { off: 'off', immediate: 'instant', digest: 'digest' }
async function modesFor(userIds, channel, scopeId) {
const teamId = Number(scopeId)
if (!Number.isInteger(teamId) || teamId < 1) return new Map()
const rows = await teamNotify.prefsForTeam(userIds, teamId)
const byUser = new Map(rows.map((r) => [Number(r.user_id), r]))
const modes = new Map()
for (const userId of userIds) {
const row = byUser.get(Number(userId))
if (row && Number(row.muted)) {
modes.set(Number(userId), 'off')
continue
}
if (channel !== 'email') continue // no opinion; the stream preference decides
modes.set(Number(userId), EMAIL_MODE[(row && row.email_mode) || 'off'] || 'off')
}
return modes
}
registerScopePreference({
prefix: 'team',
label: 'Team',
modesFor,
})
module.exports = { modesFor, EMAIL_MODE }

View File

@@ -0,0 +1,241 @@
// ── The email DeliveryChannel: addressFor + deliver ────────────────────────
//
// ENGAGEMENT.md Phase 6. Phase 3 declared this channel and deliberately left it
// behaviourless ("declaring a function nothing calls freezes a signature before
// anything has tried to use it"); this is the phase that has something to try it
// with, and the signature survived unchanged.
//
// **What it does is four lookups and one send**, and the order matters because
// each step is a way the mail should not go out:
//
// 1. the address — re-checked for `status = 'active'`, because a delayed
// row can outlive the account it was queued for
// 2. the rule — for its per-channel template key; the outbox row
// carries `rule_id` and FK CASCADE guarantees it exists
// 3. the values — the payload snapshot, plus §4.6.1's structural
// projection, plus this recipient's unsubscribe link
// 4. the template — `renderByKey`, which falls back to the shipped seed
// rather than failing, and refuses a draft
// 5. the send — `mailer.sendNotification`, which classifies rather
// than throwing
//
// **It never throws**, and that is a stronger statement than the worker's
// `try/catch` around it: a throw would be read as a transient failure and retried
// five times, so an unrenderable template would become five identical failures in
// the send log instead of one honest terminal row.
//
// **The unsubscribe link is per recipient and is built from `scope_key`, never
// from `subject_key`.** They differ for every Team event: the subject is
// `teamName` (a display string the cooldown keys on) and the scope is `team:12`.
// A Team renamed between the mail and the click must not orphan the link in it.
const rulesDb = require('../model/engagement/engagementRules.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const suppressions = require('./suppressions')
const settings = require('../model/settings/settings.model')
const unsubscribeToken = require('../utils/unsubscribeToken')
const log = require('../utils/logger')('engagement')
// **Required lazily, and it is a real cycle rather than a style preference.**
// `engagement/index.js` requires `coreChannels`, which requires this file; and
// `utils/mailer` requires `engagement/index` for the transport registry. A
// top-level `require('../utils/mailer')` here therefore resolves while
// `engagement/index` is mid-evaluation, so mailer would capture `{}` for
// `transports` and every send would fail on `transports.get is not a function` —
// at send time, on a deployment, with the boot log clean. Resolved at call time
// instead, by which point both modules are fully evaluated.
const mailer = () => require('../utils/mailer')
// The template a rule renders through when it names none. §4.6.1 property 1: a
// new trigger must be mailable with no authoring at all, and this plus
// `projection.project` is that property's implementation.
const DEFAULT_TEMPLATE = 'notify.event'
const baseUrl = () => templates.baseUrl()
// Re-exported rather than defined here since Phase 9: the send log's hash and
// the suppression list's key have to be the same function or a bounce never finds
// the row it belongs to. `suppressions.js` owns it, next to the masking.
const hashAddress = suppressions.hashAddress
/**
* The two unsubscribe URLs for one recipient of one scope, or nulls.
*
* TWO urls from one token, and they are not interchangeable. `unsubscribeUrl` is
* the human one that goes in the mail body: the site's own page, which explains
* what is about to happen and POSTs once a person has read it. `unsubscribeApiUrl`
* is the machine one for the `List-Unsubscribe` header, where RFC 8058 says a
* client may POST without showing anybody anything — so it has to be an endpoint,
* not a page. The API route answers GET on the same path with a redirect to the
* page, which covers clients that render the header as an ordinary link.
*
* A scope the token format cannot carry yields nulls rather than an exception:
* the mail is worth sending without a one-click unsubscribe, and the recipient
* still has the preferences screen. It is logged because it is a programming
* error in whatever chose the scope key.
*/
function unsubscribeUrls(userId, scopeKey) {
try {
const token = unsubscribeToken.sign(userId, 'email', scopeKey || '')
const base = baseUrl()
return {
unsubscribeUrl: `${base}/unsubscribe/${token}`,
unsubscribeApiUrl: `${base}/api/v1/public/engagement/unsubscribe/${token}`,
}
} catch (err) {
log.warn('could not build an unsubscribe link', { scope: scopeKey, message: err.message })
return { unsubscribeUrl: null, unsubscribeApiUrl: null }
}
}
/** Where this channel would send to, or null. */
const addressFor = (userId) => recipients.addressFor(userId)
/**
* Narrow an audience to the users this channel may write an outbox row for
* (Phase 9, decision 4).
*
* **One gate, and it is the Phase 1b verification setting.** With
* `email_verification_required` on, a user whose address is unverified is
* excluded here rather than refused at delivery, and the org lead settled it that
* way for two reasons. It is a STANDING property — unlike a suppression, which
* can appear inside a `delay_seconds` window and therefore has to be re-checked
* at send time — so the outbox row would be written only to be thrown away. And a
* deployment that upgraded before verifying anybody has an audience that is
* almost entirely unverified: excluding at delivery would write a `suppressed`
* row per person per rule firing, which is a send log nobody can read.
*
* The count comes back so the admin reach preview can say "1,204 excluded:
* unverified" instead of quietly promising a number the engine will not deliver.
*
* **It fails OPEN, and the try/catch is load-bearing rather than defensive
* habit.** `settings.isEmailVerificationRequired` swallows its own errors and
* answers `off`, but `unverifiedAmong` does not, and an uncaught throw here does
* not fail one recipient — `applyRule` awaits this before the per-user loop, so
* it would abandon the whole rule for every channel it names. A database having
* a bad minute would become a rule that silently sent nothing, with a clean send
* log and nothing in the outbox to retry. Same direction as the suppression
* check, for the same reason: the recoverable mistake is mail going out, not mail
* silently stopping.
*/
async function eligible(userIds) {
const list = userIds || []
if (!list.length) return { userIds: [], excluded: {} }
try {
if (!(await settings.isEmailVerificationRequired())) {
return { userIds: list.slice(), excluded: {} }
}
const unverified = await recipients.unverifiedAmong(list)
if (!unverified.size) return { userIds: list.slice(), excluded: {} }
return {
userIds: list.filter((id) => !unverified.has(Number(id))),
excluded: { unverified: unverified.size },
}
} catch (err) {
log.error('verification gate could not be evaluated; not excluding anyone', { message: err.message })
return { userIds: list.slice(), excluded: {} }
}
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
const to = await addressFor(row.user_id)
if (!to) {
// Terminal. Retrying does not give somebody an address, and a banned
// account is not going to be un-banned by a five-minute backoff.
return { ok: false, detail: 'no deliverable address for this user' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.email) || DEFAULT_TEMPLATE
// Once, not once per use: the body's link and the header's must be the same
// token, or a client that offers both offers two different unsubscribes.
const unsub = unsubscribeUrls(row.user_id, row.scope_key)
const values = projection.project(row.trigger_id, row.payload || {}, unsub)
const rendered = await templates.renderByKey(key, values)
if (!rendered) {
// Neither a usable row nor a shipped seed. Terminal, and it names the key:
// the operator deleted a template a rule points at, which the admin surface
// refuses with a 409 — so reaching here means it happened out of band.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
if (rendered.missing.length) {
// Not a refusal: an optional variable a trigger chose not to supply renders
// as nothing by design. Logged with NAMES ONLY, never values — the same
// rule the emit and dispatch log lines follow.
log.debug('template variables had no value', { key, missing: rendered.missing })
}
// **The suppression check is HERE and not at enqueue** (Phase 9). An outbox
// row can sit through a `delay_seconds` grace window, and an address can hard
// bounce inside it — so the only check that can be correct is the one taken
// immediately before the transport call. It is also the check the acceptance
// criterion describes: a `suppressed` row in the send log, and no transport
// call at all.
const blocked = await suppressions.isSuppressed(to.address)
if (blocked) {
return {
ok: false,
suppressed: true,
detail: `address is suppressed (${blocked.reason})`,
addressHash: hashAddress(to.address),
}
}
const result = await mailer().sendNotification({ to: to.address, rendered, ...unsub })
// A failed send is where a hard bounce enters the system on SMTP, and the
// reason it is worth catching rather than waiting for an API provider: a
// single-recipient send refused at RCPT TO is a synchronous 5.1.1, which is
// the most valuable deliverability signal there is and it was already being
// thrown away. `considerFailure` is narrow — see bounceClassify.js — and its
// note goes into the log on BOTH outcomes, so "this failed and was not
// suppressed" says why.
if (result && !result.ok && result.smtp) {
const verdict = await suppressions.considerFailure({ address: to.address, error: result.smtp })
// A bounce is terminal by definition. Overriding `retry` matters because
// `PERMANENT_CODES` does not contain every code that can carry a 5.1.x, so
// without this a genuine dead mailbox could still be retried four more
// times — each one another refusal on our record with the relay.
if (verdict.suppressed) {
return {
ok: false,
retry: false,
// `engagement_sends.status` has carried 'bounced' since §4.5 and
// nothing wrote it until here, so the Send Log's "Bounced" filter
// matched nothing — the live rig is what showed that. It is a distinct
// status rather than a flavour of 'failed' because the two need
// different actions: a failure means look at the relay, and a bounce
// means that person's address is gone.
bounced: true,
transport: result.transport,
detail: `${result.detail} — ${verdict.note}`,
addressHash: hashAddress(to.address),
}
}
return { ...result, detail: `${result.detail} — ${verdict.note}`, addressHash: hashAddress(to.address) }
}
// The send log stores a sha256 of the address and never the address itself
// (schema.sql): enough to correlate a bounce, useless as a mailing list.
// Attached on every outcome, because a failure is exactly the row a bounce
// would need to be matched against.
return { ...result, addressHash: hashAddress(to.address) }
} catch (err) {
// See the header: a throw here would be retried as if it were the relay's
// fault. Classified as terminal instead, with the reason in the send log.
log.error('email delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, eligible, deliver, unsubscribeUrls, hashAddress, DEFAULT_TEMPLATE }

View File

@@ -37,10 +37,27 @@ const recipients = require('../model/engagement/engagementRecipients.db')
const conditions = require('./conditions')
const audiences = require('./audiences')
const channels = require('./channels')
const scopedPrefs = require('./scopedPrefs')
const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000
// Channels one of whose payloads can REFERENCE another's result, earliest first
// (Phase 7). Only one pair qualifies today: a push tickle's `ref` deep-links to
// the inbox row `inapp` writes, and the outbox is swept `ORDER BY due_at, id`,
// so enqueueing in-app first is what makes that ref resolve on the first pass
// rather than on a retry. Everything not named here keeps the operator's own
// order, which is the order the rules screen shows.
//
// It is an ordering, not a dependency: `pushChannel` treats a missing ref as
// null and the app pulls regardless, so a rule that names only push, or a row
// that gets retried out of sequence, is still correct.
const CHANNEL_ORDER = ['inapp']
const channelRank = (id) => {
const i = CHANNEL_ORDER.indexOf(id)
return i === -1 ? CHANNEL_ORDER.length : i
}
/**
* Which of a rule's channels are actually deliverable right now?
*
@@ -49,26 +66,53 @@ const HOUR_MS = 60 * 60 * 1000
* failing the rule: the other channels of that rule are still correct, and a
* dropped one is visible in the log line below.
*/
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
const liveChannels = (rule) =>
(rule.channels || [])
.filter((c) => channels.has(c))
.sort((a, b) => channelRank(a) - channelRank(b))
/**
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
* not 'off'.
* The EFFECTIVE mode each candidate holds for (id, channel), given the event's
* scope.
*
* Effective, not stored: a row exists only where a user has expressed something,
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
* and applying the default here keeps that answer in the registry, which is the
* invariant Phase 3 established.
*
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
* an outbox row for it is still the right record of "this person should be told";
* what changes in Phase 6 is who drains it.
* **A scoped preference wins outright where one exists** (Phase 6, decision 4).
* `team_notification_prefs` stayed where it is and `scopedPrefs` is the adapter;
* for a Team-scoped event that table is the preference, exactly as it has been
* since Teams shipped. The argument for replacing rather than intersecting is in
* scopedPrefs.js's header, and it is short: intersecting would have silenced
* every existing Team-email subscriber on the deploy that migrated them.
*/
async function subscribedTo(userIds, streamId, channel) {
if (!userIds.length) return []
async function effectiveModes(userIds, streamId, channel, scopeKey) {
const modes = new Map()
if (!userIds.length) return modes
const scoped = await scopedPrefs.resolve(userIds, channel, scopeKey)
const stored = await recipients.storedModes(userIds, streamId, channel)
const fallback = channels.defaultMode(channel)
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
for (const id of userIds) modes.set(id, scoped.get(id) ?? stored.get(id) ?? fallback)
return modes
}
/**
* The candidates who should get an OUTBOX ROW for this channel.
*
* `off` is excluded for the obvious reason. **`digest` is excluded too, and that
* corrects what Phase 4a said here** — its comment read "a 'digest' preference is
* kept, not dropped… what changes in Phase 6 is who drains it", and what changed
* in Phase 6 is that nothing drains it. §4.2b keeps `teamDigestWorker`'s
* compute-at-send-time design, so a digest is re-derived from the source tables
* when it goes out, not assembled from snapshots taken hours earlier. An outbox
* row for a digest recipient would be a second copy of the content with none of
* the three properties that design exists for — most importantly, it would mail
* a user who lost access between the post and the send.
*/
async function subscribedTo(userIds, streamId, channel, scopeKey = null) {
const modes = await effectiveModes(userIds, streamId, channel, scopeKey)
return userIds.filter((id) => modes.get(id) === 'instant')
}
/**
@@ -76,7 +120,18 @@ async function subscribedTo(userIds, streamId, channel) {
* for tests; it is not read by the caller for control flow.
*/
async function applyRule(rule, event, now) {
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
const summary = {
ruleId: rule.id,
enqueued: 0,
deduped: 0,
cooled: 0,
capped: 0,
// Phase 9: people a CHANNEL refused to enqueue for, by reason. Counted
// separately from `cooled` and `capped` because those are the engine holding
// a message back and this is a channel saying it cannot carry one at all.
ineligible: {},
skipped: null,
}
if (!conditions.evaluate(rule.conditions, event.data)) {
summary.skipped = 'conditions'
@@ -95,13 +150,21 @@ async function applyRule(rule, event, now) {
// G24, re-run at send time. A rule saved when its trigger permitted a wider
// audience must not keep reaching it after a module upgrade narrowed the
// declaration - and that is the only way this can fail, since the save path
// declaration - and that was the only way this could fail, since the save path
// ran the same check.
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
//
// **Phase 10 gave it a second way, and it is the one that fires in practice:**
// the event may carry a narrowing ceiling of its own. A rehearsal emits
// `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
// for the real thing is then refused here rather than mailing subscribers about
// an event that is not happening. Nothing about the rule changed; the occasion
// did. See `audiences.permitted`.
if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
log.warn('rule audience exceeds its trigger ceiling - refusing', {
rule: rule.id,
trigger: event.triggerId,
audience: resolved.ceiling,
emitted: event.ceiling || null,
})
summary.skipped = 'ceiling'
return summary
@@ -132,15 +195,34 @@ async function applyRule(rule, event, now) {
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
for (const channel of live) {
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
// Phase 9, and it runs BEFORE the preference filter rather than after. Both
// orders reach the same recipients; this one costs one query against the
// narrower set only when the channel declares an `eligible` at all, and it
// means `summary.ineligible` counts people the CHANNEL cannot reach rather
// than people who happened to also be opted in. Channels that declare none —
// push and in-app — pass straight through.
const gated = await channels.eligibleFor(channel, resolved.userIds)
for (const [why, n] of Object.entries(gated.excluded)) {
summary.ineligible[why] = (summary.ineligible[why] || 0) + n
}
const eligible = await subscribedTo(gated.userIds, event.triggerId, channel, event.scopeKey)
for (const userId of eligible) {
if (budget <= 0) {
summary.capped += 1
continue
}
// One statement, guarded on the interval, so two concurrent emits cannot
// both pass a read-then-write check (§4.1).
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
// Guarded on the interval, so two concurrent emits cannot both pass a
// read-then-write check (§4.1).
//
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
// rather than what makes it work. Without the channel, the first channel of
// a rule claims the cooldown and every later one is refused as cooling —
// and `inapp` is ranked first above, so a rule naming email + in-app would
// deliver the in-app item and silently never the mail. Found on Phase 11b's
// live rig; a cooldown is per delivery, not per occasion.
const allowed = await cooldownsDb.claim(
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
)
if (!allowed) {
summary.cooled += 1
continue
@@ -151,6 +233,10 @@ async function applyRule(rule, event, now) {
user_id: userId,
channel,
subject_key: subjectKey,
// The scope a PREFERENCE and an UNSUBSCRIBE are keyed on, which is not
// `subject_key`: for the Team triggers the subject is `teamName` (what a
// cooldown counts) and the scope is `team:12` (what survives a rename).
scope_key: event.scopeKey ?? null,
payload: event.data,
// Scoped per (rule, user, channel) by the unique index, so one event
// fanned out to fifty people is fifty rows carrying the same key.
@@ -238,4 +324,13 @@ async function dispatch(event, now = new Date()) {
return summary
}
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }
module.exports = {
dispatch,
applyRule,
applyCancellations,
subscribedTo,
effectiveModes,
liveChannels,
CHANNEL_ORDER,
HOUR_MS,
}

View File

@@ -0,0 +1,204 @@
// ── The in-app DeliveryChannel: addressFor + deliver ───────────────────────
//
// ENGAGEMENT.md Phase 7. The third channel to get behaviour, and the one whose
// "address" is not an address at all: the destination is the user's own row in
// this deployment's own table. `addressFor` still exists and still answers null,
// because the question it asks — *can this channel reach this user right now* —
// has a real answer here, and it is the same answer email's has: not if the
// account is no longer active. An outbox row can sit through a `delay_seconds`
// grace window, so a user banned between the emit and the send is exactly the
// case this catches.
//
// **What makes it different from email is what it does NOT have to do.** There
// is no transport, no relay to classify a failure for us, no unsubscribe link to
// mint per recipient, and no address to hash — an inbox item is addressed to a
// user id, and `engagement_sends.address_hash` exists to correlate a bounce that
// this channel cannot have. So `deliver` is two steps: render the template into
// the three columns, and insert.
//
// **It never throws**, for the reason `emailChannel` states: the worker reads a
// throw as a transient failure and retries five times, so an unrenderable
// template would become five identical failures in the send log instead of one
// honest terminal row.
//
// **A duplicate `dedupe_key` reports success.** The acceptance line calls it a
// no-op; from the recipient's side it is a delivery — they have the item — and
// recording `failed` for it would put a red row in the send log for the
// mechanism working exactly as designed. The detail says which it was.
const rulesDb = require('../model/engagement/engagementRules.db')
const registries = require('../modules/registries')
const channelRegistry = require('./channels')
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const templates = require('./templates')
const projection = require('./projection')
const log = require('../utils/logger')('engagement')
// The template a rule renders through when it names none — §4.6.1 property 1's
// implementation for this channel, exactly as `notify.event` is for email.
const DEFAULT_TEMPLATE = 'inapp.event'
/**
* Can this channel reach `userId`?
*
* Returns the shape every `addressFor` returns rather than a boolean, so the
* registry's contract stays one contract. The "address" is the user id as a
* string, which is the honest answer: this channel's destination is an account,
* and there is nothing else to name.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Render one event into an inbox item. Shared with `ctx.inbox.push`'s rule-less
* path only in spirit — that one is handed its title and body by the module and
* renders nothing.
*/
async function renderItem(triggerId, payload, templateKey) {
const values = projection.project(triggerId, payload || {})
const rendered = await templates.renderInappByKey(templateKey, values)
if (!rendered) return null
if (rendered.missing.length) {
// Names only, never values — the rule every log line in this subsystem
// follows. An optional variable a trigger chose not to supply renders as
// nothing by design, so this is debug rather than a warning.
log.debug('template variables had no value', { key: templateKey, missing: rendered.missing })
}
return rendered
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
// Terminal. A five-minute backoff does not un-ban an account, and writing
// the item anyway would put content in the inbox of somebody who is no
// longer allowed to open it.
return { ok: false, detail: 'this user can no longer be reached' }
}
const rule = await rulesDb.getById(row.rule_id)
const key = (rule && rule.template_keys && rule.template_keys.inapp) || DEFAULT_TEMPLATE
const rendered = await renderItem(row.trigger_id, row.payload, key)
if (!rendered) {
// Neither a usable row nor a shipped seed: the operator deleted a template
// a rule points at, which the admin surface refuses with a 409, so reaching
// here means it happened out of band. Terminal, and it names the key.
return { ok: false, detail: `no template and no shipped default for "${key}"` }
}
const { inserted } = await inbox.insert({
userId: row.user_id,
triggerId: row.trigger_id,
title: rendered.title,
body: rendered.body,
url: rendered.url,
dedupeKey: row.dedupe_key || null,
})
// `transport` is left absent rather than invented. The column means "which
// implementation of this channel delivered it", and this channel has one
// sink by construction — a value there would be a name nothing else uses.
return inserted
? { ok: true }
: { ok: true, detail: 'already in this inbox (duplicate dedupe key)' }
} catch (err) {
log.error('in-app delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
// ── The rule-less sink: ctx.inbox.push (§5.1) ──────────────────────────────
//
// A module writing the inbox directly, with no trigger declaration to project
// from, no rule to pick a template, and no audience to resolve. It exists for
// the cases a rule cannot express — something that concerns exactly one person
// and needs no operator configuration to be worth telling them about.
//
// **It respects the user's in-app preference where there is one to respect**
// (settled by the org lead 2026-08-31). If `triggerId` names a REGISTERED
// trigger, the user's effective mode for it decides, and 'off' drops the write:
// a toggle somebody switched off on the preferences screen must not be walkable
// around by the module that owns the trigger behind it. If it names nothing
// registered there is no toggle, nothing on any screen to have switched off, and
// the item is written — refusing it would make the sink useless for the one job
// it has while protecting a preference that does not exist.
//
// Scoped preferences are deliberately not consulted: a scope is a property of an
// EVENT (`team:12`), and a caller with no trigger declaration has no scope to
// name. The engine's path, which does, still applies them.
//
// Fire-and-forget, never throws, never rejects — `ctx.teams.activity.push`'s
// posture, for its reason: this is called from inside a game-event handler and a
// storage problem of core's must not become the module's control flow.
// user_notifications.title. Truncated rather than refused: a module that built a
// long title has still said something worth showing.
const MAX_TITLE = 300
// user_notifications.body is TEXT; this is a sanity bound, not the column's.
const MAX_BODY = 4000
/**
* Write one item on a module's behalf.
*
* @param {string} moduleId bound by the loader, never taken from the arguments
* @param {number} userId
* @param {{triggerId: string, title: string, body?: string, url?: string, dedupeKey?: string}} item
* @returns {Promise<{written: boolean, reason?: string}>} for tests; the loader
* discards it, because a module has nothing correct to do with it.
*/
async function pushDirect(moduleId, userId, item = {}) {
try {
const uid = Number(userId)
if (!Number.isInteger(uid) || uid <= 0) return { written: false, reason: 'invalid user id' }
const triggerId = String(item.triggerId || '').trim()
const title = String(item.title || '').trim().slice(0, MAX_TITLE)
if (!triggerId || !title) return { written: false, reason: 'triggerId and title are required' }
// The declaration is consulted for ONE thing — whether a preference for this
// id exists — and not to validate a payload: there is no payload here, only
// the three strings the module composed itself.
if (registries.eventTrigger(triggerId)) {
const stored = await recipients.storedModes([uid], triggerId, 'inapp')
const mode = stored.get(uid) ?? channelRegistry.defaultMode('inapp')
if (mode !== 'instant') return { written: false, reason: 'the user has this switched off' }
}
if (!(await addressFor(uid))) return { written: false, reason: 'this user can no longer be reached' }
// Same relative-only rule the rendered path applies, and for the same reason:
// this string ends up in an href on a page a signed-in user is looking at.
const url = item.url ? templates.relativeUrl(item.url, templates.baseUrl()) : null
if (item.url && !url) {
log.warn('ctx.inbox.push dropped an off-site url', { module: moduleId, trigger: triggerId })
}
const body = item.body ? String(item.body).slice(0, MAX_BODY) : null
const { inserted } = await inbox.insert({
userId: uid,
triggerId,
title,
// A module supplies data, never markup (§4.6.2's security posture). The
// body is stored as the text it claims to be and every surface renders it
// as text, so there is no markup to sanitize and none to be trusted.
body,
url,
dedupeKey: item.dedupeKey ? String(item.dedupeKey).slice(0, 190) : null,
})
return { written: inserted, reason: inserted ? undefined : 'duplicate dedupe key' }
} catch (err) {
log.error('ctx.inbox.push failed', { module: moduleId, message: err.message })
return { written: false, reason: err.message }
}
}
module.exports = { addressFor, deliver, renderItem, pushDirect, DEFAULT_TEMPLATE }

View File

@@ -20,6 +20,7 @@
require('./transports/smtp')
require('./coreChannels')
require('./coreScopePrefs')
const transports = require('./transports')
const channels = require('./channels')

View File

@@ -0,0 +1,194 @@
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
//
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
// requiring `app.js` is what scans the volume and runs the loader. At the moment
// core seeds, no module has registered anything at all.
//
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
// `installed_modules` reconcile, so a module the operator disabled or one that
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
// exist.
//
// **It reuses core's two seeders rather than reimplementing them**, which is the
// whole argument for the registry existing (decision 7): `seedOne` owns the
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
// owns what a renderable body is, and a module supplies data. A copy of either
// living outside this directory would drift the first time core improved the
// original — and the drift would surface as a mail somebody already received.
//
// ── The asymmetry, once more, because it is the thing to get right ─────────
//
// **Templates are re-ensured every boot.** A row carries `seed_key`,
// `seed_version` and `customized`, so re-ensuring is how a better default
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
// and a template added in a later module version reaches every deployment rather
// than only fresh ones.
//
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
// rule would resurrect one an operator deleted and reset one they enabled. This
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
// reaches fresh installs only, and a rule that must reach already-stamped
// deployments takes a new group key. The module chooses; this file honours it.
//
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
// default — `renderByKey`'s fallback stays in charge — not the deployment.
const templatesDb = require('../model/engagement/engagementTemplates.db')
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const emailBlocks = require('../emailBlocks')
const log = require('../utils/logger')('engagement')
/**
* The one-shot guard for one module's rule group.
*
* Namespaced by owner AND by group so two modules may use the same group name,
* and so a module can add a second group later without touching the first. Its
* VALUE is the timestamp — purely so an operator reading the settings table can
* tell when it ran; only its presence is read.
*/
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
/**
* Ensure one module's templates, and bring un-customized rows up to the current
* seed. Idempotent.
*/
async function seedModuleTemplates(owner, templates, deps = {}) {
const templates_ = deps.templatesDb || templatesDb
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
for (const seed of templates) {
// Validated against the block registry before it is stored, exactly as core's
// own seeds are and for the same reason: a shipped block array no renderer
// understands sitting in the table reads to an operator as their deployment
// being broken. Refusing to write it leaves the fallback in charge and puts
// the reason in the boot log, with the module named.
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
if (!valid) {
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
counts.invalid += 1
continue
}
try {
counts[await templates_.seedOne(seed)] += 1
} catch (err) {
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
}
}
// The third arm of §4.6.1 property 3: a customized row is never touched, and
// the fact that a better default now exists is surfaced instead of applied.
let stale = []
try {
stale = await templates_.staleCustomized(
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
)
} catch {
stale = []
}
if (stale.length) {
log.info('customized module templates have a newer shipped default', {
owner,
keys: stale.map((t) => t.key),
})
}
return { ...counts, stale: stale.map((t) => t.key) }
}
/**
* Seed one named rule group, once, under its own guard.
*
* Mirrors `coreRules.seedGroup` deliberately, including the claim-before-insert
* ordering and what it costs: re-running would duplicate the rules that DID
* insert, and a duplicate rule is two mails per event — worse than the one
* missing rule an operator can add from the Rules screen. The guard is taken
* atomically for the same reason; see the comment at the claim.
*/
async function seedRuleGroup(owner, group, deps = {}) {
const rules_ = deps.rulesDb || rulesDb
const settings_ = deps.settingsDb || settingsDb
const summary = { inserted: 0, skipped: 0 }
const key = guardKey(owner, group.key)
try {
// **Claim BEFORE inserting, not after.** The guard used to be a `get()` here
// and a `set()` after the loop, which is not a guard under concurrency: two
// processes starting in the same moment both read "absent" and both insert
// the whole group. That is not hypothetical — `docker compose up
// --scale app=2` and a rolling restart both boot two instances deliberately,
// and Phase 13's acceptance walk hit it with two, ending up with 52 module
// rules where the module ships 26. `claim()` is an `INSERT IGNORE` reporting
// its own `affectedRows`, so exactly one caller wins.
//
// The cost is the one this function already accepted below: 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. Duplicates are the
// worse failure — two mails per event, for every rule in the group — which
// is why the order is this way round rather than the other.
if (!(await settings_.claim(key, new Date().toISOString()))) {
return { ...summary, skipped: group.rules.length }
}
for (const rule of group.rules) {
try {
await rules_.insert(rule)
summary.inserted += 1
} catch (err) {
log.error('module rule seed failed', {
owner,
group: group.key,
trigger: rule.trigger_id,
message: err.message,
})
}
}
if (summary.inserted) {
log.info('seeded module engagement rules, all disabled', {
owner,
group: group.key,
rules: summary.inserted,
note: group.note || undefined,
})
}
} catch (err) {
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
}
return summary
}
/**
* Seed every registered module's engagement content.
*
* @param {object} [deps]
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
*/
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
// eslint-disable-next-line global-require
const read = seeds || require('../modules/registries').allEngagementSeeds
const totals = { templates: 0, rules: 0 }
for (const entry of read()) {
if (skip.has(entry.owner)) {
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
continue
}
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
totals.templates += t.inserted + t.updated
for (const group of entry.ruleGroups || []) {
const r = await seedRuleGroup(entry.owner, group, dbs)
totals.rules += r.inserted
}
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
}
return totals
}
module.exports = {
seedModuleEngagement,
seedModuleTemplates,
seedRuleGroup,
guardKey,
}

View File

@@ -0,0 +1,73 @@
// ── The structural projection: any trigger through a generic template ──────
//
// ENGAGEMENT.md §4.6.1 property 1, implemented in Phase 6. The property is that
// **a new trigger renders through `notify.event` with no authoring at all** —
// "add a trigger" must not mean "and now write a template". Nothing implemented
// it before this phase, and building the email channel is what made the hole
// visible: a trigger payload is domain-named (`teamName`, `threadTitle`,
// `postUrl`) while the generic seeds are structural (`title`, `intro`, `items`,
// `actionUrl`). The two vocabularies never met.
//
// **The rule, settled by the org lead 2026-08-29: the payload wins, and the
// projection fills gaps.** A name the payload already carries is left exactly as
// emitted — `news.post` and `team.announcement` both declare their own `title`,
// and a projection that overwrote it would replace a real headline with a
// category label. Only a name the payload does NOT define is supplied here.
//
// **What it is careful not to do is guess at domain meaning.** There is no table
// mapping `threadTitle` onto `title`, and there will not be one: every such
// mapping is a piece of one game's vocabulary compiled into core, and it is wrong
// the first time a module names the same thing differently. The three fallbacks
// below are all derived from the DECLARATION — a trigger's own label, its own
// description, its own first declared url — which every trigger has by
// construction because `registerEventTriggers` refuses one without them.
//
// The consequence, stated plainly: an unauthored mail for `team.forum.post` is
// titled "Team — new forum post" rather than the thread's title. That is a plain
// mail, not a wrong one, and the operator's answer is the bespoke template that
// ships beside it (`notify.team-post` reads the payload's own names). A projection
// clever enough to do better would be a projection that is confidently wrong on
// the first module that does not follow core's naming.
const registries = require('../modules/registries')
/**
* The values a template renders with, for one event and one recipient.
*
* @param {string} triggerId
* @param {Record<string, unknown>} payload the outbox row's snapshot — already
* validated at emit, so it holds declared variables and nothing else
* @param {Record<string, unknown>} [extra] per-recipient additions the channel
* computes (`unsubscribeUrl`), merged LAST because they are facts about
* the delivery rather than about the event
* @returns {Record<string, unknown>}
*/
function project(triggerId, payload = {}, extra = {}) {
const declaration = registries.eventTrigger(triggerId)
const values = { ...payload }
// A dormant trigger still has an outbox row to deliver — the module was
// uninstalled between enqueue and now. The payload is intact and the template
// may well only reference payload names, so the mail goes out with whatever the
// snapshot holds rather than being refused for want of a label.
if (declaration) {
if (values.title === undefined) values.title = declaration.label
if (values.intro === undefined) values.intro = declaration.description || ''
if (values.actionUrl === undefined) {
const url = (declaration.variables || []).find(
(v) => v.type === 'url' && typeof payload[v.name] === 'string' && payload[v.name],
)
if (url) values.actionUrl = payload[url.name]
}
}
// Set rather than left absent, so a generic template's item list renders as
// nothing instead of reporting `items` as a missing variable. `missing` is what
// the editor's preview shows an operator, and a name no trigger was ever going
// to supply is noise in it.
if (values.items === undefined) values.items = []
return { ...values, ...extra }
}
module.exports = { project }

View File

@@ -0,0 +1,91 @@
// ── The push DeliveryChannel: addressFor + deliver ─────────────────────────
//
// ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a
// `deliver` last, because until this phase there was nothing for a tickle to
// point AT: `{ stream, ref }` carries no content by design, so a rule firing on
// push before the inbox existed would have woken a phone to pull a screen that
// had nothing on it.
//
// **The tickle invariant is the whole of this file's security posture.** What
// leaves the server is the stream id and an opaque ref, never a title, never a
// body, never the payload — `carriesContent: false` on the registration is the
// declaration and this is the implementation. ntfy is treated as an untrusted
// relay, so a leaked topic must reveal nothing but that *something* happened;
// the app then pulls the real item over the authenticated, ownership-checked
// inbox API. Every claim in that paragraph is one `pushDispatch` already makes,
// which is why delivery here is a call into it rather than a second publisher.
//
// **`ref` points at the inbox row when there is one, and is null otherwise.**
// A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders
// `inapp` first precisely so the row exists by the time this runs — but that is
// an optimisation, not a guarantee: the two rows are independent, either can be
// retried, and a push-only rule has no inbox row at all. So the ref is a HINT.
// The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a
// client that renders the ref instead of pulling is a client that will show
// nothing the first time a retry reorders these two rows.
const inbox = require('../model/userNotifications/userNotifications.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const pushDispatch = require('../utils/pushDispatch')
const log = require('../utils/logger')('engagement')
/**
* Can this channel reach `userId`?
*
* Active account only, the same re-check `emailChannel` and `inappChannel` make
* for the same reason (a row can sit through a `delay_seconds` window). It does
* NOT check for a registered device: whether any endpoint is subscribed is the
* question `publishToUsers` answers in its own query, and asking it twice would
* mean two different definitions of "reachable" that could disagree.
*/
const addressFor = async (userId) => {
const active = await recipients.filterActive([userId])
return active.length ? { address: String(active[0]) } : null
}
/**
* Deliver one claimed outbox row.
*
* @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>}
*/
async function deliver(row) {
try {
if (!(await addressFor(row.user_id))) {
return { ok: false, detail: 'this user can no longer be reached' }
}
// Best effort, and it fails to null rather than to an error: no dedupe key,
// no in-app row for it, or an inapp row this rule never enqueued all mean
// the same thing to the app — wake up and pull.
let ref = null
try {
const item = await inbox.findByDedupe(row.user_id, row.dedupe_key)
if (item) ref = `notification:${item.id}`
} catch (err) {
log.debug('could not resolve a push ref', { outbox: row.id, message: err.message })
}
// The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2.
// A push stream and an event trigger share a name space, so the app's
// existing `{ stream }` switch keeps working for an engagement rule without
// learning a second vocabulary.
await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] })
// **Success here means "handed to the relay", and the send log must not
// claim more than that.** `publishToUsers` resolves whether it found a
// subscribed device or none at all, and a tickle is fire-and-forget over
// HTTP to a relay that owes us no receipt. Retrying on "we are not sure"
// would mean five wakeups for one event, which is worse than one uncertain
// log line — so this is the one channel whose 'sent' is weaker than email's,
// and saying so in the detail is how an operator reading G15 finds that out.
return { ok: true, transport: 'unifiedpush', detail: 'tickle published' }
} catch (err) {
// pushDispatch never throws, so reaching here is a programming error rather
// than a relay being down. Terminal for that reason: retrying a bug is five
// identical rows in the send log.
log.error('push delivery failed', { outbox: row.id, message: err.message })
return { ok: false, detail: `delivery error: ${err.message}` }
}
}
module.exports = { addressFor, deliver }

View File

@@ -0,0 +1,122 @@
// ── Scoped preferences: "this channel, for this one Team" ──────────────────
//
// ENGAGEMENT.md Phase 6, decision 4. `notification_channel_prefs` is keyed
// (user, stream, channel) and has no scope column; `team_notification_prefs` is
// keyed (user, Team) and is the preference people actually hold today — someone
// in six Teams silences one. Migrating the second into the first would mean a
// live migration of user data, a wire-shape change on two clients, and the loss
// of the granularity in between. The org lead's decision was to keep the Team
// table and have the engine consult it; this file is the seam that lets it,
// without core's engine learning what a Team is.
//
// A registrant claims a scope PREFIX — the part of a scope key before the colon,
// `team` in `team:12` — and answers, for a set of users and one channel, what
// that scope says their mode is.
//
// **Where a scope answers, its answer REPLACES the stream-level preference; it
// does not intersect with it.** The decision was phrased as "a suppression below
// the channel preference", and building it showed that reading is the one that
// cannot ship: `notification_channel_prefs` holds a row only where a user has
// expressed something, absence means the channel's `defaultMode`, and email's is
// `off`. Nobody has ever expressed a stream-level opinion about `team.forum.post`
// — the screen that would let them is Phase 3's and the preference predates it —
// so intersecting would resolve every existing Team-email subscriber to `off` and
// silence the entire live pipeline on the deploy that migrated it. That is the
// G22 failure mode with a different cause. Replacement keeps today's behaviour
// byte-for-byte: for a Team-scoped event, `team_notification_prefs` is the
// preference, exactly as it has been since Teams shipped.
//
// The cost, stated so nobody has to rediscover it: a user cannot turn Team email
// off for every Team at once from the channels screen. That control lives on the
// per-Team screen, which is where it has always lived and where the unsubscribe
// link points.
//
// Nothing here caches. A preference read is one indexed query per (event,
// channel), against a table the user can change between two events.
const log = require('../utils/logger')('engagement')
// prefix → provider
const providers = new Map()
const PREFIX_RE = /^[a-z][a-z0-9_-]*$/
/**
* Parse a scope key into its prefix and id. `''` and anything malformed are
* `null` — an unparseable scope must read as "no scope", never as some other
* scope's.
*
* @returns {{ prefix: string, id: string }|null}
*/
function parse(scopeKey) {
const raw = String(scopeKey || '')
const at = raw.indexOf(':')
if (at < 1 || at === raw.length - 1) return null
const prefix = raw.slice(0, at)
if (!PREFIX_RE.test(prefix)) return null
return { prefix, id: raw.slice(at + 1) }
}
/**
* Register a scope-preference provider.
*
* Validate-then-commit, the same discipline the transport and channel registries
* use: every check runs before the map is touched.
*
* @param {object} def
* @param {string} def.prefix the scope-key prefix this provider owns, e.g. 'team'
* @param {string} def.label operator-facing, for the send log and admin copy
* @param {(userIds: number[], channel: string, scopeId: string) => Promise<Map<number, string>>} def.modesFor
* A mode per user for the users this scope has an opinion about. A user
* left OUT of the map defers to the stream-level preference; a user in it
* is answered by the scope. Must not throw — see `resolve`.
*/
function registerScopePreference(def) {
if (!def || typeof def !== 'object') throw new Error('registerScopePreference: definition required')
const { prefix, label, modesFor } = def
if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
throw new Error(`registerScopePreference: invalid prefix ${JSON.stringify(prefix)}`)
}
if (providers.has(prefix)) throw new Error(`registerScopePreference: ${prefix} is already registered`)
if (typeof label !== 'string' || !label) throw new Error(`registerScopePreference(${prefix}): label required`)
if (typeof modesFor !== 'function') throw new Error(`registerScopePreference(${prefix}): modesFor required`)
providers.set(prefix, { prefix, label, modesFor })
return prefix
}
/**
* What does this scope say about these users on this channel?
*
* @returns {Promise<Map<number, string>>} empty when the scope is absent,
* unparseable, or owned by nobody — all three of which mean "this event
* is not scoped as far as preferences are concerned", which is the right
* answer for a module whose scope provider has been uninstalled.
*/
async function resolve(userIds, channel, scopeKey) {
const parsed = parse(scopeKey)
if (!parsed || !userIds.length) return new Map()
const provider = providers.get(parsed.prefix)
if (!provider) return new Map()
try {
const modes = await provider.modesFor(userIds, channel, parsed.id)
return modes instanceof Map ? modes : new Map()
} catch (err) {
// **Fails OPEN, and that is the uncomfortable choice made deliberately.** A
// provider that throws leaves the stream-level preference in charge, which
// for every core channel is `off` — so the practical effect of a failure is
// that nothing is sent, not that everybody is mailed. Failing closed by
// refusing the whole event would instead drop an IDOC warning because a Team
// preference query timed out.
log.error('scope preference lookup failed', { scope: scopeKey, channel, message: err.message })
return new Map()
}
}
const has = (prefix) => providers.has(prefix)
// Test-only: the registry is module-level state.
function _reset() {
providers.clear()
}
module.exports = { registerScopePreference, resolve, parse, has, _reset }

View File

@@ -0,0 +1,160 @@
// ── The suppression list ───────────────────────────────────────────────────
//
// ENGAGEMENT.md G16, Phase 9. Addresses this deployment has stopped mailing,
// and the two questions asked of them: "may I send to this one?" at delivery
// time, and "why did this one stop?" on the admin screen.
//
// **Scope: the engagement email channel only** (Phase 9 decision 2). A password
// reset, an invite, a verification mail and the contact form are all
// user-INITIATED and still attempt, exactly as they still attempt to an
// unverified address (`passwordReset.controller.js`). The posture is the same one
// that file already states: a background system's opinion about an address must
// not be able to lock somebody out of their own account. One reset to a dead
// mailbox is not a reputation problem; a rule mailing three thousand people every
// week is, and that is what this list guards.
//
// **The table holds a hash and a mask, never an address.** The hash is what
// correlates a bounce back to an `engagement_sends` row (Phase 6 was already
// writing `address_hash` on every outcome for this). The mask —
// `d***@example.com` — is Phase 9's one addition to §4.5's DDL and exists because
// a screen of sha256 digests cannot be operated: an operator has to be able to
// see that a whole domain is refusing mail, and to find the person who fixed
// their mailbox and let them back in. The local part is DESTROYED rather than
// shortened, so the column cannot be turned back into an address book.
//
// **Nothing here writes a suppression from "the send failed".** What may write
// one is `bounceClassify.classify`, which is a much narrower question — see that
// file's header for why reusing `mailer.PERMANENT_CODES` would have suppressed
// every address the moment an SMTP password went stale.
const crypto = require('crypto')
const db = require('../model/engagement/engagementSuppressions.db')
const bounceClassify = require('./bounceClassify')
const log = require('../utils/logger')('engagement')
const REASONS = ['bounce', 'complaint', 'manual', 'unverified']
/**
* The key an address is stored under.
*
* Lower-cased first, and that matters more here than anywhere else in the
* subsystem: a bounce reported for `Darrow@example.com` has to find the row
* written for `darrow@example.com`, and a hash of two spellings is two rows that
* never meet. RFC 5321 says the local part is technically case-sensitive; no
* relay anybody deploys treats it that way.
*/
const hashAddress = (address) =>
crypto.createHash('sha256').update(String(address).trim().toLowerCase()).digest('hex')
/**
* `darrow@example.com` → `d***@example.com`. Null for anything that is not an
* address.
*
* The domain survives intact because domain-level patterns are the signal an
* operator is actually looking for — "everything to this company is bouncing" is
* a different problem from three people mistyping their own address, and only the
* domain distinguishes them.
*
* The first character of the local part survives only when there are at least
* three, which is not fussiness: for a two-letter local part, one revealed
* character plus the domain is most of the address.
*/
function maskAddress(address) {
const s = String(address || '').trim()
const at = s.lastIndexOf('@')
if (at <= 0 || at === s.length - 1) return null
const local = s.slice(0, at)
const domain = s.slice(at + 1).toLowerCase()
const head = local.length >= 3 ? local[0].toLowerCase() : ''
return `${head}***@${domain}`.slice(0, 190)
}
/** Is this address suppressed on this channel? */
async function isSuppressed(address, channel = 'email') {
if (!address) return null
try {
return await db.get(hashAddress(address), channel)
} catch (err) {
// Fail OPEN, and the direction is deliberate. A database that cannot answer
// "is this suppressed" must not stop the deployment's mail; the failure mode
// it would otherwise produce is total silence with a clean send log, which is
// exactly G22's shape. Mailing one dead address during an outage is the
// cheaper mistake.
log.error('suppression check failed; sending anyway', { message: err.message })
return null
}
}
/**
* Suppress an address. Returns true when this call created the row.
*
* `reason` is validated rather than trusted: it is an ENUM in the schema, so an
* unknown value is a 500 from the driver at the worst possible moment (inside a
* failure handler), and the callers include an admin route.
*/
async function suppress({ address, reason, detail = null, channel = 'email', createdBy = null }) {
if (!address) return false
if (!REASONS.includes(reason)) throw new Error(`suppress: unknown reason "${reason}"`)
const created = await db.add({
address_hash: hashAddress(address),
address_masked: maskAddress(address),
channel,
reason,
detail,
created_by: createdBy,
})
if (created) {
// Masked, never the address — the same rule every other log line in this
// subsystem follows. It is logged at all because an address dropping off the
// mailing list is the kind of change an operator finds out about weeks later
// otherwise.
log.info('address suppressed', { address: maskAddress(address), reason, channel })
}
return created
}
/** Un-suppress. Returns true when a row was removed. */
async function unsuppress(address, channel = 'email') {
if (!address) return false
const removed = await db.remove(hashAddress(address), channel)
if (removed) log.info('suppression lifted', { address: maskAddress(address), channel })
return removed
}
/**
* Consider a failed send for suppression, and say what was decided.
*
* The seam between a delivery failure and this list, and the only one — nothing
* else in the codebase writes a `bounce` row. Called from `emailChannel.deliver`
* with the error the transport threw.
*
* @returns {Promise<{suppressed: boolean, note: string}>} `note` goes into the
* send log's detail, on both outcomes.
*/
async function considerFailure({ address, error, channel = 'email' }) {
const verdict = bounceClassify.classify(error)
if (!verdict.suppress) {
return { suppressed: false, note: `not suppressed (${verdict.reason})` }
}
try {
const detail = verdict.evidence ? `hard bounce: ${verdict.evidence}` : 'hard bounce'
await suppress({ address, reason: 'bounce', detail, channel })
return { suppressed: true, note: detail }
} catch (err) {
// A failure to record the suppression must not change how the send itself is
// reported. The mail failed either way, and that is the row the log owes.
log.error('could not record a suppression', { message: err.message })
return { suppressed: false, note: `hard bounce, not recorded: ${err.message}` }
}
}
module.exports = {
hashAddress,
maskAddress,
isSuppressed,
suppress,
unsuppress,
considerFailure,
REASONS,
}

View File

@@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([
{ name: 'year', type: 'string', required: true, example: '2026' },
])
// The per-DELIVERY additions, which are a different thing from the ambient set
// above and are declared separately because they apply to a different set of
// templates.
//
// `emailChannel.deliver` computes an unsubscribe token per recipient and merges
// it LAST over the projection, so a body may always reference it — but a template
// bound to a TRIGGER takes its variable list from that trigger's declaration
// (`variablesFor`), and a trigger has no business declaring a fact about how the
// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and
// then the save-time undeclared-variable check refuses the first operator who
// tries to EDIT the body around it.
//
// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first
// trigger-bound templates in the system to carry an unsubscribe line of their
// own: core's generic `notify.event` declares it in its own seed and is bound to
// no trigger, so nothing had ever taken this path.
const DELIVERY_VARIABLES = Object.freeze([
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
])
// A tiny helper so the block arrays below read as content rather than as JSON.
const text = (id, body, opts = {}) => ({
id,
@@ -236,21 +257,21 @@ const SEEDS = [
name: 'Team post notification',
channel: 'email',
protected: false,
seedVersion: 1,
seedVersion: 2,
subject: '{{teamName}}: {{threadTitle}}',
variables: [
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'authorName', type: 'string', required: true, example: 'Aldric' },
{ name: 'threadTitle', type: 'string', required: true, example: 'Meeting moved to Friday' },
{ name: 'excerpt', type: 'string', required: false, example: 'We are pushing this week back a day so more people can make it.' },
{ name: 'threadUrl', type: 'string', required: false, example: 'https://example.com/teams/1?thread=9' },
{ name: 'postUrl', type: 'string', required: false, example: '/guilds/the-silver-anvil/forum/412' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
text('p1', '{{authorName}} posted in {{teamName}}.'),
heading('h', '{{threadTitle}}', 'h2'),
text('excerpt', '{{excerpt}}', { muted: true }),
button('cta', 'Read the thread', '{{threadUrl}}'),
button('cta', 'Read the thread', '{{postUrl}}'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails for this team, use this link:'),
],
@@ -260,19 +281,90 @@ const SEEDS = [
name: 'On-site notification',
channel: 'inapp',
protected: false,
seedVersion: 1,
// **seedVersion 2, and the bump is a correction rather than an improvement.**
// Phase 5a wrote this template before the channel that renders it existed, and
// named its variables `body` and `url` — names NOTHING supplies. A trigger
// declares domain names (`teamName`, `threadTitle`), and `projection.project`
// fills the gaps with the STRUCTURAL ones the generic seeds use: `title`,
// `intro`, `actionUrl`. So every rendering of this template would have found
// `body` and `url` missing and produced a title and nothing else. Renamed to
// the vocabulary `notify.event` uses, which is the same property stated once:
// a new trigger must render with no authoring at all.
seedVersion: 2,
// No subject: an inbox row has a title, and the title is a block. The column
// is email's, and leaving it NULL is how a non-email template says so.
subject: null,
variables: [
{ name: 'title', type: 'string', required: true, example: 'Your house is close to collapsing' },
{ name: 'body', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'url', type: 'string', required: false, example: 'https://example.com/houses' },
{ name: 'intro', type: 'string', required: false, example: 'The Silver Anvil in Britain has entered its final decay stage.' },
{ name: 'actionUrl', type: 'string', required: false, example: '/player/uo/houses' },
],
// The three blocks map onto the three columns of `user_notifications` by ROLE
// (templates.js `renderInappByKey`): the heading is the item's title, the
// button is its one action, and everything else is the body. There is no
// unsubscribe line — an inbox item has nowhere to send someone that the
// preferences screen it links to from does not already reach.
blocks: [
heading('h', '{{title}}', 'h3'),
text('body', '{{body}}'),
button('cta', 'Open', '{{url}}'),
text('intro', '{{intro}}'),
button('cta', 'Open', '{{actionUrl}}'),
],
},
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
//
// **One body, not seven.** Six of the seven `event.` triggers render through
// `notify.event` and the structural projection with no authoring at all
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
// is already headed with the event's name — and seeding a bespoke body per
// trigger would be seven templates an operator has to maintain to change one
// sentence.
//
// `event.run.started` gets one because it is the flagship: the mail that
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
// an operator will actually enable, and the one where the generic body reads
// visibly worse — `notify.event` renders the title over the TRIGGER's
// description, while this reads the payload's own names and says what is
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
// makes beside the generic body, one feature along.
//
// **Every optional line is one token on its own**, which is this template
// language's whole conditional (see `email.text`: a block whose content is a
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
// **The button arrived with the page it points at** (Phase 14a). Until then
// there was no public event page, the six public triggers declared no url
// variable, and a button here would have rendered as an inert grey label in
// every mail — worse than no button, because it advertises a link the reader
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
// its url interpolates to nothing, so an event that is not public still mails
// correctly: the block disappears rather than degrading.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
// Bumped with the button. A deployment whose operator has not customized
// this template gets the new one; one that has is left alone and reported as
// stale, which is the whole mechanism.
seedVersion: 2,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
heading('h', '{{title}}'),
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
]
@@ -282,4 +374,4 @@ function seedByKey(key) {
return SEEDS.find((s) => s.key === key) || null
}
module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey }
module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey }

View File

@@ -18,7 +18,7 @@ const templatesDb = require('../model/engagement/engagementTemplates.db')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const emailBlocks = require('../emailBlocks')
const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds')
const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds')
// The trigger registry lives with the module registries, not here — a trigger is
// something a MODULE declares (see engagement/index.js's header).
const { eventTrigger } = require('../modules/registries')
@@ -84,6 +84,11 @@ function variablesFor(template) {
if (template && template.trigger_id) {
const declared = eventTrigger(template.trigger_id)
if (declared && Array.isArray(declared.variables)) own.push(...declared.variables)
// A trigger-bound body is engagement mail, and engagement mail always carries
// an unsubscribe the channel computes per recipient. A trigger declares what
// HAPPENED and has no business declaring how the mail was sent, so the
// delivery facts are added here rather than to every declaration.
own.push(...DELIVERY_VARIABLES)
} else if (template && template.seed_key) {
const seed = seedByKey(template.seed_key)
if (seed) own.push(...seed.variables)
@@ -123,13 +128,17 @@ function renderTemplate(template, values, resolved) {
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
* The template `key` should actually render through, or null.
*
* Extracted from `renderByKey` in Phase 7 rather than duplicated into the in-app
* channel: the fallback chain below is a policy about what this deployment sends
* when its own table is in a bad state, and a second channel resolving templates
* by its own rules would be a second answer to that. `renderInappByKey` takes the
* same rows, the same seeds and the same three refusals.
*
* @returns {Promise<{subject: string|null, blocks: object[], text_body: string|null}|null>}
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
async function resolveTemplate(key) {
let template = null
try {
template = await templatesDb.getByKey(key)
@@ -158,9 +167,113 @@ async function renderByKey(key, values = {}) {
if (unusable === 'unpublished') log.warn('stored template is a draft; using the shipped default', { key })
template = { subject: seed.subject, blocks: seed.blocks, text_body: null }
}
return template
}
/**
* Render the template stored under `key`, falling back to its shipped default.
* @returns {Promise<{subject: string, html: string, text: string, missing: string[]}|null>}
* null when `key` names no usable row AND no seed — which now includes a
* duplicated (seedless) template still in draft.
*/
async function renderByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
return renderTemplate(template, values, resolved)
}
// ── The in-app projection (Phase 7) ────────────────────────────────────────
//
// `user_notifications` has three columns — title, body, url — where email has a
// subject and a document, so the in-app channel needs the template rendered into
// those three rather than into a mail. **The mapping is by block ROLE**, and it
// is here rather than in the channel because it is a statement about what the
// block registry means, not about how a row gets written:
//
// - the first `email.heading` → `title` (a heading IS the item's headline)
// - the first `email.button` → `url` (a button IS the item's one action)
// - everything else, as TEXT → `body`
//
// **Text, not the email HTML, and that is the load-bearing choice.** The block
// renderer's HTML is built for mail clients: table rows, inline hex colours, a
// light-only palette declared with `color-scheme`. Dropped into a page that
// follows the viewer's theme it renders as a pale card floating in a dark one.
// `toText` is the same content with none of that, and it is the part the block
// contract already promises every block can produce.
//
// The three refusals a mail can afford and an inbox row cannot are handled here
// too: a title is NOT NULL, so an empty one falls back to the projected `title`
// and then to the trigger id; and a url that is not site-relative is dropped
// rather than stored, because the column's whole contract is that a template
// cannot aim a signed-in user's click off-site.
const HEADING = 'email.heading'
const BUTTON = 'email.button'
// user_notifications.title / .url. Truncated rather than refused: a long title is
// a cosmetic problem and a dropped notification is not.
const MAX_TITLE = 300
const MAX_URL = 500
// The same character class `pageUrlTemplate` and the engine's `url` variables
// use (registries.js, engagementEmit.js). Duplicated as a literal rather than
// imported from `engagementEmit`, which would be a cycle through the engine.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
/**
* Site-relative form of `raw`, or null.
*
* An absolute url on this deployment's own base is accepted and reduced — a
* template that writes `{{siteUrl}}/guilds/4` is saying the same thing as
* `/guilds/4`, and refusing it would make the ambient `siteUrl` variable a trap
* in the one channel where the link never leaves the site.
*/
function relativeUrl(raw, base) {
const value = String(raw || '').trim()
if (!value) return null
const stripped = base && value.startsWith(`${base}/`) ? value.slice(base.length) : value
if (!RELATIVE_URL.test(stripped)) return null
return stripped.slice(0, MAX_URL)
}
/**
* Render one template into an inbox item.
*
* @returns {Promise<{title: string, body: string|null, url: string|null, missing: string[]}|null>}
* null when `key` names no usable row and no seed — the caller reports a
* terminal failure, exactly as the email channel does.
*/
async function renderInappByKey(key, values = {}) {
const resolved = await ambient()
const template = await resolveTemplate(key)
if (!template) return null
const merged = { ...values, ...resolved.values }
const missing = new Set()
const ctx = emailBlocks.buildContext({
values: merged,
theme: resolved.theme,
baseUrl: resolved.baseUrl,
missing,
})
const blocks = Array.isArray(template.blocks) ? template.blocks : []
const visible = blocks.filter((b) => b && b.visible !== false)
const heading = visible.find((b) => b.type === HEADING)
const button = visible.find((b) => b.type === BUTTON)
// Only the FIRST of each is consumed; a second heading or button is ordinary
// body content, which is what an operator who added one meant.
const rest = visible.filter((b) => b !== heading && b !== button)
const headingText = heading ? ctx.t((heading.props || {}).text || '').trim() : ''
const title = (headingText || String(merged.title || '').trim() || key).slice(0, MAX_TITLE)
const url = button ? relativeUrl(ctx.t((button.props || {}).url || ''), resolved.baseUrl) : null
const body = emailBlocks.renderBlocks(rest, ctx).text.trim()
return { title, body: body || null, url, missing: [...missing] }
}
/**
* Ensure every shipped template exists, and bring un-customized rows up to the
* current seed. Idempotent: a second run reports nine skips and writes nothing.
@@ -215,4 +328,16 @@ async function seedTemplates() {
const KEY_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_KEY = 96
module.exports = { ambient, variablesFor, renderTemplate, renderByKey, seedTemplates, baseUrl, KEY_RE, MAX_KEY }
module.exports = {
ambient,
variablesFor,
renderTemplate,
resolveTemplate,
renderByKey,
renderInappByKey,
relativeUrl,
seedTemplates,
baseUrl,
KEY_RE,
MAX_KEY,
}

View File

@@ -0,0 +1,258 @@
// ── A run's lifecycle, told to the engagement engine ───────────────────────
//
// EVENTS.md §J, and Phase 10 of EVENTS_PLAN.md. Seven moments in a run's life
// become seven `event.` triggers, and **Events owns none of the delivery**.
//
// That sentence is the whole design and it is worth being exact about what it
// buys. Nothing in this file knows what email is, whether anyone is subscribed,
// what a template says, or how often somebody may be mailed. It says a thing
// happened, with the facts the declaration asked for; an operator's rule decides
// the rest. Every announcement channel the platform has — email, the in-app
// inbox, content-free push tickles, Discord and the town crier through the
// announce legs — arrives for free the day a rule points at one, and none of
// them arrives by anything in `events/` growing a second delivery path.
//
// **Nothing here throws and nothing here is awaited for its answer.** `emit`
// itself is fire-and-forget by construction (see `engagementEmit`'s header) —
// the whole point of the seam is that the emitter does not wait on rule lookups
// and a dozen inserts. What IS awaited here is the read that assembles the
// payload, and it is wrapped: a run must not fail to start because the row that
// says what it is called could not be read.
//
// **A rehearsal narrows the ceiling rather than staying silent.** §I: "run for
// real with announcements ceilinged to `staff`". Every emit below carries
// `ceiling: 'staff'` when the run is a rehearsal, so the same triggers fire, the
// same rules are evaluated, the same log lines are written — and the only rules
// that survive the G24 gate are ones whose audience a staff member is in. A
// rehearsal that emitted nothing would be a rehearsal of everything except the
// announcements, which are the part most worth rehearsing.
const definitionsDb = require('../model/events/eventDefinitions.db')
const participantsDb = require('../model/events/eventRunParticipants.db')
const logDb = require('../model/events/eventRunLog.db')
const engagementEmit = require('../utils/engagementEmit')
const log = require('../utils/logger')('events')
// §I, and the one place a rehearsal differs from the real thing on the announce
// path. `staff` rather than `admin` because a rehearsal is the event team's
// dress run and a moderator on it should see what an attendee would.
const REHEARSAL_CEILING = 'staff'
/**
* The start time written out in the shard-local zone, for a mail to read.
*
* **A presentational fragment computed at the emitter** (ENGAGEMENT.md §4.6.1
* convention 1). `startsAt` also goes down the wire as a `datetime`, which the
* seam normalises to an ISO string — right as data and wrong in a sentence — and
* a template has no logic with which to format one. The zone is the shard's own,
* because "8pm" means the shard's evening to everyone reading it and the
* recipient's browser is not in the room when a mail is rendered.
*
* A bad zone answers null rather than throwing: `Intl` rejects an unknown
* identifier, and an event whose timezone column holds a typo must still
* announce. The variable is optional and its block is one token, so an absent
* label renders as nothing at all rather than as a broken line.
*/
function startsAtLabel(at, zone) {
const when = at instanceof Date ? at : new Date(at)
if (Number.isNaN(when.getTime())) return undefined
try {
const text = new Intl.DateTimeFormat('en-GB', {
timeZone: zone || 'UTC',
weekday: 'long',
day: 'numeric',
month: 'long',
hour: 'numeric',
minute: '2-digit',
// Explicit rather than left to the locale, because `en-GB` would otherwise
// render midnight as "00:00" while the schedule editor beside it writes
// "12:00 AM" — one event, two spellings of the same instant.
//
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
// and does not survive a Node upgrade. For a locale whose default cycle is
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
// is V8's ECMA-402 behaviour and not locale data, and the image ships
// node:20-alpine 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. `recurrence.js` states the mirror-image rule for `h23`; there
// is no `hour12` left in this repo and it should stay that way.
hourCycle: 'h12',
}).format(when)
return `${text} (${zone || 'UTC'})`
} catch {
return undefined
}
}
/**
* The facts every `event.` trigger shares, read once per emit.
*
* A run row from `findDue` is `SELECT *` over `event_runs` alone — no title, no
* series, no summary — so the definition is fetched here rather than threaded
* through every call site in the runner. It is one indexed read per lifecycle
* transition, which is a handful per run.
*/
async function baseFor(run) {
const definition = await definitionsDb.getById(run.definition_id)
if (!definition) return null
return {
runId: String(run.id),
title: definition.title,
summary: definition.summary || undefined,
seriesName: definition.series_name || undefined,
timezone: run.timezone || definition.timezone || undefined,
eventUrl: eventUrl(definition, run),
definition,
}
}
/**
* The public page for one occurrence (Phase 14a).
*
* **Site-relative, and it carries the run.** The page lives at the definition's
* slug — one stable address for a weekly event, which is what makes a link in
* Discord survive a retitle — so the occurrence has to be in the query string or
* a mail about last Friday's invasion would open next Friday's.
*
* **`undefined` when the event is not public**, rather than a path that answers
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
* declared optional precisely so its block can disappear from a template instead
* of rendering a dead button. That is `news.post`'s lesson applied before it
* costs anything: a link nobody can follow is worse than no link, because it
* advertises one.
*/
function eventUrl(definition, run) {
if (!definition.slug) return undefined
if (definition.state !== 'ready' || !definition.listed) return undefined
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
}
/**
* Fire one lifecycle trigger.
*
* `extra` is merged over the shared facts and may drop any of them — a payload
* key set to `undefined` is simply absent, and `validatePayload` treats absent
* and null alike, so a trigger that declares fewer variables than this assembles
* is not a problem: undeclared keys are dropped at the seam and logged as a
* debug line rather than refused.
*
* Answers nothing. Every caller is a transition in the runner and none of them
* has anything it could correctly do with a failure of core's own notification
* bookkeeping.
*/
async function fire(run, triggerId, extra = {}) {
try {
const base = await baseFor(run)
if (!base) {
// The definition is gone. `event_runs.definition_id` cascades on delete, so
// this is a race with an archive rather than an ordinary state — nothing to
// announce and nothing broken.
return
}
const { definition, ...facts } = base
const ceiling = run.rehearsal ? REHEARSAL_CEILING : undefined
engagementEmit.emit('core', triggerId, {
// The run, not the definition. Two occurrences of a weekly event are two
// subjects, so last week's mail does not throttle this week's — and within
// one run a cooldown means "at most one line an hour about THIS", which is
// the sentence an operator writing `phase.changed` actually wants.
subject: String(run.id),
// Bounded and stable, because it ends up in a signed unsubscribe token that
// will sit in a mailbox for months. A run id is both.
scopeKey: `event:${run.id}`,
ceiling,
data: { ...facts, ...extra },
})
// Written here rather than at each call site: what an operator wants in the
// run log is that the run SAID something happened, and with what bound. How
// many people were told is the engagement engine's own log line and its own
// decision — a run log that claimed to know the number would be reporting a
// decision it does not make.
await logDb.write({
runId: run.id,
kind: 'announcement.emitted',
phase: run.current_phase || null,
detail: { trigger: triggerId, ...(ceiling ? { ceiling, because: 'rehearsal' } : {}) },
})
} catch (err) {
log.error('lifecycle announcement failed', { run: run.id, trigger: triggerId, message: err.message })
}
}
// ── One function per moment, so the runner names a moment and not a payload ──
//
// The alternative — `fire(run, 'event.run.started', { … })` at each call site —
// would put the payload assembly in `eventRunner.js`, where a change to a
// declaration becomes a change to the runner. These are the seam.
const runScheduled = (run) =>
fire(run, 'event.run.scheduled', {
startsAt: run.scheduled_for,
startsAtLabel: startsAtLabel(run.scheduled_for, run.timezone),
})
const runStarted = (run, startedAt) => {
const at = startedAt || run.started_at || new Date()
return fire(run, 'event.run.started', { startsAt: at, startsAtLabel: startsAtLabel(at, run.timezone) })
}
const phaseChanged = (run, { phase, label, index, count }) =>
fire(run, 'event.phase.changed', {
phase,
phaseLabel: label || phase,
// One-based, because it is read by a human in a sentence. Every caller
// passes the zero-based index it already has and the conversion is here, in
// one place, rather than at three call sites where two of them would drift.
phaseIndex: index + 1,
phaseCount: count,
})
const runEnding = (run) => fire(run, 'event.run.ending')
async function runCompleted(run, endedAt) {
// Counted at emit rather than carried by the caller: the last thing a run does
// before completing is its teardown, and a module's collect step may have
// written rows within the same tick.
let participantCount = 0
try {
participantCount = await participantsDb.countForRun(run.id)
} catch (err) {
// Declared `required`, so it has to be a number. Zero is the honest answer
// for a count that could not be read, and it is also the answer for the far
// more common case of a run nothing collected for.
log.warn('participant count unavailable for announcement', { run: run.id, message: err.message })
}
const started = run.started_at ? new Date(run.started_at) : null
const ended = endedAt ? new Date(endedAt) : new Date()
const durationMinutes = started ? Math.max(0, Math.round((ended - started) / 60_000)) : 0
return fire(run, 'event.run.completed', { participantCount, durationMinutes })
}
const runCancelled = (run, reason) =>
fire(run, 'event.run.cancelled', { reason: reason || undefined })
const runFailed = (run, error) =>
fire(run, 'event.run.failed', {
phase: run.current_phase || undefined,
error: error || run.last_error || undefined,
// The one destination that exists today. See `coreTriggers.js`'s note above
// the six public declarations for why none of them has one.
runUrl: `/admin/events/runs/${run.id}`,
})
module.exports = {
fire,
startsAtLabel,
runScheduled,
runStarted,
phaseChanged,
runEnding,
runCompleted,
runCancelled,
runFailed,
REHEARSAL_CEILING,
}

View File

@@ -0,0 +1,400 @@
// ── The whole authorisation decision, behind one function ──────────────────
//
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
// action being *declared* and an action being *carried out* — role, enablement,
// cap, and the shard's own switch — and §K asks for them in one place rather
// than spread across route middleware:
//
// > **Keep the check in one function.** Not for tidiness: it is what makes an
// > EM-style delegation model a *later* option rather than a redesign. If a
// > deployment ever wants named coordinators with their own budgets, that is
// > one function learning to consult a second table, and nothing else in this
// > document changes.
//
// So `mayInvoke()` below is the only thing in this codebase that answers "may
// this happen". The router still calls `requireRole` — that gate is about
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
// decided here, once, on every path that can cause it: authoring a step,
// publishing, the dry run, starting a run, and the runner's own unattended
// dispatch.
//
// ## Four layers, and where each one is actually enforced
//
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
// checked here: `dispatch.js` already answers a step whose action nobody
// registers, and it answers it `dormant` rather than `refused`, because an
// uninstalled module is a different fact from a forbidden one.
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
// 3. **Enablement and caps** — this file, against `event_action_settings` and
// `event_run_budget`.
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
// live on the shard host, outside the website's reach entirely, and core
// deliberately does not duplicate them. A module honours them when it
// translates an action into a sidecar command (P9); a second copy of that
// decision in core would be a copy that could disagree with the shard about
// whether the shard is accepting writes. It is named as a layer because
// leaving it unnamed is how it comes to be re-implemented.
//
// ## The role line, and why it is drawn at `change`
//
// §K's table says "any step whose action is above `notify`, and the action
// switchboard — `admin` only". Read literally that is the same sentence that made
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
// between `notify` and `inspect`. An `inspect` action reads state and writes
// nothing, so neither the default nor the role floor gains a deployment anything
// by excluding it, and an editor who cannot author a step that WAITS has an
// authoring role that cannot author.
//
// ## Why `user` may be null, and what that means
//
// The runner dispatches with nobody logged in. It is not "the system escalating":
// the role was checked when a human published the version and again when a human
// or the scheduler started the run, and **a run already in flight is not re-gated
// against its starter's current role**. Re-checking would mean that demoting an
// admin at midnight silently strands every event they started — an event stopping
// halfway through because of an unrelated personnel change. §K's "a demoted user
// loses access at once" is about reaching a route, and it still holds exactly
// there. Cancel is the control for a run that should stop.
//
// ## Why the cap check can spend
//
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
// checking and then spending would be two statements with a race between them —
// the exact race the conditional increment exists to remove. `spend: true` is
// therefore a parameter rather than a separate function: one decision procedure,
// one set of layers, and the authoritative check is the one that also commits.
const settingsDb = require('../model/events/eventActionSettings.db')
const budgetDb = require('../model/events/eventRunBudget.db')
const registries = require('../modules/registries')
const log = require('../utils/logger')('events')
// The risk classes that change the world, and the two things that follow from
// being on this list: the action arrives DISABLED on a fresh deployment, and only
// an admin may author a step that names it. Both were one sentence in §K and both
// were settled together (org lead, 2026-09-03).
const WORLD_CHANGING = ['change', 'irreversible']
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
/**
* Whether an action is enabled, given the deployment's stored opinion — or, when
* it has none, its risk class.
*
* Exported because the switchboard renders the same answer, and a screen that
* computed the default itself would be a second copy of the posture.
*/
function isEnabled(action, settingsRow) {
if (settingsRow) return Boolean(settingsRow.enabled)
return !changesWorld(action)
}
/**
* What one invocation of `action` costs, as `{dimension: amount}`.
*
* **A module's `cost()` is called here and nowhere else.** It is declared as a
* function of params (§F) and it is called with the params a step actually
* carries, so the number core enforces is the number the module said. A `cost`
* that throws, or that answers something other than a flat object of
* non-negative finite numbers, is treated as an unpriceable action rather than a
* free one: `null` comes back, and every caller reads `null` as a refusal. That
* is the fail-closed direction, and it is the only honest one — an action whose
* own accounting is broken is not an action whose consumption is zero.
*/
function priceOf(action, params) {
if (typeof action?.cost !== 'function') return {}
let raw
try {
raw = action.cost(params || {})
} catch (err) {
log.warn('event action cost() threw', { action: action.id, message: err.message })
return null
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
const out = {}
for (const [dimension, amount] of Object.entries(raw)) {
const n = Number(amount)
if (!Number.isFinite(n) || n < 0) return null
if (n > 0) out[dimension] = n
}
return out
}
/**
* The dimensions an action can spend, discovered by pricing its declared
* examples.
*
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
* which is what the screen reads. What a registry cannot answer is *which*
* dimensions THIS action spends, because `cost` is a function of params (§F) and
* the only honest way to ask it is to call it. So the discovery stays: core
* prices each action's own `example` values, which is a use every param already
* has a required `example` for.
*
* It is honest about its limits: a `cost()` that returns different dimension KEYS
* for different params under-reports here. That costs an operator a cap box on
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
* from the params its steps were actually authored with, never from examples.
*/
function dimensionsOf(action) {
const params = {}
for (const p of action?.params || []) {
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
}
const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : []
}
/**
* The same dimensions, dressed with what the registry says they are called.
*
* The switchboard's read (Phase 7). `registered: false` is the case worth having
* a field for: an action that prices a dimension no module declares is a
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
* save refuses it — so the screen has to be able to show the operator the reason
* their action will not run, rather than silently listing one fewer cap box than
* the action has dimensions. Hiding it would make a broken module look like a
* cheap one.
*/
function budgetsOf(action) {
return dimensionsOf(action).map((id) => {
const declared = registries.eventBudget(id)
return declared
? { id, label: declared.label, unit: declared.unit, registered: true }
: { id, label: id, unit: '', registered: false }
})
}
/**
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
*
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
* rule only if something asks, and this is what asks — from `mayInvoke` at
* dispatch and at the dry run, and from the spec validator at save. Three places
* because they answer at three different moments and only the first of them is
* cheap: catching it at save costs an editor a red line, catching it at dispatch
* costs a run a refused step at two in the morning.
*
* Not folded into `priceOf`, which answers *what does this cost* and should keep
* answering only that: a cost of 12 creatures is a true statement about the
* action whether or not anyone declared the dimension, and the two facts have
* different fixes.
*/
function undeclaredDimensions(cost) {
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
}
/**
* The effective per-run cap for each dimension a set of steps will spend.
*
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
* is per action while `event_run_budget` is one row per dimension, so two actions
* both spending `uo.creatures` have to agree on one number, and the number a
* safety limit should settle on is the smaller. It is what keeps a dimension a
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
* can each draw in full.
*
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
* seeded, so the meter counts it and a missing row keeps its one meaning.
*
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
*/
function effectiveCaps(steps, settingsByAction) {
const out = {}
for (const step of steps || []) {
const action = registries.eventAction(step.actionId)
if (!action) continue
const priced = priceOf(action, step.params)
if (!priced) continue
const declared = (settingsByAction.get(action.id) || {}).caps || {}
for (const dimension of Object.keys(priced)) {
const raw = declared[dimension]
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
if (!(dimension in out)) {
out[dimension] = { cap, from: cap === null ? null : action.id }
continue
}
const held = out[dimension]
// `null` is uncapped, so it never wins a minimum — an action that declines
// to cap a dimension must not raise the ceiling another action set.
if (cap !== null && (held.cap === null || cap < held.cap)) {
out[dimension] = { cap, from: action.id }
}
}
}
return out
}
/**
* May this action be carried out, and — when asked — spend its cost.
*
* Answers an envelope, never throws, and never answers a bare boolean: every
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
* The reason is written here rather than at the four call sites for the same
* argument Phase 5 made about the diagnosis panel — one place the words are
* written, so the dry run, the editor, the run console and the log all say the
* same sentence about the same fact.
*
* `{ user }` null means the unattended runner; see the header. `{ run }` null
* means there is no budget to draw on yet — authoring and the dry run — and the
* cap layer then compares the cost against the effective cap instead of against
* what is left of it.
*/
async function mayInvoke({
user = null,
action,
params = {},
run = null,
settings = undefined,
spend = false,
} = {}) {
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
// ── Layer 2: the role ──
if (user && changesWorld(action) && user.role !== 'admin') {
return {
ok: false,
code: 'role',
reason: `"${action.label}" changes the world, so only an administrator may use it`,
}
}
// ── Layer 3a: enablement ──
const row = settings === undefined ? await settingsDb.get(action.id) : settings
if (!isEnabled(action, row)) {
return {
ok: false,
code: 'disabled',
reason: `"${action.label}" is not enabled on this deployment`,
}
}
// ── Layer 3b: the cap ──
const cost = priceOf(action, params)
if (cost === null) {
return {
ok: false,
code: 'unpriceable',
reason: `"${action.label}" could not report what it costs`,
}
}
const dimensions = Object.keys(cost)
if (!dimensions.length) return { ok: true, cost }
// Before any cap arithmetic, because a dimension nobody declared has no cap to
// be under and no meter to draw on — asking "is 12 within the limit" about a
// resource core has never been told the name of would be answering a question
// that has not been asked yet. It is also the honest reading of the refusal:
// this is a module whose declaration is incomplete, not a deployment whose
// allowance is spent, and an operator who is told the second will go and raise
// a cap that changes nothing.
const undeclared = undeclaredDimensions(cost)
if (undeclared.length) {
return {
ok: false,
code: 'undeclared',
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
dimension: undeclared[0],
requested: cost[undeclared[0]],
}
}
if (!run) {
// No run, so nothing to draw on: the question is whether the cost could EVER
// fit, which is what the dry run and the editor are asking. A cost larger
// than the cap is an authoring error and it is answerable before anything is
// scheduled — which is the entire value of catching it here.
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
for (const dimension of dimensions) {
const { cap } = caps[dimension] || { cap: null }
if (cap !== null && cost[dimension] > cap) {
return {
ok: false,
code: 'cap',
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
dimension,
requested: cost[dimension],
cap,
}
}
}
return { ok: true, cost }
}
if (!spend) {
// A read of the meter rather than a draw on it. Deliberately advisory: this
// answer is stale the moment another step in the same tick spends, which is
// exactly why the authoritative check is the one that commits.
const rows = await budgetDb.forRun(run.id)
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
for (const dimension of dimensions) {
const held = byDimension.get(dimension)
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
}
}
return { ok: true, cost }
}
// ── The committing path ──
//
// One statement per dimension, because the atomicity that matters is per
// dimension: a cap is a bound on one thing, and a transaction spanning three of
// them would serialise three unrelated counters to buy nothing. What it does
// create is a partial spend — creatures taken, bosses refused — and a step that
// did not run must not have spent anything, so the taken ones are given back.
const taken = []
for (const dimension of dimensions) {
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
taken.push(dimension)
continue
}
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
const rows = await budgetDb.forRun(run.id)
const held = rows.find((r) => r.dimension === dimension)
return held
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
}
return { ok: true, cost, spent: true }
}
/** The two cap refusals, written once so they cannot drift apart. */
function refusal(dimension, requested, cap, consumed, code) {
if (code === 'unbudgeted') {
return {
ok: false,
code: 'unbudgeted',
reason: `spends "${dimension}", which this run has no budget for`,
dimension,
requested,
}
}
return {
ok: false,
code: 'cap',
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
dimension,
requested,
cap,
consumed,
}
}
module.exports = {
mayInvoke,
isEnabled,
priceOf,
dimensionsOf,
budgetsOf,
undeclaredDimensions,
effectiveCaps,
changesWorld,
WORLD_CHANGING,
}

View File

@@ -0,0 +1,437 @@
// ── Giving back what a run took ────────────────────────────────────────────
//
// EVENTS.md §C ("Cleanup is generated, never authored"), §L and its two ledger
// rules, and Phase 8 of EVENTS_PLAN.md. `events/ledger.js` is the write half;
// this is the undo half, plus the reconcile that answers "is any of it still
// there?" after something outside core restarted.
//
// **Cleanup is derived from the ledger, never authored.** An operator cannot be
// relied on to write the undo, and an aborted run never reaches the phase they
// wrote it in — so there is no cleanup phase in a spec and no `on_teardown` on an
// action. There is one function, it reads rows, and it runs on EVERY terminal
// path: completion, cancellation and abort alike.
//
// **It is not built out of `event_run_steps` rows** (org lead, 2026-09-03). The
// plan's phrase is "cleanup steps are generated from the ledger", and the
// tempting reading is a synthetic phase of real step rows so the console's
// per-step retry comes free. It is the wrong shape here for one concrete reason:
// `event_run_resources` already carries `revert_attempts` and `last_error`, so
// synthetic steps would put a second retry counter beside the first and the two
// would disagree the first time a step reverted three of its four resources.
// The manual retry the API surface promises is a route over the ledger —
// `POST /admin/events/runs/:runId/cleanup` — rather than a step control.
//
// **Where it runs from.** One place: the runner's cleanup leg, which finds
// terminal runs that still owe the world something and works their rows. Hooking
// each terminal path instead would be four call sites, three of which are inside
// a request, and none of which would survive the process dying mid-cleanup. The
// leg is ordered AFTER advance in the tick, so a run that completes in one tick
// is cleaned in the same one.
//
// **The scan's WHERE clause cost two live-walk findings, in opposite
// directions.** A run whose only resource was a LEASE never went through
// `ledger.markRunDirty` — `core.lease` reserves its own row — so its
// `cleanup_status` stayed `not_required` and the lease was never given back at
// all. And a run whose first sweep failed was moved to `incomplete` by that very
// sweep, so it was never picked up again: `MAX_REVERT_ATTEMPTS` meant ONE attempt
// rather than three. The first is why `not_required` is in the scan; the second
// is why `incomplete` is written HERE only once nothing retryable is left.
//
// **Rule 2 is what the bounds are for.** A revert that never succeeds must stay
// visible rather than cycle: `MAX_REVERT_ATTEMPTS` stops the automatic retry, the
// run reaches `completed` with `cleanup_status = 'incomplete'`, and the rows stay
// on the console with their last error. Only a human's cleanup clears the
// counter — Engagement Phase 14's rule, whose defect was a sweep that reset every
// stale row and made the ceiling unreachable for ever.
const resourcesDb = require('../model/events/eventRunResources.db')
const runsDb = require('../model/events/eventRuns.db')
const logDb = require('../model/events/eventRunLog.db')
const stepsDb = require('../model/events/eventRunSteps.db')
const registries = require('../modules/registries')
const { withDeadline } = require('./dispatch')
const log = require('../utils/logger')('events')
// How many times the automatic sweep will ask before leaving a resource for a
// human. Three, like a step's, and for the same reason: a fourth attempt against
// a shard that has answered the same way three times is not new information.
const MAX_REVERT_ATTEMPTS = Number(process.env.EVENT_REVERT_MAX_ATTEMPTS) || 3
// The bound on one revert call, when the action that made the resource is gone
// and there is no `budgetMs` to read. A restore is a round trip like any other.
const DEFAULT_REVERT_BUDGET_MS = 10_000
// How many runs one cleanup leg looks at, and how many resource groups it works
// per run. Bounds rather than targets, exactly like `RUN_BATCH`: the tick runs
// again, and an unbounded teardown is how one run's bad night stalls every other.
const CLEANUP_RUN_BATCH = Number(process.env.EVENT_CLEANUP_RUN_BATCH) || 10
const CLEANUP_GROUPS_PER_RUN = Number(process.env.EVENT_CLEANUP_GROUPS_PER_RUN) || 25
/**
* Classify one revert answer, with `dispatch.classify`'s posture: no shape a
* failure can take may read as success.
*
* The extra value here is `drifted`. It is NOT an error — the module did exactly
* what it was asked and found somebody else's value in place — so it is a third
* outcome rather than a failure with a flag, and the row it produces is the one
* §L wants surfaced beside the unreverted ones.
*/
function classifyRevert(raw, what) {
if (raw && raw.__timedOut) return { outcome: 'retry', error: raw.error }
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return { outcome: 'retry', error: `${what} answered with no envelope` }
}
if (raw.ok === true) {
// §L, and the Rust wipe: "gone, and that is fine" is a successful revert. A
// module never has to distinguish "I deleted it" from "it was not there".
return { outcome: 'done', failed: Array.isArray(raw.failed) ? raw.failed.map(String) : [] }
}
if (raw.drifted === true) {
return {
outcome: 'drifted',
error: `the value is now ${JSON.stringify(raw.current)} rather than what this run applied, so it was left alone`,
}
}
return {
outcome: raw.retry === false ? 'terminal' : 'retry',
error: raw.error ? String(raw.error) : `${what} refused`,
}
}
/** Call a lease's `restore`, under a deadline, never throwing. */
async function restoreLease(row) {
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
// (Phase 12b). A bare map lookup would miss every property lease and report
// "no module registers" for one that is registered — leaving a spawner turned
// up for good and blaming an uninstalled module for it.
const found = registries.eventLeaseForRef(row.ref)
const lease = found && found.lease
if (!lease) {
// The module that owned it is uninstalled or failed to boot. Not a failure to
// retry away — nothing will change until an operator reinstalls it — and not
// an orphan either, because core has no idea whether the value is still
// applied. It stays unresolved with the reason on it, which is exactly what
// `cleanup_status = 'incomplete'` is for.
return { outcome: 'terminal', error: `no module registers the lease "${row.ref}"` }
}
const payload = row.payload || {}
let raw
try {
raw = await withDeadline(
() =>
lease.restore(payload.baseline, {
expected: payload.applied,
runId: row.run_id,
target: found.target,
}),
DEFAULT_REVERT_BUDGET_MS,
row.ref,
)
} catch (err) {
return { outcome: 'retry', error: err.message }
}
return classifyRevert(raw, row.ref)
}
/** Call an action's `revert` over a group of its rows, under a deadline, never throwing. */
async function revertGroup(actionId, rows, idempotencyKey) {
const action = registries.eventAction(actionId)
if (!action || typeof action.revert !== 'function') {
return {
outcome: 'terminal',
error: action
? `${actionId} declares no revert()`
: `no module registers "${actionId}", so its resources cannot be given back`,
}
}
const payload = rows
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null, memberKey: r.member_key || null }))
let raw
try {
raw = await withDeadline(
() => action.revert({ runId: rows[0].run_id, resources: payload, idempotencyKey }),
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
actionId,
)
} catch (err) {
// A module should not throw from `revert` any more than from `perform`, and
// one that does has produced a transient failure rather than a crashed sweep.
log.warn('event revert threw', { action: actionId, message: err.message })
return { outcome: 'retry', error: err.message }
}
return classifyRevert(raw, actionId)
}
/**
* Work one run's ledger once.
*
* Answers what it found and what it managed, and sets `cleanup_status` from the
* rows that are left rather than from what it did — the two differ whenever
* another writer touched the run, and the rows are the truth.
*
* `resetAttempts` is the human's flag. It is never set by the automatic leg.
*/
async function cleanupRun(run, { resetAttempts = false, actor = null } = {}) {
const summary = { attempted: 0, reverted: 0, drifted: 0, failed: 0, remaining: 0 }
if (resetAttempts) {
const cleared = await resourcesDb.resetAttempts(run.id)
await runsDb.setCleanupStatus(run.id, 'pending')
if (cleared) {
await logDb.write({
runId: run.id,
kind: 'cleanup.retry',
detail: { resources: cleared, by: actor },
})
}
}
const rows = await resourcesDb.unresolvedForRun(run.id, {
maxAttempts: resetAttempts ? null : MAX_REVERT_ATTEMPTS,
})
// **Grouped by the step that made them**, because that is what names the verb:
// the resource row records the module and the opaque names, the step records
// the action, and `revert()` takes a LIST so one round trip can give back
// twelve creatures. A lease is its own group of one — core restores it through
// the lease registry rather than through any action, which is the split §F
// draws and the reason `core.lease` needs no `revert()` of its own.
const leases = rows.filter((r) => r.kind === 'override')
const byStep = new Map()
for (const row of rows) {
if (row.kind === 'override') continue
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
if (!byStep.has(key)) byStep.set(key, [])
byStep.get(key).push(row)
}
const groups = [...leases.map((r) => ({ lease: r })), ...[...byStep.values()].map((rs) => ({ rows: rs }))]
for (const group of groups.slice(0, CLEANUP_GROUPS_PER_RUN)) {
if (group.lease) {
const row = group.lease
if (!(await resourcesDb.claimRevert(row.id))) continue
summary.attempted += 1
const verdict = await restoreLease(row)
await applyVerdict(run, [row], verdict, summary, row.ref)
continue
}
const rs = group.rows
// The step is what names the action and carries the idempotency key. A row
// whose step was deleted keeps the action id in its own payload, which is why
// the placeholder writes one.
const step = rs[0].step_id === null ? null : await stepsDb.getById(rs[0].step_id)
const actionId = step?.action_id || rs[0].payload?.action || null
if (!actionId) {
await noteUnrevertable(run, rs, 'nothing records which action created this', summary)
continue
}
const claimed = []
for (const row of rs) if (await resourcesDb.claimRevert(row.id)) claimed.push(row)
if (!claimed.length) continue
summary.attempted += claimed.length
const verdict = await revertGroup(actionId, claimed, step?.idempotency_key || rs[0].ref)
await applyVerdict(run, claimed, verdict, summary, actionId)
}
summary.remaining = await resourcesDb.unresolvedCount(run.id)
// **`incomplete` means "finished with, and not finished"**, so it is written
// only once there is nothing left this sweep will try. Writing it after the
// FIRST failure — which is what the first draft did — took the run straight out
// of the leg's own scan, and `MAX_REVERT_ATTEMPTS` quietly meant one attempt
// rather than three. Found by the live walk, watching `revert_attempts` sit at
// 1 through half a minute of ticks.
const retryable = await resourcesDb.unresolvedForRun(run.id, { maxAttempts: MAX_REVERT_ATTEMPTS })
const status = summary.remaining === 0 ? 'complete' : retryable.length ? 'pending' : 'incomplete'
await runsDb.setCleanupStatus(run.id, status)
if (summary.attempted > 0) {
await logDb.write({
runId: run.id,
kind: 'cleanup.swept',
detail: { ...summary, by: actor },
})
}
return summary
}
/** Write one verdict across the rows it covers, and count it. */
async function applyVerdict(run, rows, verdict, summary, what) {
for (const row of rows) {
if (verdict.outcome === 'done' && !(verdict.failed || []).includes(row.ref)) {
await resourcesDb.markReverted(row.id)
summary.reverted += 1
continue
}
if (verdict.outcome === 'drifted') {
await resourcesDb.failRevert(row.id, verdict.error, 'drifted')
summary.drifted += 1
continue
}
const error =
verdict.outcome === 'done'
? `${what} could not give "${row.ref}" back`
: verdict.error
await resourcesDb.failRevert(row.id, error, 'confirmed')
summary.failed += 1
}
await logDb.write({
runId: run.id,
kind: verdict.outcome === 'done' ? 'cleanup.reverted' : 'cleanup.failed',
detail: {
what,
outcome: verdict.outcome,
resources: rows.map((r) => `${r.kind}:${r.ref}`),
...(verdict.error ? { error: verdict.error } : {}),
},
})
}
/** A group core cannot even name a verb for. Counted as failed, and said once. */
async function noteUnrevertable(run, rows, reason, summary) {
for (const row of rows) {
if (!(await resourcesDb.claimRevert(row.id))) continue
await resourcesDb.failRevert(row.id, reason, 'confirmed')
summary.attempted += 1
summary.failed += 1
}
await logDb.write({
runId: run.id,
kind: 'cleanup.failed',
detail: { what: null, outcome: 'terminal', resources: rows.map((r) => `${r.kind}:${r.ref}`), error: reason },
})
}
/**
* The cleanup leg of the tick: every TERMINAL run with something left to give
* back.
*
* Terminal only. A run still in flight has a ledger that is still growing, and
* reverting a resource the next step is about to use would be core undoing an
* event while it is happening.
*/
async function sweep() {
// The ceiling goes INTO the query, so a run whose rows are all spent is not
// selected, worked over and found to have nothing to do on every tick for the
// rest of its life. It is also what excludes a run an admin cancelled without
// cleanup, whose counters were spent deliberately.
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
let swept = 0
for (const candidate of candidates) {
if (!runsDb.TERMINAL.includes(candidate.status)) continue
try {
await cleanupRun(candidate)
swept += 1
} catch (err) {
log.error('event cleanup failed', { run: candidate.id, message: err.message })
}
}
return swept
}
/**
* Ask one module which of its ledgered resources the game still has (§L, and
* §N7's "the shard stays stateless about events").
*
* **Core cannot know when to ask**, and that is not an omission: §F says core has
* no concept of the game being up, because a module with six sidecars cannot
* answer that question in the singular. So the module triggers this, through
* `ctx.events.reconcile()`, when it sees its own reconnect — module-uo already
* watches `bootId` for exactly that. Core also asks once at boot, for its own
* restart.
*
* **A resource the module no longer has becomes `orphaned`, never `reverted`.**
* Reverting it would be core recording that it put something back when what
* actually happened is that the thing vanished while nobody was looking, and the
* two are different sentences to the operator reading the console afterwards.
*
* A module with no `reconcile()` on the action is not broken: core keeps
* believing its own ledger, which is precisely the behaviour before this phase.
*/
async function reconcileModule(owner) {
const rows = await resourcesDb.liveForModule(owner)
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
if (!rows.length) return summary
const byStep = new Map()
for (const row of rows) {
if (row.kind === resourcesDb.STEP_KIND) continue // nothing to ask about yet
const key = row.step_id === null ? `orphan:${row.id}` : `step:${row.step_id}`
if (!byStep.has(key)) byStep.set(key, [])
byStep.get(key).push(row)
}
for (const group of byStep.values()) {
const step = group[0].step_id === null ? null : await stepsDb.getById(group[0].step_id)
const actionId = step?.action_id || group[0].payload?.action || null
const action = actionId ? registries.eventAction(actionId) : null
if (!action || typeof action.reconcile !== 'function') {
summary.unanswered += group.length
continue
}
summary.asked += group.length
let raw
try {
raw = await withDeadline(
() =>
action.reconcile({
runId: group[0].run_id,
resources: group.map((r) => ({ kind: r.kind, ref: r.ref, payload: r.payload || null })),
}),
action.budgetMs || DEFAULT_REVERT_BUDGET_MS,
actionId,
)
} catch (err) {
log.warn('event reconcile threw', { action: actionId, message: err.message })
raw = null
}
// Same posture as everywhere else: nothing that is not an explicit answer
// counts as one. A module that could not answer leaves the ledger alone,
// because "I do not know" must never be read as "it is gone".
if (!raw || raw.__timedOut || raw.ok !== true || !Array.isArray(raw.inForce)) {
summary.unanswered += group.length
continue
}
const held = new Set(raw.inForce.map(String))
for (const row of group) {
if (held.has(row.ref)) {
summary.inForce += 1
continue
}
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
summary.orphaned += 1
await logDb.write({
runId: row.run_id,
kind: 'resource.orphaned',
detail: { module: owner, resource: `${row.kind}:${row.ref}`, action: actionId },
})
}
}
return summary
}
/** Ask every module that owns a live row. Core's own boot-time sweep. */
async function reconcileAll() {
const owners = await resourcesDb.modulesWithLiveRows()
const out = {}
for (const owner of owners) {
try {
out[owner] = await reconcileModule(owner)
} catch (err) {
log.error('event reconcile failed', { module: owner, message: err.message })
}
}
return out
}
module.exports = {
MAX_REVERT_ATTEMPTS,
classifyRevert,
cleanupRun,
sweep,
reconcileModule,
reconcileAll,
}

View File

@@ -0,0 +1,253 @@
// ── Dispatching one step to one action ─────────────────────────────────────
//
// EVENTS.md §F. This is the boundary between the runner and code core did not
// write, and it exists as its own file because it has exactly one job: call
// `perform()` and turn whatever comes back — an envelope, a lie, a throw, a
// promise that never settles — into one of four classifications the runner knows
// how to act on.
//
// **§F's load-bearing rule, and the reason none of this is inlined into the
// runner: no shape a failure can take may read as success.** A rejected promise,
// a throw, a timeout, a non-object and a missing `ok` are all
// `{ ok: false, retry: true }`. That is the inverse of `registerTeamProvider`'s
// default, deliberately — a team provider that refuses leaves core showing what
// it already had, because staleness is cheap, whereas an action that half-ran and
// was recorded as `done` is a world change nothing will ever come back for.
//
// **The timeout is the module contract's, not this file's opinion.** Every action
// declares `budgetMs` at registration and the registry bounds it there; here it
// is enforced. Without it a module whose `perform()` awaits a socket that never
// answers holds a step's claim until the lease expires, and the reclaim then
// re-dispatches it — which is how one wedged sidecar becomes an infinite loop
// rather than a failed step.
const registries = require('../modules/registries')
const log = require('../utils/logger')('events')
// What a classification can be. `parked` is Phase 2's addition and it is the one
// outcome that is neither terminal nor a retry: the action succeeded, and the
// step is not finished, because something outside this system has to happen next.
const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
// The upper bound on `holdFor`, in seconds. A wait is a scheduling instruction,
// not a lease, so this is generous — but it is bounded, because an action that
// answers `holdFor: 1e9` would park the phase past the heat death of the shard
// and the step that did it would look, in the console, exactly like one that
// worked.
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
// in `event_run_log.detail` and is read back by the run console, so it is a
// diagnostic line rather than a data channel — a module with more to say than
// this has a table of its own to say it in. Dropped rather than truncated when it
// is over: a truncated JSON object is not a JSON object, and a console that
// rendered half of one would be a second bug on top of the first.
const MAX_DETAIL_BYTES = 4096
/**
* A module's own account of what a successful step actually did.
*
* Optional, module-opaque, and **never interpreted by core** — it is carried to
* the run log and rendered, and nothing here or in the runner reads a key out of
* it. That is the whole contract: a module knows things about its own verb that
* core cannot compute and has no other way to say. `uo.item.grant` is the case
* that forced it — a grant reaches the players a run's 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.
*
* **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 would be a world write repeated for a log line. Same posture
* `participants` takes, and for the same reason.
*/
function safeDetail(detail, actionId) {
if (detail === undefined || detail === null) return null
// Objects only. The column is JSON and the console renders keys, so a bare
// string or a number has nothing to render under — and core inventing a key to
// put it beneath would be core interpreting it after all.
if (typeof detail !== 'object' || Array.isArray(detail)) {
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
return null
}
let encoded
try {
encoded = JSON.stringify(detail)
} catch (err) {
// A circular reference, or a `toJSON` that throws. Reaching the runner would
// make the INSERT throw instead, inside the one write that is documented
// never to.
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
return null
}
if (encoded === undefined) {
log.warn('action detail serialised to nothing', { action: actionId })
return null
}
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
log.warn('action detail is too large', {
action: actionId,
bytes: Buffer.byteLength(encoded, 'utf8'),
max: MAX_DETAIL_BYTES,
})
return null
}
// Re-parsed rather than passed through, so what the runner writes is a plain
// JSON value with no getters, no prototype and no live reference into whatever
// the module still holds.
return JSON.parse(encoded)
}
/**
* Run `fn()` under a deadline.
*
* The loser of the race is not cancelled — JavaScript has no such thing, and a
* `perform()` still awaiting a socket keeps awaiting it. What the deadline buys
* is that the RUNNER stops waiting, which is the half that matters: the step is
* classified, the claim is released, and the tick moves on. A late answer from
* the abandoned call lands on a step that has already been written, and the
* idempotency key is what makes the retry that follows safe on the game side.
*/
function withDeadline(fn, ms, actionId) {
let timer = null
const deadline = new Promise((resolve) => {
timer = setTimeout(
() => resolve({ __timedOut: true, error: `${actionId} exceeded its ${ms}ms budget` }),
ms,
)
if (timer.unref) timer.unref()
})
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => {
if (timer) clearTimeout(timer)
})
}
/**
* Turn a raw `perform()` answer into
* `{ outcome, error?, holdSeconds?, resources?, participants? }`.
*
* Exported and pure, so the classification rules are testable without a registry,
* a database or a clock — which matters because they are the rules that decide
* whether a world change is recorded as having happened.
*/
function classify(result, actionId) {
if (result && result.__timedOut) {
// Transient by default: a timeout says nothing about whether the action ran.
// That ambiguity is exactly what the idempotency key exists to resolve, and
// resolving it on the game side is Phase 11's protocol work — until then a
// retry is the honest choice and the risk class decides what happens when the
// retries run out.
return { outcome: 'retry', error: result.error }
}
if (result === null || typeof result !== 'object' || Array.isArray(result)) {
return { outcome: 'retry', error: `${actionId} answered with no envelope` }
}
if (result.ok !== true) {
// `retry` must be opted into. An action that means "this will never work"
// says `retry: false`, and an envelope that forgot to say anything gets the
// benefit of the doubt on the transient question but not on the success one.
const retry = result.retry !== false
return {
outcome: retry ? 'retry' : 'terminal',
error: result.error ? String(result.error) : `${actionId} refused`,
}
}
// ── The two success shapes that are not "finished" ──
//
// Both were settled by the org lead on 2026-09-02, and both are envelope
// members rather than special cases keyed on an action id, so that the runner
// never names a verb. `core.cue` and `core.wait` reach them through the same
// door Phase 7 opens to a module's own long-running action.
if (result.await === 'human') {
return {
outcome: 'parked',
error: null,
resources: result.resources || [],
participants: result.participants || [],
detail: safeDetail(result.detail, actionId),
}
}
let holdSeconds = 0
if (result.holdFor !== undefined && result.holdFor !== null) {
const n = Number(result.holdFor)
if (!Number.isFinite(n) || n < 0) {
return { outcome: 'terminal', error: `${actionId} answered a bad holdFor "${result.holdFor}"` }
}
holdSeconds = Math.min(Math.floor(n), MAX_HOLD_SECONDS)
}
// `participants` rides beside `resources` and on the same two success shapes
// (Phase 10). It is carried rather than interpreted here: what a member key
// means is the module's business, and this file's whole job is to know
// nothing about the verb it just called.
return {
outcome: 'done',
error: null,
holdSeconds,
resources: result.resources || [],
participants: result.participants || [],
// On the same two success shapes as `resources` and `participants`, and for
// the same reason: `await: 'human'` is a success, and a cue's confirm
// finishes the step without a second dispatch, so this is the only moment
// its module could ever have said anything.
detail: safeDetail(result.detail, actionId),
}
}
/**
* Dispatch one step. Never throws.
*
* `verify` rides through to `perform()` unchanged (§I's dry run, Phase 6's
* route): `verify === true` means validate and report, change nothing. It is
* passed from here rather than being a separate code path so that the dry run
* exercises the real dispatcher — a dry run down a second path is a dry run of
* the second path.
*/
async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
const action = registries.eventAction(step.action_id)
if (!action) {
// §L, verbatim: "a step naming one fails terminal with the module named, and
// the run degrades rather than claiming success. Never a silent skip." The
// module was uninstalled or failed to boot between publish and now — publish
// refuses a dormant step, so this cannot be an authoring mistake.
return { outcome: 'terminal', error: `no module registers "${step.action_id}"`, dormant: true }
}
const envelope = {
runId: run.id,
stepId: step.id,
idempotencyKey: step.idempotency_key,
scope: run.scope || '',
params: step.params || {},
actor,
verify: Boolean(verify),
}
let raw
try {
raw = await withDeadline(() => action.perform(envelope), action.budgetMs, action.id)
} catch (err) {
// A module should not throw, and if one does it is a transient failure rather
// than a crashed tick — announceWorker's posture with its legs, and the
// reason one bad module cannot stop every other run on the deployment.
log.warn('event action threw', { action: action.id, run: run.id, step: step.id, message: err.message })
return { outcome: 'retry', error: err.message }
}
const classification = classify(raw, action.id)
if (step.action_version && action.version !== step.action_version) {
// Not a refusal: the step was authored against an older declaration and the
// module has moved on. The editor is where that becomes a warning (§F); here
// it is recorded, so a run that behaved oddly can be explained afterwards by
// reading the log rather than by guessing.
classification.actionVersionDrift = { authored: step.action_version, registered: action.version }
}
return classification
}
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }

244
server/src/events/gates.js Normal file
View File

@@ -0,0 +1,244 @@
// ── Phase advance gates: the emit-path observer, and the words for the panel ─
//
// EVENTS.md §E and § Observability, and Phase 5 of EVENTS_PLAN.md. Two things
// live here because they are two halves of one claim — that an operator can
// answer *"why didn't phase 3 start?"* without reading a server log:
//
// `observe(event)` — the trigger stream's other subscriber. Beside
// `engine.dispatch`, on the same seam, with the same
// fire-and-forget posture.
// `describe(gate)` — the same gate rendered in the condition builder's own
// words, which is what the diagnosis panel shows.
//
// **Why the counting happens here and not on the runner's tick.** A gate that
// waits for three boss spawns is counting things that happen *between* ticks. A
// poller cannot count them: fifteen seconds after the third spawn there is
// nothing left to observe, and a tally kept in a process's memory is a tally a
// restart silently sets back to zero — with the phase then waiting for three
// more of something that already happened. So the emit path writes, and the tick
// reads. That division is the whole design of this file.
//
// **The cost of that, and its bound.** Every game event of every trigger some
// run is waiting on costs one indexed lookup, and the common answer is zero
// rows. Only when a gate is open does anything else happen, and then it is one
// UPDATE per open gate — bounded by how many runs can be waiting on one trigger
// at once, which is bounded by how many runs exist.
//
// **The words are rendered here, on the server, not in the client.** The panel's
// entire value is that it reads the way the condition builder reads — `gte` as
// *"is at least"*, `present` as *"is present"* — and those labels are defined in
// `engagement/conditions.js`. A renderer in the browser would be a second
// implementation of a grammar the server owns, and the first operator to meet a
// clause it spelled differently would be the operator diagnosing a stalled run
// at two in the morning.
const gatesDb = require('../model/events/eventPhaseGates.db')
const runsDb = require('../model/events/eventRuns.db')
const logDb = require('../model/events/eventRunLog.db')
const conditions = require('../engagement/conditions')
const log = require('../utils/logger')('event-gates')
// How long an `on` gate may wait before the run is called `stalled` (§E's third
// health value, which nothing had ever written before this phase). It is a
// VISIBILITY threshold and not a timeout: nothing advances, nothing fails, and
// the operator decides. An hour is long enough that a champion spawn nobody has
// killed yet is not an alarm, and short enough that a run which will wait for
// ever is on the screen inside one shift.
//
// It does not apply to an `after` gate. A phase waiting out six hours it was
// authored to wait is not stalled, it is working, and health that said otherwise
// would train an operator to ignore it.
const STALL_MS = Number(process.env.EVENT_PHASE_STALL_MS) || 60 * 60 * 1000
/** Every variable a condition tree names, in the order it names them. */
function variablesIn(node, out = []) {
if (!node || typeof node !== 'object') return out
if (Array.isArray(node.nodes)) {
node.nodes.forEach((child) => variablesIn(child, out))
return out
}
if (node.variable && !out.includes(node.variable)) out.push(node.variable)
return out
}
const quote = (v) => (typeof v === 'string' ? `"${v}"` : String(v))
/**
* One condition tree as a sentence, using the grammar's own operator labels.
*
* `null` answers null rather than "always": the caller renders *"on any
* `uo.champ.boss_up`"* for a gate with no predicate, and a phrase saying
* "everything is true" would be a clause an operator has to read past.
*/
function phrase(node) {
if (!node || typeof node !== 'object') return null
if (node.op === 'not') {
const inner = phrase((node.nodes || [])[0])
return inner ? `not (${inner})` : null
}
if (node.op === 'and' || node.op === 'or') {
const parts = (node.nodes || []).map(phrase).filter(Boolean)
if (!parts.length) return null
// Parenthesised only where it changes the reading. A flat `and` of three
// comparisons is a sentence; the same three wrapped in brackets is a
// diagnosis an operator has to parse rather than read.
const joined = parts.map((p) => (p.includes(' or ') || p.includes(' and ') ? `(${p})` : p))
return joined.join(node.op === 'and' ? ' and ' : ' or ')
}
const operator = conditions.OPERATORS[node.cmp]
if (!operator) return null
if (operator.arity === 0) return `${node.variable} ${operator.label}`
if (operator.arity === 'list') return `${node.variable} ${operator.label} ${node.value.map(quote).join(', ')}`
return `${node.variable} ${operator.label} ${quote(node.value)}`
}
/**
* The shape the run console renders — a gate as an operator reads it.
*
* Derived rather than stored, every field of it. `stalled` in particular is a
* comparison against the clock and not a column: a threshold that had been
* written into a row at entry could not be changed by an operator raising
* `EVENT_PHASE_STALL_MS`, and one written at the moment of stalling would be a
* fourth writer on a row two already share.
*/
function describe(gate, now = new Date()) {
if (!gate) return null
const since = new Date(gate.entered_at)
const satisfied = Boolean(gate.satisfied_at)
// **A satisfied gate's clock stops when it was satisfied**, not at read time.
// Live it answers "how long has this phase been waiting"; afterwards it
// answers "how long did it wait", and those are the same number only while it
// is still waiting. The walk caught it disagreeing with `phase.advanced`'s
// own `waitedSeconds` by the age of the screen — 139s beside a logged 121.
const until = satisfied ? new Date(gate.satisfied_at) : now
const elapsedSeconds = Math.max(0, Math.round((until.getTime() - since.getTime()) / 1000))
return {
phase: gate.phase,
kind: gate.kind,
satisfied,
satisfiedAt: gate.satisfied_at || null,
satisfiedBy: gate.satisfied_by || null,
since: gate.entered_at,
elapsedSeconds,
// An `after` gate is never stalled; an `on` gate is stalled once it has
// waited past the threshold and not before.
stalled: !satisfied && gate.kind === 'on' && now.getTime() - since.getTime() >= STALL_MS,
...(gate.kind === 'after'
? { after: gate.after_seconds, dueAt: gate.due_at }
: {
waitingOn: gate.trigger_id,
where: phrase(gate.conditions),
seen: gate.tally,
needed: gate.needed,
lastEvent: gate.last_event,
lastEventAt: gate.last_event_at || null,
}),
}
}
/**
* The trigger stream's second subscriber.
*
* Called from `engagementEmit.emit` beside `engine.dispatch` and, like it, never
* awaited and never allowed to reject. An emit is a module saying something
* happened in the game; whether some event run cared is core's business, and a
* failure of core's must not become the module's control flow.
*
* **Every firing is logged, matched or not** (§ Observability: "trigger
* evaluations that did and did not satisfy a condition"). The near miss is the
* more valuable of the two on the night: *"the boss did spawn, in Britain"* and
* *"no boss has spawned"* are different answers, and without this line they look
* identical on the screen.
*/
async function observe(event) {
const summary = { gates: 0, counted: 0, satisfied: 0 }
try {
const open = await gatesDb.openForTrigger(event.triggerId)
summary.gates = open.length
if (!open.length) return summary
const now = new Date()
for (const gate of open) {
const matched = conditions.evaluate(gate.conditions, event.data || {})
// ONLY the variables the condition names, never the payload. This row is
// read back onto an admin screen, and a copy of a whole game event's data
// is a second copy of exactly the content `engagement_sends` is careful
// not to keep. The named variables are also the useful ones: they are the
// reason it did or did not count.
const named = variablesIn(gate.conditions)
const lastEvent = {
trigger: event.triggerId,
at: event.occurredAt,
subject: event.subject ?? null,
matched,
variables: Object.fromEntries(
named.filter((n) => event.data && n in event.data).map((n) => [n, event.data[n]]),
),
}
const result = matched
? await gatesDb.count(gate.id, { lastEvent, now })
: { counted: false, satisfied: false, tally: gate.tally, near: await gatesDb.noteNearMiss(gate.id, { lastEvent, now }) }
if (matched && result.counted) summary.counted += 1
if (result.satisfied) summary.satisfied += 1
await logDb.write({
runId: gate.run_id,
kind: 'condition.evaluated',
phase: gate.phase,
detail: {
trigger: event.triggerId,
matched,
// The tally the DATABASE holds after the write, not the one this
// process predicted — two emits arriving together each read the same
// stale number, and only one of them is right about what it became.
seen: result.tally ?? gate.tally,
needed: gate.needed,
satisfied: result.satisfied,
variables: lastEvent.variables,
},
})
}
if (summary.counted || summary.satisfied) {
log.info('phase gate advanced', { trigger: event.triggerId, ...summary })
}
} catch (err) {
log.error('gate observation failed', { trigger: event.triggerId, message: err.message })
}
return summary
}
/**
* Is this phase's gate open — and if it is not, why not?
*
* The runner's question, and the one place an `after` gate is closed: its
* deadline passing is not an event anything emits, so the tick that finds it
* past `due_at` is what records it. Doing that here rather than in the runner
* keeps `satisfied_by` a fact one file writes.
*
* Answers `{ open, gate }`. `open: true` with a null gate is a phase with no
* advance condition at all — every phase before this one, and most after it.
*/
async function check(runId, phase, now = new Date()) {
const gate = await gatesDb.forPhase(runId, phase)
if (!gate) return { open: true, gate: null }
if (gate.satisfied_at) return { open: true, gate }
if (gate.kind === 'after' && gate.due_at && new Date(gate.due_at) <= now) {
if (await gatesDb.satisfy(gate.id, 'elapsed', { now })) {
return { open: true, gate: await gatesDb.byId(gate.id) }
}
// Somebody else closed it between the read and the write — a force, or the
// tick that overran into this one. Either way it is open, and whichever
// reason won is the one in the row.
return { open: true, gate: await gatesDb.byId(gate.id) }
}
return { open: false, gate }
}
module.exports = { observe, check, describe, phrase, variablesIn, STALL_MS }

224
server/src/events/ledger.js Normal file
View File

@@ -0,0 +1,224 @@
// ── Recording what a run changed in the world ──────────────────────────────
//
// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
// resource ledger; `events/cleanup.js` is the read-and-undo half.
//
// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
// serial does not exist until the module answers, so there is nothing to write a
// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
// keyed by the step's idempotency key rather than by the object:
//
// pre-dispatch INSERT pending { kind: '@step', ref: <idempotency key> }
// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
// resolve the placeholder
// ack lost the placeholder is still `pending`
// cleanup revert({ idempotencyKey, resources: [] })
//
// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
// the key at all. A module that half-ran and never answered is reachable by its
// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
// answering it exact. Until then the contract is still honest, because §L
// requires reverting something that does not exist to be a SUCCESS.
//
// **Recording is idempotent, and the database is what makes it so.** A retry
// re-dispatches the same idempotency key, and a module that answers with the same
// resources twice must not produce two rows. `uq_evres_target` refuses the second
// insert, and this file reads that refusal as "already recorded" rather than as an
// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
//
// **A lease does not use the placeholder.** Its target is knowable before the
// dispatch — it is the lease id the step names — so `core.lease` reserves the
// real row first, which is both a stronger form of rule 1 and the only place the
// two-events-one-target refusal can happen before the world has been written to.
const resourcesDb = require('../model/events/eventRunResources.db')
const runsDb = require('../model/events/eventRuns.db')
const registries = require('../modules/registries')
const log = require('../utils/logger')('events')
// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
// done and `self` undoes itself, so neither has anything core could come back
// for; `override` reserves its own target instead (see the header). That leaves
// `ledger` — the class that declares `revert()`, which is exactly the class whose
// refs core cannot know until the module speaks.
const PLACEHOLDER_CLASSES = ['ledger']
// A resource `kind` may be anything a module likes except core's own reserved
// one. Bounded to the column, and refused rather than truncated: a truncated ref
// is a cleanup call naming the wrong object.
const MAX_KIND = 64
const MAX_REF = 190
const MAX_MEMBER_KEY = 190
/** Does this action produce anything core will have to come back for? */
const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
/**
* Turn one entry of a module's `resources` array into a row, or say why not.
*
* Every failure here is the module's mistake rather than the world's, so none of
* them is a retry: a badly shaped resource will be just as badly shaped on the
* second attempt. They are logged and dropped, and the step still counts as done
* — because it IS done; something happened in the world, and refusing to record
* it would be the one outcome worse than recording it imperfectly.
*/
function normalise(entry, actionId) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return { ok: false, reason: `${actionId} reported a resource that is not an object` }
}
const kind = String(entry.kind || '')
const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
if (!kind || kind.length > MAX_KIND) {
return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
}
if (kind === resourcesDb.STEP_KIND) {
// Core's own. A module that could write one would be a module that could make
// its own step's placeholder look resolved.
return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
}
if (!ref || ref.length > MAX_REF) {
return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
}
const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
}
let leaseUntil = null
if (entry.until !== undefined && entry.until !== null) {
const at = new Date(entry.until)
if (Number.isNaN(at.getTime())) {
return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
}
leaseUntil = at
}
// A borrowed value must name a lease core knows how to give back. Core restores
// an `override` through the lease registry — that is the split §F draws — so a
// ref naming nothing registered is a resource core would be recording with no
// way to undo it, which is the promise rule 2 exists to stop core making.
// Through the ref parser: a targeted lease's ref carries its target after a
// `#` (Phase 12b), and matching the whole string against the registry would
// reject a lease that IS registered.
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
}
return {
ok: true,
row: {
kind,
ref,
payload: entry.payload === undefined ? null : entry.payload,
leaseUntil,
memberKey,
},
}
}
/**
* Write the pre-dispatch placeholder for a step that is about to change the
* world. Answers the row id, or null when this action ledgers nothing.
*
* **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
* the second attempt's placeholder collides with the first's — and finding it
* already there is the correct answer, not an error. The existing row is reused.
*/
async function reserveStep(run, step, action) {
if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
const owner = action.owner || 'core'
const reserved = await resourcesDb.reserve({
runId: run.id,
stepId: step.id,
owner,
kind: resourcesDb.STEP_KIND,
ref: step.idempotency_key,
payload: { action: action.id, phase: step.phase, seq: step.seq },
})
if (reserved.ok) {
await markRunDirty(run.id)
return reserved.id
}
// The only way a '@step' row collides is with this step's own earlier attempt,
// because an idempotency key is minted once per step and never varies by
// attempt (§E). Reuse it.
const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
return existing ? existing.id : null
}
/**
* Record what a module said it made, and close out the placeholder.
*
* Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
* entry was dropped. Never throws: a step that changed the world has changed it,
* and a ledger that threw would turn a bookkeeping problem into a failed step and
* then into a retry of a world write that already happened.
*/
async function recordAnswer({ run, step, action, placeholderId, resources }) {
const out = { recorded: 0, rejected: [] }
if (!ledgers(action)) return out
const owner = action.owner || 'core'
const list = Array.isArray(resources) ? resources : []
for (const entry of list) {
const parsed = normalise(entry, action.id)
if (!parsed.ok) {
out.rejected.push(parsed.reason)
log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
continue
}
try {
const reserved = await resourcesDb.reserve({
runId: run.id,
stepId: step.id,
owner,
kind: parsed.row.kind,
ref: parsed.row.ref,
payload: parsed.row.payload,
leaseUntil: parsed.row.leaseUntil,
memberKey: parsed.row.memberKey,
})
if (!reserved.ok) {
// Already ledgered — by this step's own earlier attempt, or (a module bug
// rather than a race) by another run that still holds the same target.
// Either way there is a live row for it and a second would be the double
// cleanup the unique key exists to prevent.
if (reserved.holder && reserved.holder.run_id !== run.id) {
out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
}
continue
}
await resourcesDb.confirm(reserved.id)
out.recorded += 1
} catch (err) {
// Bookkeeping must not become the step's control flow.
out.rejected.push(err.message)
log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
}
}
if (out.recorded > 0) await markRunDirty(run.id)
// The placeholder's job is over the moment the real rows exist. It is resolved
// even when the module reported nothing at all — an action that ledgers and
// then answers `ok` with an empty list is saying "I made nothing", and holding
// its placeholder open would make cleanup call `revert()` for a step that has
// nothing to give back on every terminal path for ever.
if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
return out
}
/**
* There is now something to clean up. Idempotent and guarded, so it can never
* walk a run back from `complete` or `incomplete` to `pending` — only a human's
* cleanup does that, and it does it deliberately.
*/
async function markRunDirty(runId) {
await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
}
module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }

View File

@@ -0,0 +1,162 @@
// ── Recording who took part ────────────────────────────────────────────────
//
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The twin of
// `events/ledger.js`: that file records what a run did to the world, this one
// records who it happened to.
//
// **Participants ride the SAME envelope resources do** (org lead, 2026-09-04). An
// action answers `{ ok: true, participants: [...] }` and the runner writes them
// beside the resources, on the same two success shapes, through the same
// classify → record path. There is no `ctx.events.participants` API and no route:
// a second write path into a run core is mid-tick on would be a second thing that
// can race the claim, for a caller that does not exist until a module can source
// the data at all (Phase 11's plugin-side participation ledger; Phase 12's
// collect step is the first consumer).
//
// **Core cannot source a participant and does not try.** §J: `member_key` is
// module-opaque, `user_id` is filled in by whoever knows the link table. For
// module-uo that is `shard_links`; for another game it is something else, and a
// core that guessed would be one game's identity model compiled into core. So a
// module reports both halves, or reports the key alone and the row stays
// anonymous — which is the honest record of an unlinked player who turned up.
//
// **A bad entry is dropped, never a retry.** Exactly `ledger.normalise`'s
// posture and for exactly its reason: a malformed participant will be just as
// malformed on the second attempt, and failing the step would re-dispatch a
// world write that already happened. Rejections are logged and surfaced on the
// run log so an author can see what their module sent.
const participantsDb = require('../model/events/eventRunParticipants.db')
const log = require('../utils/logger')('events')
// Bounded to the column, and refused rather than truncated: a truncated member
// key is a different participant, and under `uq_evpart_member` it would silently
// merge two people into one row.
const MAX_MEMBER_KEY = 190
// The most one step may report. A run's participants are people, and a step
// answering with a hundred thousand of them is a module bug rather than a very
// popular event — one that would otherwise spend a tick's whole budget on
// inserts while holding the step's claim. `MAX_AUDIENCE` in the engagement
// engine is 5000 for the same class of reason and this matches it deliberately:
// the two bound the same thing, a list of users one call may assert.
const MAX_PER_STEP = 5000
/**
* Turn one entry of a module's `participants` array into a row, or say why not.
*/
function normalise(entry, actionId) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
return { ok: false, reason: `${actionId} reported a participant that is not an object` }
}
const memberKey = String(entry.memberKey === undefined || entry.memberKey === null ? '' : entry.memberKey)
if (!memberKey || memberKey.length > MAX_MEMBER_KEY) {
return { ok: false, reason: `${actionId} reported a participant with a bad memberKey "${entry.memberKey}"` }
}
// Optional, and checked rather than coerced. A `userId` is a foreign key into
// `users`, so a module that passed a character serial here would either fail
// the insert or — worse, if the number happened to be a real user — attribute
// somebody else's attendance to a stranger.
let userId = null
if (entry.userId !== undefined && entry.userId !== null) {
if (!Number.isInteger(entry.userId) || entry.userId < 1) {
return { ok: false, reason: `${actionId} reported a participant with a bad userId "${entry.userId}"` }
}
userId = entry.userId
}
// `score` is optional and defaults to 0 — a run that only records attendance
// is a run where everybody scored nothing, which is a true statement and a
// renderable table. Non-finite is refused rather than coerced: `NaN` written
// into a DECIMAL would either throw at the driver or land as 0, and a 0 that
// meant "the module sent nonsense" is indistinguishable from an honest zero.
let score = 0
if (entry.score !== undefined && entry.score !== null) {
const n = Number(entry.score)
if (!Number.isFinite(n)) {
return { ok: false, reason: `${actionId} reported a participant with a bad score "${entry.score}"` }
}
score = n
}
let joinedAt = null
if (entry.joinedAt !== undefined && entry.joinedAt !== null) {
const at = entry.joinedAt instanceof Date ? entry.joinedAt : new Date(entry.joinedAt)
if (Number.isNaN(at.getTime())) {
return { ok: false, reason: `${actionId} reported a participant with a bad joinedAt "${entry.joinedAt}"` }
}
joinedAt = at
}
return {
ok: true,
row: {
memberKey,
userId,
score,
// Opaque, exactly like a resource's payload: core stores it and never
// reads it. Anything but an object is dropped rather than refused —
// `meta` is decoration on a row whose identity is already valid, and
// losing an event's whole attendance over a stray string would be the
// wrong trade.
meta: entry.meta && typeof entry.meta === 'object' && !Array.isArray(entry.meta) ? entry.meta : null,
joinedAt,
},
}
}
/**
* Record what a module said about who took part.
*
* Answers `{ recorded, rejected }`. Never throws, for `recordAnswer`'s reason: a
* step that changed the world has changed it, and a bookkeeping failure must not
* become a retry of a world write.
*/
async function recordAnswer({ run, step, action, participants }) {
const out = { recorded: 0, rejected: [] }
const list = Array.isArray(participants) ? participants : []
if (!list.length) return out
if (list.length > MAX_PER_STEP) {
// Refused whole rather than truncated. Half a leaderboard silently cut at
// five thousand is worse than none: the table would look complete and be
// wrong, and nothing downstream could tell.
const reason = `${action.id} reported ${list.length} participants, more than the ${MAX_PER_STEP} one step may`
log.warn('event participants refused', { run: run.id, step: step.id, reason })
return { recorded: 0, rejected: [reason] }
}
// **Deduplicated in memory before the write.** One step reporting the same
// member twice is a module bug, and letting both reach the upsert would make
// the LAST one win silently. Refusing the step would be worse — the other
// ninety-nine participants are fine — so the first wins and the duplicate is
// named, which is a thing an author can act on.
const seen = new Set()
for (const entry of list) {
const parsed = normalise(entry, action.id)
if (!parsed.ok) {
out.rejected.push(parsed.reason)
log.warn('event participant rejected', { run: run.id, step: step.id, reason: parsed.reason })
continue
}
if (seen.has(parsed.row.memberKey)) {
out.rejected.push(`${action.id} reported "${parsed.row.memberKey}" twice in one step`)
continue
}
seen.add(parsed.row.memberKey)
try {
await participantsDb.record({ runId: run.id, ...parsed.row })
out.recorded += 1
} catch (err) {
out.rejected.push(err.message)
log.error('event participant insert failed', { run: run.id, step: step.id, message: err.message })
}
}
return out
}
module.exports = { normalise, recordAnswer, MAX_MEMBER_KEY, MAX_PER_STEP }

185
server/src/events/price.js Normal file
View File

@@ -0,0 +1,185 @@
// ── The live cap meter ─────────────────────────────────────────────────────
//
// EVENTS.md §I, Phase 13: the step editor's "live cap meter". What would this
// plan spend, and what does this deployment allow?
//
// **It is not the dry run, and the difference is the whole reason it exists.**
// `verify.js` dispatches every step with `verify: true` — through the module,
// and through the module to a sidecar and a game tick — and a pass against a
// published version is RECORDED, because that record is what §K's gate reads
// before letting a schedule start something unattended. Both of those are right
// for an act an author performs once, deliberately, when the plan is finished.
// Neither is right for a number that has to move while somebody types: a meter
// on the dry run's path would put a shard round trip behind every keystroke and
// would stamp `verified_at` from a form still being edited.
//
// So this file answers the half of the question core can answer ON ITS OWN:
// `cost()` is a pure function of params (§F), and the caps come from the
// switchboard. Nothing is dispatched, nothing is written, and no definition need
// exist — the body is the spec in the author's hands, saved or not.
//
// **What it therefore cannot tell you** is everything the module knows: whether
// the landmark exists, whether the creature is on the allowlist, whether the
// shard is reachable. That is the dry run's, and the meter must not read as a
// substitute for it — which is why the editor keeps both and labels them apart.
//
// ## Why the per-phase subtotal is here rather than computed in the browser
//
// §I asks the timeline for "its cap draw" per phase, and the arithmetic is
// trivial — but the *inputs* are not in the browser. `cost()` runs on the server
// and only on the server; a client that summed anything would first have to be
// handed per-step costs, which is this same call. Returning the phase rollup
// beside the total costs one pass over a list core has already walked.
const authorize = require('./authorize')
const settingsDb = require('../model/events/eventActionSettings.db')
const registries = require('../modules/registries')
const spec = require('./spec')
/**
* Flatten `{ phases: [{ key, steps: [...] }] }` into the priceable steps.
*
* Bounded by the spec's own limits rather than by a number invented here: this
* route takes an unsaved spec, so it is reachable with a body the save path
* would refuse, and the paste guard has to be the same one.
*/
function flatten(body) {
const phases = Array.isArray(body?.phases) ? body.phases : []
if (phases.length > spec.MAX_PHASES) {
return { ok: false, error: `at most ${spec.MAX_PHASES} phases` }
}
const flat = []
for (const [index, phase] of phases.entries()) {
const steps = Array.isArray(phase?.steps) ? phase.steps : []
if (steps.length > spec.MAX_STEPS_PER_PHASE) {
return { ok: false, error: `at most ${spec.MAX_STEPS_PER_PHASE} steps in one phase` }
}
for (const [seq, step] of steps.entries()) {
flat.push({
// The key is what the editor groups by, and an unsaved phase may not
// have a valid one yet — so the ordinal is what is echoed back. A meter
// that could only address a phase whose key already validates would go
// blank exactly while somebody is naming it.
phase: index,
phaseKey: typeof phase?.key === 'string' ? phase.key : null,
seq,
actionId: typeof step?.actionId === 'string' ? step.actionId : '',
params: step && typeof step.params === 'object' && !Array.isArray(step.params) ? step.params : {},
})
}
}
if (flat.length > spec.MAX_STEPS) {
return { ok: false, error: `at most ${spec.MAX_STEPS} steps in one definition` }
}
return { ok: true, flat }
}
/**
* Price a spec.
*
* **A step core cannot price is reported, never treated as free.** Three things
* make one: no module registers the action, the action's `cost()` failed its own
* contract (`priceOf` answers `null`), or it prices a dimension nobody declared.
* All three make the totals below an UNDER-count, and a meter that silently
* under-counts is worse than no meter — it is a number an author trusts that is
* smaller than what will happen. So each one comes back in `unpriced` with the
* step it belongs to, and the client shows the meter as incomplete.
*
* The third is not a refusal to price: an action that spends `uo.creatures`
* spends it whether or not a module declared the dimension, so the amount is
* still counted and the entry says the total is *unenforceable* rather than
* unknown. Same split `authorize.undeclaredDimensions` makes for the same
* reason.
*/
async function priceSpec(body) {
const flattened = flatten(body)
if (!flattened.ok) return { ok: false, error: flattened.error }
const { flat } = flattened
const settings = await settingsDb.byIds(flat.map((s) => s.actionId))
const totals = {}
const byPhase = new Map()
const unpriced = []
let priced = 0
const addTo = (bag, dimension, amount) => {
bag[dimension] = (bag[dimension] || 0) + amount
}
for (const step of flat) {
if (!byPhase.has(step.phase)) {
byPhase.set(step.phase, { phase: step.phase, key: step.phaseKey, steps: 0, draw: {} })
}
const phase = byPhase.get(step.phase)
phase.steps += 1
const where = { phase: step.phase, seq: step.seq, actionId: step.actionId || null }
const action = step.actionId ? registries.eventAction(step.actionId) : null
if (!action) {
// A step with no action chosen yet is not a problem — it is a form being
// filled in — so it is not reported. A step naming an action nothing
// registers is, because that is the dormant case and it under-counts.
if (step.actionId) {
unpriced.push({ ...where, code: 'dormant', message: `no module registers "${step.actionId}"` })
}
continue
}
const cost = authorize.priceOf(action, step.params)
if (cost === null) {
unpriced.push({ ...where, code: 'unpriceable', message: `"${action.label}" could not report what it costs` })
continue
}
priced += 1
for (const [dimension, amount] of Object.entries(cost)) {
addTo(totals, dimension, amount)
addTo(phase.draw, dimension, amount)
}
for (const dimension of authorize.undeclaredDimensions(cost)) {
unpriced.push({
...where,
code: 'undeclared',
message: `spends "${dimension}", which no installed module declares as a budget — this step is refused at dispatch`,
})
}
}
const caps = authorize.effectiveCaps(
flat.map((s) => ({ actionId: s.actionId, params: s.params })),
settings,
)
const cost = Object.entries(totals)
.sort(([a], [b]) => a.localeCompare(b))
.map(([dimension, total]) => {
const cap = (caps[dimension] || {}).cap ?? null
return {
dimension,
total,
cap,
from: (caps[dimension] || {}).from || null,
over: cap !== null && total > cap,
}
})
return {
ok: true,
steps: flat.length,
priced,
cost,
// Ordinal order, because that is timeline order and the client draws it
// beside each phase. A phase whose steps price to nothing still appears, so
// the rollup and the timeline have the same number of rows.
phases: [...byPhase.values()].map((p) => ({
phase: p.phase,
key: p.key,
steps: p.steps,
draw: Object.entries(p.draw)
.sort(([a], [b]) => a.localeCompare(b))
.map(([dimension, total]) => ({ dimension, total })),
})),
unpriced,
}
}
module.exports = { priceSpec }

View File

@@ -0,0 +1,335 @@
// ── Occurrence arithmetic, in the event's own timezone ─────────────────────
//
// EVENTS.md §E, "Two scheduling decisions the calendar forces", and Phase 4 of
// EVENTS_PLAN.md. Given a closed recurrence shape, an IANA zone and a window,
// this file answers *which UTC instants* an event happens at. Nothing else
// computes an occurrence; the runner materialises what this returns and the
// calendar projects what this returns, so there is exactly one arithmetic to be
// wrong.
//
// **Why there is no library here.** The server's dependency tree has no date
// library at all — no luxon, no date-fns, no tz package (check `package.json`
// before adding one). What it does have is Node's own full tzdata behind
// `Intl.DateTimeFormat`, which is the same database a library would ship a copy
// of and is already what `eventDefinitions.model.js` validates a zone name
// against. So the arithmetic is: *format an instant into the zone's wall clock*
// (which `Intl` does exactly) and invert that mapping by search. Everything
// below is that one idea.
//
// **Why not cron.** Decided in §E and restated in the plan: there is no parser
// in the tree, the only precedent is in the bot (another process), and a cron
// string is the one field an operator cannot proofread. Four closed shapes
// render as a form, and a form is checkable.
//
// **The two DST rules** (org lead, 2026-09-02), which exist because a weekly
// 02:30 event in `Europe/Berlin` is a real thing an operator will author:
//
// - A **nonexistent** local time — the spring-forward gap — steps forward to the
// first wall clock that does exist. 02:30 becomes 03:00, not 03:30: the event
// happens as close to the authored time as the calendar allows.
// - An **ambiguous** local time — the fall-back hour, which happens twice —
// takes the FIRST, the pre-transition offset.
//
// Both are reported back as `adjusted`, so a run can record why its clock reads
// oddly rather than leaving an operator to discover DST for themselves at 3am.
// Neither rule ever drops an occurrence: a weekly event happens every week.
// Indexed to match `Date#getUTCDay`, which is what the civil-calendar helpers
// below return. Names rather than numbers everywhere an operator can see them —
// `days: ['friday']` is proofreadable and `days: [5]` is not, which is the same
// argument that rejected cron.
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
// `nth: -1` is "the last one in the month", and it is not a synonym for 4: a
// month with five Fridays has a last Friday that is not the fourth. 1..4 always
// exist in every month (1 + 6 + 21 = 28), so there is deliberately no fifth and
// therefore no absent-occurrence case to define (org lead, 2026-09-02).
const MONTHLY_NTH = [1, 2, 3, 4, -1]
const TIME_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
const AT_RE = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([01][0-9]|2[0-3]):([0-5][0-9])$/
const MINUTE_MS = 60_000
const DAY_MS = 86_400_000
// No real DST gap exceeds two hours (Lord Howe's is 30 minutes; the largest
// historical jumps are a day, and those are line-of-date changes rather than
// gaps in the local clock). Four hours is a bound, not an expectation: it stops
// a malformed zone turning the search into a hang.
const MAX_GAP_MINUTES = 240
// Bounds on what one call may return. A projection window is operator-supplied
// (the calendar's month, the horizon), and an unbounded expansion of a daily
// schedule across a decade is how a calendar request becomes an outage.
const MAX_OCCURRENCES = 500
const formatters = new Map()
function formatterFor(zone) {
let f = formatters.get(zone)
if (!f) {
// `hourCycle: 'h23'` rather than `hour12: false`, which renders midnight as
// hour 24 in some ICU versions and would put every midnight event on the
// previous day.
f = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
formatters.set(zone, f)
}
return f
}
/** The wall clock an instant reads as, in this zone. */
function wallPartsAt(zone, ms) {
const parts = formatterFor(zone).formatToParts(new Date(ms))
const get = (type) => Number(parts.find((p) => p.type === type)?.value)
return {
y: get('year'),
m: get('month'),
d: get('day'),
h: get('hour'),
mi: get('minute'),
s: get('second'),
}
}
/**
* That same wall clock as a number, by reading it as though it were UTC.
*
* This is the trick the whole file rests on: two wall clocks are equal exactly
* when these numbers are, and `wallMs - instant` is the zone's offset at that
* instant. It is never a real instant and must not be used as one.
*/
function wallMs(zone, ms) {
const p = wallPartsAt(zone, ms)
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s)
}
const offsetMs = (zone, ms) => wallMs(zone, ms) - ms
/**
* Every instant that reads as this wall clock in this zone, earliest first.
*
* Ordinarily one. Two in the fall-back hour, none in the spring-forward gap —
* and the length of this array is how the caller tells those three apart.
*
* Sampling the offset a day either side is what makes it correct across a
* transition: subtracting each candidate offset gives the two instants worth
* testing, and the test is whether the instant formats back to what was asked.
*/
function instantsForWall(zone, target) {
const candidates = new Set([
target - offsetMs(zone, target - DAY_MS),
target - offsetMs(zone, target + DAY_MS),
])
const valid = []
for (const ms of candidates) {
if (wallMs(zone, ms) === target) valid.push(ms)
}
return valid.sort((a, b) => a - b)
}
/**
* Resolve a local wall clock to a UTC instant, applying the two DST rules.
*
* `{ at, adjusted, shiftMinutes }` — `adjusted` is `null` on an ordinary day,
* `'gap'` when the authored time did not exist and was stepped forward, and
* `'ambiguous'` when it happened twice and the first was taken.
*/
function resolveWall(zone, y, m, d, h, mi) {
const target = Date.UTC(y, m - 1, d, h, mi, 0)
const valid = instantsForWall(zone, target)
if (valid.length === 1) return { at: new Date(valid[0]), adjusted: null, shiftMinutes: 0 }
if (valid.length > 1) return { at: new Date(valid[0]), adjusted: 'ambiguous', shiftMinutes: 0 }
// The gap. Step the WALL CLOCK forward — not the instant — until it lands on
// a time that exists, which is the first instant after the transition.
for (let step = 1; step <= MAX_GAP_MINUTES; step += 1) {
const shifted = instantsForWall(zone, target + step * MINUTE_MS)
if (shifted.length) return { at: new Date(shifted[0]), adjusted: 'gap', shiftMinutes: step }
}
return null
}
// ── The civil calendar ─────────────────────────────────────────────────────
//
// Dates with no zone attached: "the 14th of September" as a thing to iterate,
// before any question of what instant it starts at. `Date.UTC` is used purely
// as calendar arithmetic here and none of these numbers is an instant.
const dayIndex = (y, m, d) => Date.UTC(y, m - 1, d) / DAY_MS
function civilFromIndex(n) {
const dt = new Date(n * DAY_MS)
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() }
}
const weekdayOf = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).getUTCDay()
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate()
/** Is this a real date? `2026-02-30` parses as a string and is not a day. */
const isRealDate = (y, m, d) => m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)
/**
* The day of the month that is the nth (or last) given weekday.
*
* `nth` is 1..4 or -1. Answers `null` only for an nth that cannot exist, which
* the validated shapes never produce — the guard is here so that a spec written
* by hand into the database cannot make the runner throw.
*/
function nthWeekdayDay(y, m, weekday, nth) {
const last = daysInMonth(y, m)
if (nth === -1) {
const back = (weekdayOf(y, m, last) - weekday + 7) % 7
return last - back
}
const forward = (weekday - weekdayOf(y, m, 1) + 7) % 7
const day = 1 + forward + (nth - 1) * 7
return day <= last ? day : null
}
// ── Expansion ──────────────────────────────────────────────────────────────
/**
* Every occurrence of `schedule` in `[from, to)`, earliest first.
*
* `[{ at: Date, adjusted, shiftMinutes }]`. `manual` answers `[]` — it is the
* shape that means "there is no recurrence", and an admin's own
* `POST /:id/runs` is the only thing that creates one of its occurrences.
*
* The window is in INSTANTS and the walk is in LOCAL DAYS, which is why each
* walk starts a day early and ends a day late: a local day can begin up to
* fourteen hours either side of the same UTC day.
*/
function occurrencesBetween(schedule, zone, from, to, { limit = MAX_OCCURRENCES } = {}) {
const fromMs = from instanceof Date ? from.getTime() : Number(from)
const toMs = to instanceof Date ? to.getTime() : Number(to)
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
if (!schedule || typeof schedule !== 'object') return []
const cap = Math.min(Math.max(Number(limit) || MAX_OCCURRENCES, 1), MAX_OCCURRENCES)
const out = []
const keep = (resolved) => {
if (!resolved) return
const t = resolved.at.getTime()
if (t >= fromMs && t < toMs && out.length < cap) out.push(resolved)
}
if (schedule.kind === 'manual') return []
if (schedule.kind === 'once') {
const m = AT_RE.exec(String(schedule.at || ''))
if (!m) return []
keep(resolveWall(zone, Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])))
return out
}
const time = TIME_RE.exec(String(schedule.time || ''))
if (!time) return []
const hour = Number(time[1])
const minute = Number(time[2])
if (schedule.kind === 'weekly') {
const wanted = new Set(
(schedule.days || []).map((d) => WEEKDAYS.indexOf(String(d))).filter((i) => i >= 0),
)
if (!wanted.size) return []
const first = wallPartsAt(zone, fromMs)
const last = wallPartsAt(zone, toMs)
const startDay = dayIndex(first.y, first.m, first.d) - 1
const endDay = dayIndex(last.y, last.m, last.d) + 1
for (let n = startDay; n <= endDay && out.length < cap; n += 1) {
const { y, m, d } = civilFromIndex(n)
if (wanted.has(weekdayOf(y, m, d))) keep(resolveWall(zone, y, m, d, hour, minute))
}
return out
}
if (schedule.kind === 'monthly') {
const weekday = WEEKDAYS.indexOf(String(schedule.weekday))
const nth = Number(schedule.nth)
if (weekday < 0 || !MONTHLY_NTH.includes(nth)) return []
const first = wallPartsAt(zone, fromMs)
const last = wallPartsAt(zone, toMs)
// Months as a single running count, so a window crossing a new year is not
// a special case.
const startMonth = first.y * 12 + (first.m - 1) - 1
const endMonth = last.y * 12 + (last.m - 1) + 1
for (let n = startMonth; n <= endMonth && out.length < cap; n += 1) {
const y = Math.floor(n / 12)
const m = (n % 12) + 1
const day = nthWeekdayDay(y, m, weekday, nth)
if (day) keep(resolveWall(zone, y, m, day, hour, minute))
}
return out
}
return []
}
/** The next occurrence at or after `from`, or null. A bounded look-ahead. */
function nextOccurrence(schedule, zone, from, { withinDays = 400 } = {}) {
const fromMs = from instanceof Date ? from.getTime() : Number(from)
const [first] = occurrencesBetween(schedule, zone, fromMs, fromMs + withinDays * DAY_MS, {
limit: 1,
})
return first || null
}
/**
* How a schedule reads to a person, in the event's own zone.
*
* Server-side because two surfaces need the same sentence — the calendar's list
* and the run's own record of why it exists — and because the client's copy in
* `eventAuthoring.js` is a mirror that is allowed to drift on wording but not on
* meaning.
*/
function describe(schedule, zone = 'UTC') {
if (!schedule || typeof schedule !== 'object') return 'No schedule'
const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1)
const nthLabel = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', '-1': 'last' }
switch (schedule.kind) {
case 'manual':
return 'Started by hand'
case 'once':
return `Once, on ${String(schedule.at).replace('T', ' ')} (${zone})`
case 'weekly': {
const days = (schedule.days || []).map(cap)
const list =
days.length <= 1
? days.join('')
: `${days.slice(0, -1).join(', ')} and ${days[days.length - 1]}`
return `Every ${list} at ${schedule.time} (${zone})`
}
case 'monthly':
return `The ${nthLabel[String(schedule.nth)]} ${cap(schedule.weekday)} of every month at ${schedule.time} (${zone})`
default:
return 'No schedule'
}
}
module.exports = {
WEEKDAYS,
MONTHLY_NTH,
TIME_RE,
AT_RE,
MAX_OCCURRENCES,
DAY_MS,
wallPartsAt,
offsetMs,
instantsForWall,
resolveWall,
isRealDate,
nthWeekdayDay,
occurrencesBetween,
nextOccurrence,
describe,
}

622
server/src/events/spec.js Normal file
View File

@@ -0,0 +1,622 @@
// ── The event spec, and the one place it is validated ──────────────────────
//
// EVENTS.md §C ("phases and actions are configuration inside a version snapshot,
// not tables") and §D. A spec is the authored tree a definition carries and a
// version freezes: phases, and the steps inside them. It is stored as JSON in
// `event_definitions`' working copy and in `event_versions.spec`, and this file
// is the only thing that decides whether one is well formed.
//
// **Every check here is a boundary, not a convenience.** The authoring UI (Phase
// 3, then Phase 13) will re-implement some of them for the sake of a good
// inline error, and that second copy is expected to drift — so this one is the
// one that decides. A spec arriving by any other route (a restore, a fixture, a
// module shipping a definition as content) gets the same answer.
//
// **What this file knows, and what it deliberately refuses.** Two top-level keys
// exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather
// than preserved: a spec that silently carries `announcements` today is a spec
// whose author believes announcements work, and the later phase that gives the
// key meaning would inherit a corpus of unvalidated ones. The refusal list is the
// changelog — Phase 4 added the recurrence shapes, Phase 5 added a phase's
// `advance`, Phase 10 adds `announcements`.
//
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
// arithmetic they describe lives in `events/recurrence.js`, and the zone they are
// computed in is `event_definitions.timezone`, a sibling column this file cannot
// see and does not need to: a well-formed wall clock resolves in every zone (a
// DST gap shifts it, it is never rejected), so a schedule that validates here
// computes there.
const registries = require('../modules/registries')
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
// `authorize` requires two `.db.js` modules and requiring one opens no
// connection, which is the same rule this file already lives under.
const authorize = require('./authorize')
const recurrence = require('./recurrence')
const conditionGrammar = require('../engagement/conditions')
const { checkLiteral } = conditionGrammar
// A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the
// run console groups by, and it is what an operator reads in "phase 3 has not
// started". Same grammar as a template key's segment.
const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
const MAX_PHASE_KEY = 64
// Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal
// with a number in it rather than a run that materialises fifty thousand step
// rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side.
const MAX_PHASES = 40
const MAX_STEPS_PER_PHASE = 100
const MAX_STEPS = 500
// The two shapes a phase's `advance` may take (§E). There is deliberately no
// third: a gate that never opens is held, made visible and left to an operator
// (org lead, 2026-09-02), so there is no authored timeout and no disposition to
// validate. Adding one later is one key and one branch, and this is the list a
// reader should find it missing from.
const ADVANCE_KINDS = ['after', 'on']
// `after: '30m'` — one integer and one unit, and nothing else. No `1h30m`, no
// fractions: the whole reason a duration is a string here rather than the plain
// integer seconds `core.wait` takes is that an operator proofreads it, and a
// grammar that admits two spellings of ninety minutes is one an operator has to
// parse rather than read.
const AFTER_RE = /^(\d{1,6})(s|m|h|d)$/
const AFTER_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 }
const MIN_AFTER_SECONDS = 1
// A paste guard rather than a policy, in the spirit of MAX_PHASES: thirty days
// is longer than any event this system is for, and a phase gate of ten years is
// a typo that would otherwise hold a run — and its concurrency key — for ever.
const MAX_AFTER_SECONDS = 30 * 86_400
// How many firings one `on` gate may wait for. Bounded for the reason MAX_LIST
// is: it is authored into a JSON column, and "count: 100000" is a phase that
// never advances written as one that eventually does.
const MAX_ADVANCE_COUNT = 1000
// The four closed shapes of §E. `manual` is first because it is the default and
// what an unscheduled draft carries; the other three are recurrences the runner
// expands into occurrences ahead of time.
const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly']
// The keys each shape may carry, and the ONLY ones. A `weekly` that also names
// an `at` is an author who believes something about it that is not true — the
// same argument the top-level refusal makes, one level down.
const SCHEDULE_KEYS = {
manual: [],
once: ['at'],
weekly: ['days', 'time'],
monthly: ['nth', 'weekday', 'time'],
}
// What a step does when its attempts are exhausted (§L). The disposition only —
// retry is not one of the values, it is what happens BEFORE one of them. Each
// risk class has a default, which is the whole reason `risk` is required at
// registration: a `change` action that fell back to `skip` would leave a run
// advancing over a half-changed world.
const ON_FAILURE = ['skip', 'pause', 'abort_run']
const ON_FAILURE_BY_RISK = {
notify: 'skip',
inspect: 'skip',
change: 'pause',
irreversible: 'abort_run',
}
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
/** The default disposition for an action whose risk class core knows. */
const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause'
/**
* Check one schedule shape and answer the normalised form of it.
*
* Always answers a valid schedule — `{ kind: 'manual' }` when the input was not
* one — because `validate` collects every error and carries on, and a caller
* reading `spec.schedule.days` of a refused spec should find an empty recurrence
* rather than a half-built one.
*
* **`days` is normalised into week order**, not into the order they were typed.
* The spec is compared, described and diffed, and `['friday','monday']` and
* `['monday','friday']` naming the same schedule while differing as JSON is a
* version history that reports edits nobody made.
*/
function validateSchedule(kind, raw, errors) {
const at = (key) => `spec.schedule.${key}`
if (kind === 'once') {
const m = recurrence.AT_RE.exec(String(raw.at ?? ''))
if (!m) {
errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`)
return { kind: 'manual' }
}
const [, y, mo, d, h, mi] = m.map(Number)
// The regex admits `2026-02-30`, which is a string and not a day.
if (!recurrence.isRealDate(y, mo, d)) {
errors.push(`${at('at')}: "${raw.at}" is not a real date`)
return { kind: 'manual' }
}
// Stored as the operator wrote it — a wall clock in the definition's own
// zone, never a UTC instant. §E: the schedule belongs to the event, and the
// instant is derived at materialisation.
const pad = (n) => String(n).padStart(2, '0')
return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` }
}
if (kind === 'weekly' || kind === 'monthly') {
const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null
if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`)
if (kind === 'weekly') {
const rawDays = Array.isArray(raw.days) ? raw.days : null
if (!rawDays || rawDays.length === 0) {
errors.push(`${at('days')}: expected a non-empty array of weekday names`)
return { kind: 'manual' }
}
const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase()))
if (unknown.length) {
errors.push(
`${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`,
)
}
const days = recurrence.WEEKDAYS.filter((name) =>
rawDays.some((d) => String(d).toLowerCase() === name),
)
if (!time || !days.length) return { kind: 'manual' }
return { kind: 'weekly', days, time }
}
const weekday = String(raw.weekday ?? '').toLowerCase()
if (!recurrence.WEEKDAYS.includes(weekday)) {
errors.push(
`${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`,
)
}
const nth = Number(raw.nth)
if (!recurrence.MONTHLY_NTH.includes(nth)) {
// -1 is "last", which a month with five Fridays makes different from 4.
// There is no 5: every month has a first through fourth of every weekday,
// so the closed set has no absent case (org lead, 2026-09-02).
errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`)
}
if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) {
return { kind: 'manual' }
}
return { kind: 'monthly', nth, weekday, time }
}
return { kind: 'manual' }
}
/**
* Parse `'30m'` into seconds, or answer null.
*
* Exported because the run console renders the same duration back and must not
* grow a second opinion about what `'2h'` means.
*/
function parseAfter(raw) {
const m = AFTER_RE.exec(String(raw ?? ''))
if (!m) return null
const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
if (seconds < MIN_AFTER_SECONDS || seconds > MAX_AFTER_SECONDS) return null
return seconds
}
/** Seconds back to the largest whole unit that expresses them exactly. */
function formatAfter(seconds) {
for (const unit of ['d', 'h', 'm']) {
const size = AFTER_UNIT_SECONDS[unit]
if (seconds % size === 0) return `${seconds / size}${unit}`
}
return `${seconds}s`
}
/**
* Check a phase's `advance` gate and answer the normalised form of it.
*
* Returns `null` for a phase with no gate — the common case, and the behaviour
* every phase had before Phase 5: it advances when its steps go terminal and on
* nothing else. A gate is an ADDITIONAL condition, never a replacement, so a
* phase whose steps are still running is not advanced by a satisfied gate.
*
* **The duration is normalised the way `days` is** — `'120m'` is stored as
* `'2h'` — because the spec is diffed between versions, and two spellings of one
* delay differing as JSON is a version history that reports edits nobody made.
*
* **`where` is validated against the trigger's DECLARATION, at save, with the
* offending variable named.** This is the whole trap of this phase, and it is
* `engagement/conditions.js`'s own argument one system across: a predicate that
* silently reads `undefined` is a phase that silently never advances, and the
* night you find out is the night of the event.
*
* **A trigger nobody registers makes the gate DORMANT, not invalid.** Same rule
* as a step naming an action no installed module declares: it saves, so
* uninstalling a module is not destructive to an author's work, and it refuses
* to publish, because a version runs are pinned to must not wait on a trigger
* that can never fire.
*/
function validateAdvance(raw, path, errors) {
if (raw === undefined || raw === null) return null
if (!isPlainObject(raw)) {
errors.push(`${path}: expected an object`)
return null
}
const keys = Object.keys(raw)
const named = ADVANCE_KINDS.filter((k) => keys.includes(k))
if (named.length !== 1) {
errors.push(`${path}: expected exactly one of "after" or "on"`)
return null
}
const kind = named[0]
if (kind === 'after') {
const extra = keys.filter((k) => k !== 'after')
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "after" gate`)
return null
}
const seconds = parseAfter(raw.after)
if (seconds === null) {
errors.push(
`${path}.after: expected a duration like "30m" — a whole number of s, m, h or d, ` +
`between ${MIN_AFTER_SECONDS}s and ${formatAfter(MAX_AFTER_SECONDS)}`,
)
return null
}
// Only the canonical string is stored. The seconds are re-derived by the one
// caller that needs them (the runner, when it opens the gate) through the
// exported `parseAfter`, rather than kept beside it as a second field two
// versions of the spec could disagree about.
return { after: formatAfter(seconds) }
}
// `dormant` is in this list for the reason `actionVersion` and `dormant` are
// in a step's — **validate must accept its own output.** A saved spec is
// re-validated on every later save and again at publish, so a field the
// validator itself added and then refused would make the second save of any
// gated definition impossible. It is accepted and then RECOMPUTED below,
// never trusted: dormancy is whether anybody registers that trigger right
// now, not what was true when the spec was last written.
const extra = keys.filter((k) => !['on', 'where', 'count', 'dormant'].includes(k))
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')} for an "on" gate`)
return null
}
const triggerId = raw.on
if (typeof triggerId !== 'string' || !triggerId) {
errors.push(`${path}.on: expected a trigger id`)
return null
}
let count = 1
if (raw.count !== undefined && raw.count !== null) {
if (!Number.isInteger(raw.count) || raw.count < 1 || raw.count > MAX_ADVANCE_COUNT) {
errors.push(`${path}.count: expected a whole number between 1 and ${MAX_ADVANCE_COUNT}`)
return null
}
count = raw.count
}
const declaration = registries.eventTrigger(triggerId)
if (!declaration) {
// Dormant, exactly as an unregistered action is. `where` is carried through
// unvalidated and unnormalised — there is no declaration to check it
// against, and dropping it would silently delete an author's predicate the
// moment a module was uninstalled.
return { on: triggerId, where: raw.where ?? null, count, dormant: true }
}
const checked = conditionGrammar.validate(declaration, raw.where ?? null)
if (!checked.ok) {
// The grammar paths its own errors from the root token `conditions`; this
// re-roots them at the phase so an author reading five of them at once can
// tell which phase each belongs to. The text after the path — the part that
// names the variable — is the grammar's, unchanged.
checked.errors.forEach((e) =>
errors.push(`${path}.where${e.startsWith('conditions') ? e.slice('conditions'.length) : `: ${e}`}`),
)
return null
}
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
/**
* Check one authored param object against an action's declared params.
*
* Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent
* drop: an author who typed `creatures` where the action declares `creature` has
* written a step that would dispatch with the count missing, and dropping the key
* makes that look like it saved cleanly.
*/
function checkParams(declaration, raw, path) {
const errors = []
const params = {}
const given = isPlainObject(raw) ? raw : {}
if (raw !== undefined && raw !== null && !isPlainObject(raw)) {
return { params, errors: [`${path}.params: expected an object`] }
}
const declared = new Map((declaration.params || []).map((p) => [p.name, p]))
for (const name of Object.keys(given)) {
if (!declared.has(name)) {
errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`)
}
}
for (const p of declaration.params || []) {
const value = given[p.name]
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
continue
}
const checked = checkLiteral(p.type, value)
if (checked.error) {
errors.push(`${path}.params: "${p.name}" ${checked.error}`)
continue
}
params[p.name] = checked.value
}
return { params, errors }
}
/**
* Validate and normalise a whole spec.
*
* `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }`
* listing EVERY problem rather than the first — the posture `conditions.validate`
* takes, and for the same reason: an author fixing one step at a time is an
* author making six round trips through a form.
*
* `knownActionIds` widens what may be named beyond what is registered right now,
* and it is how a definition survives its module being uninstalled. The rule,
* lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger:
* **a step already in the saved spec may keep an unregistered action; a new step
* may not add one.** Refusing to save the whole definition would make an
* uninstall destructive after the fact, and silently dropping the step would
* delete authored work to make a form submit. A kept step is marked
* `dormant: true` and its params pass through unvalidated, because the only
* thing that could validate them left with the module.
*/
function validate(raw, { knownActionIds = [] } = {}) {
const errors = []
const known = new Set(knownActionIds)
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
const allowed = new Set(['schedule', 'phases'])
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) {
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
}
}
// ── schedule ──
const rawSchedule =
raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule
let schedule = { kind: 'manual' }
if (!isPlainObject(rawSchedule)) {
errors.push('spec.schedule: expected an object')
} else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) {
errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`)
} else {
const kind = rawSchedule.kind
const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]])
const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k))
if (extra.length) {
errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`)
}
schedule = validateSchedule(kind, rawSchedule, errors)
}
// ── phases ──
const rawPhases = raw.phases
const phases = []
if (!Array.isArray(rawPhases) || rawPhases.length === 0) {
errors.push('spec.phases: expected a non-empty array')
return { ok: false, errors }
}
if (rawPhases.length > MAX_PHASES) {
errors.push(`spec.phases: at most ${MAX_PHASES} phases`)
return { ok: false, errors }
}
const seenKeys = new Set()
let totalSteps = 0
rawPhases.forEach((rawPhase, pi) => {
const path = `spec.phases[${pi}]`
if (!isPlainObject(rawPhase)) {
errors.push(`${path}: expected an object`)
return
}
const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps', 'advance'].includes(k))
if (extra.length) {
errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
}
const advance = validateAdvance(rawPhase.advance, `${path}.advance`, errors)
const key = rawPhase.key
if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) {
errors.push(`${path}.key: bad phase key "${key}"`)
} else if (seenKeys.has(key)) {
// Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two
// phases sharing a key would silently collapse into one at materialisation
// and half the authored steps would never exist.
errors.push(`${path}.key: "${key}" is used by more than one phase`)
} else {
seenKeys.add(key)
}
if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`)
const rawSteps = rawPhase.steps
if (!Array.isArray(rawSteps)) {
errors.push(`${path}.steps: expected an array`)
return
}
if (rawSteps.length > MAX_STEPS_PER_PHASE) {
errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`)
return
}
totalSteps += rawSteps.length
const steps = []
rawSteps.forEach((rawStep, si) => {
const spath = `${path}.steps[${si}]`
if (!isPlainObject(rawStep)) {
errors.push(`${spath}: expected an object`)
return
}
// `actionVersion` and `dormant` are in this list because **validate must
// accept its own output**. A saved spec is re-validated on every later
// save and again at publish, so a normalised field that the validator
// itself added and then refused would make the second save of any
// definition impossible. They are accepted and then RECOMPUTED below
// rather than trusted: the version comes from the declaration, and
// dormancy from whether anyone registers the action right now.
const stepExtra = Object.keys(rawStep).filter(
(k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k),
)
if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`)
const actionId = rawStep.actionId
const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null
if (typeof actionId !== 'string' || !actionId) {
errors.push(`${spath}.actionId: a step needs an action`)
return
}
if (!declaration && !known.has(actionId)) {
errors.push(`${spath}.actionId: no module registers "${actionId}"`)
return
}
if (!declaration) {
// Dormant: kept verbatim, params untouched, and flagged so the editor and
// the run console can both say WHY rather than showing an empty step.
steps.push({
actionId,
label: rawStep.label || actionId,
params: isPlainObject(rawStep.params) ? rawStep.params : {},
actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1,
onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause',
dormant: true,
})
return
}
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
errors.push(...paramErrors)
// §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension
// no module declares as a budget. Refused HERE as well as at dispatch
// because this is the cheap moment — the editor is open, the author is
// looking at the step, and the alternative is a run that refuses at two in
// the morning for a reason that was decidable when it was written.
//
// Only when the params validated. Pricing a step whose params were just
// refused would run a module's `cost()` over values core has already said
// are wrong, and report its answer as a second, confusing error about the
// same mistake.
if (!paramErrors.length) {
const priced = authorize.priceOf(declaration, params)
if (priced === null) {
errors.push(`${spath}: "${declaration.label}" could not report what it costs`)
} else {
for (const dimension of authorize.undeclaredDimensions(priced)) {
errors.push(
`${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`,
)
}
}
}
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
}
steps.push({
actionId,
label: rawStep.label || declaration.label,
params,
// Captured at SAVE time, from the declaration this step was authored
// against (§F). It is what lets a later bump render a warning in the
// editor instead of dispatching a mistyped parameter.
actionVersion: declaration.version,
onFailure: ON_FAILURE.includes(rawStep.onFailure)
? rawStep.onFailure
: defaultOnFailure(declaration.risk),
dormant: false,
})
})
// `advance` is omitted rather than written as null when there is no gate:
// the overwhelming majority of phases have none, and a spec full of
// `"advance": null` is a diff between two versions that says something
// changed about every phase the first time one phase gained a gate.
phases.push(advance ? { key, label: rawPhase.label, steps, advance } : { key, label: rawPhase.label, steps })
})
if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`)
if (errors.length) return { ok: false, errors }
return { ok: true, spec: { schedule, phases } }
}
/** Every action id a spec names, dormant ones included. */
const actionIdsIn = (spec) =>
(spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean)
/**
* A spec is publishable when nothing in it is dormant.
*
* Separate from `validate` on purpose: a dormant step must not stop an author
* SAVING (that is what makes an uninstall non-destructive), and it must stop
* them PUBLISHING, because publishing is what makes a version a thing runs are
* pinned to and a run cannot dispatch a verb nobody registers.
*
* **A phase's advance gate is dormant on the same rule** (Phase 5), and it is in
* the same list because it fails for the same reason and the message already
* reads correctly for both: a version that waits on a trigger nothing can emit
* is a run that would never leave that phase.
*/
function publishable(spec) {
const phases = spec?.phases || []
const dormant = [
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
]
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
}
/** An empty, valid spec — what a newly created draft carries. */
const emptySpec = () => ({
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [] }],
})
module.exports = {
validate,
publishable,
actionIdsIn,
emptySpec,
defaultOnFailure,
parseAfter,
formatAfter,
PHASE_KEY,
SCHEDULE_KINDS,
ADVANCE_KINDS,
MAX_ADVANCE_COUNT,
MAX_AFTER_SECONDS,
ON_FAILURE,
ON_FAILURE_BY_RISK,
MAX_PHASES,
MAX_STEPS_PER_PHASE,
MAX_STEPS,
}

153
server/src/events/verify.js Normal file
View File

@@ -0,0 +1,153 @@
// ── The dry run ────────────────────────────────────────────────────────────
//
// EVENTS.md §I ("four affordances worth building in from the start") and §K's
// last bound, in Phase 6. Materialise nothing, dispatch every step with
// `verify: true`, and report what would happen and what it would cost.
//
// > **Dry run before anything unattended.** A scheduled definition that has never
// > been verified is the case worth refusing to start; verification is cheap and
// > it is the last point a human sees the plan.
//
// **What it verifies depends on the definition's state, and that is not a
// compromise.** A `ready` definition is verified against its PUBLISHED VERSION,
// because a published version is the only thing that ever actually runs and §K's
// gate is about letting one run unattended. A draft is verified against its
// working spec, because §API's note is explicit that an author prices their work
// *before* asking an admin to publish it. The two readings do not conflict — they
// are the same act at two moments — and the answer says which one it did.
//
// **Only a pass against a version is recorded.** A version is immutable, so a dry
// run that passed against one stays true; a draft changes under the author's
// hands, so a pass on it would be a claim about a spec that no longer exists.
//
// ## The finding that only exists here
//
// Every per-step check — is the action registered, is it enabled, does this one
// invocation fit the cap — is a check something else also makes, at save or at
// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a
// cap of 30 pass every individual check and breach the cap on the third, at two
// in the morning, with the world half-changed. Adding the costs up across the
// whole version is the one thing that can only be done by looking at the plan as
// a whole, and it is the reason a dry run is worth more than the sum of its
// step checks.
const { dispatchStep } = require('./dispatch')
const authorize = require('./authorize')
const settingsDb = require('../model/events/eventActionSettings.db')
const registries = require('../modules/registries')
/**
* Dry-run a spec.
*
* `user` is the caller, so the role layer answers for *them* — an editor gets
* told that a step needs an administrator, at the moment they can still do
* something about it, rather than at the moment it does not run.
*
* Never throws: a `perform()` that explodes under `verify: true` is a finding
* about that action, not a 500 on the author's screen. `dispatchStep` already
* guarantees that, and this file adds no path around it.
*/
async function verifySpec(spec, { user = null, scope = '' } = {}) {
const phases = spec?.phases || []
const flat = []
for (const phase of phases) {
for (const [seq, step] of (phase.steps || []).entries()) {
flat.push({ phase: phase.key, seq, step })
}
}
const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId))
const findings = []
const totals = {}
for (const { phase, seq, step } of flat) {
const where = { phase, seq, actionId: step.actionId, label: step.label || null }
const action = registries.eventAction(step.actionId)
if (!action) {
// The same fact `publishable()` refuses on, said in the dry run's voice.
// Reported rather than thrown so that an author sees EVERY problem in one
// pass — a verification that stops at the first finding makes fixing a
// twelve-step definition twelve round trips.
findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` })
continue
}
const verdict = await authorize.mayInvoke({
user,
action,
params: step.params || {},
settings: settings.get(action.id) || null,
})
if (!verdict.ok) {
findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason })
continue
}
for (const [dimension, amount] of Object.entries(verdict.cost || {})) {
totals[dimension] = (totals[dimension] || 0) + amount
}
// The module's own answer. This is the half core cannot compute: whether the
// landmark exists, whether the creature is on the allowlist, whether the
// shard is reachable at all. `verify: true` rides through the real
// dispatcher rather than down a second path, because a dry run down a second
// path is a dry run OF the second path.
const result = await dispatchStep(
{
id: null,
run_id: null,
phase,
seq,
action_id: step.actionId,
params: step.params || {},
action_version: step.actionVersion || null,
idempotency_key: null,
attempts: 0,
},
{ run: { id: null, scope }, actor: user ? user.id : null, verify: true },
)
if (result.outcome === 'retry' || result.outcome === 'terminal') {
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
} else if (result.actionVersionDrift) {
findings.push({
...where,
level: 'warning',
code: 'version-drift',
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
})
}
}
// ── The whole-plan check ──
const caps = authorize.effectiveCaps(
flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })),
settings,
)
const cost = Object.entries(totals)
.sort(([a], [b]) => a.localeCompare(b))
.map(([dimension, total]) => {
const cap = (caps[dimension] || {}).cap ?? null
const over = cap !== null && total > cap
if (over) {
findings.push({
phase: null,
seq: null,
actionId: null,
label: null,
level: 'error',
code: 'cap-total',
message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`,
})
}
return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over }
})
return {
ok: !findings.some((f) => f.level === 'error'),
steps: flat.length,
findings,
cost,
}
}
module.exports = { verifySpec }

View File

@@ -11,7 +11,7 @@
const { query } = require('../../utils/db')
const COLS = 'id, post_id, status, created_at, updated_at'
const COLS = 'id, post_id, run_id, status, created_at, updated_at'
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
async function legsFor(jobIds) {
@@ -34,8 +34,15 @@ async function attachLegs(jobs) {
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
// an empty list is legal and yields a job with nothing to deliver.
async function create(postId, legs = []) {
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
//
// `runId` is Phase 10's (EVENTS.md §J): a job an EVENT asked for, rather than
// the one a news publish enqueues. It changes nothing about how the job is
// dispatched, retried or rolled up — the whole point of reusing this pipeline is
// that an event announcement gets the legs, the backoff and the classification
// already written — and everything it does change is in the two places below
// that ask "whose job is this".
async function create(postId, legs = [], { runId = null } = {}) {
const res = await query('INSERT INTO announce_jobs (post_id, run_id) VALUES (?, ?)', [postId, runId])
const jobId = Number(res.insertId)
if (legs.length > 0) {
const values = legs.map(() => '(?, ?)').join(', ')
@@ -65,9 +72,15 @@ async function findById(id) {
return (await attachLegs(rows))[0]
}
// **The post's OWN job, which is what `run_id IS NULL` means here.** A post may
// now have more than one — the news publish enqueued one, and an event linked the
// same post later — and every caller of this function is the post admin panel or
// its retry button, which are about the news announcement. Without the clause the
// panel would silently start rendering an event's job the moment one existed, and
// the retry button would retry that instead.
async function findByPostId(postId) {
const rows = await query(
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? AND run_id IS NULL ORDER BY id DESC LIMIT 1`,
[postId],
)
if (rows.length === 0) return null

View File

@@ -45,6 +45,33 @@ async function enqueueIfNeeded(post, transition) {
}
}
/**
* Enqueue an announcement a RUN asked for (EVENTS.md §J, Phase 10).
*
* The same job, the same legs, the same worker — so the town crier and Discord
* come free, with their retry and their classification, rather than an event
* growing a second delivery pipeline that would need both again and get them
* subtly wrong. Two things differ, and both are about not standing on the news
* pipeline's toes:
*
* **The post's back-pointer is written only when it has none.** `announce_job_id`
* is what the post admin panel reads and what `shouldEnqueue` guards on, so
* moving it to an event's job would make a re-published post announce itself
* again. A post that has never been announced gains the pointer, because then
* this job IS its announcement and the panel should show it.
*
* **`announced_at` is not stamped by a run's job** — see `refreshStatus`.
*
* Returns the new job id.
*/
async function enqueueForRun(postId, runId) {
const jobId = await db.create(postId, registries.announceLegIds(), { runId })
const post = await posts.getById(postId)
if (post && !post.announce_job_id) await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued for a run', { jobId, postId, runId })
return jobId
}
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
// attempt count and schedule the next run (or fail the leg once the cap is hit).
@@ -82,7 +109,12 @@ async function refreshStatus(jobId) {
const status = logic.rollupStatus(job.legs.map((l) => l.status))
if (status !== job.status) await db.setStatus(jobId, status)
job.status = status
if (status === 'done') {
// **A run's job does not stamp the post** (Phase 10). `announced_at` means
// "when this post was announced", and an event that links a three-week-old
// news article would otherwise rewrite that to today — making the post admin
// panel report a publication date it does not have. The event's own record of
// having announced is the run log line and the job's `run_id`.
if (status === 'done' && !job.run_id) {
try {
await posts.markAnnounced(job.post_id)
} catch (err) {
@@ -127,6 +159,7 @@ async function getByPostId(postId) {
module.exports = {
enqueue,
enqueueForRun,
shouldEnqueue,
enqueueIfNeeded,
recordOutcome,

View File

@@ -1,8 +1,14 @@
const { query } = require('../../utils/db')
/**
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
* cooling. ENGAGEMENT.md §4.1.
* Claim a fire for (rule, user, subject, channel), or refuse it because that
* delivery is still cooling. ENGAGEMENT.md §4.1.
*
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
* engine claims inside its per-channel loop, so a key without the channel means
* the first channel of a two-channel rule claims the cooldown and every later one
* is refused as cooling - which made every in-universe email body of Phase 11b
* unreachable behind the in-app one. A cooldown is per delivery.
*
* **Two statements, each of which is its own atomic decision** - and it is worth
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
*/
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
const moved = await query(
`UPDATE engagement_cooldowns
SET last_fired_at = ?, fire_count = fire_count + 1
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
AND last_fired_at <= ? - INTERVAL ? SECOND`,
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
)
if (Number(moved?.affectedRows || 0) === 1) return true
const inserted = await query(
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, now],
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, channel, now],
)
return Number(inserted?.affectedRows || 0) === 1
}
const get = async (ruleId, userId, subjectKey) => {
const get = async (ruleId, userId, subjectKey, channel) => {
const [row] = await query(
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
[ruleId, userId, subjectKey],
`SELECT * FROM engagement_cooldowns
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
[ruleId, userId, subjectKey, channel],
)
return row || null
}
@@ -71,8 +78,23 @@ const get = async (ruleId, userId, subjectKey) => {
* failure `teamActivityPrune` was written for. A dropped row means the next fire
* is treated as a first fire, which is correct as long as the retention window is
* longer than the longest configured cooldown - the caller's job, not this one's.
* Phase 14 gave it that caller (`engagementRetentionPrune`), which also enforces
* the horizon-versus-longest-cooldown rule this comment names.
*
* `limit` bounds one statement, for `userNotificationsPrune`'s reason: a first
* sweep after a long outage must be a series of bounded DELETEs rather than one
* that holds locks over a million rows. Omitting it keeps the original
* unbounded behaviour, so existing callers and tests are unaffected.
*
* @returns {Promise<number>} rows deleted
*/
const prune = (olderThan) =>
query('DELETE FROM engagement_cooldowns WHERE last_fired_at < ?', [olderThan])
const prune = async (olderThan, limit = 0) => {
const bounded = Number(limit) > 0
const result = await query(
`DELETE FROM engagement_cooldowns WHERE last_fired_at < ?${bounded ? ' LIMIT ?' : ''}`,
bounded ? [olderThan, Math.floor(limit)] : [olderThan],
)
return Number(result?.affectedRows || 0)
}
module.exports = { claim, get, prune }

View File

@@ -0,0 +1,62 @@
// ── engagement_digest_state (ENGAGEMENT.md §4.2b, Phase 6) ─────────────────
//
// The state a digest keeps, and deliberately the ONLY state a digest keeps. What
// goes IN a digest is re-derived from the source tables when the mail is about to
// go out; this table answers one question — "what window does this person's next
// digest cover?" — and nothing else.
//
// Lifted out of `team_notification_prefs.last_digest_at`, where it was a worker's
// column sitting on a user's preferences row. Keyed (user, channel, scope) so a
// second digest — on another channel, or over another scope — needs no second
// column on somebody else's table.
const { query } = require('../../utils/db')
/**
* The stamps for a set of users in one scope, as a Map.
*
* Returns only the rows that exist. Absence is the CALLER's to interpret, and it
* matters that it is: `clampSince` treats a missing row and a NULL stamp
* identically (reach back one interval, not to the seven-day floor), so a person
* who has never had a digest and a person whose row was written by the backfill
* get the same first window.
*/
async function stampsFor(userIds, channel, scopeKey = '') {
const ids = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!ids.length) return new Map()
const rows = await query(
`SELECT user_id, last_digest_at FROM engagement_digest_state
WHERE channel = ? AND scope_key = ? AND user_id IN (${ids.map(() => '?').join(',')})`,
[channel, scopeKey, ...ids],
)
return new Map(rows.map((r) => [Number(r.user_id), r.last_digest_at]))
}
/** One user's stamp, or undefined. */
async function stampFor(userId, channel, scopeKey = '') {
const rows = await query(
`SELECT last_digest_at FROM engagement_digest_state
WHERE user_id = ? AND channel = ? AND scope_key = ?`,
[Number(userId), channel, scopeKey],
)
return rows.length ? rows[0].last_digest_at : undefined
}
/**
* Stamp a digest as delivered.
*
* Written ONLY after a successful send, which is the property the old
* `stampDigest` had and the one worth restating: stamping first would silently
* eat a day of somebody's notifications every time the mail provider has a bad
* minute.
*/
async function stamp(userId, channel, scopeKey, at) {
await query(
`INSERT INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE last_digest_at = VALUES(last_digest_at)`,
[Number(userId), channel, scopeKey || '', at],
)
}
module.exports = { stampsFor, stampFor, stamp }

View File

@@ -22,14 +22,18 @@ const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, {}) }
async function enqueue(row) {
const result = await query(
`INSERT IGNORE INTO engagement_outbox
(rule_id, trigger_id, user_id, channel, subject_key, payload, dedupe_key, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(rule_id, trigger_id, user_id, channel, subject_key, scope_key, payload, dedupe_key, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.rule_id,
row.trigger_id,
row.user_id,
row.channel,
row.subject_key || '',
// NULL, not '', for an unscoped event: '' is a scope key that means
// "deployment-wide" in engagement_digest_state, and this column has to be
// able to say "no scope at all" as well.
row.scope_key ?? null,
JSON.stringify(row.payload || {}),
row.dedupe_key ?? null,
row.due_at,
@@ -128,11 +132,61 @@ async function cancel(ruleId, subjectKey, userId = null) {
* stamped it), and the window has to be comfortably longer than the slowest
* legitimate send or this reclaims rows that are merely slow.
*/
const reclaimStale = (before) =>
query(
const reclaimStale = async (before, maxAttempts = 0) => {
// Give up first, reclaim second, and in that order: a row that has already
// burned its attempts must leave 'sending' as `failed`, or the reclaim below
// hands it straight back to `findDue` and it is retried forever.
//
// **This is what makes `pruneTerminal` a bound at all** (Phase 14). Attempts
// are incremented by `claim`, but `MAX_ATTEMPTS` is only consulted on a
// graceful `retry` outcome — a send that kills the process mid-flight never
// reaches that branch, so before this the row cycled sending → scheduled →
// sending forever, never reached a terminal status, and was therefore never
// eligible for any retention sweep. One poisoned payload was an outbox row
// that outlived every horizon.
let failed = 0
if (Number(maxAttempts) > 0) {
const gaveUp = await query(
`UPDATE engagement_outbox
SET status = 'failed', last_error = 'gave up after repeated interruptions'
WHERE status = 'sending' AND updated_at < ? AND attempts >= ?`,
[before, Math.floor(maxAttempts)],
)
failed = Number(gaveUp?.affectedRows || 0)
}
const reclaimed = await query(
"UPDATE engagement_outbox SET status = 'scheduled' WHERE status = 'sending' AND updated_at < ?",
[before],
)
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
}
/**
* Delete terminal rows older than `before` (Phase 14).
*
* **Terminal only, and the status list is the whole policy.** A `scheduled` row
* is a promise the engine has not kept yet — `delay_seconds` can legitimately
* put one up to a day out (`MAX_DELAY_SECONDS`) — and a `sending` row may be a
* worker mid-flight. Deleting either is not retention, it is cancelling a send
* nobody asked to cancel. Only `sent`, `failed`, `cancelled` and `suppressed`
* are outcomes that have already happened.
*
* `created_at` rather than `updated_at` is the clock deliberately: the horizon
* an operator sets means "how long we keep the record of a delivery", which is
* measured from when it was enqueued, not from whenever it was last touched.
*
* @returns {Promise<number>} rows deleted
*/
const pruneTerminal = async (before, limit = 1000) => {
const result = await query(
`DELETE FROM engagement_outbox
WHERE status IN ('sent', 'failed', 'cancelled', 'suppressed')
AND created_at < ?
LIMIT ?`,
[before, Math.floor(limit)],
)
return Number(result?.affectedRows || 0)
}
const getById = async (id) => {
const [row] = await query('SELECT * FROM engagement_outbox WHERE id = ?', [id])
@@ -153,6 +207,7 @@ module.exports = {
finish,
cancel,
reclaimStale,
pruneTerminal,
getById,
listForRule,
}

View File

@@ -122,4 +122,91 @@ const storedModes = async (userIds, streamId, channel) => {
return new Map(rows.map((r) => [Number(r.user_id), r.mode]))
}
module.exports = { active, staff, subscribers, filterActive, storedModes, MAX_AUDIENCE }
/**
* One user's mailable address, or null — the email channel's `addressFor`
* (Phase 6).
*
* `status = 'active'` is re-checked here even though every audience query already
* filtered on it, and the gap it closes is real rather than theoretical: an
* outbox row can sit through a `delay_seconds` grace window, so a user banned
* between the emit and the send is exactly the case this catches. The cost is one
* primary-key lookup on a path that is about to open an SMTP conversation.
*
* **It still does not gate on `email_verified`, and Phase 9 kept it that way.**
* §7.1 Q1's narrower half was settled by the org lead 2026-08-31: the gate
* excludes an unverified user at ENQUEUE, in `emailChannel.eligible`, so no
* outbox row is written and the admin reach preview can say how many were
* dropped. Adding the same condition here as well would look like defence in
* depth and would in fact be a second, invisible answer to the question — this
* function is also what a password reset would reach if it ever routed through
* the channel, and reset mail is deliberately ungated (`passwordReset.controller.js`).
*/
const addressFor = async (userId) => {
const rows = await query(
`SELECT email FROM users
WHERE id = ? AND status = 'active' AND email IS NOT NULL AND email <> ''`,
[Number(userId)],
)
return rows.length ? { address: rows[0].email } : null
}
/**
* Which of these users hold an UNVERIFIED address - the set the Phase 1b gate
* excludes when it is on (Phase 9).
*
* A user with no address at all is in this set, and that is not incidental: the
* gate's question is "may we mail this person", and nowhere to send is a stronger
* no than an unconfirmed somewhere. `addressFor` refuses them at delivery either
* way; including them here is what stops an outbox row being written for a send
* that is already known to be impossible.
*
* One query for a whole audience. The engine calls it once per rule per event
* with up to MAX_AUDIENCE ids, so a per-user lookup would be five thousand round
* trips on the path that is supposed to be the cheap one.
*/
const unverifiedAmong = async (userIds) => {
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!wanted.length) return new Set()
const capped = wanted.slice(0, MAX_AUDIENCE)
const rows = await query(
`SELECT id FROM users
WHERE id IN (${marks(capped)})
AND (email_verified = 0 OR email IS NULL OR email = '')`,
capped,
)
return new Set(ids(rows))
}
/**
* The addresses for a set of active users, as a Map - what the admin reach
* preview hashes to count how many of them are suppressed.
*
* It returns PLAINTEXT, which is the one thing this subsystem otherwise avoids,
* and there is no way around it: a suppression is keyed on the sha256 of an
* address, so answering "how many of these people are suppressed" requires
* hashing each one. The caller (`engagement.controller`) hashes immediately and
* returns only a count - no route ever serializes what this returns.
*/
const addressesFor = async (userIds) => {
const wanted = [...new Set(userIds.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
if (!wanted.length) return new Map()
const capped = wanted.slice(0, MAX_AUDIENCE)
const rows = await query(
`SELECT id, email FROM users
WHERE id IN (${marks(capped)}) AND status = 'active' AND email IS NOT NULL AND email <> ''`,
capped,
)
return new Map(rows.map((r) => [Number(r.id), r.email]))
}
module.exports = {
active,
staff,
subscribers,
filterActive,
storedModes,
addressFor,
unverifiedAmong,
addressesFor,
MAX_AUDIENCE,
}

View File

@@ -0,0 +1,175 @@
// ── Engagement retention policy ────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so
// the nightly worker and Admin → Engagement → Retention cannot disagree about
// what this deployment keeps — and so `/privacy` has one thing to describe.
//
// **The fourth engagement table is deliberately absent from this file.**
// `engagement_suppressions` does not expire (org lead, 2026-09-01): a
// suppression is a standing decision, and ageing out a hard bounce re-mails an
// address that already bounced, which is how a sender loses a domain's
// reputation. The one way out of that table stays what Phase 9 built — a
// deliberate act by an admin, which Phase 14 only made reachable per row.
//
// Settings rows rather than env, for `teamActivityPrune`'s reason: an operator
// tightening a busy shard should not need a deploy. Unlike the two workers this
// copies, these three get a screen — the send log's horizon changes what an
// operator-facing page can show, so it cannot be an invisible key.
const settings = require('../settings/settings.model')
const rulesDb = require('./engagementRules.db')
const log = require('../../utils/logger')('engagement')
/**
* One entry per horizon. `min` is not a UI nicety — each is the point below
* which the sweep breaks something that is not retention:
*
* - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a
* duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so
* 2 days is the smallest provably-safe value against any rule that can be
* saved. `checkCooldownHorizon` re-checks it against the rules that exist.
* - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is
* ever more than a day out; terminal rows younger than that are still the
* most recent thing an operator would look at.
* - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a
* horizon under an hour would silently disable it. The floor is set far
* above that, at the point the Send Log stops being worth opening.
*/
const HORIZONS = {
sends: {
key: 'engagement_sends_retain_days',
default: 180,
min: 7,
max: 3650,
label: 'Send log',
},
cooldowns: {
key: 'engagement_cooldowns_retain_days',
default: 30,
min: 2,
max: 3650,
label: 'Cooldowns',
},
outbox: {
key: 'engagement_outbox_retain_days',
default: 30,
min: 2,
max: 3650,
label: 'Outbox',
},
}
const NAMES = Object.keys(HORIZONS)
/**
* The current policy, as `{ sends, cooldowns, outbox }` in days.
*
* Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the
* nightly worker calls this with nobody watching, so a settings table that is
* briefly unavailable must yield defaults rather than an exception that kills
* the job. An unreadable, absent, non-numeric or out-of-range value all mean
* the same thing — use the default — because none of them is a horizon.
*/
async function get() {
const out = {}
for (const name of NAMES) {
const spec = HORIZONS[name]
let days = spec.default
try {
const raw = await settings.get(spec.key)
const n = Number(raw)
if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n)
} catch (err) {
log.debug('retention setting unreadable; using the default', {
key: spec.key,
message: err.message,
})
}
out[name] = days
}
return out
}
/**
* Write one or more horizons. Unknown names are ignored rather than rejected, so
* a client sending the whole object back is not coupled to this list; an
* out-of-range value IS rejected, because silently clamping a number an operator
* typed would leave the screen showing something the deployment is not doing.
*
* @returns {Promise<object>} the policy as it now stands
*/
async function set(patch = {}, updatedBy = null) {
for (const name of NAMES) {
if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue
const spec = HORIZONS[name]
const n = Number(patch[name])
if (!Number.isFinite(n) || Math.floor(n) !== n) {
throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), {
status: 400,
})
}
if (n < spec.min || n > spec.max) {
throw Object.assign(
new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`),
{ status: 400 },
)
}
await settings.set(spec.key, String(n), updatedBy)
}
return get()
}
/**
* The longest cooldown any ENABLED rule configures, in seconds — see
* `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only.
*
* Swallows its error rather than propagating: this is consulted by a worker
* running on a timer, and a database hiccup must degrade the WARNING, never the
* sweep. Zero reads as "no enabled rule has a cooldown", which produces no
* warning — the same answer as an unreadable table, and the safe one, because
* the alternative is a nightly alarm nobody can act on.
*/
async function longestEnabledCooldownSeconds() {
try {
return await rulesDb.maxEnabledCooldownSeconds()
} catch (err) {
log.debug('could not read the longest enabled cooldown', { message: err.message })
return 0
}
}
/**
* Phase 14's acceptance line: the cooldown horizon is CHECKED against the
* longest enabled rule's cooldown rather than picked.
*
* It returns a warning rather than throwing, and the worker sweeps anyway. The
* alternative — refusing to prune — trades a bounded, describable fault (one
* rule may re-fire early once) for the unbounded one this phase exists to end.
* The screen surfaces the same warning, which is where an operator can act on it.
*
* @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>}
*/
async function checkCooldownHorizon(days) {
const longest = await longestEnabledCooldownSeconds()
const horizonSeconds = days * 24 * 60 * 60
if (longest > 0 && horizonSeconds <= longest) {
return {
ok: false,
longestCooldownSeconds: longest,
message:
`Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of `
+ `${longest} second(s). Pruning a cooldown row that is still in force makes the next `
+ 'fire count as a first fire, so that rule can send twice. Raise the horizon.',
}
}
return { ok: true, longestCooldownSeconds: longest, message: null }
}
module.exports = {
HORIZONS,
NAMES,
get,
set,
longestEnabledCooldownSeconds,
checkCooldownHorizon,
}

View File

@@ -135,9 +135,25 @@ const countUsingSegment = async (segmentId) => {
return Number(row?.n || 0)
}
/**
* The longest cooldown any ENABLED rule configures, in seconds (Phase 14).
*
* Enabled only, deliberately: a disabled rule fires nothing, so it writes no
* cooldown row a retention sweep could destroy, and letting a forgotten disabled
* rule with a 24-hour cooldown veto a tighter horizon would make the warning
* advice nobody can act on.
*/
const maxEnabledCooldownSeconds = async () => {
const [row] = await query(
'SELECT MAX(cooldown_seconds) AS n FROM engagement_rules WHERE enabled = 1',
)
return Number(row?.n || 0)
}
module.exports = {
list,
getById,
maxEnabledCooldownSeconds,
enabledForTrigger,
enabledCancelledBy,
insert,

View File

@@ -40,6 +40,12 @@ const MAX_DELAY_SECONDS = 86_400
// what makes rules-as-data safe (§7.1 Q3).
const MAX_SENDS_PER_HOUR = 10_000
// The one key in `template_keys` that is not a delivery channel. `teamDigestWorker`
// renders it for a rule whose email channel an individual has set to digest mode,
// so it belongs to a MODE rather than to the rule's channel list and can never
// appear there.
const DIGEST_SLOT = 'digest'
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
/**
@@ -91,7 +97,17 @@ async function validate(input, { existing = null } = {}) {
errors.push('templateKeys must be an object of { channel: templateKey }')
} else {
for (const [channel, key] of Object.entries(raw.templateKeys || {})) {
if (!wanted.includes(channel)) {
// **`digest` is a template SLOT, not a channel**, and it is legal here for
// exactly the reason `registries.js` `checkSeedRule` says it is: it names
// the body `teamDigestWorker` renders for a rule whose email channel an
// individual has set to digest mode, so it never appears in `channels` and
// never could. Rejecting it made every rule that ships one unsaveable from
// the Rules screen — core's own team and news rules included, and sixteen
// of module-uo's — with a 400 naming a key the operator never typed, whose
// only remedy was deleting the digest body and silently dropping digest
// support. Found by Phase 13's acceptance walk; the two validators now
// agree about what `digest` is.
if (channel !== DIGEST_SLOT && !wanted.includes(channel)) {
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
continue
}

View File

@@ -107,4 +107,26 @@ const count = async (opts = {}) => {
return Number(row?.n || 0)
}
module.exports = { record, countSentSince, list, count, TEST_SEND_TRIGGER }
/**
* Delete send-log rows older than `before` (Phase 14).
*
* **Every row here is terminal**, which is why this has no status filter and the
* outbox's sweep does: `engagement_sends` records an attempt that has already
* resolved. The care is entirely in the horizon, because this table has two live
* readers and they pull in opposite directions — `countSentSince` implements the
* per-rule hourly ceiling (§7.1 Q3), so any horizon under an hour silently
* disables that ceiling, and Admin -> Engagement -> Send Log is the operator's
* only answer to "was this person told", so a short one blinds it. Both are the
* caller's problem, and `engagementRetention` is where that judgement lives.
*
* @returns {Promise<number>} rows deleted
*/
const prune = async (before, limit = 1000) => {
const result = await query('DELETE FROM engagement_sends WHERE created_at < ? LIMIT ?', [
before,
Math.floor(limit),
])
return Number(result?.affectedRows || 0)
}
module.exports = { record, countSentSince, list, count, prune, TEST_SEND_TRIGGER }

View File

@@ -0,0 +1,139 @@
const { query } = require('../../utils/db')
/**
* The suppression list (ENGAGEMENT.md G16, Phase 9).
*
* Every function here takes an ALREADY HASHED address. Hashing lives in
* `engagement/suppressions.js` beside the masking, so the two can never disagree
* about what a row for one address looks like; this file only reads and writes.
*/
/** Is this address suppressed on this channel? The row, or null. */
const get = async (addressHash, channel = 'email') => {
const rows = await query(
'SELECT * FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
[addressHash, channel],
)
return rows.length ? rows[0] : null
}
/**
* Suppress an address, or leave an existing row exactly as it is.
*
* `INSERT IGNORE`, deliberately, rather than an upsert. The FIRST reason an
* address was suppressed is the true one and the one an operator needs: an
* address that hard-bounced in March and was then manually re-added in June
* should still read `bounce`, because that is the fact that explains the mail
* stopping. An upsert would let the most recent write overwrite the diagnosis.
*
* @returns {Promise<boolean>} true when this call created the row
*/
const add = async (entry) => {
const result = await query(
`INSERT IGNORE INTO engagement_suppressions
(address_hash, address_masked, channel, reason, detail, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[
entry.address_hash,
entry.address_masked ?? null,
entry.channel || 'email',
entry.reason,
entry.detail ? String(entry.detail).slice(0, 500) : null,
entry.created_by ?? null,
],
)
return Number(result.affectedRows || 0) > 0
}
/**
* Un-suppress an address. The one way out of this table, which is why the admin
* screen exists at all (Phase 9 decision 3).
*
* @returns {Promise<boolean>} true when a row was removed
*/
const remove = async (addressHash, channel = 'email') => {
const result = await query(
'DELETE FROM engagement_suppressions WHERE address_hash = ? AND channel = ?',
[addressHash, channel],
)
return Number(result.affectedRows || 0) > 0
}
/** WHERE-clause builder shared by `list` and `count`, so the two cannot disagree. */
const filters = ({ reason = null, channel = null, search = null } = {}) => {
const where = []
const params = []
if (reason) {
where.push('reason = ?')
params.push(reason)
}
if (channel) {
where.push('channel = ?')
params.push(channel)
}
if (search) {
// Against the MASKED column only. Searching the hash would need the caller to
// hash first, which makes a partial search impossible, and there is no
// plaintext column to search — that is the point of the table. A domain
// ("example.com") is what an operator actually types here, and the mask keeps
// the domain intact precisely so this works.
where.push('address_masked LIKE ?')
params.push(`%${String(search).slice(0, 120)}%`)
}
return { clause: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
}
/** The admin list, newest first. */
const list = (opts = {}) => {
const { clause, params } = filters(opts)
return query(
`SELECT * FROM engagement_suppressions ${clause} ORDER BY created_at DESC, address_hash LIMIT ? OFFSET ?`,
[...params, opts.limit || 50, opts.offset || 0],
)
}
/** How many rows match the same filters — the paged screen's total. */
const count = async (opts = {}) => {
const { clause, params } = filters(opts)
const [row] = await query(`SELECT COUNT(*) AS n FROM engagement_suppressions ${clause}`, params)
return Number(row?.n || 0)
}
/**
* How many suppressions per reason — the summary the screen leads with.
*
* A count by reason is the difference between "eleven addresses are suppressed"
* and "eleven addresses hard-bounced", and only the second tells an operator
* whether to go and look at their relay.
*/
const countsByReason = async () => {
const rows = await query(
'SELECT reason, COUNT(*) AS n FROM engagement_suppressions GROUP BY reason',
)
const out = {}
for (const r of rows) out[r.reason] = Number(r.n || 0)
return out
}
/**
* The subset of these hashes that is suppressed, as a Set.
*
* One query for a whole audience rather than one per recipient. Not used by the
* delivery path — that checks a single address at send time, after the delay
* window, which is the only check that can be correct — but by the admin reach
* preview, which is asked about thousands of users at once and must not become
* thousands of round trips.
*/
const suppressedAmong = async (addressHashes, channel = 'email') => {
const wanted = [...new Set((addressHashes || []).filter(Boolean))]
if (!wanted.length) return new Set()
const marks = wanted.map(() => '?').join(', ')
const rows = await query(
`SELECT address_hash FROM engagement_suppressions
WHERE channel = ? AND address_hash IN (${marks})`,
[channel, ...wanted],
)
return new Set(rows.map((r) => r.address_hash))
}
module.exports = { get, add, remove, list, count, countsByReason, suppressedAmong }

View File

@@ -0,0 +1,86 @@
// ── event_action_settings — SQL only ───────────────────────────────────────
//
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
// one row per action an admin has an opinion about, and **nothing else in the
// permission model beyond the role**.
//
// **A missing row is not "disabled".** It is "the default for this action's risk
// class", and that default is computed in `eventActionSettings.model.js` from the
// registry rather than stored here. The reason is structural: the registry is
// assembled by `registerCore()` and by module `register()`, both of which run
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
// §2.2), so a boot-time seed of one row per registered action would be precisely
// the database write those two forbid. A deployment that never opens the
// switchboard has no rows at all and behaves correctly.
//
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
// settings standing, so re-installing restores the caps the operator chose
// instead of silently resetting them to the default. The switchboard lists what
// is registered *now*, so a stranded row is invisible until its action returns.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
/** Every stored row, action id first. Stranded rows included — the caller filters. */
async function all() {
const rows = await query(
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
FROM event_action_settings s
LEFT JOIN users u ON u.id = s.updated_by
ORDER BY s.action_id`,
)
return rows.map(hydrate)
}
/** One row, or null when the deployment has never had an opinion about this action. */
async function get(actionId) {
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings WHERE action_id = ?`,
[actionId],
)
return rows.length ? hydrate(rows[0]) : null
}
/**
* The rows for a set of action ids, as a Map keyed by id.
*
* The shape the authorisation path wants: `mayInvoke` is asked about one action
* at a time but the run start prices a whole version at once, and one query per
* step of a twelve-step definition is twelve round trips to answer a question
* about a table with one row per action in the process.
*/
async function byIds(actionIds) {
const ids = [...new Set(actionIds || [])].filter(Boolean)
if (!ids.length) return new Map()
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
}
/**
* Write one action's switch and caps.
*
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
* the switchboard at once should leave one of their two opinions standing, not an
* error and not a row that never appeared. There is no compare-and-set here
* because there is nothing to race — this is configuration, and the value that
* matters is the last one a human chose.
*/
async function put(actionId, { enabled, caps }, userId = null) {
await query(
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
)
return get(actionId)
}
module.exports = { all, get, byIds, put }

View File

@@ -0,0 +1,170 @@
// ── The calendar ───────────────────────────────────────────────────────────
//
// EVENTS.md §I: "month and list view, filtered by category, scope and series",
// and Phase 4's stated deliverable — *the thing this feature exists to replace*
// is a WordPress calendar plugin with no series field and no recurrence.
//
// **A calendar entry is one of two things, and the difference is not cosmetic.**
//
// - A **run**: a real `event_runs` row. It has an id, a status, a health, a
// pinned version and a console. Somebody can cancel it. It exists because the
// runner materialised it inside its fourteen-day horizon, or because an admin
// started it by hand.
// - A **projection**: arithmetic. There is no row, nothing to cancel, and
// nothing has been committed to. It exists so that a monthly event is visible
// three weeks out instead of the calendar simply ending at the horizon (org
// lead, 2026-09-02).
//
// The API says which each is and the UI renders them differently, because an
// operator acting on a projection as though it were a booking is the failure
// this distinction exists to prevent. A projection is a forecast of what the
// runner *will* materialise, computed by the same `occurrencesBetween` the
// runner itself calls — one arithmetic, so the forecast cannot disagree with
// what later appears.
//
// **A projection is never emitted for an instant a run already occupies**, which
// is what keeps the fortnight inside the horizon from showing everything twice.
// That rule also does the right thing for a CANCELLED occurrence: the row is
// still there, so nothing re-projects it, and an event an operator called off
// does not reappear on the calendar as though it were still coming.
const runsDb = require('./eventRuns.db')
const definitionsDb = require('./eventDefinitions.db')
const recurrence = require('../../events/recurrence')
// A calendar request is operator-supplied, and a year-wide window across forty
// weekly definitions is how a month view becomes an outage. Ninety-two days is
// a three-month view — more than the month grid and the list either need.
const MAX_WINDOW_DAYS = 92
const MAX_ENTRIES = 1000
const runEntry = (run) => ({
kind: 'run',
runId: run.id,
definitionId: run.definition_id,
title: run.definition_title,
slug: run.definition_slug,
seriesId: run.series_id || null,
seriesName: run.series_name || null,
seriesSlug: run.series_slug || null,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
scope: run.scope,
status: run.status,
health: run.health,
version: run.version_number,
rehearsal: Boolean(run.rehearsal),
waitingSteps: Number(run.waiting_steps || 0),
})
const projectedEntry = (definition, occurrence) => ({
kind: 'projected',
runId: null,
definitionId: definition.id,
title: definition.title,
slug: definition.slug,
seriesId: definition.series_id || null,
seriesName: definition.series_name || null,
seriesSlug: definition.series_slug || null,
scheduledFor: occurrence.at,
timezone: definition.timezone,
scope: '',
status: null,
health: null,
// Why this instant is not the wall clock the schedule names. Carried on the
// projection as well as on the materialised run, so the calendar can explain
// a DST-shifted time before it happens rather than after.
adjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
})
/**
* The calendar for a window.
*
* `{ ok, window, horizon, entries }` — entries ascending by instant, runs and
* projections interleaved. `horizon` is the instant past which nothing is
* materialised yet, so the UI can draw the line rather than infer it.
*
* **The instants are UTC and the placement is the client's.** A month grid has
* one date axis and the viewer's own zone is what "this month" means to the
* person reading it; each entry carries its own `timezone` so the time beside it
* reads `20:00 Europe/Berlin` and nobody misreads a shard's local schedule as
* their own. That is the split §E's "the timezone belongs to the event" implies:
* the event owns the time, the reader owns the calendar.
*/
async function calendar({
from,
to,
status = null,
scope = null,
seriesId = null,
horizonDays = 14,
now = new Date(),
} = {}) {
const start = from instanceof Date ? from : new Date(from)
const end = to instanceof Date ? to : new Date(to)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
}
if (end <= start) {
return { ok: false, status: 400, errors: ['to must be after from'] }
}
if (end - start > MAX_WINDOW_DAYS * recurrence.DAY_MS) {
return { ok: false, status: 400, errors: [`the window may span at most ${MAX_WINDOW_DAYS} days`] }
}
const runs = await runsDb.listInWindow({ from: start, to: end, status, scope, seriesId })
const entries = runs.map(runEntry)
// Every instant a run already occupies, keyed by definition. Projections are
// per definition at the empty scope, so the definition and the instant are the
// whole key -- the same triple the unique index uses, with the scope fixed.
const taken = new Set(
runs
.filter((r) => !r.scope)
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
)
// A status filter is a filter on RUNS. A projection has no status, so asking
// for "everything that failed" must not answer with a forecast — it would be a
// forecast that failed, which is not a thing.
// The scope filter behaves the same way, and for the same reason: automatic
// expansion is at the empty scope (org lead, 2026-09-02), so a request narrowed
// to a named scope has no forecast to give.
if (!status && !scope) {
const definitions = await definitionsDb.findSchedulable()
for (const definition of definitions) {
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
occurrences = recurrence.occurrencesBetween(
schedule,
definition.timezone || 'UTC',
start,
end,
)
} catch {
continue
}
for (const occurrence of occurrences) {
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
entries.push(projectedEntry(definition, occurrence))
}
}
}
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
return {
ok: true,
status: 200,
window: { from: start, to: end },
horizon: new Date(now.getTime() + horizonDays * recurrence.DAY_MS),
entries: entries.slice(0, MAX_ENTRIES),
truncated: entries.length > MAX_ENTRIES,
}
}
module.exports = { calendar, MAX_WINDOW_DAYS, MAX_ENTRIES }

View File

@@ -0,0 +1,213 @@
// ── event_definitions — SQL only ───────────────────────────────────────────
//
// EVENTS.md §D. The `.db.js` half of the pair: parameterised SQL and hydration,
// no policy. Everything that decides whether a write is allowed lives in
// `eventDefinitions.model.js`.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) =>
row && {
...row,
spec: parseJson(row.spec, null),
// TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
// question, and the public model's filters compare against a boolean.
listed: Boolean(row.listed),
}
// `current_version` is joined rather than stored: the list screen shows "v3" and
// the column that would hold it is a denormalisation of a row this query already
// has to reach for the publish date anyway.
//
// `current_version_verified_at` rides along for the same reason and answers the
// same kind of question (Phase 6). A `ready` definition whose version has never
// been dry-run will not start on its schedule (§K), and the one place that fact
// is worth saying is the screen its author is already looking at -- the
// alternative is finding out on the Friday it did not run.
const SELECT_LIST = `
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
v.version AS current_version,
v.verified_at AS current_version_verified_at
FROM event_definitions d
LEFT JOIN event_series s ON s.id = d.series_id
LEFT JOIN event_versions v ON v.id = d.current_version_id
`
const list = async ({ state = null } = {}) => {
const rows = state
? await query(`${SELECT_LIST} WHERE d.state = ? ORDER BY d.updated_at DESC, d.id DESC`, [state])
: await query(`${SELECT_LIST} ORDER BY d.updated_at DESC, d.id DESC`)
return rows.map(hydrate)
}
const getById = async (id) => {
const [row] = await query(`${SELECT_LIST} WHERE d.id = ?`, [id])
return hydrate(row)
}
const getBySlug = async (slug) => {
const [row] = await query(`${SELECT_LIST} WHERE d.slug = ?`, [slug])
return hydrate(row)
}
/** Does any OTHER definition hold this slug? The uniqueness pre-check. */
const slugTaken = async (slug, exceptId = null) => {
const rows = exceptId
? await query('SELECT id FROM event_definitions WHERE slug = ? AND id <> ?', [slug, exceptId])
: await query('SELECT id FROM event_definitions WHERE slug = ?', [slug])
return rows.length > 0
}
const insert = async (d) => {
const result = await query(
`INSERT INTO event_definitions
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
d.title,
d.slug,
d.summary,
d.body,
d.image_url,
d.owner_module,
d.series_id,
d.series_order,
d.concurrency_key,
d.grace_seconds,
d.timezone,
d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.created_by,
d.created_by,
],
)
return result.insertId
}
const update = (id, d) =>
query(
`UPDATE event_definitions
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
listed = ?, spec = ?, updated_by = ?
WHERE id = ?`,
[
d.title,
d.slug,
d.summary,
d.body,
d.image_url,
d.series_id,
d.series_order,
d.concurrency_key,
d.grace_seconds,
d.timezone,
d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.updated_by,
id,
],
)
/**
* Point a definition at the version it just published, and mark it `ready`.
*
* One statement, because the two halves are the same fact: `ready` means "a
* version has been published and the schedule is live" (§E), so a state without
* a `current_version_id` is a lie the scheduler would act on.
*/
const markReady = (id, versionId, userId) =>
query(
`UPDATE event_definitions
SET state = 'ready', current_version_id = ?, updated_by = ?
WHERE id = ?`,
[versionId, userId, id],
)
/**
* Every definition the runner should expand a recurrence for (Phase 4).
*
* `ready` is the whole gate, and it is deliberately the only one: EVENTS.md §E
* defines `ready` as "a version has been published and the schedule is live", so
* publishing IS the switch and archiving is how an operator turns a recurrence
* off. A separate schedule-enabled flag would be a second answer to a question
* `state` already answers, and the two would eventually disagree.
*
* The VERSION's spec is joined rather than the definition's working copy: the
* draft is what an author is midway through editing, and a half-typed `weekly`
* must never materialise anything. The pinned spec comes back with it, so the
* whole expansion is one round trip.
*
* The series columns are here for the CALENDAR rather than the runner, which
* ignores them: a projected occurrence has to be filterable and labellable by
* its arc exactly as a materialised run is, and a second query to learn the name
* of a row this one already reached would be two round trips for a join.
*/
const findSchedulable = async ({ listedOnly = false } = {}) => {
const rows = await query(
`SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
s.name AS series_name, s.slug AS series_slug
FROM event_definitions d
JOIN event_versions v ON v.id = d.current_version_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
ORDER BY d.id`,
)
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
/**
* One definition by slug, for the PUBLIC event page (Phase 14a).
*
* `ready` and `listed` are both in the WHERE rather than checked by the caller,
* so an unlisted event answers exactly as a nonexistent one does — a 404 that
* cannot be told from "no such slug". A caller that filtered afterwards would
* be one forgotten early-return away from publishing a draft.
*
* An ARCHIVED definition is deliberately absent too. Archiving is what delete
* means on the admin screen, and a page that kept answering afterwards would
* make the only delete this feature has do nothing an operator could see.
*/
const getPublicBySlug = async (slug) => {
const [row] = await query(
`${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
[slug],
)
return hydrate(row)
}
/** Every listed, ready definition in one series, in the arc's own order. */
const listPublicBySeries = async (seriesId) => {
const rows = await query(
`${SELECT_LIST}
WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
ORDER BY d.series_order, d.id`,
[seriesId],
)
return rows.map(hydrate)
}
/**
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
* Archiving is what "delete" means on this screen, and the row keeps its history.
*/
const archive = (id, userId) =>
query("UPDATE event_definitions SET state = 'archived', updated_by = ? WHERE id = ?", [userId, id])
module.exports = {
list,
getById,
getBySlug,
getPublicBySlug,
listPublicBySeries,
slugTaken,
findSchedulable,
insert,
update,
markReady,
archive,
}

View File

@@ -0,0 +1,413 @@
// ── Event definitions — the save path ──────────────────────────────────────
//
// EVENTS.md §D and "Versioning, and editing a live event". A definition is
// operator-authored data, and this file is the boundary that decides whether a
// version of it may exist. The authoring UI (Phase 3, then Phase 13) will
// re-check some of this for the sake of a good inline error; that second copy is
// expected to drift, so this one is the one that decides, and a definition
// arriving by any other route gets the same answer.
//
// **The three rules with teeth, and each is a rule about time rather than about
// shape:**
//
// 1. Publishing SNAPSHOTS. It copies the working spec into an immutable
// `event_versions` row and points `current_version_id` at it. Editing
// afterwards is free and does not touch the row a live run pinned.
// 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module
// must not make an author's work uneditable, and it must not let a version be
// published that names a verb nobody can perform.
// 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so
// a hard delete of a definition that has ever run is refused by the database
// anyway — and the row's history is the thing an audit reads.
const db = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const runsDb = require('./eventRuns.db')
const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const authorize = require('../../events/authorize')
const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
const MAX_TITLE = 200
const MAX_SUMMARY = 500
const MAX_URL = 500
const MAX_CONCURRENCY_KEY = 190
// A day either side of §D's default. Below a minute the grace window cannot
// survive a single slow boot; above a day a "missed" occurrence would start
// silently the following afternoon, which is the exact behaviour §E forbids.
const MIN_GRACE_SECONDS = 60
const MAX_GRACE_SECONDS = 86_400
/**
* IANA zone names, checked against the platform's own database rather than a
* list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node
* ships the full tzdata — so this is the same check Phase 4's occurrence
* arithmetic will make, asked one screen earlier where an operator can fix it.
*/
function isTimezone(tz) {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz })
return true
} catch {
return false
}
}
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const trimOrNull = (v, max) => {
if (v === undefined || v === null) return null
const s = String(v).trim()
return s === '' ? null : s.slice(0, max)
}
/**
* Validate an incoming definition against the registries and the schema.
*
* `{ ok: true, definition }` with a normalised row ready for insert/update, or
* `{ ok: false, errors }` listing every problem rather than the first.
*
* `existing` is the row being edited, or null on a create. It is what lets a
* dormant step survive: the ids already in the saved spec widen what the spec
* validator will accept, so an uninstall is never destructive after the fact.
*/
async function validate(input, { existing = null } = {}) {
const errors = []
const body = isPlainObject(input) ? input : {}
const title = trimOrNull(body.title, MAX_TITLE)
if (!title) errors.push('title is required')
const summary = trimOrNull(body.summary, MAX_SUMMARY)
const imageUrl = trimOrNull(body.imageUrl, MAX_URL)
const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY)
// The storyline. Sanitized on write, exactly as a wiki page and a forum post
// are: it is author-supplied HTML that ends up on a public page.
const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body))
const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC'
if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`)
const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds
const graceSeconds = Number(graceRaw)
if (
!Number.isInteger(graceSeconds) ||
graceSeconds < MIN_GRACE_SECONDS ||
graceSeconds > MAX_GRACE_SECONDS
) {
errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`)
}
let seriesId = null
if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') {
seriesId = Number(body.seriesId)
if (!Number.isInteger(seriesId) || seriesId < 1) {
errors.push('seriesId must be an integer')
seriesId = null
} else if (!(await seriesDb.exists(seriesId))) {
// Checked here as well as by the foreign key, because a 1452 reaching a
// controller is a 500 and this is a 400 an author can act on.
errors.push(`seriesId ${seriesId} does not exist`)
seriesId = null
}
} else if (existing) {
seriesId = existing.series_id
}
const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder
const seriesOrder = Number(seriesOrderRaw)
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
// Whether this event is announced on the public calendar (Phase 14a). It is
// NOT whether it may run: `state` answers that, and the two are separate
// precisely because publishing is what makes a definition runnable — an
// unlisted event still schedules, still runs and is still on the admin
// calendar. A missing key means "leave it as it was", and a NEW definition
// defaults to listed, which is the column's own default and the ordinary
// case; unlisting is the deliberate act.
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
// ── the spec ──
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
const checked = spec.validate(rawSpec, { knownActionIds: known })
if (!checked.ok) errors.push(...checked.errors)
// ── the slug ──
//
// Derived from the title on create and FROZEN afterwards, like a Team's: the
// public event page lives at it, and a retitle must not break a link somebody
// posted in Discord. An author who genuinely needs a different address makes a
// new definition.
let slug = existing?.slug || null
if (!slug) {
const stem = slugify(title || '') || 'event'
const taken = (await db.list()).map((d) => d.slug)
slug = uniqueSlug(stem, taken, { fallback: 'event' })
}
if (errors.length) return { ok: false, errors }
return {
ok: true,
definition: {
title,
slug,
summary,
body: storyline,
image_url: imageUrl,
owner_module: existing?.owner_module ?? null,
series_id: seriesId,
series_order: seriesOrder,
concurrency_key: concurrencyKey,
grace_seconds: graceSeconds,
timezone,
listed,
spec: checked.spec,
},
}
}
/** Create a draft. */
async function create(input, userId, { role = null } = {}) {
const result = await validate(input)
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const id = await db.insert({ ...result.definition, created_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
/**
* Edit a definition.
*
* An `archived` definition is not editable. That is the one state check here,
* and it is a real one rather than a formality: archiving is how a definition is
* retired, and a retired definition that can still be edited is a definition
* somebody will edit and then wonder why it never runs.
*/
async function save(id, input, userId, { role = null } = {}) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] }
}
const result = await validate(input, { existing })
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
await db.update(id, { ...result.definition, updated_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
/**
* Publish: snapshot the working spec into an immutable version and go `ready`.
*
* The spec is re-validated here against the registries as they stand RIGHT NOW,
* not trusted from the save that wrote it. A module uninstalled between the two
* is the whole reason: the save was legitimate, and publishing a version whose
* steps name a verb nobody can perform would be a run that fails at dispatch
* with the world half-changed.
*/
async function publish(id, userId) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be published'] }
}
const checked = spec.validate(existing.spec, {
knownActionIds: spec.actionIdsIn(existing.spec),
})
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
const publishable = spec.publishable(checked.spec)
if (!publishable.ok) {
return {
ok: false,
status: 409,
errors: [
`cannot publish: no module registers ${publishable.dormant.join(', ')}`,
],
}
}
if (!checked.spec.phases.some((p) => p.steps.length)) {
// An empty event publishes cleanly and then does nothing, which looks like a
// broken run rather than an empty one. Refusing costs an author one click and
// saves an operator a diagnosis.
return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] }
}
const version = await versionsDb.nextVersion(id)
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
// Occurrences already materialised ahead of their instant move to the new
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
// rather than only counted, because "which version did this run actually use"
// is the first question an audit asks and the pin is no longer immutable while
// a run is still `scheduled`.
const pending = await runsDb.listScheduledFor(id)
const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId))
const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0
for (const run of stale) {
await logDb.write({
runId: run.id,
kind: 'run.status',
detail: {
to: 'scheduled',
repinned: true,
fromVersionId: run.version_id,
toVersionId: versionId,
toVersion: version,
by: userId,
},
})
}
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
}
/**
* Archive.
*
* Refused while a run of this definition is still in flight — not because the
* database would object (it would not; archiving is an UPDATE), but because the
* screen the run is on reads its title and state from here, and retiring a
* definition mid-run makes the console describe something that is no longer
* supposed to exist. Cancel the run, then archive.
*/
async function archive(id, userId) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') return { ok: true, definition: existing }
const active = await runsDb.countActiveForDefinition(id)
if (active > 0) {
return {
ok: false,
status: 409,
errors: [`cannot archive: ${active} run(s) of this definition are still in flight`],
}
}
await db.archive(id, userId)
return { ok: true, definition: await db.getById(id) }
}
/**
* The role floor on a spec's steps (§K, Phase 6).
*
* §K's table reads "any step whose action is above `notify` — `admin` only", and
* the line falls between `inspect` and `change` for the reason the default-off
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
* nothing, and an editor who cannot author a step that WAITS has an authoring
* role that cannot author.
*
* Checked at SAVE rather than only at publish, which is the difference between
* telling an editor now and telling them after they have written twelve steps.
* Publish re-checks anyway — it re-checks everything, against the registries as
* they stand at that moment — because an action's risk class is a module's
* declaration and a module can be upgraded between the two.
*/
function worldChangingSteps(specValue) {
const out = []
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (action && authorize.changesWorld(action)) out.push(action)
}
}
return out
}
function checkRoleFloor(specValue, role) {
if (!role || role === 'admin') return null
const blocked = worldChangingSteps(specValue)
if (!blocked.length) return null
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
// Agreement, because this sentence is read by the person it refuses. The list
// is almost always one long -- an editor adds one world-changing step and is
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
// and it reads as a bug in the sentence rather than a rule about the step.
const one = names.length === 1
return {
ok: false,
status: 403,
errors: [
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
],
}
}
/**
* Dry-run a definition, and record the pass when there is a version to record it
* on (Phase 6).
*
* The target follows the definition's state: a `ready` definition is verified
* against the version that would actually run, a draft against the working spec
* the author is still holding. See `events/verify.js` for why that is one act at
* two moments rather than two rules.
*/
async function verify(id, user) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
}
const version =
existing.state === 'ready' && existing.current_version_id
? await versionsDb.getById(existing.current_version_id)
: null
const target = version?.spec || existing.spec
if (!target?.phases?.length) {
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
}
const report = await verifier.verifySpec(target, { user })
if (version && report.ok) {
await versionsDb.markVerified(version.id, user?.id || null)
// Written to every run already pinned to this version, because that is where
// an operator asks the question: a scheduled occurrence that was being held
// is now going to start, and the line saying why belongs on it.
for (const run of await runsDb.listScheduledFor(id)) {
if (Number(run.version_id) !== Number(version.id)) continue
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId: version.id, version: version.version, by: user?.id || null },
})
}
}
return {
ok: true,
report,
// Which spec was verified, said plainly, because the two answer different
// questions and a report that did not say would be read as the other one.
target: version ? 'version' : 'draft',
versionId: version?.id || null,
version: version?.version || null,
recorded: Boolean(version && report.ok),
}
}
module.exports = {
validate,
create,
save,
publish,
archive,
verify,
checkRoleFloor,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,
}

View File

@@ -0,0 +1,23 @@
// ── One JSON reader for the whole events model ─────────────────────────────
//
// JSON columns come back from the driver already parsed on some MariaDB/driver
// combinations and as a string on others — it depends on whether the column is a
// real JSON type or the LONGTEXT + CHECK alias MariaDB implements it as. Every
// read in this directory goes through this, so no caller has to know which it
// got.
//
// Lifted from `engagementRules.db.js`, which learned it first, and hoisted into
// its own file here rather than copied into six: six copies of a fallback is six
// chances for one of them to fall back to `{}` where the reader expects `[]`.
function parseJson(value, fallback) {
if (value === null || value === undefined) return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
module.exports = { parseJson }

View File

@@ -0,0 +1,187 @@
// ── event_run_phase_gates — SQL only ───────────────────────────────────────
//
// EVENTS.md §E, and Phase 5 of EVENTS_PLAN.md. A phase used to advance on one
// fact — every step terminal — and that fact lives in `event_run_steps`. An
// advance CONDITION is a second fact, and it is the only one in this feature
// that is not derivable from a row somebody already wrote: `{ on:
// 'uo.champ.boss_up', count: 3 }` counts things that happen between one tick and
// the next, and the runner is not running when they happen. A gate row is where
// a firing is counted at the moment it fires.
//
// **Two writers, and they are not the same process leg.** The RUNNER opens a
// gate (at phase entry) and closes an `after` one (when its deadline passes);
// the EMIT PATH increments and closes an `on` one. Everything here is therefore
// written as a single guarded statement rather than a read-then-write, which is
// the same argument `event_run_budget`'s conditional increment makes one phase
// early and the same one `runsDb.transition` makes for a status.
//
// **Nothing here throws at the emit path.** `observe` is called from inside a
// game-event handler by way of `ctx.events.emit`, exactly as `engine.dispatch`
// is, and a database problem of core's must not become a module's control flow.
// The catch lives in `events/gates.js`; this file is the statements.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) =>
row && {
...row,
conditions: parseJson(row.conditions, null),
last_event: parseJson(row.last_event, null),
}
/**
* Open a phase's gate. **INSERT IGNORE against `uq_evgate_phase`**, so a process
* that died between entering a phase and getting here opens no second gate on
* the next tick — the idempotence `materialisePhase` has, for the same reason.
*
* Answers whether a row was created, which is what lets the caller log
* `phase.entered`'s gate detail exactly once.
*/
async function open({ runId, phase, kind, afterSeconds = null, triggerId = null, conditions = null, needed = 1, now = new Date() }) {
// `due_at` is computed here, once, from the moment the phase was entered —
// never re-derived on a later tick from a `now` that has moved. A deadline
// recomputed every fifteen seconds is a deadline that never arrives.
const dueAt = kind === 'after' ? new Date(now.getTime() + afterSeconds * 1000) : null
const result = await query(
`INSERT IGNORE INTO event_run_phase_gates
(run_id, phase, kind, after_seconds, trigger_id, conditions, needed, entered_at, due_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
runId,
phase,
kind,
afterSeconds,
triggerId,
conditions === null ? null : JSON.stringify(conditions),
needed,
now,
dueAt,
],
)
return Number(result?.affectedRows || 0) === 1
}
/** One run's gate for one phase, or null. */
const forPhase = async (runId, phase) =>
hydrate(
(
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? AND phase = ? LIMIT 1', [
runId,
phase,
])
)[0] || null,
)
/** Every gate a run has ever opened, oldest first — what the run console reads. */
const listForRun = async (runId) =>
(
await query('SELECT * FROM event_run_phase_gates WHERE run_id = ? ORDER BY entered_at, id', [
runId,
])
).map(hydrate)
/**
* Every OPEN gate waiting on one trigger, with the run's status and phase.
*
* This is the emit path's only query and the one index in this feature on a hot
* path. The join is what keeps a gate belonging to a cancelled run from counting
* for ever: a run that will never advance again must stop tallying, and its
* row's `satisfied_at` is not what says so.
*
* **`paused` counts.** The world does not stop because an operator paused the
* console, and discarding firings that arrived during a pause would make pause a
* destructive control — the tally an operator came back to would be lower than
* the one they left, with nothing recording the difference.
*/
const openForTrigger = async (triggerId) =>
(
await query(
`SELECT g.* FROM event_run_phase_gates g
JOIN event_runs r ON r.id = g.run_id
WHERE g.trigger_id = ? AND g.satisfied_at IS NULL
AND r.status IN ('running','paused')
AND r.current_phase = g.phase
LIMIT 200`,
[triggerId],
)
).map(hydrate)
/**
* Count one matching firing, and close the gate if that was the last one needed.
*
* **One statement, with the threshold inside it.** Two emits arriving together
* each add one and exactly one of them crosses `needed`; a read-then-write would
* let both see 2 of 3 and neither satisfy, or both satisfy and advance a phase
* twice. `WHERE satisfied_at IS NULL` is what makes a late arrival a no-op
* rather than a tally that keeps climbing after the phase moved on.
*
* Answers `{ counted, satisfied }` read back from the row, so the caller logs
* the tally the database actually holds rather than the one it predicted.
*/
async function count(gateId, { lastEvent = null, now = new Date() } = {}) {
// **THE INCREMENT MUST BE LAST, and this is not style.** MariaDB evaluates an
// UPDATE's SET assignments LEFT TO RIGHT, each one seeing the values already
// assigned by the ones before it — which is a documented departure from
// standard SQL, and it is invisible in a stub. With `tally = tally + 1` first,
// the CASE that follows reads the ALREADY-INCREMENTED tally, so `tally + 1 >=
// needed` is really `new + 1 >= needed` and a gate needing two firings closes
// on the first. Written this way, both CASEs see the old tally and say exactly
// what they read as. `eventRunnerSql.test.js` is what catches a reorder, and
// it is what caught this one.
const result = await query(
`UPDATE event_run_phase_gates
SET satisfied_at = CASE WHEN tally + 1 >= needed THEN ? ELSE NULL END,
satisfied_by = CASE WHEN tally + 1 >= needed THEN 'condition' ELSE NULL END,
last_event = ?,
last_event_at = ?,
tally = tally + 1
WHERE id = ? AND satisfied_at IS NULL`,
[now, lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
)
if (Number(result?.affectedRows || 0) !== 1) return { counted: false, satisfied: false }
const row = await byId(gateId)
return { counted: true, satisfied: Boolean(row?.satisfied_at), tally: row?.tally ?? null }
}
/**
* Record that a firing was seen and did NOT match.
*
* Only `last_event_at` and `last_event` move: the tally is what the phase is
* waiting on, and a near miss is not progress. It is recorded at all because
* "the boss did spawn, in the wrong region" and "no boss has spawned" are
* different answers to the operator's question, and only this column can tell
* them apart on a screen.
*/
async function noteNearMiss(gateId, { lastEvent = null, now = new Date() } = {}) {
const result = await query(
`UPDATE event_run_phase_gates
SET last_event = ?, last_event_at = ?
WHERE id = ? AND satisfied_at IS NULL`,
[lastEvent === null ? null : JSON.stringify(lastEvent), now, gateId],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Close a gate for a reason that is not a matching firing: `'elapsed'` when an
* `after` deadline passed, `'forced'` when a human pressed advance.
*
* Guarded on `satisfied_at IS NULL` like everything else here, so a force that
* races the tick that would have opened the gate anyway loses harmlessly and the
* log records whichever actually happened rather than both.
*/
async function satisfy(gateId, by, { userId = null, now = new Date() } = {}) {
const result = await query(
`UPDATE event_run_phase_gates
SET satisfied_at = ?, satisfied_by = ?, forced_by = ?
WHERE id = ? AND satisfied_at IS NULL`,
[now, by, userId, gateId],
)
return Number(result?.affectedRows || 0) === 1
}
const byId = async (id) =>
hydrate((await query('SELECT * FROM event_run_phase_gates WHERE id = ? LIMIT 1', [id]))[0] || null)
module.exports = { open, forPhase, byId, listForRun, openForTrigger, count, noteNearMiss, satisfy }

View File

@@ -0,0 +1,440 @@
// ── The public event surface ───────────────────────────────────────────────
//
// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
// anonymous visitor reads, one event's page, and an arc.
//
// **This file is a projection, and the projection is the security boundary.**
// Every other reader of these tables is staff, and every field they are shown is
// one somebody with a role was allowed to see. What comes out of here is read by
// nobody at all, so the rule is the opposite of the admin shapes': nothing is
// spread, and a field reaches a public entry because a line below put it there.
// The day somebody adds a column to `event_runs` — a claim token, an operator's
// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
// in the release after the one anybody reviewed.
//
// Three things are absent from every shape below, and each is a decision:
//
// • **The spec.** Phases, steps, actions and their params are the plan for
// changing a live world. A visitor is told what is happening and when, and
// the LABEL of the phase while it is happening; the steps are the operator's.
// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
// deployment's plumbing. "The event is running" is the fact about the event.
// • **`member_key`.** It is the game's own identifier for a character, it is
// module-opaque, and core cannot say what it discloses — so it stays unsent
// even on a results table where every other column is published.
//
// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
// the read would work exactly as well, right up until the first caller that
// forgot to.
const definitionsDb = require('./eventDefinitions.db')
const runsDb = require('./eventRuns.db')
const seriesDb = require('./eventSeries.db')
const versionsDb = require('./eventVersions.db')
const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
// The public calendar's window when a caller names neither end: a few days BACK
// through a month out. A visitor arriving at /site/events wants "what is on", and
// a client that had to compute a window before it could ask anything would make
// every deep link carry two ISO instants.
//
// **The backward tail is not padding — it is the "recent" in §I's "upcoming, live
// and recent".** The default used to start at `now`, so an event that finished an
// hour ago was already gone and a visitor had nowhere to find the results of the
// thing they had just attended. The LIVE half is answered by `listInWindow`'s
// overlap test rather than by this number, so the tail only has to be long enough
// to be a "recently" a reader would recognise.
const DEFAULT_WINDOW_DAYS = 31
const DEFAULT_RECENT_DAYS = 7
// How many past occurrences an event page carries. It shows what is next and
// what happened recently; the whole history of a three-year-old weekly event is
// a different screen and nobody has asked for one.
const PAST_RUNS = 10
const RESULTS_LIMIT = 100
// The status words a visitor is told. `paused` maps to `live` deliberately: an
// operator holding a run for two minutes while they deal with something is not a
// state a public page should render, and a page that said "paused" would invite
// a question whose answer is internal.
const PUBLIC_STATUS = {
scheduled: 'scheduled',
starting: 'live',
running: 'live',
paused: 'live',
ending: 'live',
completed: 'completed',
cancelled: 'cancelled',
failed: 'cancelled',
missed: 'cancelled',
}
/**
* The public status word for a run.
*
* **`failed` and `missed` are published as `cancelled`**, which is the mapping
* worth defending. To a visitor the three are one event: it was on the calendar
* and it did not happen. The difference between them is entirely about the
* deployment — `failed` names broken machinery, `missed` names a process that
* was down when the schedule came round — so publishing either word would tell a
* stranger something true about the server and nothing about the event.
*/
const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
/** Is this a run a visitor should be shown as happening now? */
const isLive = (status) => publicStatus(status) === 'live'
/**
* The label of the phase a run is in, resolved from the PINNED version's spec.
*
* A phase id is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the id would show `phase-2` to the public. A
* phase the spec does not name answers null and the page shows nothing, which is
* the right answer for a version edited since: the run pinned the old spec and
* the old spec is what it is executing.
*/
function phaseLabel(spec, phaseId) {
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
const phase = spec.phases.find((p) => p && p.id === phaseId)
return (phase && (phase.label || phase.id)) || null
}
/** One calendar entry, from a materialised run. */
const publicRunEntry = (run) => ({
kind: 'run',
title: run.definition_title,
slug: run.definition_slug,
seriesName: run.series_name || null,
seriesSlug: run.series_slug || null,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
status: publicStatus(run.status),
live: isLive(run.status),
})
/**
* One calendar entry, from a projection.
*
* A projection is arithmetic past the materialisation horizon (§I), and the
* public entry keeps the distinction for the visitor's version of the operator's
* reason: a forecast three weeks out is a plan rather than a booking, and a page
* drawing the two identically would promise something nothing has committed to.
* `adjusted` rides along because a DST-shifted occurrence is worth explaining
* before it happens rather than after.
*/
const publicProjectedEntry = (definition, occurrence) => ({
kind: 'projected',
title: definition.title,
slug: definition.slug,
seriesName: definition.series_name || null,
seriesSlug: definition.series_slug || null,
scheduledFor: occurrence.at,
timezone: definition.timezone,
status: 'scheduled',
live: false,
adjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
})
/**
* The public calendar for a window.
*
* **The run half is read here rather than borrowed from `eventCalendar.model`**,
* and the reason is the file header's: that model answers with `status`,
* `health`, `version` and `waitingSteps` on every entry, so reusing it would
* mean building the public answer by DELETING fields from an operator's, which
* is the direction that fails silently. The arithmetic IS shared —
* `occurrencesBetween` is the same function the runner calls, so a forecast
* still cannot disagree with what later appears — and so are the window bound
* and the entry cap, which are a defence against an expensive query on the one
* surface that has no login in front of it.
*/
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
// The default `to` is measured from NOW, not from `start` — otherwise the
// backward tail would silently push the horizon a week further out and a caller
// naming only `from` would get a different span than one naming neither.
const start = from
? new Date(from)
: new Date(new Date(now).getTime() - DEFAULT_RECENT_DAYS * recurrence.DAY_MS)
const end = to
? new Date(to)
: new Date(new Date(now).getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
}
if (end <= start) {
return { ok: false, status: 400, errors: ['to must be after from'] }
}
if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
return {
ok: false,
status: 400,
errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
}
}
const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
const entries = runs.map(publicRunEntry)
// Every instant a run already occupies, so the fortnight inside the horizon is
// not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
// it were still coming. The same key the admin calendar uses, for the same
// reason: projections are per definition at the empty scope.
const taken = new Set(
runs
.filter((r) => !r.scope)
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
)
const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
for (const definition of definitions) {
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
// **Forecast from `now`, never from `start`.** The default window now
// reaches a week backwards so that "recent" has somewhere to live, and a
// projection into that tail would advertise an occurrence that did not
// happen — a run that WAS created is a real row and arrives above, and one
// that was not is a slot the runner has already passed. A forecast is about
// the future; the tail is about the past. Only the materialised half fills
// it.
const forecastFrom = start > now ? start : new Date(now)
if (forecastFrom < end) {
occurrences = recurrence.occurrencesBetween(
schedule,
definition.timezone || 'UTC',
forecastFrom,
end,
)
}
} catch {
// A version whose schedule the recurrence engine will not read is one the
// runner will not expand either. The calendar then shows that definition's
// materialised runs and no forecast, rather than failing the whole page.
continue
}
for (const occurrence of occurrences) {
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
entries.push(publicProjectedEntry(definition, occurrence))
}
}
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
return {
ok: true,
status: 200,
window: { from: start, to: end },
entries: entries.slice(0, calendarModel.MAX_ENTRIES),
truncated: entries.length > calendarModel.MAX_ENTRIES,
}
}
/** One participant, as a results table publishes them. */
const publicParticipant = (p) => ({
// NOT `memberKey` — see the file header. A display name is whatever the module
// chose to put in `meta`, because core has no name for a character and must
// not invent one from the key.
name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
score: p.score,
rank: p.rank_at,
meta: p.meta || null,
})
/** One occurrence, as an event page lists it. */
const publicOccurrence = (run, spec) => ({
runId: run.id,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
startedAt: run.started_at,
endedAt: run.ended_at,
status: publicStatus(run.status),
live: isLive(run.status),
scope: run.scope || null,
phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
resultsPublishedAt: run.results_published_at || null,
})
/**
* One event's public page: the storyline, its arc, its occurrences, and a
* results table when there is one to show.
*
* **`runId` selects WHICH occurrence the results are about, and it is optional
* for a reason that exists only because of the announcements.** The page lives
* at the definition's slug, so a weekly event has one address and a visitor
* arriving at it should be shown what is next. But an `event.run.completed` mail
* is about ONE occurrence, and a link in it that opened next Friday's would
* answer a different question from the one the reader clicked. So the trigger's
* `eventUrl` carries `?run=`, and this is what resolves it.
*
* **A `runId` that does not belong to this definition is ignored rather than
* refused.** It names some other event's run, or none; the honest answer to
* "show me this event" is still this event, and a 404 for the whole page would
* turn a stale link in a months-old mail into a dead end rather than a page
* about the thing the mail was about.
*/
async function event(slug, { runId = null } = {}) {
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
const now = Date.now()
const live = runs.filter((r) => isLive(r.status))
// **Split on the instant, not on the status**, and the difference is visible
// in both directions. A `missed` run is in the past whatever its status says,
// and so is a `scheduled` one whose moment went by while the runner had not
// reached it — but a run an operator CANCELLED next Friday is still next
// Friday, and filing it under "previously" tells a visitor it already
// happened, which is the one thing that is certainly untrue about it. That a
// cancelled occurrence still appears under what is coming is the point:
// "next Friday is off" is exactly what somebody checking the calendar came to
// find out.
const upcoming = runs
.filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
// `next` is narrower than `upcoming[0]`, deliberately: the headline answers
// "when is the next one", and a cancelled occurrence is not one. An event
// whose only future occurrence has been called off has no `next` and says so,
// while the cancellation itself is still listed below.
const next = upcoming.find((r) => r.status === 'scheduled') || null
// Which occurrence the results table is about. An explicit `run` wins; then a
// live one, because that is what the visitor is looking at; then the most
// recent that actually published results, because a page with a table on it is
// more use than one with an empty heading.
const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
let participants = []
if (resultsRun && resultsRun.results_published_at) {
participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
publicParticipant,
)
}
// Phase labels come from the version the run is EXECUTING rather than from the
// definition's working draft, which an author may be halfway through editing.
// One extra read, and only when there is a run to label at all.
const specRun = named || live[0] || null
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
return {
ok: true,
status: 200,
event: {
title: definition.title,
slug: definition.slug,
summary: definition.summary,
body: definition.body,
imageUrl: definition.image_url,
timezone: definition.timezone,
series: series ? { name: series.name, slug: series.slug } : null,
live: live.length > 0,
current: live[0] ? publicOccurrence(live[0], spec) : null,
next: next ? publicOccurrence(next, spec) : null,
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
past: past.map((r) => publicOccurrence(r, spec)),
results:
resultsRun && resultsRun.results_published_at
? {
runId: resultsRun.id,
scheduledFor: resultsRun.scheduled_for,
publishedAt: resultsRun.results_published_at,
participants,
}
: null,
},
}
}
/**
* One arc: the series, and the listed events in it in the order an editor
* dragged them into.
*
* **A series with no listed events is a 404 rather than an empty page.** The arc
* is a label on its definitions and nothing else, so a page for an empty one
* would publish the single fact that an operator has named something they have
* not announced.
*/
async function series(slug) {
const row = await seriesDb.getBySlug(String(slug || ''))
if (!row) return { ok: false, status: 404, errors: ['Not found'] }
const definitions = await definitionsDb.listPublicBySeries(row.id)
if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
return {
ok: true,
status: 200,
series: {
name: row.name,
slug: row.slug,
description: row.description,
events: definitions.map((d) => ({
title: d.title,
slug: d.slug,
summary: d.summary,
imageUrl: d.image_url,
})),
},
}
}
/**
* One account's participation history.
*
* Self-scoped by the caller's own id and nothing else. There is no route on
* which one account reads another's, and deliberately no id parameter that could
* later grow into one.
*/
async function history(userId, { limit = 50, before = null } = {}) {
const rows = await participantsDb.listForUser(userId, { limit, before })
return {
ok: true,
status: 200,
entries: rows.map((r) => ({
id: r.id,
runId: r.run_id,
title: r.definition_title,
slug: r.definition_slug,
seriesName: r.series_name || null,
seriesSlug: r.series_slug || null,
scheduledFor: r.scheduled_for,
startedAt: r.started_at,
endedAt: r.ended_at,
timezone: r.timezone,
status: publicStatus(r.status),
joinedAt: r.joined_at,
score: r.score,
// Null until `core.results.publish` ran. The screen says so rather than
// inventing a position nobody computed.
rank: r.rank_at,
resultsPublishedAt: r.results_published_at || null,
meta: r.meta || null,
})),
}
}
module.exports = {
calendar,
event,
series,
history,
publicStatus,
phaseLabel,
DEFAULT_WINDOW_DAYS,
DEFAULT_RECENT_DAYS,
PAST_RUNS,
}

View File

@@ -0,0 +1,134 @@
// ── event_run_budget — SQL only ────────────────────────────────────────────
//
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
// dimension, and the most it may.
//
// **The whole file exists for one statement.** `spend()` is the conditional
// increment §E names as the answer to "two steps spending one cap":
//
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
//
// A read-then-write would let two steps drawing on `uo.creatures` in the same
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
//
// **The SET list here has one assignment for the reason Phase 5's had three.**
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
// the ones before it assigned — which is how Phase 5's gate closed a firing early
// — so nothing in this statement may read `consumed` after it has been written.
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
// there.
const { query } = require('../../utils/db')
/**
* Seed a run's budget rows.
*
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
* one cannot double-seed and cannot reset a cap a run has already spent against —
* the idempotence `materialisePhase` and `gates.open` both have, for the same
* reason.
*
* The caps are COPIED here rather than read live at dispatch. A run pins its
* version and is reproducible in every other respect; a cap read live would be
* the one input to a run's behaviour an admin could change underneath it while
* nobody was watching, and the console's meter would answer "what is allowed now"
* when the question afterwards is "what was this run allowed".
*/
async function seed(runId, dimensions) {
const rows = Object.entries(dimensions || {})
if (!rows.length) return 0
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
const result = await query(
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
VALUES ${values}`,
params,
)
return result.affectedRows || 0
}
/**
* Spend `amount` of one dimension, or refuse.
*
* Answers `true` when the row moved and `false` when it did not — and `false` has
* exactly two causes, both of which mean the same thing to the caller: the spend
* would breach the cap, or there is no row for this dimension at all. The second
* is not a silent pass: a run whose version names an action that costs a
* dimension always has that dimension seeded — **uncapped ones included, as a row
* with a NULL cap** — so a missing row means the step is spending something its
* own version never declared, and refusing that is the fail-closed direction.
*
* A zero or negative amount is not a spend and never touches the database. An
* action whose `cost()` answers `0` for its params is telling core it consumes
* nothing, and pricing that as a query would put one round trip per step behind a
* fact the caller already has.
*/
async function spend(runId, dimension, amount) {
if (!(amount > 0)) return true
const result = await query(
`UPDATE event_run_budget
SET consumed = consumed + ?
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
[amount, runId, dimension, amount],
)
return (result.affectedRows || 0) > 0
}
/**
* Give `amount` back.
*
* Called on exactly one path: a step that spent several dimensions and was then
* refused on a later one. The spends are separate statements — they must be, the
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
* bosses can take the creatures and be refused the bosses, and a step that did
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
* floor is worth more than a refund that is exactly right: a negative `consumed`
* would make the cap arithmetic lie in the permissive direction forever after.
*/
async function refund(runId, dimension, amount) {
if (!(amount > 0)) return
await query(
`UPDATE event_run_budget
SET consumed = GREATEST(consumed - ?, 0)
WHERE run_id = ? AND dimension = ?`,
[amount, runId, dimension],
)
}
/** Every dimension of one run, for the console's meter. */
async function forRun(runId) {
return query(
`SELECT dimension, consumed, cap, effective_from
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
[runId],
)
}
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
async function forRuns(runIds) {
const ids = [...new Set(runIds || [])].filter(Boolean)
if (!ids.length) return new Map()
const rows = await query(
`SELECT run_id, dimension, consumed, cap, effective_from
FROM event_run_budget
WHERE run_id IN (${ids.map(() => '?').join(',')})
ORDER BY run_id, dimension`,
ids,
)
const out = new Map()
for (const r of rows) {
if (!out.has(r.run_id)) out.set(r.run_id, [])
out.get(r.run_id).push({
dimension: r.dimension,
consumed: r.consumed,
cap: r.cap,
effective_from: r.effective_from,
})
}
return out
}
module.exports = { seed, spend, refund, forRun, forRuns }

View File

@@ -0,0 +1,463 @@
// ── The live run controls ──────────────────────────────────────────────────
//
// EVENTS.md §I ("live controls that are honest"), §K and §L. Six of them: pause,
// resume and cancel act on a run; confirm, skip and retry act on one step. They
// arrive in Phase 3 because Phase 2 is what gave them something to act on — a
// run that announces, waits and completes on its own is exactly the run that
// needs no control, and a run that paused on a failed world write is the one
// that does.
//
// **`advance` is the seventh, and it arrived in Phase 5 rather than Phase 3
// because that is when it started meaning something.** A phase used to advance
// when its steps went terminal and on nothing else, so "force it anyway" named
// no state an operator could be in; a phase with a gate can wait for a boss that
// will never spawn, and then it names exactly one. It is the other half of the
// diagnosis panel: a screen that explains why a phase has not started, beside a
// control that does something about it.
//
// **`cleanup` is the eighth, and Phase 8 is what gave it a ledger to work over.**
// It re-runs the teardown across every resource a run has not given back, and it
// is `admin` where the other seven are `admin` + `moderator`: it is not incident
// response, it is asking core to write to the world again. Its partner is
// cancel's new `cleanup: false`, which is §L's "cancelling WITHOUT cleanup is a
// separate, logged, admin-only action" — deliberately the flag that has to be
// asked for, because the safe default is to give back what the run took.
//
// **Every control is guarded on the status it may act from, and the guard is a
// WHERE clause rather than a read-then-write.** A run console rendered thirty
// seconds ago describes a run that has since moved — the runner ticks every
// fifteen — so a control that checked in JavaScript and then wrote would race
// the tick it exists to interrupt. `transition()` and the four step statements
// are all compare-and-set, and a `false` from one of them is reported as a 409
// naming the status the run is actually in.
//
// **Who may press them is `admin` + `moderator` (§K, §N2), and it is the widest
// gate in this feature on purpose.** Starting a run commits the deployment to
// everything the definition contains, unattended — that wants the narrowest gate
// there is. Stopping one is incident response at 2am, and it wants the widest.
const runsDb = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const announce = require('../../events/announce')
const gatesDb = require('./eventPhaseGates.db')
const resourcesDb = require('./eventRunResources.db')
const gates = require('../../events/gates')
const MAX_REASON = 500
const clean = (raw) => {
const text = typeof raw === 'string' ? raw.trim() : ''
return text ? text.slice(0, MAX_REASON) : null
}
const conflict = (message) => ({ ok: false, status: 409, errors: [message] })
/** The run, or a 404 shaped the way every other model here shapes one. */
async function loadRun(runId) {
const run = await runsDb.getById(runId)
return run || null
}
/**
* A step of THIS run, or null.
*
* Scoped to the run rather than fetched by id alone: the step id arrives from a
* URL under a run id, and a control that acted on a step belonging to a
* different run would be a real one — the console's step ids are not secret and
* the two paths would otherwise never be compared.
*/
async function loadStep(runId, stepId) {
const step = await stepsDb.getById(stepId)
if (!step || Number(step.run_id) !== Number(runId)) return null
return step
}
// ── Run-level ─────────────────────────────────────────────────────────────
/**
* Pause a run in flight.
*
* `starting` and `running` only — §K's "live control of a run **in flight**". A
* `scheduled` run has not begun, and the thing to do with an occurrence that
* should not happen is cancel it: pausing one would leave a run that is neither
* going to start nor visibly abandoned, and resuming it after its grace window
* had passed would produce a `missed` from a button labelled resume.
*
* The claim is cleared with the transition. A tick may be working the run at
* this exact moment; it will find its guarded writes returning zero rows and
* hand back a lease it no longer holds, both of which are no-ops. What it will
* NOT do is dispatch the rest of its batch — `advanceRun` re-reads the status
* between steps precisely so this control means what it says.
*/
async function pause(runId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status === 'paused') return conflict('this run is already paused')
const note = clean(reason)
if (!(await runsDb.transition(run.id, ['starting', 'running'], 'paused', { clearClaim: true }))) {
return conflict(`a ${run.status} run cannot be paused`)
}
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: run.status, to: 'paused', control: 'pause', by: userId, reason: note },
})
return { ok: true, run: await runsDb.getById(run.id) }
}
/**
* Resume a paused run.
*
* Where it goes back to is derived rather than remembered: `current_phase` is
* set by the transition into `running` and by nothing else, so a paused run that
* has one was running and a paused run that has none never got past `starting`.
* Both statuses are in `findDue`, so the next tick picks the run up either way,
* and there is no fourth column recording what a run was paused *from* — a
* column that could disagree with the run's own history.
*
* **`last_error` is cleared and `health` is not.** The error is what the pause
* was about and an operator has just dealt with it; leaving it on the banner
* would have a healthy run permanently accused of a failure that is in the log
* where it belongs. Health is a different claim — that this run has already had
* trouble — and it stays true no matter who pressed resume.
*/
async function resume(runId, options = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'paused') return conflict(`a ${run.status} run is not paused`)
const to = run.current_phase ? 'running' : 'starting'
if (!(await runsDb.transition(run.id, 'paused', to, { error: null }))) {
return conflict('this run stopped being paused')
}
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: { from: 'paused', to, control: 'resume', by: userId },
})
return { ok: true, run: await runsDb.getById(run.id) }
}
/**
* Cancel a run.
*
* Legal from every non-terminal status including `scheduled`, because "this
* event is not happening" is a decision an operator makes before it starts as
* often as during it.
*
* `cancelOpen` then closes out the steps that will never run — the pending ones
* and any parked cue. A step with a LIVE lease is left exactly where it is:
* something is dispatching it, nothing can recall a command already sent (§L),
* and a second writer on that row would race the process that owns it. It
* finishes into a cancelled run, which is honest.
*/
async function cancel(runId, { reason, cleanup = true } = {}, userId = null, { isAdmin = true } = {}) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is already ${run.status}`)
// §L: cancelling WITHOUT cleanup is a separate, logged, ADMIN-only action. The
// route itself is `admin` + `moderator`, so the narrower gate cannot live in
// middleware — which of the two you have to be depends on what is in the body,
// exactly as the authoring role floor does (§K).
if (cleanup === false && !isAdmin) {
return {
ok: false,
status: 403,
errors: ['leaving a run\'s world changes in place is an administrator\'s decision'],
}
}
const note = clean(reason)
const from = ['scheduled', 'starting', 'running', 'paused', 'ending']
if (!(await runsDb.transition(run.id, from, 'cancelled', { error: note || 'cancelled by staff' }))) {
return conflict('this run is no longer cancellable')
}
const closed = await stepsDb.cancelOpen(run.id)
await logDb.write({
runId: run.id,
kind: 'run.status',
phase: run.current_phase,
detail: {
from: run.status,
to: 'cancelled',
control: 'cancel',
by: userId,
reason: note,
cancelledSteps: closed,
cleanup: cleanup !== false,
},
})
// **After the guarded transition, so exactly one caller announces** (Phase
// 10). Two moderators pressing cancel in the same second both reach the log
// write; only one of them wins `transition`, and the loser has already
// returned a 409 above.
//
// The operator's `reason`, not the run's `last_error` — `cancel` takes a
// sentence a human typed for other humans, and the diagnostic string that
// ends up in `last_error` would read as gibberish in a mail.
await announce.runCancelled(run, note)
// **The teardown is not done here, and the request does not wait for it.**
// Cleanup is one leg of the runner's tick over terminal runs (§L), which is
// what makes it survive a process that dies halfway through it — and a cancel
// pressed at two in the morning must answer at once rather than after a dozen
// round trips to a shard that may be the reason it is being cancelled. The run
// is terminal the moment this returns, so the very next tick picks its ledger
// up.
//
// `cleanup: false` is the operator saying leave it. The resources stay
// unresolved and the run carries `incomplete`, which is the truthful value: the
// world changes are still up, they are listed on the console, and the log line
// above records who decided that.
let cleanupStatus = run.cleanup_status
if (cleanup === false && (await resourcesDb.unresolvedCount(run.id)) > 0) {
// `incomplete` is what takes the run out of the cleanup leg's scan, and it is
// the truthful value: the world changes are still up, they are listed on the
// console, and the log line above records who decided that.
//
// **The first draft spent every row's `revert_attempts` instead**, to stop the
// sweep by the same mechanism a failed retry does. It worked and it made the
// console lie: the run page rendered "3 attempts" beside resources nothing had
// ever tried, which reads as "core tried three times and could not". Found by
// opening the page. A counter that means two things is a counter a screen
// cannot render.
await runsDb.setCleanupStatus(run.id, 'incomplete')
cleanupStatus = 'incomplete'
}
return {
ok: true,
run: await runsDb.getById(run.id),
cancelledSteps: closed,
cleanup: cleanup !== false,
cleanupStatus,
}
}
/**
* Re-run cleanup over everything a run has not given back.
*
* The manual retry §L promises, and the only thing that clears
* `revert_attempts`. That licence is the same one a human's step retry has, and
* it is deliberately not extended to the automatic sweep: Engagement Phase 14's
* defect was exactly a sweep that reset every stale row, which made the attempt
* ceiling unreachable and left the row cycling for ever.
*
* Legal on a TERMINAL run only. A run still in flight has a ledger that is still
* growing, and reverting a resource the next step is about to use would be core
* undoing an event while it is happening.
*/
async function cleanupRun(runId, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (!runsDb.TERMINAL.includes(run.status)) {
return conflict(`this run is still ${run.status}; cancel it before cleaning up after it`)
}
if (run.cleanup_status === 'not_required') {
return conflict('this run recorded no resources, so there is nothing to give back')
}
// eslint-disable-next-line global-require
const summary = await require('../../events/cleanup').cleanupRun(run, {
resetAttempts: true,
actor: userId,
})
return { ok: true, run: await runsDb.getById(run.id), summary }
}
/**
* Force a phase forward: open its gate without the condition that would have.
*
* **It is legal only when the phase is actually waiting on a gate**, and the
* three refusals are the whole design. A run that is not `running` is not
* waiting on anything (409 naming what it is). A phase with no gate advances on
* its steps and always has, so forcing it would be a control that duplicated the
* runner rather than overriding it. And a phase whose steps have not all gone
* terminal is not being held by its gate — it is being held by a step, and the
* step-level skip is the honest control for that, one step at a time. A force
* that swept past pending steps would be a cancel of half a phase under a button
* labelled advance.
*
* **It satisfies the gate and stops.** The next tick advances the run, exactly
* as it does after `resume` — the phase transition, the next phase's
* materialisation, its own gate and the log lines are one sequence in
* `advanceRun`, and a second copy of it here would be a second opinion about
* what a phase boundary is. The response says which phase was released, so the
* console can say so before the tick lands.
*/
async function advancePhase(runId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'running') return conflict(`a ${run.status} run has no phase to advance`)
if (!run.current_phase) return conflict('this run has not entered a phase yet')
const gate = await gatesDb.forPhase(run.id, run.current_phase)
if (!gate) return conflict(`phase "${run.current_phase}" has no advance condition; skip its steps instead`)
if (gate.satisfied_at) return conflict(`phase "${run.current_phase}" is already past its advance condition`)
const openStep = await stepsDb.nextOpenStep(run.id, run.current_phase)
if (openStep) {
return conflict(
`phase "${run.current_phase}" is waiting on step ${openStep.seq} (${openStep.action_id}), not on its advance condition`,
)
}
const note = clean(reason)
if (!(await gatesDb.satisfy(gate.id, 'forced', { userId }))) {
return conflict('this phase stopped waiting on its advance condition')
}
const described = gates.describe(gate)
await logDb.write({
runId: run.id,
kind: 'phase.advanced',
phase: run.current_phase,
detail: {
because: 'forced',
control: 'advance',
by: userId,
reason: note,
waitedSeconds: described.elapsedSeconds,
...(gate.kind === 'on'
? { trigger: gate.trigger_id, seen: gate.tally, needed: gate.needed }
: { after: gate.after_seconds }),
},
})
return { ok: true, run: await runsDb.getById(run.id), phase: run.current_phase }
}
// ── Step-level ────────────────────────────────────────────────────────────
/**
* Confirm a parked step — the GM cue's other half.
*
* `core.cue` posts an instruction and parks: the step stays `running` with a
* NULL lease, genuinely in flight with nothing holding it, so no sweep takes it
* back and a cue posted on Friday is still waiting on Monday. This is what ends
* it, and it is the control that makes the whole system useful before any module
* automates anything — a GM does the target-driven part in-client and says so
* here.
*
* The outcome is `done`, not `skipped`: a person saying they did the thing is
* the step having succeeded. The note is what they did, and it is kept.
*/
async function confirmStep(runId, stepId, { note } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
const text = clean(note)
if (!(await stepsDb.confirmParked(step.id, text))) {
return conflict(`this step is ${step.status} and is not waiting on anyone`)
}
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'done', action: step.action_id, control: 'confirm', by: userId, note: text },
})
return { ok: true, step: await stepsDb.getById(step.id) }
}
/**
* Skip a step: one that has not started, or a parked cue nobody is going to do.
*
* This is what the `skipped` status was reserved for (§L) — which is also why
* the three `on_failure` dispositions all write `failed` instead. A status
* meaning both "a human decided against this" and "this was attempted three
* times and never worked" would make the console's summary line unreadable.
*
* A `failed` step is not skippable and does not need to be: `nextOpenStep`
* already passes over one, so resuming a run carries the phase past it.
*/
async function skipStep(runId, stepId, { reason } = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (runsDb.TERMINAL.includes(run.status)) return conflict(`this run is ${run.status}`)
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
const note = clean(reason)
if (!(await stepsDb.skipByHuman(step.id, note))) {
return conflict(`a ${step.status} step cannot be skipped`)
}
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'skipped', action: step.action_id, control: 'skip', by: userId, reason: note },
})
return { ok: true, step: await stepsDb.getById(step.id) }
}
/**
* Re-queue the failed step a run is stopped at, and resume the run — one action.
*
* **The two halves are one control because there is no state in which you would
* want half of it.** Retry is legal only from `paused`, and a paused run is
* paused *at* this step; re-queueing without resuming would leave the run in
* precisely the state it was already in, with a button the operator now has to
* find. Splitting them would read as honesty and behave as a trap.
*
* Two guards, and the second is the one worth explaining. The step must be the
* furthest one its phase has reached — `lastStartedSeq` — because a `failed`
* step under an `on_failure` of `skip` is one the run has already moved PAST.
* `nextOpenStep` selects `pending` and `running` only, so the runner steps over
* a failed row; re-queueing an earlier one puts a `pending` step behind the
* cursor, where it sits for ever.
*/
async function retryStep(runId, stepId, options = {}, userId = null) {
const run = await loadRun(runId)
if (!run) return { ok: false, status: 404, errors: ['no such run'] }
if (run.status !== 'paused') {
return conflict(`a step can only be retried while its run is paused; this run is ${run.status}`)
}
const step = await loadStep(runId, stepId)
if (!step) return { ok: false, status: 404, errors: ['no such step on this run'] }
if (step.status !== 'failed') return conflict(`a ${step.status} step cannot be retried`)
if (step.phase !== run.current_phase) {
return conflict('this step belongs to a phase the run has already left')
}
const furthest = await stepsDb.lastStartedSeq(run.id, step.phase)
if (furthest === null || Number(furthest) !== Number(step.seq)) {
return conflict('the run is not stopped at this step; only the step a phase is stopped at can be retried')
}
if (!(await stepsDb.requeue(step.id))) return conflict('this step is no longer failed')
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
phase: step.phase,
detail: { to: 'pending', action: step.action_id, control: 'retry', by: userId, attemptsReset: step.attempts },
})
const resumed = await resume(runId, {}, userId)
return {
ok: true,
step: await stepsDb.getById(step.id),
// A resume that did not take is reported rather than swallowed: the step IS
// re-queued either way, and an operator told "retried" about a run that is
// still paused would be told something false.
resumed: Boolean(resumed.ok),
run: resumed.run || (await runsDb.getById(run.id)),
}
}
module.exports = { pause, resume, cancel, cleanupRun, advancePhase, confirmStep, skipStep, retryStep }

View File

@@ -0,0 +1,144 @@
// ── event_run_log — SQL only ───────────────────────────────────────────────
//
// EVENTS.md § Observability. "Why didn't phase 3 start?" must be a query, and
// `activity_log.detail` is TEXT and unqueryable, which is why this table exists
// beside the audit log rather than instead of it. Both are written: the audit of
// WHO published WHAT goes to `activity_log`, the diagnosis goes here.
//
// **`kind` is a closed set enforced here rather than an ENUM in the DDL.** The
// set grows with almost every later phase — conditions in Phase 5, cap draws in
// Phase 6, ledger movements in Phase 8 — and an ENUM change is a table alter
// this project has no migration system for. A constant in a file is the same
// guarantee with a cheaper hinge.
const log = require('../../utils/logger')('events')
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
// Phase 1's kinds. Later phases append; nothing here is ever renamed, because a
// stored row would then name a kind no reader knows.
const KINDS = [
'run.created', // an occurrence was materialised
'run.status', // a status transition, with from/to
'phase.entered', // a phase's steps were materialised
'step.status', // a step transition, with the module's answer
'note', // a human action taken from the admin surface
// Phase 2's, all five of them answers to a question an operator asks out
// loud. `run.blocked` in particular is the whole reason this table exists
// rather than a server log line: "it did not start because run 37 holds
// invasion:Yew" is a fact with two run ids in it, and it has to be
// queryable from the run that did NOT start.
'run.blocked', // an occurrence held off: another run has its concurrency key
'run.health', // a health change, which is not a status change
'step.retry', // a step failed transiently and will be attempted again
'step.parked', // a step is waiting on a human and nothing is holding it
'phase.completed', // every step of a phase reached a terminal status
// Phase 5's four. `condition.evaluated` is written for BOTH outcomes (§
// Observability), and the non-matching one is the more valuable of the two on
// the night: "the boss did spawn, in Britain" and "no boss has spawned" are
// different answers to the same question and look identical without it.
'phase.gate', // a phase opened an advance gate, with what it waits for
'condition.evaluated', // a firing was tested against a gate, matched or not
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
// Phase 6's three. `step.refused` is the one worth naming separately from
// `step.status`: a refusal is not a failure, and an operator reading a run that
// stopped needs to see at a glance that nothing is broken -- the deployment
// simply does not permit what the author asked for.
'run.budget', // the caps this run was seeded with, and which switch set each
'step.refused', // a step was not permitted: disabled, or over a cap
'version.verified', // a dry run passed against a version, unlocking scheduled starts
// Phase 8's six, and every one of them is an answer to "what did this event
// leave behind". `resource.recorded` is written at the ANSWER rather than at
// the placeholder, because a placeholder is a promise and the operator's
// question is about the world.
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
'resource.orphaned', // a module reports a ledgered resource is no longer in force
'cleanup.reverted', // a group of resources came back
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found
'cleanup.retry', // a human cleared the attempt counter and asked again
// Phase 10's four: the integrations. `announcement.emitted` is a line about
// what the run SAID happened, not about who was told -- the engagement engine
// owns that decision and logs its own, and a run log that claimed to know how
// many mails went out would be reporting a decision it does not make.
'participants.recorded', // a step reported who took part, and they are recorded
'results.published', // the results table was ranked and stamped
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
'announcement.enqueued', // a post was linked to this run and queued on the legs
// Phase 15's one, and it is the only kind whose payload core does not compose.
// A module may answer a success envelope with a `detail` object; it is bounded
// and sanitised at the dispatcher and written here verbatim beside the action
// id. Nothing reads a key out of it — it exists because a module knows things
// about its own verb that core cannot compute and had no other way to say.
'step.detail', // a module's own account of what a successful step did
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
const listForRun = async (runId, { limit = 500 } = {}) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 2000)
return (
await query(`SELECT * FROM event_run_log WHERE run_id = ? ORDER BY at DESC, id DESC LIMIT ${n}`, [
runId,
])
).map(hydrate)
}
/**
* Write one line. **Never throws.**
*
* The diagnostic log is what an operator reads when something has already gone
* wrong, so a failure to write it must not become a second failure on top of the
* first — a runner that aborted a run because it could not record why would be
* the worst possible reading of "observability". The same posture
* `uoLinkClient.js` takes: answer, do not throw.
*/
async function write({ runId, stepId = null, kind, phase = null, detail = null }) {
if (!KINDS.includes(kind)) {
// A programming error, not an operational one, and it is louder than a
// silent drop for exactly that reason.
log.warn('event run log: unknown kind', { kind, runId })
return false
}
try {
await query(
'INSERT INTO event_run_log (run_id, step_id, kind, phase, detail) VALUES (?, ?, ?, ?, ?)',
[runId, stepId, kind, phase, detail === null ? null : JSON.stringify(detail)],
)
return true
} catch (err) {
log.error('event run log write failed', { runId, kind, message: err.message })
return false
}
}
/**
* Delete log lines belonging to runs that are both TERMINAL and older than
* `before`, a bounded number at a time.
*
* The schema comment beside `idx_evlog_at` parked this sweep here, and it is the
* rule Engagement Phase 14 arrived at applied to a second high-cardinality table:
* **only terminal rows are eligible.** A run still in flight keeps every line it
* has, however old — the log's whole job is answering "why didn't phase 3 start?"
* about a run that is, right now, not starting phase 3, and a horizon that could
* reach a live run would delete the answer while the question was still open.
*
* `LIMIT` makes one call a bounded amount of work rather than a table-sized
* transaction; the timer runs again and takes the next slice. The join is on the
* run's terminal status rather than on a precomputed id list so that a run which
* reached a terminal state between the two would not be missed.
*/
const pruneTerminal = async (before, limit = 5000) => {
const n = Math.min(Math.max(Number(limit) || 5000, 1), 50_000)
const result = await query(
`DELETE l FROM event_run_log l
JOIN event_runs r ON r.id = l.run_id
WHERE r.status IN ('completed','cancelled','failed','missed')
AND COALESCE(r.ended_at, r.updated_at) < ?
LIMIT ${n}`,
[before],
)
return Number(result?.affectedRows || 0)
}
module.exports = { KINDS, listForRun, write, pruneTerminal }

View File

@@ -0,0 +1,165 @@
// ── event_run_participants — SQL only ──────────────────────────────────────
//
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
// §D's core tables: who took part in a run, and how well.
//
// **Core writes this table and never sources it.** A `member_key` is
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
// name onto a user row and must not try, because that mapping is one game's
// (`shard_links`, for module-uo) and would be that game compiled into core. A
// module that knows both halves reports both; core stores what it is told.
//
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
// collect that duplicated its rows would double a leaderboard. It is the same
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
// along, with the difference that a re-report may carry a BETTER score and must
// win rather than be ignored.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
created_at, updated_at`
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
// every caller wants the object rather than the string the driver returns.
const hydrate = (row) =>
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
/**
* Record one participant, or update the one already recorded.
*
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
* the point of the column: it is when this participant first appeared, and a
* second report — a later collect, a corrected score — must not rewrite it. The
* same applies to `rank_at`, which is not touched here at all: ranking is
* `core.results.publish`'s job and a re-report between two publications must not
* silently invent a rank nobody computed.
*
* `user_id` DOES move on a re-report, deliberately: a player who linked their
* website account between two collects should stop being anonymous, and the
* module is the only thing that can know they did.
*
* **It answers nothing, and the reason is a trap worth naming.** The obvious
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
* sends it: with it, a re-report whose values are identical also answers 1, so
* the flag would report every idempotent retry as a fresh participant. The
* caller wants "how many were reported" anyway, which it already knows from the
* length of its own list.
*/
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
await query(
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
score = VALUES(score),
meta = VALUES(meta)`,
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
)
}
/** One run's participants, best first. The results table, and the console's. */
async function listForRun(runId, limit = 500) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_participants
WHERE run_id = ?
ORDER BY score DESC, joined_at ASC, id ASC
LIMIT ?`,
[runId, limit],
)
return rows.map(hydrate)
}
/**
* One account's participation history, most recent event first (Phase 14a).
*
* **Joined all the way out to the definition, and the join is the access
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
* definition is excluded because unlisting is what an operator does to an event
* they are not announcing — a history that named it would announce it to
* everyone who attended, which is everyone who could tell anybody.
*
* `member_key` is NOT selected. It is the game's identifier for a character and
* the caller is a player reading their own page; the run, the date, the score
* and the rank are what a history is, and the key adds a module-opaque string
* nothing on the page can render.
*
* `rank_at` is null until results are published, and that is a real state the
* screen shows rather than an error — a run whose participants are collected
* and unranked is exactly what Phase 10 made visible on the admin side.
*/
async function listForUser(userId, { limit = 50, before = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const args = [userId]
// A keyset cursor on the participation row rather than an offset: the list
// gains a row every time the reader attends something, and an offset page two
// would skip whatever arrived in between.
const cursor = before ? ' AND p.id < ?' : ''
if (before) args.push(before)
const rows = await query(
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
r.timezone, r.results_published_at,
d.title AS definition_title, d.slug AS definition_slug,
s.name AS series_name, s.slug AS series_slug
FROM event_run_participants p
JOIN event_runs r ON r.id = p.run_id
JOIN event_definitions d ON d.id = r.definition_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE p.user_id = ?${cursor}
AND r.rehearsal = 0
AND d.listed = 1
ORDER BY p.id DESC
LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
return Number(rows[0]?.n || 0)
}
/**
* Number every participant of one run by score, best first.
*
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
* wrong here in a way that would have passed every test that did not run twice
* concurrently: `query()` takes a connection from the pool per call and releases
* it, so the session variable is set on one connection and read on whichever the
* second call happens to get. A window function needs no session state at all.
*
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
* engine felt like, so two publications of the same run would hand out different
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
* way every time, which is what makes re-publishing idempotent rather than a
* reshuffle.
*
* Ties share nothing — two people on the same score get consecutive ranks rather
* than a dense or competition ranking. That is a presentation decision belonging
* to whatever renders the table; what this owes is a stable number.
*/
async function rankRun(runId) {
const result = await query(
`UPDATE event_run_participants p
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
FROM event_run_participants
WHERE run_id = ?) r ON r.id = p.id
SET p.rank_at = r.rk`,
[runId],
)
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
// than rows changed — which is the number wanted here. Re-publishing a run
// whose ranks are already correct answers "12 ranked", not "0".
return Number(result.affectedRows || 0)
}
module.exports = { record, listForRun, listForUser, countForRun, rankRun }

View File

@@ -0,0 +1,389 @@
// ── event_run_resources — SQL only ─────────────────────────────────────────
//
// EVENTS.md §D and §L ("The ledger's two rules"), and Phase 8 of EVENTS_PLAN.md.
// Everything one run created or leased, and what became of it.
//
// **Rule 1 lives in `reserve()`.** A resource is recorded BEFORE it is
// confirmed, so the placeholder this writes is the row that exists while the
// dispatch is in flight — and the row that SURVIVES when the acknowledgement is
// lost. Recording on the answer instead would make every object whose ack went
// missing invisible to cleanup for ever.
//
// **Rule 2 lives in the status column and in `failRevert()`.** A revert that
// never succeeds leaves its row unreverted, with the error on it, and the run
// completes with `cleanup_status = 'incomplete'` rather than being held open.
// Loud and sticky.
//
// **The unique key is enforced by the database, not by a read.** `reserve()`
// answers `{ ok: false, code: 'held' }` on a duplicate key rather than checking
// first and then inserting — two runs entering the same tick would both pass the
// check. It is the argument `event_run_budget.spend()` makes about the cap and
// `runsDb.transition` makes about a status, in the third place it applies.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
// The one `kind` core owns. A module's kinds are opaque and stored verbatim; this
// one is core's own, and `registries` refuses a module resource that claims it.
const STEP_KIND = '@step'
// The statuses that mean "core still believes this resource is this run's". They
// are exactly the ones the `live_marker` generated column keeps non-NULL, so the
// unique target key holds while a row is in one of them and releases when it
// leaves. Duplicated here as a JavaScript list because the sweeps read by it too,
// and a second copy that can drift is better than a query that cannot express it.
const HELD = ['pending', 'confirmed', 'reverting']
// Every status that still wants a human or a retry: `HELD` plus the two that mean
// "we let go, and not cleanly". This is what "unreverted" means everywhere in
// this feature — the console's list, `cleanup_status`, and the manual retry.
const UNRESOLVED = [...HELD, 'orphaned', 'drifted']
const COLUMNS = `id, run_id, step_id, owner_module, kind, ref, payload, lease_until,
status, revert_attempts, last_error, member_key, created_at, updated_at`
// `payload` is opaque to core and stored verbatim, but it comes back as a string
// from the driver and every caller wants the object — the cleanup sweep reads a
// lease's baseline out of it, and the console renders it. Hydrated here for the
// same reason a step's params are: one place rather than at each read.
const hydrate = (row) => row && { ...row, payload: parseJson(row.payload, null) }
/**
* Record a resource that does not exist yet.
*
* Answers `{ ok: true, id }`, or `{ ok: false, code: 'held', holder }` when the
* target is already someone's — which is the lease conflict, surfaced as a
* refusal rather than a failure because nothing is wrong with the system: another
* run has the thing.
*
* **`ER_DUP_ENTRY` is the check.** The holder is looked up only to name it in the
* refusal, and only after the insert has already lost the race.
*/
async function reserve({ runId, stepId = null, owner, kind, ref, payload = null, leaseUntil = null, memberKey = null }) {
try {
const result = await query(
`INSERT INTO event_run_resources
(run_id, step_id, owner_module, kind, ref, payload, lease_until, member_key, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
[runId, stepId, owner, kind, ref, payload === null ? null : JSON.stringify(payload), leaseUntil, memberKey],
)
return { ok: true, id: Number(result.insertId) }
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
const [holder] = await query(
`SELECT run_id, status FROM event_run_resources
WHERE owner_module = ? AND kind = ? AND ref = ? AND status IN (?, ?, ?)
LIMIT 1`,
[owner, kind, ref, ...HELD],
)
return { ok: false, code: 'held', holder: holder || null }
}
throw err
}
}
/**
* Promote a reserved row to `confirmed`, optionally attaching what the module
* finally said about it.
*
* Guarded on `pending` so a late answer cannot un-revert a row cleanup has
* already dealt with — the same reason every other write in this feature is a
* compare-and-set rather than a read-then-write.
*/
async function confirm(id, { payload, leaseUntil, memberKey } = {}) {
const sets = ["status = 'confirmed'"]
const params = []
if (payload !== undefined) {
sets.push('payload = ?')
params.push(payload === null ? null : JSON.stringify(payload))
}
if (leaseUntil !== undefined) {
sets.push('lease_until = ?')
params.push(leaseUntil)
}
if (memberKey !== undefined) {
sets.push('member_key = ?')
params.push(memberKey)
}
const result = await query(
`UPDATE event_run_resources SET ${sets.join(', ')} WHERE id = ? AND status = 'pending'`,
[...params, id],
)
return (result.affectedRows || 0) > 0
}
/**
* Resolve a step placeholder once the module has named what it actually made.
*
* The placeholder's whole job is over at this point: the real rows exist, so the
* `@step` row must stop being one of the things cleanup will try to revert.
* `reverted` is the honest terminal state for it — there is nothing left to undo
* that the rows it stood in for do not now cover — and it releases the
* idempotency key for a later run, which matters because keys are per step and a
* re-materialised step reuses its own.
*/
async function resolvePlaceholder(id) {
const result = await query(
`UPDATE event_run_resources
SET status = 'reverted', last_error = NULL
WHERE id = ? AND kind = ? AND status IN ('pending', 'confirmed')`,
[id, STEP_KIND],
)
return (result.affectedRows || 0) > 0
}
/**
* One row by its target, live or not — how a caller that lost the insert race
* finds the row it meant to write. Newest first, so a target that has been held
* and released several times answers with the current holder.
*/
async function findByTarget(owner, kind, ref) {
const [row] = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE owner_module = ? AND kind = ? AND ref = ?
ORDER BY id DESC LIMIT 1`,
[owner, kind, ref],
)
return hydrate(row) || null
}
/** One run's whole ledger, oldest first — the console's read. */
async function forRun(runId) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources WHERE run_id = ? ORDER BY id`,
[runId],
)
return rows.map(hydrate)
}
/** The rows of one run that still want something: the cleanup sweep's input. */
async function unresolvedForRun(runId, { maxAttempts = null } = {}) {
const params = [runId, ...UNRESOLVED]
const attemptClause = maxAttempts === null ? '' : ' AND revert_attempts < ?'
if (maxAttempts !== null) params.push(maxAttempts)
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)${attemptClause}
ORDER BY id`,
params,
)
return rows.map(hydrate)
}
/** How many of one run's rows are still unresolved — what `cleanup_status` is derived from. */
async function unresolvedCount(runId) {
const [row] = await query(
`SELECT COUNT(*) AS n FROM event_run_resources
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
[runId, ...UNRESOLVED],
)
return Number(row?.n || 0)
}
/** Unresolved counts for several runs at once, keyed by run id — the run LIST's read. */
async function unresolvedCounts(runIds) {
const ids = [...new Set(runIds || [])].filter(Boolean)
if (!ids.length) return new Map()
const rows = await query(
`SELECT run_id, COUNT(*) AS n FROM event_run_resources
WHERE run_id IN (${ids.map(() => '?').join(',')}) AND status IN (?, ?, ?, ?, ?)
GROUP BY run_id`,
[...ids, ...UNRESOLVED],
)
return new Map(rows.map((r) => [r.run_id, Number(r.n)]))
}
/**
* Claim one row for a revert: `pending | confirmed | orphaned | drifted → reverting`,
* and `reverting` again once the claim on it has gone stale.
*
* The compare-and-set that keeps the cleanup leg and the manual cleanup route off
* each other's rows. A row another pass is mid-revert on is left alone, exactly as
* a step with a live claim is.
*
* **"Exactly as a step" has to include the expiry, and it did not until the Phase
* 16 acceptance walk.** A step's claim carries `claim_expires_at`, so a step whose
* process died is reclaimed once the lease lapses — that reclaim is the whole
* reason §E's CAS survives §N4's single instance. A `reverting` row had no such
* bound and nothing released it, so a process killed mid-teardown stranded the row
* for good: the sweep skipped it every 15s forever, `cleanup_status` never left
* `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did
* nothing, because it claims through this same function. Observed with a lease,
* which then blocked the NEXT run of the same event from taking the value.
*
* The stale test is `updated_at`, not a new column: the row is stamped exactly
* when it enters `reverting` and is not written again until the revert resolves,
* so for a `reverting` row `updated_at` IS "when this claim was taken". The bound
* is the run lease's, for the run lease's reason — it has to outlast a whole
* tick's work on one run, and every revert in a sweep is bounded by its action's
* own `budgetMs` long before this.
*
* `revert_attempts` is deliberately NOT incremented by reclaiming. A stale claim
* is a process that died, not an attempt that failed, and counting it would burn
* the retry budget on crashes — Engagement Phase 14's rule, one table over.
*
* **`updated_at` is re-stamped explicitly, and that is what keeps this a CAS.**
* This connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows MATCHED
* rather than changed. For the four fresh statuses that is harmless — the winner
* moves the row to `reverting` and the loser's `status IN (…)` no longer matches.
* A stale `reverting` row has no such natural change: without re-stamping, the
* row would still satisfy `status = 'reverting' AND updated_at < …` and a second
* claimer would match it too. Writing the column is what makes the second one
* miss.
*/
const REVERT_CLAIM_TTL_MS = Number(process.env.EVENT_REVERT_CLAIM_TTL_MS) || 15 * 60 * 1000
async function claimRevert(id) {
const result = await query(
`UPDATE event_run_resources
SET status = 'reverting', updated_at = NOW()
WHERE id = ?
AND (status IN ('pending', 'confirmed', 'orphaned', 'drifted')
OR (status = 'reverting'
AND updated_at < (NOW() - INTERVAL ? MICROSECOND)))`,
[id, REVERT_CLAIM_TTL_MS * 1000],
)
return (result.affectedRows || 0) > 0
}
/** The revert worked. `reverted` is terminal and releases the target. */
async function markReverted(id) {
await query(
`UPDATE event_run_resources SET status = 'reverted', last_error = NULL WHERE id = ?`,
[id],
)
}
/**
* The revert did not work, and the row goes back to being unresolved.
*
* `revert_attempts` is incremented here and NOWHERE else, and it is never reset by
* a sweep — Engagement Phase 14's rule, whose defect was a reclaim that returned
* every stale row to its start state and made the attempt ceiling unreachable, so
* the row cycled for ever and was never eligible for any retention sweep. The one
* thing that may reset it is a human pressing cleanup, which is the same licence
* a human's step retry has.
*
* `restoreTo` is where the row lands: `drifted` when the module says somebody else
* moved the value, `orphaned` when it says the thing is gone, and `confirmed`
* otherwise — still ours, still out there, try again.
*/
async function failRevert(id, error, restoreTo = 'confirmed') {
await query(
`UPDATE event_run_resources
SET status = ?, revert_attempts = revert_attempts + 1, last_error = ?
WHERE id = ?`,
[restoreTo, String(error || 'the revert did not answer').slice(0, 500), id],
)
}
/**
* A human is trying again: clear the attempt counter on one run's unresolved rows.
*
* Only ever called from the cleanup route with an actor behind it. The automatic
* leg must never do this (see `failRevert`).
*/
async function resetAttempts(runId) {
const result = await query(
`UPDATE event_run_resources
SET revert_attempts = 0
WHERE run_id = ? AND status IN (?, ?, ?, ?, ?)`,
[runId, ...UNRESOLVED],
)
return result.affectedRows || 0
}
/** Every live row one module owns, for the reconcile sweep. */
async function liveForModule(owner, { limit = 500 } = {}) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE owner_module = ? AND status IN ('pending', 'confirmed')
ORDER BY id LIMIT ?`,
[owner, Number(limit)],
)
return rows.map(hydrate)
}
/** Every module that currently owns a live row — who the reconcile sweep asks. */
async function modulesWithLiveRows() {
const rows = await query(
`SELECT DISTINCT owner_module FROM event_run_resources
WHERE status IN ('pending', 'confirmed')`,
)
return rows.map((r) => r.owner_module)
}
/**
* The game no longer has it. Never reached by a revert — a revert that finds
* nothing there is a SUCCESS (§L, and it is what a Rust wipe needs) — only by
* reconcile, which is a different question: nobody asked for this to go.
*/
async function markOrphaned(id, detail = null) {
await query(
`UPDATE event_run_resources
SET status = 'orphaned', last_error = ?
WHERE id = ? AND status IN ('pending', 'confirmed', 'reverting')`,
[detail === null ? null : String(detail).slice(0, 500), id],
)
}
/**
* Terminal runs that still owe the world something — the cleanup leg's scan.
*
* **Both halves of the WHERE were live-walk findings, and they are opposite
* mistakes.**
*
* `cleanup_status = 'pending'` alone missed a run whose only resource was a
* LEASE: `core.lease` reserves its own row and never goes through the ledger's
* `markRunDirty`, so the flag stayed `not_required` and the lease was never given
* back at all. Hence `not_required` is in the list — a terminal run with an
* unresolved row has something to do whatever any summary column says, and
* treating that combination as work is the fail-safe direction.
*
* And the run status filter alone made `MAX_REVERT_ATTEMPTS` mean ONE attempt,
* because the first failing sweep set `incomplete` and nothing looked at the run
* again. That is fixed in `cleanupRun`, which now only writes `incomplete` once
* there is nothing left it will try — so `incomplete` genuinely means "finished
* with, and not finished", which is exactly what excludes both a run whose
* retries are spent and a run an admin cancelled without cleanup.
*
* The attempt bound is in the join for a different reason: without it a run whose
* rows are all spent would be selected, worked over and found to have nothing to
* do on every tick for the rest of its life.
*/
async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
return query(
`SELECT DISTINCT r.id, r.status, r.cleanup_status, r.version_id, r.definition_id, r.scope
FROM event_runs r
JOIN event_run_resources res ON res.run_id = r.id
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
AND r.cleanup_status IN ('pending', 'not_required')
AND res.status IN (?, ?, ?, ?, ?)
AND res.revert_attempts < ?
ORDER BY r.id
LIMIT ?`,
[...UNRESOLVED, Number(maxAttempts), Number(limit)],
)
}
module.exports = {
STEP_KIND,
HELD,
UNRESOLVED,
reserve,
confirm,
resolvePlaceholder,
findByTarget,
forRun,
unresolvedForRun,
unresolvedCount,
unresolvedCounts,
claimRevert,
markReverted,
failRevert,
resetAttempts,
liveForModule,
modulesWithLiveRows,
markOrphaned,
runsNeedingCleanup,
}

View File

@@ -0,0 +1,421 @@
// ── event_run_steps — SQL only ─────────────────────────────────────────────
//
// EVENTS.md §D and §E. Phase 1 materialises a run's steps and reads them back
// for the run console. **Draining them is Phase 2's**: the CAS claim, the lease,
// the attempt counter and the classification of a module's answer are the
// runner, and none of them is stubbed here.
//
// The one runtime property Phase 1 does have to get right is the idempotency key
// (§E). Core mints it ONCE, at materialisation, and it does NOT vary by attempt —
// a retry re-sends the same key so the game side can recognise the repeat. That
// makes it a property of the INSERT below rather than of the dispatch, which is
// the only reason it can be stable at all.
const crypto = require('crypto')
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, params: parseJson(row.params, {}) }
/**
* `sha256(runId | stepId)`, truncated to 40 hex — the shape `shardEvents.dedupeKey`
* already uses, so the two dedupe keys on this codebase read alike.
*
* The step id is not known until the row exists, so materialisation inserts with
* a provisional key and stamps the real one immediately afterwards. That is one
* extra statement per step and it buys the property the whole retry story rests
* on: the key is a function of identity, never of attempt or of clock.
*/
const idempotencyKey = (runId, stepId) =>
crypto.createHash('sha256').update(`${runId}|${stepId}`).digest('hex').slice(0, 40)
const listForRun = async (runId) =>
(
await query(
'SELECT * FROM event_run_steps WHERE run_id = ? ORDER BY phase, seq, id',
[runId],
)
).map(hydrate)
const getById = async (id) => {
const [row] = await query('SELECT * FROM event_run_steps WHERE id = ?', [id])
return hydrate(row)
}
/**
* Materialise one phase's steps.
*
* `INSERT IGNORE` against `UNIQUE (run_id, phase, seq)`, so a tick that overran
* into the next one cannot double-materialise a phase — the same argument the
* occurrence key makes one table up, at the other end of the run.
*
* Returns the rows as they now stand, created or pre-existing, so a caller that
* lost the race still gets the step ids.
*/
const materialisePhase = async (runId, phase, steps) => {
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
const result = await query(
`INSERT IGNORE INTO event_run_steps
(run_id, phase, seq, action_id, params, action_version, on_failure, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
[
runId,
phase,
i,
step.actionId,
JSON.stringify(step.params || {}),
step.actionVersion || 1,
step.onFailure || 'pause',
],
)
if (Number(result?.affectedRows || 0) === 1) {
// Stamped in a second statement because the key is a function of the row's
// own id. Scoped by the empty key so a re-run of this loop over an existing
// phase can never overwrite a key a dispatch has already sent.
await query(
"UPDATE event_run_steps SET idempotency_key = ? WHERE id = ? AND idempotency_key = ''",
[idempotencyKey(runId, result.insertId), result.insertId],
)
}
}
return listForRun(runId)
}
/** The run console's summary line: how many steps sit in each status. */
const statusCounts = async (runId) => {
const rows = await query(
'SELECT status, COUNT(*) AS n FROM event_run_steps WHERE run_id = ? GROUP BY status',
[runId],
)
return Object.fromEntries(rows.map((r) => [r.status, Number(r.n)]))
}
// ── Phase 2: draining a step ───────────────────────────────────────────────
//
// The CAS claim, the lease, the attempt counter and the terminal writes. Phase 1
// left all of it out rather than stubbing it, and this is where it lands.
//
// **Two rules govern everything below, and both were paid for once already.**
//
// 1. `attempts` is incremented by the CLAIM and by nothing else, and no recovery
// path ever resets it. Engagement Phase 14's defect was a stale-row sweep that
// returned rows to their start state: the attempt ceiling became unreachable,
// so the row cycled forever, never reached a terminal status, and was
// therefore never eligible for any retention sweep.
// 2. A PARKED step is `running` with a NULL lease, and the reclaim only ever
// touches a lease that is non-NULL and expired (the org lead's answer,
// 2026-09-02). That is what lets a GM cue wait for a human overnight without a
// sweep re-dispatching the instruction every fifteen minutes.
// A run's steps in authored order, for the phase the run is currently in.
const listForPhase = async (runId, phase) =>
(
await query(
'SELECT * FROM event_run_steps WHERE run_id = ? AND phase = ? ORDER BY seq, id',
[runId, phase],
)
).map(hydrate)
/**
* The next step of a phase that the runner may work on, or null.
*
* **Steps within a phase are strictly serial.** This returns the lowest-`seq`
* step that is not terminal, and the runner does nothing with step N+1 until N
* has finished — which is the only reading under which `core.wait` means anything
* at all, and the only one under which a cue can gate what follows it.
*
* A parked or running step is returned too, so the caller can see that the phase
* is occupied rather than concluding it is finished.
*/
const nextOpenStep = async (runId, phase) => {
const [row] = await query(
`SELECT * FROM event_run_steps
WHERE run_id = ? AND phase = ?
AND status IN ('pending','running')
ORDER BY seq, id LIMIT 1`,
[runId, phase],
)
return hydrate(row) || null
}
/**
* Take ownership of one pending step: the CAS `pending -> running`, plus a lease.
*
* `due_at` is honoured here rather than in the caller's filter so that the whole
* decision — is it mine, is it due — is one statement the database arbitrates. A
* NULL `due_at` is due now, which is what materialisation writes for every step
* that is not sitting behind a `core.wait`.
*/
async function claim(id, owner, leaseUntil, now) {
const result = await query(
`UPDATE event_run_steps
SET status = 'running', attempts = attempts + 1, claimed_by = ?, claim_expires_at = ?,
started_at = COALESCE(started_at, NOW())
WHERE id = ? AND status = 'pending' AND (due_at IS NULL OR due_at <= ?)`,
[owner, leaseUntil, id, now],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Park a claimed step: it stays `running`, and its lease goes NULL.
*
* This is the whole mechanism behind `core.cue`. The step is genuinely in flight
* — an instruction has been posted and nothing else in the phase may proceed —
* but no process is holding it, so the reclaim must not take it back. A NULL
* lease says exactly that, and `reclaimStale` below is written to agree.
*/
const park = (id, note) =>
query(
`UPDATE event_run_steps SET claim_expires_at = NULL, last_error = ?
WHERE id = ? AND status = 'running'`,
[note ? String(note).slice(0, 500) : null, id],
)
/**
* Release a claimed step back to `pending` for a later attempt.
*
* `attempts` is untouched — it was already incremented by the claim, which is the
* only place that may. Backoff is flat rather than exponential for the reason the
* outbox's is: `due_at` is also the event's own clock, and a doubling backoff
* pushes a step arbitrarily far past the moment the event was about.
*/
const reschedule = (id, dueAt, error) =>
query(
`UPDATE event_run_steps
SET status = 'pending', due_at = ?, claimed_by = NULL, claim_expires_at = NULL, last_error = ?
WHERE id = ? AND status = 'running'`,
[dueAt, error ? String(error).slice(0, 500) : null, id],
)
/** A terminal outcome for one step: done, failed, skipped, refused or cancelled. */
const finish = (id, status, error) =>
query(
`UPDATE event_run_steps
SET status = ?, last_error = ?, finished_at = NOW(),
claimed_by = NULL, claim_expires_at = NULL
WHERE id = ? AND status = 'running'`,
[status, error ? String(error).slice(0, 500) : null, id],
)
/**
* Delay the next not-yet-started step of a phase — what `core.wait` actually does.
*
* The wait step itself completes normally; the pause is the NEXT step's `due_at`,
* owned by the runner. A `perform()` that slept would hold its claim for the
* duration and turn a five-minute pause into a five-minute lease, which is the
* one shape this must not have.
*
* Guarded on `status = 'pending'` and on the current `due_at` being sooner, so a
* re-dispatch of a wait whose ack was lost cannot push the following step further
* out a second time.
*/
const holdNext = async (runId, phase, afterSeq, dueAt) => {
const result = await query(
`UPDATE event_run_steps
SET due_at = ?
WHERE run_id = ? AND phase = ? AND seq > ? AND status = 'pending'
AND (due_at IS NULL OR due_at < ?)
ORDER BY seq LIMIT 1`,
[dueAt, runId, phase, afterSeq, dueAt],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Recover steps whose claim outlived the process that took it.
*
* **`attempts` is not reset and the lease being NULL is not staleness.** The
* first is Engagement Phase 14's rule; the second is what makes a parked cue
* survive. A step that has already burned its attempts leaves `running` as
* `failed` rather than being handed back, and in that order — a reclaim that ran
* first would return it to `pending` and it would be retried forever.
*/
const reclaimStale = async (now, maxAttempts = 0) => {
let failed = 0
if (Number(maxAttempts) > 0) {
const gaveUp = await query(
`UPDATE event_run_steps
SET status = 'failed', last_error = 'gave up after repeated interruptions',
finished_at = NOW(), claimed_by = NULL, claim_expires_at = NULL
WHERE status = 'running'
AND claim_expires_at IS NOT NULL AND claim_expires_at < ?
AND attempts >= ?`,
[now, Math.floor(maxAttempts)],
)
failed = Number(gaveUp?.affectedRows || 0)
}
const reclaimed = await query(
`UPDATE event_run_steps
SET status = 'pending', claimed_by = NULL, claim_expires_at = NULL
WHERE status = 'running' AND claim_expires_at IS NOT NULL AND claim_expires_at < ?`,
[now],
)
return { failed, reclaimed: Number(reclaimed?.affectedRows || 0) }
}
/**
* Cancel every step of a run that has not started (Phase 3's cancel, and the
* abort_run disposition).
*
* A `running` step is deliberately left alone, parked or not: nothing can recall
* a command already sent, and a second writer on that row would race the process
* that owns it (§L).
*/
const cancelPending = async (runId) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW()
WHERE run_id = ? AND status = 'pending'`,
[runId],
)
return Number(result?.affectedRows || 0)
}
// ── Phase 3: the controls a human works ────────────────────────────────────
//
// Four statements, and every one of them is guarded on the status it is allowed
// to act from rather than trusting the button that was pressed. The run console
// decides what to OFFER; these decide what may happen, and they disagree on
// purpose — a console rendered thirty seconds ago is a console describing a run
// that has since moved.
//
// **A parked step is `running` with a NULL lease**, and that pair is the whole
// vocabulary these need. `park()` above is the only thing that produces it, so
// `status = 'running' AND claim_expires_at IS NULL` names a cue waiting on a
// human and cannot name a step some process is mid-dispatch on. Confirm and skip
// are both written against it, which is what makes them safe to expose to a
// moderator: neither can touch a step the runner is holding.
/**
* The highest `seq` of a step in this phase that is not still `pending` — the
* furthest the phase has got — or null if none of it has been attempted.
*
* It exists for the retry control, and the definition is chosen to agree with
* the runner's own cursor rather than to look tidy. Steps within a phase are
* strictly serial, so the last step that is not pending is the last one the
* runner worked on; if the run is `paused` that step is what it paused at.
*
* **The near miss worth recording: "the lowest step that is not settled" is the
* wrong rule**, and it looks right. `nextOpenStep` selects `pending` and
* `running` only, so a `failed` step is one the runner has already stepped OVER
* — which is exactly what an `on_failure` of `skip` produces. Under that rule a
* phase whose second step failed-and-skipped and whose fifth then failed-and-
* paused would offer retry on the second, re-queueing a row behind the runner's
* cursor where it would sit pending for ever.
*/
const lastStartedSeq = async (runId, phase) => {
const [row] = await query(
`SELECT MAX(seq) AS seq FROM event_run_steps
WHERE run_id = ? AND phase = ? AND status <> 'pending'`,
[runId, phase],
)
return row?.seq === null || row?.seq === undefined ? null : Number(row.seq)
}
/**
* Resolve a parked step: the GM cue's confirm.
*
* `done` rather than `skipped` — a human saying they did the thing is the step
* having succeeded, and it is the only outcome under which the instruction was
* actually carried out. The note is kept in `last_error` for the same reason the
* park's is: it is the column the console already renders beside the step, and a
* second one for prose would be a column two writers disagree about.
*/
const confirmParked = async (id, note) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'done', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ? AND status = 'running' AND claim_expires_at IS NULL`,
[note ? String(note).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Skip a step a human has decided not to run: `pending`, or a parked cue.
*
* This is what `skipped` was reserved for (§L). A `running` step with a live
* lease is excluded — nothing can recall a command already sent — and a `failed`
* one is excluded because it is already terminal and the run's own resume is
* what carries the phase past it.
*/
const skipByHuman = async (id, reason) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'skipped', finished_at = NOW(), claimed_by = NULL,
last_error = ?
WHERE id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
[reason ? String(reason).slice(0, 500) : null, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Put a failed step back in the queue for another attempt.
*
* **`attempts` goes back to zero, and that is not the rule Engagement Phase 14
* arrived at being broken.** That rule is about SWEEPS: an automatic path that
* reset a counter made the ceiling unreachable and the row immortal. This is a
* named person deciding, once, that the thing which failed three times will work
* now — `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does unattended, and a
* human is the thing it is unattended from. The decision is in the run log with
* the actor on it.
*/
const requeue = async (id) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'pending', attempts = 0, due_at = NULL, last_error = NULL,
claimed_by = NULL, claim_expires_at = NULL, finished_at = NULL
WHERE id = ? AND status = 'failed'`,
[id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Close out every step a cancelled run will never run: pending, and parked.
*
* Wider than `cancelPending` by exactly one case, and deliberately so. §L leaves
* a `running` step alone because nothing can recall a sent command — but a
* parked cue is not a sent command, it is an instruction nobody is holding, and
* leaving it `running` after the run was cancelled would leave the console
* claiming a cancelled event is still waiting for someone. The live lease is
* what distinguishes them, and it is in the WHERE clause.
*/
const cancelOpen = async (runId) => {
const result = await query(
`UPDATE event_run_steps
SET status = 'cancelled', finished_at = NOW(), claimed_by = NULL
WHERE run_id = ?
AND (status = 'pending' OR (status = 'running' AND claim_expires_at IS NULL))`,
[runId],
)
return Number(result?.affectedRows || 0)
}
module.exports = {
listForRun,
listForPhase,
getById,
materialisePhase,
statusCounts,
idempotencyKey,
nextOpenStep,
claim,
park,
reschedule,
finish,
holdNext,
reclaimStale,
cancelPending,
lastStartedSeq,
confirmParked,
skipByHuman,
requeue,
cancelOpen,
}

View File

@@ -0,0 +1,603 @@
// ── event_runs — SQL only ──────────────────────────────────────────────────
//
// EVENTS.md §D and §E. Phase 1 writes exactly one kind of row — a `scheduled`
// occurrence — and reads them back for the admin surface. **The claim, the CAS
// transitions and the lease reclaim are Phase 2's** and are deliberately not
// stubbed here: a half-written claim is worse than no claim, because it reads as
// protection.
//
// What Phase 1 does own is the INSERT, and it owns the important half of it:
// materialisation is `INSERT IGNORE` against `UNIQUE (definition_id, scope,
// scheduled_for)`, so a second attempt at one occurrence writes nothing and
// answers honestly rather than raising a duplicate-key error a caller has to
// interpret.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
// The statuses a run never leaves. A transition INTO one of these stamps
// `ended_at` and drops the claim, and only rows in one of them are eligible for
// the log retention sweep -- Engagement Phase 14's rule, which is a bound at all
// only if every path a run can take reaches one of them.
const TERMINAL = ['completed', 'cancelled', 'failed', 'missed']
// §E's health values, worst last. Health is a HIGH-WATER MARK in this system —
// nothing has ever cleared `degraded`, because a run whose announcement landed
// on the second attempt did have trouble and that stays true for the rest of its
// life — and `setHealth` enforces that rather than leaving it to every caller to
// remember. `FIELD()` gives the same order inside the WHERE clause, 1-indexed,
// which is what makes the guard one statement rather than a read and a write.
const HEALTH_ORDER = ['ok', 'degraded', 'stalled']
const HEALTH_RANK = Object.fromEntries(HEALTH_ORDER.map((h, i) => [h, i + 1]))
const HEALTH_SQL_ORDER = HEALTH_ORDER.map((h) => `'${h}'`).join(', ')
// `waiting_steps` is the count of PARKED steps: `running` with a NULL lease, the
// pair `park()` alone produces, which means a cue waiting on a human. It is a
// correlated subquery on an admin list bounded at 500 rows rather than a column,
// because it is derived from the steps and a column would be a second writer's
// opinion of them. It earns its cost on the list screen: a cue nobody notices is
// a run that never advances, and the run itself looks perfectly healthy until
// somebody opens it.
const SELECT_LIST = `
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug, v.version AS version_number,
(SELECT COUNT(*) FROM event_run_steps s
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
JOIN event_versions v ON v.id = r.version_id
`
/**
* The admin run list. Newest occurrence first, across every definition.
*
* `limit` is interpolated after an integer coercion rather than bound, because
* MariaDB will not take a placeholder in LIMIT on a prepared statement. It never
* reaches SQL as anything but a number.
*/
const list = async ({ definitionId = null, status = null, limit = 100 } = {}) => {
const where = []
const args = []
if (definitionId) {
where.push('r.definition_id = ?')
args.push(definitionId)
}
if (status) {
where.push('r.status = ?')
args.push(status)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
const rows = await query(
`${SELECT_LIST} ${clause} ORDER BY r.scheduled_for DESC, r.id DESC LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
const getById = async (id) => {
const [row] = await query(`${SELECT_LIST} WHERE r.id = ?`, [id])
return hydrate(row)
}
/**
* Materialise one occurrence. Answers the row id, or `null` when one already
* existed — which is not an error and is the ordinary answer under a tick that
* overran into the next one.
*/
const materialise = async (run) => {
const result = await query(
`INSERT IGNORE INTO event_runs
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
params, rehearsal, started_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
run.definition_id,
run.version_id,
run.scope || '',
run.scheduled_for,
run.timezone || 'UTC',
run.concurrency_key,
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
run.rehearsal ? 1 : 0,
run.started_by,
],
)
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
}
/**
* Every run whose OCCUPIED INTERVAL overlaps a window — the calendar's real half.
*
* Ascending, unlike the admin run list: a calendar is read forwards. The join
* reaches the series so a month can be filtered to one arc without a second
* round trip, and `d.timezone` is NOT what comes back — `r.timezone` is, because
* a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards.
*
* **A run OVERLAPS the window; it does not merely START in it.** This asked
* `scheduled_for >= from` alone until the Phase 16 acceptance walk, and a run is
* not an instant — it is an interval, and a multi-phase event's whole point is
* that the interval is long. A run that began before `from` and has not ended is
* happening DURING the window and belongs in it. With the instant test, the
* public calendar answered `entries: []` while that same event's own page said
* `live: true`, so the site disagreed with itself about whether something was on
* — and `EVENTS.md` §I promises this route serves "upcoming, **live** and
* recent". The admin calendar had the same hole for the same reason: a run that
* started last Sunday and is still going was missing from "this week".
*
* A finished run needs no clause: it is `recent` only if its instant is in the
* window, which is what the window's own `from` decides (see
* `eventPublic.model.calendar`, which backdates its default `from` so that
* "recent" has somewhere to live).
*/
const LIVE_STATUSES = ['starting', 'running', 'paused', 'ending']
const listInWindow = async ({
from,
to,
status = null,
scope = null,
seriesId = null,
limit = 500,
publicOnly = false,
} = {}) => {
const where = [
`((r.scheduled_for >= ? AND r.scheduled_for < ?)
OR (r.scheduled_for < ? AND r.status IN (${LIVE_STATUSES.map(() => '?').join(',')})))`,
]
const args = [from, to, to, ...LIVE_STATUSES]
if (status) {
where.push('r.status = ?')
args.push(status)
}
// The public calendar's two exclusions, in SQL rather than in the model that
// maps the rows. A rehearsal "is excluded from the public calendar and from
// participation history" by §D's own column comment, and an unlisted
// definition is one an operator chose not to announce. Both belong in the
// query because a filter applied after the read is a filter somebody can
// forget in the next caller.
if (publicOnly) {
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
}
if (scope !== null && scope !== undefined) {
where.push('r.scope = ?')
args.push(scope)
}
if (seriesId) {
where.push('d.series_id = ?')
args.push(seriesId)
}
const n = Math.min(Math.max(Number(limit) || 500, 1), 1000)
const rows = await query(
`SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
v.version AS version_number,
(SELECT COUNT(*) FROM event_run_steps s
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
JOIN event_versions v ON v.id = r.version_id
LEFT JOIN event_series se ON se.id = d.series_id
WHERE ${where.join(' AND ')}
ORDER BY r.scheduled_for, r.id
LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
/**
* Point every not-yet-started occurrence of a definition at a new version.
*
* Publishing calls this, and the guard is the whole statement: `status =
* 'scheduled'` and `started_at IS NULL`. A run that has begun keeps the version
* it pinned, for ever, because that pin is what makes it explicable afterwards
* -- and a run that has NOT begun has nothing to explain yet.
*
* **Why re-pinning is the right answer and doing nothing is not** (org lead,
* 2026-09-02): occurrences are materialised a fortnight ahead, so on the day an
* editor fixes a typo there are already fourteen days of rows carrying the old
* spec. Left alone, the fix reaches none of them, and the operator's only
* recourse -- cancelling each one -- is worse: a cancelled row still holds its
* slot in `uq_evrun_occurrence`, so the occurrence does not come back on the new
* version, it disappears.
*
* Answers how many were moved, so publish can say so rather than leaving it to
* be noticed.
*/
const repinScheduled = async (definitionId, versionId) => {
const result = await query(
`UPDATE event_runs
SET version_id = ?
WHERE definition_id = ?
AND status = 'scheduled'
AND started_at IS NULL
AND version_id <> ?`,
[versionId, definitionId, versionId],
)
return Number(result?.affectedRows || 0)
}
/** The scheduled, not-yet-started occurrences a re-pin would move. */
const listScheduledFor = async (definitionId) =>
(
await query(
`SELECT id, version_id, scheduled_for FROM event_runs
WHERE definition_id = ? AND status = 'scheduled' AND started_at IS NULL
ORDER BY scheduled_for`,
[definitionId],
)
).map(hydrate)
/** The occurrence the unique key names, whether or not this call created it. */
const findOccurrence = async (definitionId, scope, scheduledFor) => {
const [row] = await query(
`${SELECT_LIST} WHERE r.definition_id = ? AND r.scope = ? AND r.scheduled_for = ?`,
[definitionId, scope || '', scheduledFor],
)
return hydrate(row)
}
/** Is anything of this definition not yet terminal? The archive pre-check. */
const countActiveForDefinition = async (definitionId) => {
const [row] = await query(
`SELECT COUNT(*) AS n FROM event_runs
WHERE definition_id = ?
AND status IN ('scheduled','starting','running','paused','ending')`,
[definitionId],
)
return Number(row?.n || 0)
}
// ── Phase 2: the claim, the transitions and the reclaim ────────────────────
//
// Everything below is the runner's, and none of it existed in Phase 1 for a
// stated reason: a half-written claim is worse than no claim, because it reads
// as protection. It is written here now, in full.
//
// **The division of labour with the unique index has not changed.** The index one
// section up is what makes "one run per occurrence per scope" TRUE; the CAS below
// decides only WHO advances an occurrence that already exists. Neither substitutes
// for the other, and this deployment being single-instance (§N4) changes the test
// rather than the design — the same two protections are what keep a tick that
// overran into the next one from advancing a run twice.
/**
* Runs the runner should look at this tick: due, and not yet terminal.
*
* It selects rather than claims — `claimStart` and `claimTick` below are one row
* at a time — so two sweepers see the same candidates and then disagree,
* harmlessly, about which of them owns each. `idx_evrun_due (status,
* scheduled_for)` is this query.
*
* `paused` is absent from the status list on purpose. A paused run is waiting on
* a human and must not be advanced by a tick; the only thing that moves it is
* Phase 3's resume control.
*/
const findDue = async (now, limit = 50) => {
const n = Math.min(Math.max(Number(limit) || 50, 1), 500)
return (
await query(
`SELECT * FROM event_runs
WHERE status IN ('scheduled','starting','running','ending')
AND scheduled_for <= ?
ORDER BY scheduled_for, id
LIMIT ${n}`,
[now],
)
).map(hydrate)
}
/**
* Take ownership of a run that has not started: the CAS `scheduled -> starting`.
*
* Verbatim the outbox claim the org lead settled over `SELECT ... FOR UPDATE
* SKIP LOCKED` — the instance the server reports `affectedRows = 1` to owns the
* row, every other sweeper gets 0 and moves on. No transaction to hold open and
* no MariaDB version floor.
*/
async function claimStart(id, owner, leaseUntil) {
const result = await query(
`UPDATE event_runs
SET status = 'starting', claimed_by = ?, claim_expires_at = ?,
started_at = COALESCE(started_at, NOW())
WHERE id = ? AND status = 'scheduled'`,
[owner, leaseUntil, id],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Take a lease on a run already in flight, so one tick works on it at a time.
*
* Unlike `claimStart` this does not change `status` — the run is already
* `starting`, `running` or `ending`, and what is being claimed is the right to
* advance it.
*
* **A live lease is not re-enterable, not even by the process that took it**, and
* that is the whole point rather than an oversight. `setInterval` fires the next
* tick whether or not the last one has returned, so an owner-matches escape
* clause here would let one process advance one run twice at once — which is
* precisely the overrun the plan says the CAS is meant to protect against. A run
* this process still holds is a run this process is still working on; the tick
* skips it, and `releaseClaim` below is what ends that in the ordinary case.
*/
async function claimTick(id, owner, leaseUntil, now) {
const result = await query(
`UPDATE event_runs
SET claimed_by = ?, claim_expires_at = ?
WHERE id = ?
AND status IN ('starting','running','ending')
AND (claim_expires_at IS NULL OR claim_expires_at < ?)`,
[owner, leaseUntil, id, now],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Give a still-in-flight run back, so the next tick can pick it up at once.
*
* A run left parked on a GM cue, or waiting out a `core.wait`, is not finished
* and must not carry a lease: without this the run would be unadvanceable until
* the lease expired, which would turn every wait into `max(wait, leaseMs)`.
* Scoped to `claimed_by = ?` so a process can only release its own claim.
*/
async function releaseClaim(id, owner) {
const result = await query(
'UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL WHERE id = ? AND claimed_by = ?',
[id, owner],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* A guarded status transition: `from -> to`, and only from `from`.
*
* Every move the runner makes goes through here rather than through a bare
* UPDATE, so "did this transition actually happen" is answerable at each call
* site. A `false` is not an error — it is another worker, or this run having been
* cancelled from the admin surface between the read and the write, which is a
* race Phase 3's live controls make ordinary.
*/
async function transition(id, from, to, { phase, error, clearClaim = false } = {}) {
const sets = ['status = ?']
const args = [to]
if (phase !== undefined) {
sets.push('current_phase = ?')
args.push(phase)
}
if (error !== undefined) {
sets.push('last_error = ?')
args.push(error === null ? null : String(error).slice(0, 500))
}
if (TERMINAL.includes(to)) sets.push('ended_at = COALESCE(ended_at, NOW())')
if (clearClaim || TERMINAL.includes(to)) sets.push('claimed_by = NULL', 'claim_expires_at = NULL')
const froms = Array.isArray(from) ? from : [from]
const result = await query(
`UPDATE event_runs SET ${sets.join(', ')}
WHERE id = ? AND status IN (${froms.map(() => '?').join(',')})`,
[...args, id, ...froms],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Just this run's status, for a caller that must not act on a stale read.
*
* The runner drains a bounded batch of steps from one run inside a single tick,
* and Phase 3 put a pause and a cancel button in a human's hand — so between two
* steps of that batch the run may have stopped. A loop that only re-checked at
* the top of the tick would answer a pause by dispatching another two dozen
* steps, which is not a pause. One column, by primary key.
*/
const statusOf = async (id) => {
const [row] = await query('SELECT status FROM event_runs WHERE id = ?', [id])
return row?.status || null
}
/**
* Set health without touching status (§E).
*
* The two columns are separate because a run can be genuinely running and
* degraded at once — announcements landing, world writes parked — and one column
* cannot say both. Guarded on the current value so a tick that re-observes the
* same degradation does not restamp `updated_at`.
*/
async function setHealth(id, health) {
// **Escalation only, and this is the guard rather than a convention.** Health
// has always been a high-water mark here — `degraded` is never cleared,
// because a run whose announcement landed on the second attempt DID have
// trouble and that stays true — and Phase 5 gave the column a second writer
// for `stalled`. Without a rank, a step that retried after a stall would
// quietly demote `stalled` to `degraded` and a run that waited ninety minutes
// on a boss that never came would end its life claiming it merely wobbled.
const rank = HEALTH_RANK[health]
if (!rank) return false
const result = await query(
`UPDATE event_runs SET health = ?
WHERE id = ? AND FIELD(health, ${HEALTH_SQL_ORDER}) < ?`,
[health, id, rank],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Set `cleanup_status`, optionally guarded on where it is now (Phase 8).
*
* Four values and three writers, which is why the guard is a parameter rather
* than baked in. The ledger stamps `pending` the first time a run records
* anything, and it must do so only over `not_required` — a run already marked
* `complete` must not be walked back to `pending` by a late resource, and a
* `incomplete` one must not be silently tidied. The cleanup sweep sets `complete`
* or `incomplete` from what it found, unguarded, because the sweep IS the
* authority on that. A human's cleanup route re-opens `pending` deliberately, and
* says so in the log with the actor.
*
* **`pending` on a run that is still running is not a bug and reads correctly**:
* there is something to clean up and it has not happened yet. The alternative -
* a fifth value meaning "there will be something later" - is a state nothing
* would ever branch on.
*/
async function setCleanupStatus(id, to, from = null) {
const guard = from === null ? '' : ` AND cleanup_status IN (${from.map(() => '?').join(',')})`
const result = await query(
`UPDATE event_runs SET cleanup_status = ? WHERE id = ?${guard}`,
from === null ? [to, id] : [to, id, ...from],
)
return Number(result?.affectedRows || 0) === 1
}
/**
* Stamp this run's results table as published (EVENTS.md §J, Phase 10).
*
* **Unguarded, and re-stampable.** `core.results.publish` is an ordinary step
* that an author may place more than once — before an announcement and again
* after a late correction — and each publication is a real one whose moment is
* worth recording. Guarding it on `IS NULL` would make the second silently do
* nothing while the ranking beside it did move, which is the worst of both.
*/
async function markResultsPublished(id, at = new Date()) {
const result = await query('UPDATE event_runs SET results_published_at = ? WHERE id = ?', [at, id])
return Number(result?.affectedRows || 0) === 1
}
/**
* Runs whose start instant passed more than their own grace window ago (§E, §L).
*
* The window is per definition, so the comparison is against `grace_seconds` on
* the joined row rather than against a constant here: an event whose announcement
* gives a fifteen-minute window and one that must start on the second are the
* same query with different data.
*
* Only `scheduled` runs qualify. A run that reached `starting` has begun, and
* "began and then stalled" is a different fact from "never began" — conflating
* them would let `missed` describe a run that had already announced itself.
*/
const findMissed = async (now, limit = 100) => {
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
return (
await query(
`SELECT r.* FROM event_runs r
JOIN event_definitions d ON d.id = r.definition_id
WHERE r.status = 'scheduled'
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?
ORDER BY r.scheduled_for
LIMIT ${n}`,
[now],
)
).map(hydrate)
}
/**
* Is another run holding this concurrency key?
*
* The org lead's answer for a held key (2026-09-02) is to leave the run
* `scheduled` and let the grace window decide, so this is a READ rather than a
* claim: the caller holds off, logs which run holds the key, and tries again next
* tick. `idx_evrun_concurrency (concurrency_key, status)` is this query, and a
* NULL key is skipped by that index — which is right, because a definition with
* no key never contends.
*/
const concurrencyHolder = async (key, exceptRunId) => {
if (!key) return null
const [row] = await query(
`SELECT id, status, definition_id FROM event_runs
WHERE concurrency_key = ?
AND id <> ?
AND status IN ('starting','running','paused','ending')
ORDER BY id LIMIT 1`,
[key, exceptRunId],
)
return row || null
}
/**
* Recover runs whose claim outlived the process that took it.
*
* **It does not change status and it touches no counter.** All it releases is the
* lease; the run stays exactly where it was and the next tick picks it up through
* `findDue`. This is Engagement Phase 14's lesson applied one table over: a sweep
* that returned a stale row to its start state made the attempt ceiling
* unreachable, so the row cycled forever, never terminal, and therefore never
* eligible for any retention sweep.
*/
const reclaimStale = async (now) => {
const result = await query(
`UPDATE event_runs SET claimed_by = NULL, claim_expires_at = NULL
WHERE status IN ('starting','running','ending')
AND claim_expires_at IS NOT NULL
AND claim_expires_at < ?`,
[now],
)
return Number(result?.affectedRows || 0)
}
/**
* One definition's public occurrences, newest first (Phase 14a).
*
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
* caller has already resolved the definition through `getPublicBySlug`, and a
* second copy of that rule is a second thing to keep in step with the first.
*
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
* visitor came to the page for — and the caller splits past from future on the
* instant rather than on the status, because a `missed` run is in the past
* whatever its status says.
*/
const listPublicForDefinition = async (definitionId, limit = 50) => {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const rows = await query(
`SELECT r.*, v.version AS version_number
FROM event_runs r
JOIN event_versions v ON v.id = r.version_id
WHERE r.definition_id = ? AND r.rehearsal = 0
ORDER BY r.scheduled_for DESC, r.id DESC
LIMIT ${n}`,
[definitionId],
)
return rows.map(hydrate)
}
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
const terminalBefore = async (before, limit = 500) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
return (
await query(
`SELECT id FROM event_runs
WHERE status IN (${TERMINAL.map(() => '?').join(',')})
AND COALESCE(ended_at, updated_at) < ?
ORDER BY id LIMIT ${n}`,
[...TERMINAL, before],
)
).map((r) => Number(r.id))
}
module.exports = {
list,
getById,
materialise,
listInWindow,
listPublicForDefinition,
repinScheduled,
listScheduledFor,
findOccurrence,
countActiveForDefinition,
findDue,
findMissed,
claimStart,
claimTick,
releaseClaim,
statusOf,
transition,
setHealth,
setCleanupStatus,
markResultsPublished,
concurrencyHolder,
reclaimStale,
terminalBefore,
TERMINAL,
}

View File

@@ -0,0 +1,288 @@
// ── Event runs — creating an occurrence ────────────────────────────────────
//
// EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's
// steps. **It does not start anything**: there is no runner until Phase 2, so a
// row created here sits at `scheduled` indefinitely. That is the correct
// behaviour for this phase and it has to be VISIBLE as such rather than looking
// broken, which is why `create()` answers with the row and the admin surface
// renders the status verbatim.
//
// Two properties this file owns, both of which are the reason it exists before
// the runner rather than with it:
//
// - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two
// attempts at one occurrence produce one row and an honest answer, not a
// duplicate-key error a caller has to interpret. The unique index — not a
// claim — is what makes "one run per occurrence per scope" true (§E).
// - **The idempotency key is minted with the step row and never varies by
// attempt.** It is a function of identity, so it can only be stable if it is
// stamped where the identity is created.
const db = require('./eventRuns.db')
const stepsDb = require('./eventRunSteps.db')
const logDb = require('./eventRunLog.db')
const gatesDb = require('./eventPhaseGates.db')
const gates = require('../../events/gates')
const definitionsDb = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const settingsDb = require('./eventActionSettings.db')
const budgetDb = require('./eventRunBudget.db')
const resourcesDb = require('./eventRunResources.db')
const participantsDb = require('./eventRunParticipants.db')
const authorize = require('../../events/authorize')
const MAX_SCOPE = 190
/**
* Render a definition's `concurrency_key` template against a run's params.
*
* `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A
* placeholder with no matching param is left standing rather than replaced with
* an empty string: `invasion:` would collide with every other unrendered key on
* the deployment, which is the opposite of what a concurrency key is for, and a
* literal `invasion:{region}` in the column is a visible mistake.
*/
function renderConcurrencyKey(template, params) {
if (!template) return null
return String(template)
.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => {
const value = params && params[name]
return value === undefined || value === null || value === '' ? whole : String(value)
})
.slice(0, MAX_SCOPE)
}
/**
* Create one occurrence of a definition and materialise its first phase.
*
* `scheduledFor` defaults to now — "start now" is an occurrence whose instant is
* the present, not a separate concept, which is what keeps the runner's one
* materialise/advance path honest now that Phase 4 has put recurrence on top.
*
* **Phase 4's expansion calls this, rather than a second insert path beside it.**
* That is deliberate: every check here — the definition is still `ready`, the
* version still has phases, the concurrency key renders, the first phase's steps
* are materialised with their idempotency keys — is one a scheduled occurrence
* needs at least as much as a hand-started one, because there is nobody watching
* when it happens. The `INSERT IGNORE` answering `created: false` is what makes
* it safe to call on every tick for every occurrence inside the horizon.
*/
async function create(
definitionId,
{ scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
userId,
) {
const definition = await definitionsDb.getById(definitionId)
if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] }
if (definition.state !== 'ready') {
return {
ok: false,
status: 409,
errors: [`a ${definition.state} definition has no published version to run`],
}
}
if (!definition.current_version_id) {
return { ok: false, status: 409, errors: ['this definition has no published version'] }
}
const version = await versionsDb.getById(definition.current_version_id)
if (!version?.spec?.phases?.length) {
return { ok: false, status: 409, errors: ['the published version has no phases'] }
}
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
// *"a scheduled definition that has never been verified is the case worth
// refusing to start"*. An admin pressing start is watching, and that human IS
// the review the gate exists to require — so the gate falls on the path where
// nobody is. A version is immutable, so a dry run that passed against it stays
// true, which is what makes the pass a property of the version rather than
// something re-earned every occurrence.
if (source === 'schedule' && !version.verified_at) {
return {
ok: false,
status: 409,
code: 'unverified',
errors: ['this version has not been verified, so it will not start unattended'],
}
}
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
const when = scheduledFor ? new Date(scheduledFor) : new Date()
if (Number.isNaN(when.getTime())) {
return { ok: false, status: 400, errors: ['scheduledFor is not a date'] }
}
const runId = await db.materialise({
definition_id: definitionId,
version_id: version.id,
scope: scopeValue,
// Stored as UTC. The definition's zone is what an occurrence is COMPUTED in
// (Phase 4); what is stored is the instant.
scheduled_for: when,
timezone: definition.timezone,
concurrency_key: renderConcurrencyKey(definition.concurrency_key, params),
params,
rehearsal,
started_by: userId,
})
if (runId === null) {
// The occurrence already existed. Not an error — it is what the unique index
// is for — so the existing row is the answer.
const existing = await db.findOccurrence(definitionId, scopeValue, when)
return { ok: true, created: false, run: existing }
}
await logDb.write({
runId,
kind: 'run.created',
detail: {
definitionId,
versionId: version.id,
version: version.version,
scope: scopeValue,
rehearsal: Boolean(rehearsal),
// 'manual' is an admin pressing start; 'schedule' is the runner expanding
// a recurrence (Phase 4). Both produce the same row, and the log is the
// only place the difference is recorded — `started_by` is NULL for both a
// scheduled occurrence and one started by a since-deleted account.
source,
by: userId,
},
})
// The run's budget, seeded from EVERY phase's steps rather than from the first
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
// knowable now — and a budget that grew as phases were entered would let a
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
// opposite of a per-run bound. The caps are copied here, so an admin moving a
// switch tomorrow does not change what a run already in flight is allowed.
const allSteps = version.spec.phases.flatMap((p) =>
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
)
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
if (Object.keys(budget).length) {
await budgetDb.seed(runId, budget)
await logDb.write({
runId,
kind: 'run.budget',
detail: {
dimensions: Object.entries(budget).map(([dimension, d]) => ({
dimension,
cap: d.cap,
from: d.from,
})),
},
})
}
// The first phase's steps, materialised at creation rather than at start.
// Phase 2 materialises each LATER phase as the run enters it; doing the first
// one here is what makes a Phase 1 run row inspectable — an operator can see
// the steps that would run, with their params and their idempotency keys,
// before there is anything to run them.
const first = version.spec.phases[0]
await stepsDb.materialisePhase(runId, first.key, first.steps || [])
await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } })
return { ok: true, created: true, run: await db.getById(runId) }
}
/**
* A run, its steps, its status counts and its phase gates — the run console.
*
* The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's
* whole value is that it reads the way the condition builder reads, and those
* words come from `engagement/conditions.js`'s own operator labels. Rendering
* them in the browser would be a second implementation of a grammar the server
* owns, and the first clause the two spelled differently would meet its operator
* at two in the morning.
*
* Every gate the run has opened is returned, not only the current phase's. A
* completed phase's gate answers "how long did phase 2 actually wait, and what
* released it" — which is the same question as the live one, asked afterwards.
*/
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
budgetDb.forRun(runId),
resourcesDb.forRun(runId),
participantsDb.listForRun(runId),
])
const now = new Date()
return {
run,
steps,
counts,
gates: gateRows.map((g) => gates.describe(g, now)),
// The meter, as rows rather than as a sentence: a cap is two numbers and a
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
// null` is uncapped and the client says so — a dimension the run counts but
// nothing bounds.
budget: budget.map((b) => ({
dimension: b.dimension,
consumed: b.consumed,
cap: b.cap,
from: b.effective_from,
})),
// What this run changed in the world, and what became of it (Phase 8). The
// WHOLE ledger, reverted rows included, because "what did last night's
// invasion actually spawn, and did all of it come back" is the question this
// panel exists for and a list of only the failures cannot answer the second
// half of it.
//
// **The `@step` placeholders are filtered out.** They are core's own
// bookkeeping — a row that says "a dispatch is in flight and may have made
// something" — and the console's list is of things in the world. One left in
// would read as a resource nobody can name, which is exactly the confusion it
// exists to prevent internally.
resources: resources
.filter((r) => r.kind !== resourcesDb.STEP_KIND)
.map((r) => ({
id: r.id,
stepId: r.step_id,
module: r.owner_module,
kind: r.kind,
ref: r.ref,
payload: r.payload,
leaseUntil: r.lease_until,
status: r.status,
revertAttempts: r.revert_attempts,
lastError: r.last_error,
memberKey: r.member_key,
createdAt: r.created_at,
})),
// How many rows are still unresolved, counted over the WHOLE ledger rather
// than over the list above — a placeholder left standing by a lost
// acknowledgement is exactly the case `cleanup_status` must not call clean.
unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length,
// Who took part, best first (Phase 10). Returned on every run rather than
// only on a published one: the console's question is "what did this event
// record", and a run whose module has collected but whose author never
// placed a publish step is exactly the case an operator needs to see. What
// `results_published_at` on the run row then says is whether anyone OUTSIDE
// this screen may read it — which is Phase 14's question, not this one's.
//
// **`rank` is `rank_at`, renamed at the boundary and not in the column.**
// `rank` is a reserved word in MariaDB 10.2+ (it is the window function),
// so the column carries the suffix and the API carries the name a client
// wants. The alternative — backticking the column at every use — is one
// forgotten pair of backticks away from a syntax error in a query nobody
// runs until a run completes at four in the morning.
participants: attendees.map((p) => ({
memberKey: p.member_key,
userId: p.user_id,
score: p.score,
rank: p.rank_at,
joinedAt: p.joined_at,
meta: p.meta,
})),
}
}
module.exports = { create, detail, renderConcurrencyKey }

View File

@@ -0,0 +1,78 @@
// ── event_series — SQL only ────────────────────────────────────────────────
//
// EVENTS.md §D. The arc a definition may belong to. Phase 1 needed only the
// reads — `event_definitions.series_id` is a foreign key and the definition save
// path has to check it resolves — and Phase 4 adds the writes, because the
// calendar is what makes an arc visible and a form cannot offer a value nobody
// can create.
//
// `ordering` here places a SERIES among the others on the calendar. A
// definition's place WITHIN its arc is `event_definitions.series_order`, which
// is the column an editor drags; the two are deliberately different columns on
// different tables and the schema comment says so.
const { query } = require('../../utils/db')
// `definition_count` is a correlated subquery rather than a join with a GROUP BY:
// the list is a handful of rows, and the delete path needs the same number to
// tell an operator what they are about to detach.
const SELECT_LIST = `
SELECT s.*,
(SELECT COUNT(*) FROM event_definitions d WHERE d.series_id = s.id) AS definition_count
FROM event_series s
`
const list = async () => query(`${SELECT_LIST} ORDER BY s.ordering, s.name, s.id`)
const getById = async (id) => {
const [row] = await query(`${SELECT_LIST} WHERE s.id = ?`, [id])
return row || null
}
const getBySlug = async (slug) => {
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
return row || null
}
const exists = async (id) => {
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
return Boolean(row)
}
/** Does any OTHER series hold this slug? The uniqueness pre-check. */
const slugTaken = async (slug, exceptId = null) => {
const rows = exceptId
? await query('SELECT id FROM event_series WHERE slug = ? AND id <> ?', [slug, exceptId])
: await query('SELECT id FROM event_series WHERE slug = ?', [slug])
return rows.length > 0
}
const insert = async (s) => {
const result = await query(
`INSERT INTO event_series (name, slug, description, ordering, created_by)
VALUES (?, ?, ?, ?, ?)`,
[s.name, s.slug, s.description, s.ordering, s.created_by],
)
return Number(result.insertId)
}
const update = (id, s) =>
query(
`UPDATE event_series SET name = ?, slug = ?, description = ?, ordering = ? WHERE id = ?`,
[s.name, s.slug, s.description, s.ordering, id],
)
/**
* A hard delete, and the one place in this feature that is one.
*
* A series is a label rather than authored content: nothing pins one, no run
* references one, and `event_definitions.series_id` is `ON DELETE SET NULL`, so
* removing a series detaches its definitions and destroys nothing. That is why
* it is not archived the way a definition is — an archived label would be a
* state every calendar query has to remember for no benefit. The model answers
* with how many definitions were detached, so the operator learns what happened
* rather than discovering it on the calendar.
*/
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }

View File

@@ -0,0 +1,93 @@
// ── Event series — the arc ─────────────────────────────────────────────────
//
// EVENTS.md §D and §I. "Royal Spy Mission → Risky Partner → Message From the
// Void" is continuity that exists nowhere in the tooling this feature replaces
// (§ "What the real calendar shows, and what it is missing": *no series or
// recurrence field*). One small table buys it, and this is the policy half.
//
// **Why the writes are `admin, editor` and not `admin`.** A series is authoring,
// and it is the same act as writing the definition that goes in it — §N2's
// narrow gate is about *committing the deployment to a run* (publish, start),
// which naming an arc does not do. An editor who can write the events but not
// the arc they belong to would have to ask an admin to type a title.
//
// **A slug is derived once and then frozen**, exactly as a definition's is: the
// public arc page lives at `/events/series/:slug` (Phase 14), and a slug that
// moved would break every link to it. Renaming the series is free.
const db = require('./eventSeries.db')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const MAX_NAME = 160
const MAX_DESCRIPTION = 2000
const trimOrNull = (v, max) => {
if (v === undefined || v === null) return null
const s = String(v).trim()
return s === '' ? null : s.slice(0, max)
}
const list = () => db.list()
const getById = (id) => db.getById(id)
async function validate(input, { existing = null } = {}) {
const errors = []
const body = input && typeof input === 'object' ? input : {}
const name = trimOrNull(body.name, MAX_NAME)
if (!name) errors.push('name is required')
const description = trimOrNull(body.description, MAX_DESCRIPTION)
const orderingRaw = body.ordering === undefined ? (existing?.ordering ?? 0) : body.ordering
const ordering = Number(orderingRaw)
if (!Number.isInteger(ordering) || ordering < 0 || ordering > 9999) {
errors.push('ordering must be an integer 0..9999')
}
if (errors.length) return { ok: false, errors }
return { ok: true, series: { name, description, ordering } }
}
async function create(input, userId) {
const checked = await validate(input)
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
// The taken set is read here rather than inside `uniqueSlug` because that
// helper is pure — the same shape the team and definition paths use.
const taken = (await db.list()).map((s) => s.slug)
const slug = uniqueSlug(checked.series.name, taken, { fallback: 'series' })
const id = await db.insert({ ...checked.series, slug, created_by: userId || null })
return { ok: true, status: 201, series: await db.getById(id) }
}
async function update(id, input, userId) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
const checked = await validate(input, { existing })
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
// The slug is the existing one, deliberately: renaming a series must not move
// the address its arc page lives at.
await db.update(id, { ...checked.series, slug: existing.slug })
return { ok: true, status: 200, series: await db.getById(id) }
}
/**
* Delete a series, detaching whatever belonged to it.
*
* The count comes back so the caller can say *"3 events were detached"* rather
* than leaving an operator to notice on the calendar. `series_id` is
* `ON DELETE SET NULL`, so nothing is destroyed and re-attaching is a dropdown.
*/
async function remove(id) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such series'] }
await db.remove(id)
return { ok: true, status: 200, detached: Number(existing.definition_count || 0) }
}
module.exports = { list, getById, validate, create, update, remove, slugify, MAX_NAME }

Some files were not shown because too many files have changed in this diff Show More