docs(events): the Event System record — the cutover step 16b missed (edgemain) #232

Merged
whitlocktech merged 46 commits from edge into main 2026-09-10 02:55:11 +00:00
3 changed files with 308 additions and 11 deletions
Showing only changes of commit 3f5aa3b074 - Show all commits

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; 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. 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 ```js
api.registerEventBudgets([ api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, { 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 ### What is contract rather than implementation
**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02). **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 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. 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 > **[§F](#f--the-module-contract)'s `registerEventBudgets` landed in Phase 7 and replaced half of
> action's dimensions by pricing its declared `example` values.** That is a use every param's required > this.** A dimension's id, label and unit are now declared, so the switchboard no longer invents a
> `example` already has, it costs nothing, and it is honest about its limit: a `cost()` returning > name for a cap box — and a `cost()` naming a dimension nobody declared is refused outright, at save,
> different dimension *keys* for different params under-reports, which costs an operator a cap box on > at the dry run and at dispatch. What a registry cannot answer is *which* dimensions an action
> the switchboard and costs a run nothing at all — a run's budget is seeded from the params its steps > spends, because `cost` is a function of params, so core still discovers those by pricing the
> were actually authored with, never from examples. > 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? ### 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/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/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 | | `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` | 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 | a module's option list for a param | | `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 | | `GET /admin/events/series` | staff | the arcs a definition may belong to |
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) | | `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 | | `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 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. 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 /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 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 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` | | **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` | | **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` | | **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` | | **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `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` | | **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |

View File

