feat(events): the Event System — core (Phase 16b cutover, 2 of 6) #199

Merged
whitlocktech merged 35 commits from edge into main 2026-09-10 00:43:49 +00:00
Member

The Event System cutover, step 2 of 6 (EVENTS_PLAN.md Phase 16b). edgemain, a clean fast-forwardmain is 0 ahead. 17 commits, 109 files.

Merge after link#40 + servuo-plugins#26, and before Module-uo#…. The order matters in one direction only: the module's ci/core-ref.json has to name a main sha of this repo carrying MODULE_API_VERSION 1.10.0, and that sha does not exist until this merges.

What this is

A game-agnostic engine for scheduled, bounded, audited changes to a live game world. Core owns the schedule, the run, the permission gate, the ledger and the audit trail; it knows nothing about Ultima Online. Everything UO-specific arrives through the module contract, which is the point of the whole shape.

Phase
1 Schema, CRUD and the core action registry.
2 The runner — claim, CAS, lease and reclaim, on the single-instance decision (§N4) with every claim path still built as specified.
3 The admin surface and six live controls.
4 Schedule, recurrence, the 14-day horizon and the calendar.
5 Conditions, phase advancement and the diagnosis panel.
6 Enablement, per-run caps and mayInvoke.
7 The module contractMODULE_API_VERSION 1.10.0.
8 The resource ledger, leases and cleanup.
10 The integrations: seven event.* triggers, the participation ledger, core.results.publish, core.announce.post.
11b / 12b The reconcile path for a lease core itself owns; targeted leases, value sets and searchable option sources.
13 The authoring UI proper — the two raw-JSON boxes Phase 3 shipped as placeholders become forms.
14a The public calendar, event pages, arcs and participation history; the events capability string; eventUrl on the six public triggers.
16a The three defects the acceptance walk found in this repo.

