feat(events): schema, CRUD and the core action registry (Phase 1) #183

Merged
whitlocktech merged 1 commits from feat/events-phase-1 into edge 2026-09-02 04:38:56 +00:00
Member

Event System Phase 1 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#209.

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

Six of the nine core tables: event_series, event_definitions, event_versions, event_runs, event_run_steps, event_run_log — the ones that do not depend on the module contract. event_action_settings, event_run_budget, event_run_resources and event_run_participants arrive with the phases that give them a writer, rather than as empty tables nothing reads.

The two indexes that carry the weight are in from the start:

  • UNIQUE (definition_id, scope, scheduled_for) on event_runsit, not the claim, is what makes "one run per occurrence per scope" true. scope is '' rather than NULL, because multiple NULLs do not collide in MariaDB and a NULL scope would silently permit two runs of one occurrence.
  • UNIQUE (run_id, phase, seq) on event_run_steps — materialisation is INSERT IGNORE against it, so a tick that overran into the next one cannot double-materialise a phase.

The action registry — modules/registries.js + config/coreEventActions.js

registerEventActions staging and commit, with its own id namespace (an action names a verb, a trigger names an event — one id may legitimately be both), the closed risk and reversible sets with no default, 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, exactly as an audience's resolve is.

Core declares core.announce, core.wait and core.cue through the same staging area a module will use, so the seam is exercised on every boot long before a module touches it.

It is reachable only by registerCore(). loader.js builds its own api facade for a module 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 makes the bump — this PR does not turn the integration kit red.

Surface — 13 routes under /api/v1/admin/events

Reads staff-wide. Publish, archive and run creation are admin only from this phase, per EVENTS.md §N2, even though the switchboard they will eventually 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 (pause/resume/advance/cancel, step skip/retry/confirm, cleanup) and verify are absent rather than stubbed — nothing is in flight until Phase 2, and a control that returns 200 and does nothing is worse than one that is not there.

Four things the build settled that the plan had left open

All four are recorded in docs#209 and all four are worth a look:

  1. event_definitions gained a spec column. §D's column list does not name one, because §D describes what a published event is made of. A draft's working copy cannot be an event_versions row: that table is immutable and a run pins one.
  2. The spec validator did not accept its own output — a test caught it. validate() adds actionVersion and dormant, then refused them as unknown keys on the next call, which would have made the second save of any definition, and publish's own re-validation, impossible. Both are now accepted and recomputed rather than trusted.
  3. A param's example is required, optional params included, matching registerEventTriggers.
  4. Two routes the § API-surface table did not name: GET /admin/events/:id and GET /admin/events/series.

One thing worth defending explicitly

Core's three perform() bodies answer { ok: false, retry: false } rather than { ok: true }. A truthy stub on an action that did nothing is a recorded world change that did not occur — the exact mistake §F's failure default exists to prevent. They are unreachable in this phase either way.

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 rather than two that drift.

Verify

  • npm test1641 tests, 1616 pass, 24 skipped, 1 fail. The one failure is engagementManifest.test.js, and it is pre-existing and environmental: engagement-triggers.json is CRLF in a Windows working tree under core.autocrlf=true while the generator writes LF. Content identical, line endings differ; confirmed still failing with this branch's changes stashed, and green on CI's Linux runners. This PR touches neither the triggers nor the generator.
  • 44 new tests across eventActionRegistry.test.js, eventSpec.test.js, eventsAdmin.test.js.
  • npm run check:modules — clean; core names no game noun.
  • npm run routes:manifest+13 routes, zero moved, zero deletions.
  • npm run swagger — regenerated; all 13 routes annotated.

  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 1** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#209**. > **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 Six of the nine core tables: `event_series`, `event_definitions`, `event_versions`, `event_runs`, `event_run_steps`, `event_run_log` — the ones that do not depend on the module contract. `event_action_settings`, `event_run_budget`, `event_run_resources` and `event_run_participants` arrive with the phases that give them a writer, rather than as empty tables nothing reads. The two indexes that carry the weight are in from the start: - `UNIQUE (definition_id, scope, scheduled_for)` on `event_runs` — **it, not the claim, is what makes "one run per occurrence per scope" true.** `scope` is `''` rather than NULL, because multiple NULLs do not collide in MariaDB and a NULL scope would silently permit two runs of one occurrence. - `UNIQUE (run_id, phase, seq)` on `event_run_steps` — materialisation is `INSERT IGNORE` against it, so a tick that overran into the next one cannot double-materialise a phase. ## The action registry — `modules/registries.js` + `config/coreEventActions.js` `registerEventActions` staging and commit, with **its own id namespace** (an action names a verb, a trigger names an event — one id may legitimately be both), the closed `risk` and `reversible` sets with no default, `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, exactly as an audience's `resolve` is. Core declares `core.announce`, `core.wait` and `core.cue` through the same staging area a module will use, so the seam is exercised on every boot long before a module touches it. **It is reachable only by `registerCore()`.** `loader.js` builds its own `api` facade for a module 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 makes the bump — this PR does not turn the integration kit red. ## Surface — 13 routes under `/api/v1/admin/events` Reads staff-wide. **Publish, archive and run creation are `admin` only from this phase**, per `EVENTS.md` §N2, even though the switchboard they will eventually 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 (pause/resume/advance/cancel, step skip/retry/confirm, cleanup) and `verify` are **absent rather than stubbed** — nothing is in flight until Phase 2, and a control that returns `200` and does nothing is worse than one that is not there. ## Four things the build settled that the plan had left open All four are recorded in docs#209 and all four are worth a look: 1. **`event_definitions` gained a `spec` column.** §D's column list does not name one, because §D describes what a *published* event is made of. A draft's working copy cannot be an `event_versions` row: that table is immutable and a run pins one. 2. **The spec validator did not accept its own output** — a test caught it. `validate()` adds `actionVersion` and `dormant`, then refused them as unknown keys on the next call, which would have made the *second save of any definition*, and publish's own re-validation, impossible. Both are now accepted and **recomputed** rather than trusted. 3. **A param's `example` is required**, optional params included, matching `registerEventTriggers`. 4. **Two routes the § API-surface table did not name**: `GET /admin/events/:id` and `GET /admin/events/series`. ## One thing worth defending explicitly Core's three `perform()` bodies answer `{ ok: false, retry: false }` rather than `{ ok: true }`. A truthy stub on an action that did nothing is a **recorded world change that did not occur** — the exact mistake §F's failure default exists to prevent. They are unreachable in this phase either way. `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 rather than two that drift. ## Verify - `npm test` — **1641 tests, 1616 pass, 24 skipped, 1 fail.** The one failure is `engagementManifest.test.js`, and it is **pre-existing and environmental**: `engagement-triggers.json` is CRLF in a Windows working tree under `core.autocrlf=true` while the generator writes LF. Content identical, line endings differ; confirmed still failing with this branch's changes stashed, and green on CI's Linux runners. This PR touches neither the triggers nor the generator. - 44 new tests across `eventActionRegistry.test.js`, `eventSpec.test.js`, `eventsAdmin.test.js`. - `npm run check:modules` — clean; core names no game noun. - `npm run routes:manifest` — **+13 routes, zero moved**, zero deletions. - `npm run swagger` — regenerated; all 13 routes annotated. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-02 04:29:42 +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>
whitlocktech merged commit d88906e43c into edge 2026-09-02 04:38:56 +00:00
whitlocktech deleted branch feat/events-phase-1 2026-09-02 04:38:57 +00:00
Sign in to join this conversation.
No description provided.