docs(events): Phase 6 as built — enablement, caps and mayInvoke
EVENTS.md §D — the two tables move from "arrive with a later phase" to built, with the two facts that are not obvious from the column list: a missing settings row is the risk-class default rather than "disabled", and a NULL cap is uncapped and still a row, so a MISSING row keeps its one meaning. event_versions gains verified_at/verified_by, and the note on why two mutable columns do not break an immutable table. §E — a new subsection, "Enablement and caps, in front of every dispatch": where the check sits and why, the retry that does not pay twice (and the failure that gets no refund), the partial spend that is unwound, the budget seeded from every phase at creation, the tightest cap, and the one place a demoted user's access deliberately is not re-checked. §K — the amendment. "Nothing above notify" becomes "nothing that changes the world", with the reason: read literally it shipped core.wait disabled. The same line is the role floor. Plus mayInvoke as built — why user and run may be null, why the cap check can write, why the role floor is in the model rather than in middleware, and the example-pricing stand-in until registerEventBudgets lands. §I, §L, § API surface and § Observability — the switchboard and the dry run marked built; the two refusal rows and the held-occurrence row; the three new log kinds and the run's fourth derived field; why the whole-plan cost check is the one finding no other path makes. EVENTS_PLAN.md — Phase 6 complete in the shape Phases 0–5 use: the four org-lead decisions, the two derived calls flagged for review, the three things the build settled, the one thing this phase deliberately cannot demonstrate with a module (registerEventActions is Phase 7's seam), the two defects the live walk found, the verified numbers and the walk itself. Checked by hand — docs has no CI: every anchor resolves, and every route, column, env var and file path named here exists on website#XXX. Diff is 151/23 and 118/2 with no CRLF artefact, verified against the real content diff rather than --numstat alone. 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:
@@ -409,11 +409,11 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
|
||||
| --- | --- | --- |
|
||||
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. |
|
||||
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
|
||||
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. |
|
||||
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). |
|
||||
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
|
||||
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
|
||||
| `event_action_settings` | `action_id`, `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per registered action, all disabled above `notify` until an admin says otherwise. Not a grant table — nobody is named, because the role check already answered who. |
|
||||
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
|
||||
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
|
||||
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
|
||||
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
|
||||
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
|
||||
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
|
||||
@@ -456,7 +456,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
|
||||
| `event_runs.status` | `scheduled` · `starting` · `running` · `paused` · `ending` · `completed` · `cancelled` · `failed` · `missed` | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal for a schedule that passed its grace window while the process was down — **never a late silent start**. |
|
||||
| `event_runs.health` | `ok` · `degraded` · `stalled` | Separate from status, because a run can be genuinely *running and degraded* — announcements landing, world writes parked — and one column cannot say both. This is `installed_modules`' split. |
|
||||
| `event_runs.cleanup_status` | `not_required` · `pending` · `complete` · `incomplete` | Also separate: a run **reaches `completed` with `cleanup_status = 'incomplete'`** rather than being held open, and stays on the admin screen until a human resolves it. |
|
||||
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is the cap breach, and it is deliberately not `failed` — nothing is wrong with the system. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
|
||||
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is a cap breach **or a disabled action**, and it is deliberately not `failed` — nothing is wrong with the system, the deployment simply does not permit what the author asked for. It takes the same **disposition** a failure takes (`on_failure`, and health `degraded`), and says a different thing: a distinct status and a distinct log kind, so an operator reading a stopped run at two in the morning can tell "raise the cap" from "the shard did not answer" at a glance. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
|
||||
|
||||
### The scheduler
|
||||
|
||||
@@ -616,6 +616,49 @@ publish, because a version runs are pinned to must not wait on a trigger that ca
|
||||
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
|
||||
against, and deleting it would silently discard what the author wrote.
|
||||
|
||||
### Enablement and caps, in front of every dispatch
|
||||
|
||||
Phase 6. The runner gained one thing: before it asks a module to do anything, it asks
|
||||
`mayInvoke()` whether it may — [§K](#k--security-model)'s four layers behind one function. Three
|
||||
properties of *where* that check sits are load-bearing.
|
||||
|
||||
**It is after the claim and before the dispatch.** After, because the cap is held by a conditional
|
||||
`UPDATE` and two ticks that both priced a step before either claimed it would both spend. Before,
|
||||
because a refusal means the action does not happen at all: nothing is sent, nothing is created, and
|
||||
the step never reaches the module.
|
||||
|
||||
**A retry does not pay twice.** The spend happens on the first attempt only. A retry re-dispatches
|
||||
the same idempotent operation against the same key, and charging a cap for a flaky socket would
|
||||
exhaust a deployment's allowance through unreliability rather than through effect. The corollary is
|
||||
deliberate: a step that spent and then failed for good **keeps** its spend, because the attempt may
|
||||
have half-run and a refund would be core asserting that it did not.
|
||||
|
||||
**A step costing two dimensions can be refused on the second**, and the first is given back. The
|
||||
spends are separate statements — the atomicity that matters is per dimension, and a transaction
|
||||
spanning three of them would serialise three unrelated counters to buy nothing — so the partial spend
|
||||
is real and has to be unwound. A step that did not run must not have spent anything.
|
||||
|
||||
**The run's budget is seeded from every phase's steps at creation**, not phase by phase as the run
|
||||
enters them. The version is pinned and immutable, so all of its steps are knowable at once, and a
|
||||
budget that grew as phases were entered would let a phase-1 step spend what a phase-3 step was going
|
||||
to need — the opposite of a per-run bound. **The caps are copied**, so an admin moving a switch
|
||||
tomorrow does not change what a run already in flight is allowed, and the console's meter afterwards
|
||||
answers *"what was this run allowed"* rather than *"what is allowed now"*.
|
||||
|
||||
**Two actions spending one dimension resolve to the tightest cap** (org lead, 2026-09-03), pinned at
|
||||
creation with the action it came from. `event_action_settings.caps` is per action while
|
||||
`event_run_budget` is one row per dimension, so the two have to agree on one number, and the number a
|
||||
safety limit should settle on is the smaller. An action that declines to cap a dimension never raises
|
||||
a ceiling another one set — `null` is uncapped and does not win a minimum — because otherwise adding
|
||||
a second verb to an event would silently remove the bound on the first.
|
||||
|
||||
**The runner never re-checks the role of whoever started the run.** `mayInvoke` takes a null user on
|
||||
this path and skips the role layer entirely. The role was checked when a human published the version
|
||||
and again when a human or the scheduler started the run; re-checking at three in the morning would
|
||||
mean an unrelated personnel change silently strands every event an admin started.
|
||||
[§K](#k--security-model)'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.
|
||||
|
||||
### Concurrency
|
||||
|
||||
| Contention | Protection | Not an in-process mutex, because |
|
||||
@@ -623,7 +666,7 @@ against, and deleting it would silently discard what the author wrote.
|
||||
| Two instances materialising one occurrence | `UNIQUE (definition_id, scope, scheduled_for)` + `INSERT IGNORE` | the database is the only thing both instances share. |
|
||||
| Two instances starting one run | CAS `scheduled → starting`; the instance told `affectedRows = 1` owns it | verbatim the outbox claim chosen over `SKIP LOCKED`. |
|
||||
| Two instances running one step | CAS `pending → running` plus a lease | the lease covers the instance that died mid-step. |
|
||||
| Two steps spending one cap | Conditional increment with the cap in the `WHERE` | a read-then-write would let two steps each see 28/30 and both spend. |
|
||||
| Two steps spending one cap | Conditional increment with the cap in the `WHERE` | a read-then-write would let two steps each see 28/30 and both spend. **The guard must stay in the `WHERE`**: MariaDB evaluates a `SET` list left to right with the values already assigned, which is how Phase 5's gate closed a firing early, so nothing in this statement may read `consumed` after writing it. Proved against a real server, not a stub. |
|
||||
| An orphaned claim | Reclaim on lease expiry, **without resetting `attempts`** | Engagement Phase 14's exact defect: a reclaim that reset state made `MAX_ATTEMPTS` unreachable and the row cycled forever, never terminal and therefore never retention-eligible. |
|
||||
| Two events overlapping | `concurrency_key` as a **template rendered from the run's params** — e.g. `invasion:{region}` | a flat definition-id key would wrongly stop the same definition running on two Rust servers, or in two regions, at once. |
|
||||
|
||||
@@ -953,8 +996,8 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th
|
||||
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. |
|
||||
| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. |
|
||||
| **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. |
|
||||
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. |
|
||||
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. |
|
||||
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. **Built in Phase 6**, `admin` only. Each row says whether it is showing a stored decision or a risk-class default, because "an admin turned this on" and "this has always been on" are different facts and only one is a decision somebody made. A cap may only name a dimension the action actually spends — a cap on one it never names would be a number an operator believes is protecting them while it bounds nothing. |
|
||||
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. **Built through Phase 6**, caps included: a meter per dimension rather than a sentence, because unlike a gate a cap is two numbers and a name and needs no grammar rendered to be read. Shown on finished runs too — *"how much did last night's invasion actually spawn"* is the same question asked the morning after. |
|
||||
|
||||
**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.**
|
||||
A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can
|
||||
@@ -995,7 +1038,7 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
|
||||
|
||||
### Four affordances worth building in from the start
|
||||
|
||||
- **Dry run.** Materialise the steps, dispatch each with `verify: true`, report what *would* happen
|
||||
- **Dry run.** *(Built, Phase 6.)* Materialise the steps, dispatch each with `verify: true`, report what *would* happen
|
||||
and what it *would* cost against the caps. The project's own idiom, and what turns an unattended
|
||||
world write from alarming into routine.
|
||||
- **Rehearsal.** Run for real with announcements ceilinged to `staff`. Free from the ceilings lattice
|
||||
@@ -1097,13 +1140,32 @@ live game world on a schedule.
|
||||
|
||||
1. **Declaration.** A module says a verb exists, what it costs, and what risk class it is. This is
|
||||
code the operator installed; it is not a permission.
|
||||
2. **Enablement.** An admin enables an action for this deployment and sets its per-run caps. **Nothing
|
||||
above `notify` is enabled by default** — the posture a seeded engagement rule already takes,
|
||||
arriving `enabled = 0` because installing a module must never start doing things.
|
||||
2. **Enablement.** An admin enables an action for this deployment and sets its per-run caps.
|
||||
**Nothing that CHANGES THE WORLD is enabled by default** — `change` and `irreversible` arrive off,
|
||||
`notify` and `inspect` arrive on — the posture a seeded engagement rule already takes, arriving
|
||||
`enabled = 0` because installing a module must never start doing things.
|
||||
|
||||
> **This line said "nothing above `notify`" until Phase 6 built it** (org lead, 2026-09-03). Read
|
||||
> literally it shipped `core.wait` — which is `risk: 'inspect'` — disabled, so every published
|
||||
> event that waits would break on a fresh deployment until an admin found the switch. An `inspect`
|
||||
> action reads state and writes nothing, so a deployment gains no risk by having it on. **The same
|
||||
> line is the role floor below**, and it moved with it.
|
||||
|
||||
A missing row is not "disabled": it is *the default for the action's risk class*, computed rather
|
||||
than stored. Nothing is seeded at boot, and that is structural rather than tidy — registration runs
|
||||
under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API §2.2), so a boot-time
|
||||
seed of one row per registered action would be exactly the database write those two forbid.
|
||||
3. **Invocation.** `requireRole`, exactly as every other admin capability is gated, plus the cap check
|
||||
and — for anything reaching the game — the shard's own `AdminWriteEnabled` and
|
||||
`AdminAccessFloor`. Admin routes are already re-validated against the database on every request, so
|
||||
a demoted user loses access immediately.
|
||||
a demoted user loses access immediately. **A run already in flight is the one place that
|
||||
re-validation deliberately does not reach** — see [§E](#e--runtime-model).
|
||||
|
||||
The shard's own switches are named as a layer and **not duplicated in core**. They live on the
|
||||
shard host, outside the website's reach entirely, and 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. A module
|
||||
honours them when it translates an action into a sidecar command (P9). Naming the layer is what
|
||||
stops it being re-implemented.
|
||||
|
||||
### Which role, per surface
|
||||
|
||||
@@ -1117,7 +1179,7 @@ decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping
|
||||
| Authoring and editing a draft | `admin`, `editor` |
|
||||
| Publishing a version, **starting a run** | `admin` only |
|
||||
| **Live control of a run in flight** — cancel, abort | `admin`, `moderator` |
|
||||
| Any step whose action is above `notify`, and the action switchboard | `admin` only |
|
||||
| Any step whose action **changes the world** (`change`, `irreversible`), and the action switchboard | `admin` only |
|
||||
|
||||
> **Why start and stop are gated differently.** Starting commits the deployment to everything the
|
||||
> definition contains, unattended, up to every cap it declares — it wants the narrowest gate there is.
|
||||
@@ -1131,6 +1193,41 @@ decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping
|
||||
> 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.
|
||||
|
||||
**As built** (Phase 6, `events/authorize.js`). It answers an envelope rather than a boolean — every
|
||||
refusal carries a `code` a caller branches on and a `reason` a human reads, written in one place so
|
||||
the dry run, the editor, the run console and the log all say the same sentence about the same fact.
|
||||
Four callers: authoring a step, publishing, the dry run, and the runner's own unattended dispatch.
|
||||
Three things about it are not obvious from the signature:
|
||||
|
||||
- **`user` may be null**, and that is the unattended runner rather than the system escalating. The
|
||||
role layer is skipped, for the reason given in [§E](#e--runtime-model).
|
||||
- **`run` may be null**, and the cap question changes when it is. With a run, the question is "does
|
||||
this fit in what is left"; without one — the editor, the dry run — it is "could this cost *ever*
|
||||
fit", which is an authoring error answerable before anything is scheduled.
|
||||
- **The cap check can WRITE.** The cap is held by a conditional `UPDATE` whose `WHERE` carries the
|
||||
guard, so checking and then spending would be two statements with a race between them — the exact
|
||||
race the conditional increment removes. `spend: true` is therefore a parameter rather than a second
|
||||
function: one decision procedure, one set of layers, and the authoritative check is the one that
|
||||
also commits.
|
||||
|
||||
**The role floor is checked in the model, not in route middleware**, and it is the one gate that
|
||||
could not live there: the route is `admin, editor` and stays that way, while *which of the two you
|
||||
have to be* depends on what is in the body. Checked at save as well as at publish — the difference
|
||||
between telling an editor now and telling them after they have written twelve steps.
|
||||
|
||||
**A module's `cost()` is called by core and nowhere else.** It is declared as a function of params
|
||||
([§F](#f--the-module-contract)) and called with the params the step actually carries, so the number
|
||||
core enforces is the number the module said. A `cost()` that throws, or answers anything but a flat
|
||||
object of non-negative finite numbers, makes the action **unpriceable and therefore refused** — never
|
||||
free. An action whose own accounting is broken is not an action whose consumption is zero.
|
||||
|
||||
> **Until [§F](#f--the-module-contract)'s `registerEventBudgets` lands in Phase 7, core discovers an
|
||||
> action's dimensions by pricing its declared `example` values.** That is a use every param's required
|
||||
> `example` already has, it costs nothing, and it is honest about its limit: a `cost()` returning
|
||||
> different dimension *keys* for different params under-reports, which costs an operator a cap box on
|
||||
> the switchboard and costs a run nothing at all — a run's budget is seeded from the params its steps
|
||||
> were actually authored with, never from examples.
|
||||
|
||||
### Can arbitrary module commands be injected through the browser?
|
||||
|
||||
**No, and the design must keep it that way.** The browser posts an action *id* and a params object;
|
||||
@@ -1144,10 +1241,10 @@ controller stamps it from the session.
|
||||
| Bounded by | How |
|
||||
| --- | --- |
|
||||
| Per-run caps | The strongest control, and the reason to keep quotas after dropping delegation. A stolen admin session cannot exceed 30 creatures per run because the cap is enforced in a conditional `UPDATE`, not in a role check — and a role check is exactly what a stolen session has already passed. |
|
||||
| Enablement defaulting off | Nothing above `notify` is invocable until an admin turned it on deliberately. |
|
||||
| Enablement defaulting off | Nothing that changes the world is invocable until an admin turned it on deliberately. |
|
||||
| The shard's own switches | `AdminWriteEnabled` is off by default in `Bridge.cfg`, lives on the shard host outside the website's reach, and `AdminAccessFloor` refuses any target at or above CoOwner. |
|
||||
| The ledger | Every world write is recorded with its run, step and actor, and every ledgered resource has a declared revert. Damage is attributable and, by construction, undoable. |
|
||||
| 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. |
|
||||
| Dry run before anything unattended | **Built and enforced in Phase 6, for SCHEDULED starts only** (org lead, 2026-09-03). A version records who verified it and when; the scheduler refuses to materialise an occurrence of one that never passed, and says so once per definition per tick rather than silently. A human pressing *Start now* is not gated, because that human **is** the review the gate exists to require. A failing dry run records nothing, so it cannot unlock anything. |
|
||||
|
||||
> **Stated plainly.** The module boundary is **not** a security boundary — MODULE_SYSTEM §2.2 says so
|
||||
> explicitly, and a module runs in the same Node process with full access. None of the above defends
|
||||
@@ -1171,7 +1268,9 @@ controller stamps it from the session.
|
||||
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
||||
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
||||
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
||||
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. |
|
||||
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. It then takes the step's own `on_failure`, so a `change` step's default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes (org lead, 2026-09-03). Health goes `degraded`. |
|
||||
| **A step names an action this deployment has disabled** | The same `refused`, in the same words as a cap breach, with the reason naming the action by its label. It is not an authoring mistake — the plan was legal when it was written — so the switchboard is the fix, and a disabled action reached by a run in flight is exactly the case the dry run catches beforehand. Phase 6. |
|
||||
| **A scheduled occurrence of a version nobody dry-ran** | Not materialised at all, and logged once per definition per tick rather than once per occurrence — there is no run row to hang a diagnostic on, which is the point. The definition itself carries the fact, so the editor says so where its author is already looking. §K, Phase 6. |
|
||||
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
|
||||
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
||||
| **A human skips a step** | `skipped`, which is what that status is reserved for. Legal for a `pending` step and for a parked cue; refused for a step with a live lease, and unnecessary for a `failed` one — `nextOpenStep` already passes over it, so resuming the run carries the phase past it. |
|
||||
@@ -1224,7 +1323,7 @@ no URL moved.
|
||||
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
|
||||
| `DELETE /admin/events/:id` | admin | archive — never a hard delete while runs reference it |
|
||||
| `GET /admin/events/:id/versions` | staff | version history |
|
||||
| `POST /admin/events/:id/verify` | admin, editor | **dry run** — dispatch with `verify: true`, report cost against the caps |
|
||||
| `POST /admin/events/:id/verify` | admin, editor | **dry run** (Phase 6) — dispatch every step with `verify: true`, report the findings and the whole-plan cost against the caps. Verifies the PUBLISHED version of a `ready` definition and the working spec of a draft; a pass against a version is recorded and is what unlocks scheduled starts (§K) |
|
||||
| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` |
|
||||
| `GET /admin/events/runs` | staff | run history across definitions |
|
||||
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
|
||||
@@ -1240,7 +1339,7 @@ no URL moved.
|
||||
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
|
||||
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
|
||||
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
|
||||
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps |
|
||||
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time |
|
||||
| `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series |
|
||||
| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results |
|
||||
| `GET /public/events/series/:slug` | — | the arc |
|
||||
@@ -1281,9 +1380,37 @@ runner ticks every fifteen seconds, so a console rendered thirty seconds ago des
|
||||
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
||||
interrupt. A refusal is a `409` naming the status the run is actually in.
|
||||
|
||||
Still absent, and still for reasons rather than for scope: `cleanup` and cancel's `cleanup` flag (no
|
||||
resource ledger until Phase 8), `verify` and `GET/PUT /admin/events/actions` (no caps to price
|
||||
against and no switchboard to serve until Phase 6). `advance` left this list in Phase 5.
|
||||
**Phase 6 added the last three, and the list of absent routes is now one item long.** `GET/PUT
|
||||
/admin/events/actions` is the switchboard, `admin` in **both** directions — §K puts it in the same
|
||||
row as the world-changing actions it governs, and knowing exactly what a deployment permits is not a
|
||||
staff-wide read. The PUT takes **one action per request** rather than the whole board: the board is
|
||||
rendered from the registry, so a whole-board write would have to decide what an action *missing* from
|
||||
the body means, and on a screen listing what registered on this boot that is "a module booted between
|
||||
the read and the write" — answering it by writing a default over an admin's stored choice is quiet
|
||||
data loss.
|
||||
|
||||
`POST /admin/events/:id/verify` is the dry run, at `admin, editor`. **What it verifies follows the
|
||||
definition's state**, and the answer says which: a `ready` definition is checked against its
|
||||
PUBLISHED version, because that is the only thing that ever actually runs and it is that pass §K's
|
||||
gate is about; a draft is checked against the working spec the author is still holding. Only a pass
|
||||
against a version is recorded — a version is immutable, so a pass on one stays true, while a pass on
|
||||
a draft would be a claim about a spec that changes under the author's hands. **Findings come back
|
||||
with a 200**: the request succeeded, the plan has problems, and answering 4xx would make "this event
|
||||
asks for 45 and you allow 30" indistinguishable from "you sent a bad event id".
|
||||
|
||||
Still absent, and still for a reason rather than for scope: `cleanup` and cancel's `cleanup` flag,
|
||||
because there is no resource ledger until Phase 8. `advance` left this list in Phase 5.
|
||||
|
||||
> **The whole-plan cost check is the one thing no other path makes.** Every per-step check the dry run
|
||||
> performs is also made at save or at dispatch; the TOTAL is not. Three steps each spawning 15 under a
|
||||
> cap of 30 pass every individual check and breach the cap on the third — at two in the morning,
|
||||
> unattended, with the world half-changed. Adding the costs up across a whole version is what only a
|
||||
> look at the plan as a whole can do, and it is why a dry run is worth more than the sum of its step
|
||||
> checks.
|
||||
|
||||
A run's detail gained a fourth derived field: **`budget`**, one row per dimension with what the run
|
||||
has spent, the cap it was given, and *which action's switch set that cap* — so a number on the meter
|
||||
traces back to something an operator can change rather than a figure they have to go looking for.
|
||||
|
||||
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
|
||||
one entry per phase that authored an advance condition, already rendered. Every gate the run has
|
||||
@@ -1324,7 +1451,8 @@ Phase 3 — "The Boss" has not started.
|
||||
| --- | --- |
|
||||
| Definition edits, publishes and manual transitions, with actor | `event_run_log` + `activity_log` |
|
||||
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
||||
| Cap draws and refusals, per dimension | `event_run_budget` + log |
|
||||
| Cap draws and refusals, per dimension | `event_run_budget` + `event_run_log`, kinds `run.budget` (the caps a run was seeded with, and which switch set each) and `step.refused` (the dimension, what was asked, what was already spent). **`step.refused` is its own kind rather than a `step.status`**, so a run that stopped because the deployment does not permit something does not read as a run that broke. Phase 6 |
|
||||
| A version passing its dry run, on the runs already pinned to it | `event_run_log`, kind `version.verified` — the line that explains why a held occurrence is suddenly going to start |
|
||||
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
||||
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
|
||||
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
|
||||
@@ -1359,7 +1487,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
|
||||
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
||||
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
||||
| **P5** ✓ | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
||||
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
||||
| **P6** ✓ | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
||||
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
||||
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||||
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
||||
|
||||
@@ -584,12 +584,123 @@ it saves, and it will not publish.*
|
||||
|
||||
### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`)
|
||||
|
||||
`event_action_settings` (one row per registered action, everything above `notify` disabled by default)
|
||||
> **Complete.** `edge` in `website` and `docs`. Two new tables — `event_action_settings` (the
|
||||
> switchboard) and `event_run_budget` (what a run has spent and the most it may) — plus two columns on
|
||||
> `event_versions`, `verified_at` and `verified_by`. `GET/PUT /admin/events/actions` and
|
||||
> `POST /admin/events/:id/verify` are the last routes this feature was missing bar `cleanup`, and
|
||||
> `events/authorize.js` is the one function §K asked for.
|
||||
>
|
||||
> **The four decisions the org lead settled (2026-09-03), all as recommended:**
|
||||
>
|
||||
> - **The default-off line falls between `inspect` and `change`, not between `notify` and `inspect`.**
|
||||
> §K's sentence read literally would have shipped `core.wait` — which is `risk: 'inspect'` —
|
||||
> disabled, so every published event that waits breaks on a fresh deployment until an admin finds
|
||||
> the switch. An `inspect` action reads state and writes nothing, so a deployment gains no risk by
|
||||
> having it on. §K is amended, and **the same line is the role floor**: `change` and `irreversible`
|
||||
> are the steps only an admin may author.
|
||||
> - **The tightest cap wins.** `event_action_settings.caps` is per action while `event_run_budget` is
|
||||
> one row per dimension, so two actions spending `uo.creatures` have to agree on one number, and the
|
||||
> number a safety limit should settle on is the smaller. It is pinned into the run at creation with
|
||||
> the action it came from, so the console can say whose switch set it.
|
||||
> - **A refusal follows the step's `on_failure`, and takes health to `degraded`.** A cap breach or a
|
||||
> disabled action is not an outage, so it is `refused` rather than `failed` and gets its own log
|
||||
> kind — but it takes the same **disposition** a failure takes, which means a `change` step's
|
||||
> default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes.
|
||||
> Skipping and carrying on would leave a completed run with a hole in it, which nobody reads a log
|
||||
> for.
|
||||
> - **The verify gate is enforced for SCHEDULED starts only.** A version must have passed its dry run
|
||||
> before the scheduler will materialise an occurrence of it unattended; a human pressing *Start now*
|
||||
> is not gated, because that human **is** the review the gate exists to require.
|
||||
>
|
||||
> **Two things derived rather than decided, and both flagged for review.** A dry run **fails** — it
|
||||
> does not merely warn — when a version names a disabled action or over-spends a cap, which is exactly
|
||||
> what the scheduled-start gate is for; and the unattended path **does not re-check the starter's
|
||||
> role**, because demoting an admin at midnight must not silently strand every event they started
|
||||
> (cancel is the control for a run that should stop).
|
||||
>
|
||||
> **Three things the build settled:**
|
||||
>
|
||||
> - **The check is after the claim and before the dispatch, and it can write.** After, because the cap
|
||||
> is held by a conditional `UPDATE` and two ticks that both priced a step before either claimed it
|
||||
> would both spend. Before, because a refusal means the action does not happen at all. And `spend`
|
||||
> is a *parameter* of `mayInvoke` rather than a second function, because checking and then spending
|
||||
> would be two statements with a race between them — the exact race the conditional increment
|
||||
> removes.
|
||||
> - **A retry does not pay the cap twice, and a failure does not get a refund.** The spend happens on
|
||||
> the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance
|
||||
> through unreliability rather than through effect. The corollary is deliberate — a step that spent
|
||||
> and then failed for good keeps its spend, because the attempt may have half-run and a refund would
|
||||
> be core asserting that it did not. (A *partial* spend across dimensions IS unwound: a step refused
|
||||
> on its second dimension gives the first one back, because a step that did not run must not have
|
||||
> spent anything.)
|
||||
> - **`NULL` cap is uncapped and still a row; a MISSING row is a refusal.** Keeping the two distinct
|
||||
> is what lets the meter count a dimension nothing bounds while a step spending something its own
|
||||
> run's version never priced still fails closed.
|
||||
>
|
||||
> **The one thing this phase deliberately cannot demonstrate with a module.** `registerEventActions`
|
||||
> is not on the module-facing API yet — that seam is **Phase 7's** — and core's own three actions
|
||||
> declare no `cost`. So the cap machinery ships with no live consumer, exactly as the plan intends
|
||||
> ("ships: a system that cannot be made to do an unbounded amount of anything"). It is proved instead
|
||||
> by unit tests, by a real-MariaDB concurrency test, and by a **rig action added to core for the walk
|
||||
> and reverted before commit**.
|
||||
>
|
||||
> **Two defects the live walk found, and neither could have been caught anywhere else:**
|
||||
>
|
||||
> 1. **The run console route dropped the budget.** `runs.detail()` returned it and the client read it,
|
||||
> but `getRun` builds its response field by field and never listed it — so the meter would have
|
||||
> rendered empty for ever, with the model test and the client both correct in isolation. **A
|
||||
> hand-built response shape is a place a new field silently does not arrive**, and only a request
|
||||
> through the whole stack says so.
|
||||
> 2. **The role refusal did not agree with itself.** `"Spawn creatures" change the world` — the
|
||||
> sentence always used the plural verb over a list that is almost always one long, so the case an
|
||||
> editor actually sees read as a broken sentence rather than as a rule.
|
||||
>
|
||||
> **Verified:** `npm test` — **1921 tests, 1847 pass, 73 skipped, 1 fail**, that one still the
|
||||
> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch and
|
||||
> watching it fail unchanged; `edge` before: 1810/1746/63/1). **+111 tests**, of which the +10 skipped
|
||||
> are the new SQL cases skipping without a database. `eventAuthorize.test.js` (27) is `mayInvoke`'s
|
||||
> four layers and the cap arithmetic; `eventVerify.test.js` (12) the dry run, including the
|
||||
> whole-plan total; `eventsRoles.test.js` (31) is **the 403 walk**, driving the real router with every
|
||||
> handler replaced so it measures gates rather than handlers; plus 12 in `eventRunner.test.js`, 19 in
|
||||
> `eventsAdmin.test.js`, and **10 in `eventRunnerSql.test.js` against a real MariaDB** (49/49 with a
|
||||
> database, skipped without) — among them the plan's own criterion, two concurrent spends against one
|
||||
> cap. Client: **362 pass**, 1 new; the client builds. Three routes added, **none moved**.
|
||||
>
|
||||
> **The live walk, on the local review stack.** The gate announced itself before anything was
|
||||
> authored: a leftover Phase 4 definition began logging *"scheduled occurrences held: the published
|
||||
> version has never been verified"* on the first tick after boot.
|
||||
>
|
||||
> - **The board arrived with the right posture.** `core.wait` (`inspect`) enabled, `core.announce` and
|
||||
> `core.cue` enabled, the rig's `change` action disabled, and every row marked `configured: false` —
|
||||
> a deployment that has never opened the screen, behaving correctly with no rows at all.
|
||||
> - **The dimension was discovered by pricing the declared example**, live, and the cap editor
|
||||
> offered exactly it. A cap naming a dimension the action does not spend was refused by name.
|
||||
> - **The dry run caught the whole-plan total.** Three steps of 15 under a cap of 30: each legal
|
||||
> alone, `45 of "core.creatures" across all its steps` refused together, before anything was
|
||||
> scheduled.
|
||||
> - **The dry run found a real defect in a leftover definition** — an announce leg `"site"` that no
|
||||
> module registers — and, being a failure, **recorded nothing**, so the schedule stayed held. Fixing
|
||||
> and re-verifying lifted the hold on the same tick and the occurrence materialised.
|
||||
> - **An editor was refused a world-changing step** and allowed an announcing one; an admin saved the
|
||||
> same body.
|
||||
> - **The cap bit at dispatch.** Two 15s spent 30 of 30 and the third came back `refused: asks for 15
|
||||
> of "core.creatures"; 30 of 30 is already spent this run`, health `degraded`, run `completed`
|
||||
> under `on_failure: skip`. With `pause`, a disabled action stopped the run at step 0 and left the
|
||||
> step after it `pending`.
|
||||
> - **The 403 walk held live** as well as in the harness: `GET` and `PUT /actions` answered 403 to the
|
||||
> editor and the moderator alike, and 200 to the admin.
|
||||
>
|
||||
> **One thing this PR fixes in passing:** the committed OpenAPI spec still carried the pre-Phase-5
|
||||
> catalog description — the annotation had been edited without a regenerate. `npm run swagger` here
|
||||
> picks it up, which is why the spec diff has two deletions in it that are not this phase's.
|
||||
|
||||
`event_action_settings` (one row per action an admin has an opinion about — everything that CHANGES
|
||||
THE WORLD disabled by default; the line moved off `notify` when this phase built it, see above)
|
||||
and `event_run_budget` with the conditional increment:
|
||||
|
||||
```sql
|
||||
UPDATE event_run_budget SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)
|
||||
```
|
||||
|
||||
A breach is `refused` — the step does not run, does not retry, and is surfaced to the author with the
|
||||
@@ -606,6 +717,11 @@ while cancelling and aborting a run in flight are `admin` + `moderator`**. Start
|
||||
deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first
|
||||
named a state an operator could be in.)
|
||||
|
||||
*As built, P3 had already put every route gate in place, so what P6 actually added here is the two
|
||||
new routes' own gates and the one gate that could not live in route middleware at all: the per-STEP
|
||||
role floor. The route is `admin, editor` and stays that way — **which of the two you have to be
|
||||
depends on what is in the body**, so it is checked in the model, at save as well as at publish.*
|
||||
|
||||
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
||||
anything.
|
||||
**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
|
||||
|
||||
Reference in New Issue
Block a user