Nine new routes (/admin/events/*, GET /player/events/history, three /public/events), all annotated and the OpenAPI spec regenerated. No new environment variables — every operator-facing knob is admin-managed, per this repo's own convention.

What an operator sees on upgrade

Nothing to do. The schema is ensured on boot as always. The one migration worth naming lives in module-uo's fragment rather than here — the stored uo_link_config.protocol row is carried 5 → 7 one-shot against its own marker, so an existing deployment does not come up 409-ing against a protocol-7 sidecar, and an operator who has deliberately pinned an older sidecar stays pinned.

Events are off until someone enables one. There is no seeded event.

Verification

edge has had pr-checks on every PR into it, and Phase 16a walked the whole system on a real rig — ServUO 57.4 → cargo --release sidecar on protocol 7 → this core with module-uo installed from a release-shaped bundle → the Android app on an emulator.

server suite      2060 pass, 0 fail
eventRunnerSql      68 pass against a real MariaDB

All three of Phase 16's deliberate failures pass: a mid-teardown process kill resumed with every step still at attempts = 1; a sidecar killed mid-phase left the run degraded with its world writes parked; a cap breach answered refused with code: "cap" and the dry run had already refused it in the author's own words.

16b re-verifies this from the real released artefacts — installer → composed bundle → fresh ServUO → the module installed through this core's own https module installer → boot → one event with a real teardown.

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

The Event System cutover, step 2 of 6 (`EVENTS_PLAN.md` Phase 16b). **`edge` → `main`, a clean fast-forward** — `main` is 0 ahead. 17 commits, 109 files. **Merge after `link#40` + `servuo-plugins#26`, and before `Module-uo#…`.** The order matters in one direction only: the module's `ci/core-ref.json` has to name a `main` sha of *this* repo carrying `MODULE_API_VERSION` 1.10.0, and that sha does not exist until this merges. ## What this is A game-agnostic engine for **scheduled, bounded, audited changes to a live game world**. Core owns the schedule, the run, the permission gate, the ledger and the audit trail; it knows nothing about Ultima Online. Everything UO-specific arrives through the module contract, which is the point of the whole shape. | Phase | | |---|---| | **1** | Schema, CRUD and the core action registry. | | **2** | The runner — claim, CAS, lease and reclaim, on the single-instance decision (§N4) with every claim path still built as specified. | | **3** | The admin surface and six live controls. | | **4** | Schedule, recurrence, the 14-day horizon and the calendar. | | **5** | Conditions, phase advancement and the diagnosis panel. | | **6** | Enablement, per-run caps and `mayInvoke`. | | **7** | **The module contract** — `MODULE_API_VERSION` 1.10.0. | | **8** | The resource ledger, leases and cleanup. | | **10** | The integrations: seven `event.*` triggers, the participation ledger, `core.results.publish`, `core.announce.post`. | | **11b / 12b** | The reconcile path for a lease core itself owns; targeted leases, value sets and searchable option sources. | | **13** | The authoring UI proper — the two raw-JSON boxes Phase 3 shipped as placeholders become forms. | | **14a** | The public calendar, event pages, arcs and participation history; the `events` capability string; `eventUrl` on the six public triggers. | | **16a** | The three defects the acceptance walk found in this repo. | Nine new routes (`/admin/events/*`, `GET /player/events/history`, three `/public/events`), all annotated and the OpenAPI spec regenerated. **No new environment variables** — every operator-facing knob is admin-managed, per this repo's own convention. ## What an operator sees on upgrade Nothing to do. The schema is ensured on boot as always. The one migration worth naming lives in `module-uo`'s fragment rather than here — the stored `uo_link_config.protocol` row is carried **5 → 7** one-shot against its own marker, so an existing deployment does not come up 409-ing against a protocol-7 sidecar, and an operator who has deliberately pinned an older sidecar stays pinned. Events are **off** until someone enables one. There is no seeded event. ## Verification `edge` has had `pr-checks` on every PR into it, and Phase 16a walked the whole system on a real rig — ServUO 57.4 → `cargo --release` sidecar on protocol 7 → this core with `module-uo` installed from a release-shaped bundle → the Android app on an emulator. ``` server suite 2060 pass, 0 fail eventRunnerSql 68 pass against a real MariaDB ``` All three of Phase 16's deliberate failures pass: a mid-teardown process kill resumed with every step still at `attempts = 1`; a sidecar killed mid-phase left the run `degraded` with its world writes parked; a cap breach answered `refused` with `code: "cap"` and the dry run had already refused it in the author's own words. **16b re-verifies this from the real released artefacts** — installer → composed bundle → fresh ServUO → the module installed through this core's own https module installer → boot → one event with a real teardown. - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 33 commits 2026-09-09 15:37:31 +00:00
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
8e03497eb3
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>
Reviewed-on: #183
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
2e964cfeee
`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>
Reviewed-on: #184
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
7b570c8ea1
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
Reviewed-on: #185
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
6e73660b52
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>
Reviewed-on: #186
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
9bc0bf5a3d
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
Reviewed-on: #187
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
4077c4e79e
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
Reviewed-on: #188
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
fd9fb50351
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
Reviewed-on: #189
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
fdc118166c
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>
Reviewed-on: #190
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
82a50e5e04
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>
Reviewed-on: #191
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
7d3d6d5abd
`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)
Reviewed-on: #192
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
809426ad73
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>
Reviewed-on: #193
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
37f4623068
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
Reviewed-on: #194
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
8453762e3b
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
Reviewed-on: #195
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
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
eb167558e3
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
Reviewed-on: #196
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
e46842a28c
`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
Reviewed-on: #197
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
6dd4e5e3eb
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
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
b92b85c3a9
Reviewed-on: #198
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-09-09 19:54:54 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-09-09 20:08:01 +00:00
whitlocktech approved these changes 2026-09-09 20:08:45 +00:00
whitlocktech added 2 commits 2026-09-10 00:28:09 +00:00
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
7d7840eb6b
`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
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
baa4f7d5ba
Reviewed-on: #200
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-09-10 00:28:46 +00:00
whitlocktech merged commit 655fbf3f69 into main 2026-09-10 00:43:49 +00:00
Sign in to join this conversation.
No description provided.