@@ -731,6 +731,116 @@ a 403 walk across all four roles on every route.
### Phase 7 — The module contract (`website` + `docs` + a throwaway test module) ### Phase 7 — The module contract (`website` + `docs` + a throwaway test module)
> **Complete.** `edge` in `website` and `docs`. MODULE_API **1.10.0**, in both halves. Four names
> forwarded on the module-facing `api` — `registerEventActions`, `registerEventBudgets`,
> `registerEventLeases`, `registerEventOptionSources` — one new route
> (`GET /admin/events/catalog/options/:sourceId`), and one rule made real: a `cost()` naming a
> dimension no module declared is refused.
>
> **The seam is narrower than the four names suggest, and that is the point.**
> `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 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 — the argument
> `registerCore()` has made since the module system's Phase 3, and this is the phase where it pays.
>
> **The four decisions the org lead settled (2026-09-03), all as recommended:**
>
> - **Option sources are their own registration**, `registerEventOptionSources([{ id, label,
> resolve }])`, modelled on `registerAudiences`. Not a field on the action that names one: 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.
> - **An undeclared dimension is REFUSED, at save, at the dry run and at dispatch.** Fail closed, so
> that §F's *"a module cannot spend a budget it did not declare"* is a rule rather than a sentence.
> Not at registration time: `cost` is a function of params, so core could only enforce it against
> the declared examples, which is a rule about examples rather than about what runs.
> - **A lease is DECLARED here and acquired by nothing.** The ledger that holds one, the deadline that
> goes down the wire and the drift answer are Phase 8's. Declaring it now keeps the module contract
> one version a module author reads once rather than two.
> - **Core registers an option source of its own**, `core.options.legs`. `core.announce`'s `leg` param
> was a free-text box whose typo was caught at DISPATCH, mid-run — which is exactly the defect Phase
> 6's walk hit, an announce leg `"site"` no module registers. The legs are already a registry with
> labels in them, so it costs nothing new, and it means the seam's first exercise is not a module's.
>
> **Three things the build settled:**
>
> - **The undeclared refusal gets its own code (`undeclared`) and runs 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.
> - **A budget's `unit` is required and its vocabulary is open.** Required because a bare number on a
> cap box is ambiguous in the case that matters — 30 of what? — and open because core never
> interprets it. Closing the set would make "kilometres" a MODULE_API bump for a noun core does not
> read.
> - **A dimension nobody declares is SHOWN on the switchboard, not filtered out.** The action is
> refused wherever it is used, so the screen has to be able to say which module is incomplete;
> hiding the row would make a broken module look like a cheap one. `registerEventBudgets` supplies
> the label and unit, while WHICH dimensions an action spends is still discovered by pricing its
> declared examples — a registry cannot answer that, because `cost` is a function of params.
>
> **The plan's own instruction, followed literally.** *"Prove it with a throwaway module, not with
> module-uo."* `eventModuleContract.test.js` (17) writes a real module to a real directory, points
> `MODULES_DIR` at it and lets the **real loader** scan, validate, `register()` and commit it — so a
> test that staged directly, which would have passed just as happily before this phase, is not what is
> being run. It covers all five failure shapes dispatched from a module (rejected promise, throw,
> `budgetMs` timeout, non-object, missing `ok`), `retry: false`, `await: 'human'`, `holdFor`, the
> whole envelope including the idempotency key, `verify: true` writing nothing, the four id spaces,
> `once` on a second call, the un-namespaced refusal, a module that registers nothing at all, every
> option-source failure mode, and an action going dormant when its module is uninstalled.
>
> **The defect only the browser could find, and it is a React one.**
> `setSources((s) => { if (s[id]) return s; started = true; … })` — the guard was written inside the
> state updater and read on the next line. **`setSources` QUEUES its updater rather than running it**,
> so `started` was always `false`, the function always returned early, and the request was **never
> made**: the field sat on *"Reading the list…"* for ever. Every server test passed, the route
> answered correctly by `curl`, and nothing but opening the page could say so. **State is the wrong
> tool for a question that must be answered synchronously, at the call** — it is a `useRef` now.
>
> **And one thing the docs caught the code doing.** The § API surface table has always named this
> route `GET /admin/events/catalog/options/:sourceId`; it was built one segment shallower at
> `/admin/events/options/:sourceId`, and the divergence surfaced only when the docs half was written.
> Moved to match the spec — which is also the better shape, because a source's values ARE catalog
> data, fetched separately so a slow source cannot take the catalog with it.
>
> **Verified:** `npm test` — **1950 tests, 1876 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: 1921/1847/73/1). **+29 tests**: 17 in
> `eventModuleContract.test.js`, 11 in `eventActionRegistry.test.js` (the three new shape checks and
> the id spaces), and 1 in `eventsRoles.test.js` — the new route joining the 403 walk. `eventsAdmin`,
> `eventAuthorize`, `eventVerify` and `eventRunner` all gained the budget declarations their cap tests
> now need, and the test dimensions were renamed `x.*` → `test.*` so they carry the registering
> owner's prefix, exactly as their action ids already do. Client: **362 pass**; the client builds.
> `npm run routes:manifest` and `npm run swagger` — **one route added, none moved.**
> `check:modules` and `check:hosts` green.
>
> **The live walk, on the local review stack**, driven by a throwaway `rig` module in
> `website/modules/` — a real module directory, scanned by the real loader, **deleted before commit**.
>
> - **`coreApi: "^1.10.0"` resolved and `module-uo`'s `"^1.9.0"` still did**, on the same boot. That
> is the additive claim, proved rather than asserted.
> - **The catalog served all four registrations** with every callable stripped — two budgets with
> their labels and units, one lease, three option sources including core's own.
> - **The option route answered four ways**: a module's list, a source that throws
> (`200`, `ok: false`, *"could not be read"*), a source nobody registers, and core's `core.options.legs`
> — which returned `discord` **and** module-uo's `towncrier`, proving the per-request resolve.
> - **The switchboard named the dimension.** `Wisps summoned … count` on the cap box, and the
> undeclared one rendered disabled with *"No module declares this as a budget, so a step using this
> action is refused."*
> - **The save refused the undeclared dimension** by name and phase-step path, before anything was
> scheduled; the role floor refused an editor the same world-changing step an admin saved.
> - **The dry run priced a MODULE's action** — 3 of a cap of 5 — and refused the two-step version at
> `8 of "rig.wisps"`. At dispatch the first step spent 3 and the second came back
> `refused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run`, health `degraded`.
> - **The dropdown wrote the exact spelling into the params box.** Picking *Britain Hall* put
> `"place": "britain-hall"` into the JSON, one request served two steps on the same action, breaking
> the JSON greyed that step's picker to *"Fix the params JSON to pick a value"* while the other
> stayed live, and pointing the param at the failing source rendered
> *"… could not be read — type the value by hand"* in red beside a field that stayed editable.
>
> **This PR turns the integration kit red**, on purpose. `checkCoreApi.js` asserts equality against the
> `main` sha `ci/core-ref.json` pins, so it stays red until the cutover re-pins it (Phase 16).
Generalise P1's registry into the public contract: `registerEventActions` with `cost`, `risk`, Generalise P1's registry into the public contract: `registerEventActions` with `cost`, `risk`,
`reversible`, `budgetMs`, `params` and `perform` / `revert`; `registerEventBudgets`; `reversible`, `budgetMs`, `params` and `perform` / `revert`; `registerEventBudgets`;
`registerEventLeases`; and param **option sources**. `MODULE_API_VERSION`**1.10.0**, with §2.4 and `registerEventLeases`; and param **option sources**. `MODULE_API_VERSION`**1.10.0**, with §2.4 and

View File

@@ -26,13 +26,93 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`: Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js ```js
const MODULE_API_VERSION = '1.9.0' const MODULE_API_VERSION = '1.10.0'
``` ```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer. module chunk evaluates, which is earlier than any network round trip could answer.
**1.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phase 7). Four
additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
rule, because core with none of this installed is still an event engine that can announce, wait, cue
a human and publish results.
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam 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. That is the same argument `registerCore()` has made since the module
system's Phase 3, and 1.10.0 is when it pays.
```js
api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, // a DIMENSION core can bound
])
api.registerEventActions([{
id: 'uo.creature.spawn', // <moduleId>.-prefixed; its OWN id space
label: 'Spawn creatures',
risk: 'change', // closed: notify | inspect | change | irreversible
reversible: 'ledger', // closed: none | self | ledger | override
version: 1,
budgetMs: 10000,
cost: (p) => ({ 'uo.creatures': p.count }), // what ONE invocation consumes
params: [
{ name: 'creature', type: 'string', required: true,
example: 'Orc', source: 'uo.options.creatures' }, // a `source` makes it a dropdown
{ name: 'count', type: 'int', required: true, example: 12 },
],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
if (verify) return { ok: true } // dry run: validate, change NOTHING
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
},
async revert({ runId, resources, idempotencyKey }) { return { ok: true } }, // iff reversible: 'ledger'
}])
api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
}])
api.registerEventLeases([{ // DECLARED here; acquired by nothing yet
id: 'uo.rate.skillgain', label: 'Skill gain rate',
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
async read() { return { ok: true, value: 1.0 } },
async apply(v, until) { return { ok: true } },
async restore(baseline, { expected }) { return { ok: true } },
}])
```
**What a module author has to know beyond the four names**, because each is a rule rather than a
field:
- **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs`
timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. That is
`registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core
showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded
as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that
means "this will never work" says `retry: false`.
- **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module
registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because
the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same
as bounding it: a declared dimension with no operator cap is counted and unbounded.
- **`verify: true` must change nothing and must answer honestly.** It rides the same dispatcher a
real run uses, because a dry run down a second code path is a dry run of the second path.
- **`example` is required on every param, optional ones included** — the same rule
`registerEventTriggers` makes of a variable's example, for the same reason: it is the authoring
form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards.
- **Actions, budgets, leases and option sources are four separate id spaces**, each namespaced
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
would forbid the most natural set of names a module will ever write.
- **A lease is DECLARED at 1.10.0 and acquired by nothing.** Core owns a lease's duration and its
conflict check, and both live in the resource ledger, which is `EVENTS_PLAN.md` Phase 8. It is in
this version rather than the next so the module contract is one version an author reads once.
**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`** **1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module (`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
written against 1.8.0 keeps working and simply seeds nothing. written against 1.8.0 keeps working and simply seeds nothing.
@@ -142,6 +222,13 @@ branches lacks — so it is the only place `permits` is true between two values
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
unchanged, and so is every rule about composition narrowing rather than widening. unchanged, and so is every rule about composition narrowing rather than widening.
**1.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Nothing was added
to `ctx`: an action is called BY core, so what a module needs from this contract it is handed in the
envelope rather than reaching for.
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals **1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and `api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
@@ -489,6 +576,10 @@ api.registerSlashCommands([{ name, description, options, access, handler }]) //
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0 api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0 api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert }]) // 1.10.0
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }]) // 1.10.0
api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0
api.onBoot(async (ctx) => {}) api.onBoot(async (ctx) => {})
api.onShutdown(async () => {}) api.onShutdown(async () => {})
``` ```
@@ -861,6 +952,36 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
in `channels`. Every other key must be one of the rule's channels. in `channels`. Every other key must be one of the rule's channels.
- **It is not a send path.** Every value on the object is data. Core still decides who is told. - **It is not a send path.** Every value on the object is data. Core still decides who is told.
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
shapes and the six rules that come with them are in §1.1 under **1.10.0**; four things are contract
rather than implementation and belong here:
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
dispatch, and unlike everything above them — but from further away than either, because the thing
on the other end may be a shard. That is why `budgetMs` is declared per action and enforced by the
dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim
until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar
becomes an infinite loop rather than a failed step.
- **`once`, on all four.** A batch is a module's complete statement about what it declares; a second
call is a module changing its mind halfway through `register()` rather than adding to it. And they
STAGE, like every registration above: a module that registers two budgets and then throws has left
nothing behind.
- **Everything is optional, and §F says so once because it governs every member.** A module may
register no actions, no budgets, no leases and no option sources. Each registration *adds* what an
author can reach for; a module that omits one costs its deployment a capability rather than a boot,
exactly as a module with no `onBoot` still reaches `started`.
- **An option source that refuses degrades its field to free text with a warning.** It never blocks
the authoring form and it never raises. The alternative is a screen a module's outage can take
away, for a field whose value the operator very often already knows — which is a worse failure than
the typo the dropdown exists to prevent.
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant
trigger — and a dormant step blocks the PUBLISH, because a version is what a run pins and a run
cannot dispatch a verb nobody registers. A step that reaches dispatch naming one fails `terminal`
with the module named and the run degrades: never a silent skip.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5. **`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle ### 2.5 Lifecycle