docs(events): Phase 7 as built -- the module contract at MODULE_API 1.10.0

Code: RunicGateway/website#189.

MODULE_API.md gains 1.10.0 in three places: the number itself, the entry in
Part 1 with the four call shapes and the six rules that come with them, and
the four names in 2.4's call list with the contract-rather-than-
implementation notes beside them.

EVENTS.md F is marked built, with a new subsection recording what the build
settled -- the open unit vocabulary, the undeclared-dimension refusal and
why it has its own code, why example-pricing survives the arrival of a
budget registry, why restore cannot be read, and why an option source that
refuses answers 200. K's Phase 7 note becomes what shipped. The API surface
table's option-source row is filled in, and the absent-routes list is down
to cleanup.

EVENTS_PLAN.md: Phase 7 complete, in the shape phases 0-6 use -- the four
org-lead decisions, the three things the build settled, the throwaway-module
proof, the React defect only the browser could find, and the route the docs
caught the code building one segment shallower than this file specifies.

Checked by hand (docs has no CI): every anchor resolves, and every route,
identifier and file path named here exists on website#189.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-03 14:16:19 -05:00
parent 9d05a56198
commit 426e9bfbfb
3 changed files with 308 additions and 11 deletions

View File

@@ -709,6 +709,24 @@ command that *did* apply is otherwise indistinguishable from one that did not.
One new registry and two new declarations, at MODULE_API **1.10.0**. Additive; no signature changes;
module-uo's `coreApi: "^1.9.0"` continues to resolve.
> **Built in Phase 7** (`website#189`), and the seam it opens is narrower than the four names suggest.
> `registerEventActions` has existed since Phase 1 and has staged core's three actions on every boot
> since; what it never had was a way in, because `loader.js` builds its own `api` facade and had no
> method that delegated to it. So Phase 7 forwards four names, adds the three declarations beside the
> action registry, and makes the fail-closed budget rule real. The registry a module now reaches is
> one that has been exercised on every boot for six phases rather than one whose first registrant is
> a stranger — which is the argument `registerCore()` has made since the module system's Phase 3, and
> this is the phase where it pays.
>
> **`registerEventOptionSources` is a fourth registration, not a field on the action that names a
> source** (org lead, 2026-09-03). A catalog has more than one consumer — `uo.options.items` is the
> allowlist for granting an item and for taking one back — and two actions declaring it separately
> would be two allowlists that can disagree.
>
> **A lease is declared here and acquired by nothing.** Core owns a lease's duration and its conflict
> check; both live in the resource ledger, which is Phase 8. Declaring it now is what keeps the module
> contract one version a module author reads once rather than two.
```js
api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' },
@@ -780,6 +798,44 @@ api.registerEventLeases([{
}])
```
### What Phase 7 settled about the three declarations
- **A budget's `unit` is required and its vocabulary is open.** Required because a bare number on a
cap box is ambiguous in exactly the case that matters — 30 of what? — and open because core never
interprets it. It is a display word beside a number, and closing the set would make "kilometres" a
MODULE_API bump for a noun core does not read.
- **A `cost()` naming a dimension nobody declared is REFUSED** — at save, at the dry run and at
dispatch, with its own code (`undeclared`) rather than the cap layer's (org lead, 2026-09-03). It is
what makes *"a module cannot spend a budget it did not declare"* a rule instead of a sentence, and
it is checked before any cap arithmetic: a dimension nobody declared has no cap to be under and no
meter to draw on. The separate code is not tidiness — an operator told "the cap is spent" goes and
raises a cap, and nothing changes, because the fix is a module's declaration.
- **WHICH dimensions an action spends is still discovered by pricing its declared examples.** A
registry can say what a dimension is called; it cannot say which action spends it, because `cost` is
a function of params and calling it is the only honest way to ask. So Phase 6's example-pricing
stays as the discovery, and `registerEventBudgets` supplies the label and unit the switchboard
renders. A dimension an action prices but nobody declares is **shown** on that screen rather than
filtered out, because the action is refused and the operator needs to be told which module is
incomplete.
- **A lease declares all three callables, and `restore` is not optional even though `read` could
stand in for it.** They answer different questions: `read` is *"what is it now"*, `restore` is
*"put this back, and tell me if someone else has moved it"* — the drift check, which is the one
thing a module must not be allowed to skip. A lease whose restore writes blindly silently reverts
an operator's manual fix. The numeric types carry a **required** `min`/`max`, because unlike a cap a
bad lease value is in force the moment it is applied.
- **An option source that refuses degrades its field to free text with a warning, and its route
answers `200`.** Both halves are the contract. A `4xx` would make an authoring screen something a
module's outage can take away, for a field whose value the operator very often already knows — a
worse failure than the typo the dropdown prevents. Its values are resolved on their own request
(`GET /admin/events/catalog/options/:sourceId`) rather than inside the catalog, so a slow source
cannot take the catalog with it, and per request rather than once, so a leg registered by a module
that booted later still appears.
- **Core registers an option source of its own** (org lead, 2026-09-03). `core.announce`'s `leg` param
was a free-text box whose typo was caught at DISPATCH, mid-run — which is the defect Phase 6's own
walk hit, an announce leg `"site"` no module registers. The legs are already a registry with labels
in them, so `core.options.legs` costs nothing new, and it means the seam's first exercise is not a
module's.
### What is contract rather than implementation
**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02).
@@ -1221,12 +1277,16 @@ core enforces is the number the module said. A `cost()` that throws, or answers
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.
> **[§F](#f--the-module-contract)'s `registerEventBudgets` landed in Phase 7 and replaced half of
> this.** A dimension's id, label and unit are now declared, so the switchboard no longer invents a
> name for a cap box — and a `cost()` naming a dimension nobody declared is refused outright, at save,
> at the dry run and at dispatch. What a registry cannot answer is *which* dimensions an action
> spends, because `cost` is a function of params, so core still discovers those by pricing the
> 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?
@@ -1332,8 +1392,8 @@ no URL moved.
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force the current phase past its advance condition (Phase 5). Legal only while the phase is genuinely waiting on its gate; `409` naming what it is waiting on otherwise. Satisfies the gate and stops — the next tick performs the boundary |
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions — and, since Phase 5, the **trigger** catalog and condition operators the advance form renders. Served here rather than borrowed from `/admin/engagement/triggers` because that route is `adminOnly` while a definition is authored by `admin` **and** `editor` |
| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions — and, since Phase 5, the **trigger** catalog and condition operators the advance form renders. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step, and four requests to draw one form would be four chances to render half of it. Served here rather than borrowed from `/admin/engagement/triggers` because that route is `adminOnly` while a definition is authored by `admin` **and** `editor` |
| `GET /admin/events/catalog/options/:sourceId` | staff | **the values behind a param's `source`** (Phase 7) — resolved by the module that registered it, on its own request rather than inside the catalog, because a source can be slow or down and must not take the catalog with it. A refusal is a **`200` with `ok: false` and a `reason`**, deliberately: the field degrades to free text with a warning rather than the form breaking |
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
@@ -1380,7 +1440,13 @@ 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.
**Phase 6 added the last three, and the list of absent routes is now one item long.** `GET/PUT
**Phase 7 filled the option-source route, and the list of absent routes is now one item long
`POST /admin/events/runs/:runId/cleanup`, which arrives with the ledger in Phase 8.** The route
answers a refusal with a `200`, which is the one thing about it worth stating twice: §F requires a
source that cannot answer to degrade its field rather than block the form, and a status code is how a
client tells the two apart.
**Phase 6 added the three before it.** `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
@@ -1488,7 +1554,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
| **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` |
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `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` |
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |