diff --git a/website/EVENTS.md b/website/EVENTS.md index d41ee90..ae6f8bf 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 0b290d4..0ee4536 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -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) +> **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`, `reversible`, `budgetMs`, `params` and `perform` / `revert`; `registerEventBudgets`; `registerEventLeases`; and param **option sources**. `MODULE_API_VERSION` → **1.10.0**, with §2.4 and diff --git a/website/MODULE_API.md b/website/MODULE_API.md index bb9e3f5..662d015 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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`: ```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 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. +**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', // .-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 + `.`. 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(...)`** (`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. @@ -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 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 and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `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.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.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.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. - **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. ### 2.5 Lifecycle