feat(events): enablement, per-run caps and mayInvoke (Phase 6)
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user