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
This commit is contained in:
2026-09-03 05:50:58 -05:00
parent 4ac917c3a3
commit 4077c4e79e
31 changed files with 3890 additions and 24 deletions

View File

@@ -2086,10 +2086,11 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
-- ── The Event System (EVENTS.md §D — Phase 1) ──────────────────────────────
-- Six of the nine core tables land here: 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 (P6, P8, P10) rather than as empty tables nothing reads.
-- 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 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
@@ -2410,3 +2411,94 @@ CREATE TABLE IF NOT EXISTS event_run_phase_gates (
-- 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;
-- §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;