|
|
|
|
@@ -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` |
|
|
|
|
|
|