From cc6775e20b841041f2a9583f90c1e0c78a617ccc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 20:52:37 -0500 Subject: [PATCH 01/24] =?UTF-8?q?docs(events):=20Phase=200=20=E2=80=94=20t?= =?UTF-8?q?he=20eleven=20decisions,=20and=20the=20two=20documents=20they?= =?UTF-8?q?=20reach=20into?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of EVENTS_PLAN.md Phase 0. All of EVENTS.md §N1–N11 were answered by the org lead on 2026-09-01, before any code, which lifts the ⚠ gate from P11 and P12 and unblocks P1. Nine went the way §N recommended. Two did not, and each changes a phase: - N2 — publishing a version and starting a run are `admin` only; a moderator keeps live control of a run already in flight (cancel, abort) and nothing more. Start and stop are deliberately not the same gate: starting commits the deployment to everything a definition contains, unattended, while cancelling is incident response. §K's table and the API surface table are updated, and P3 and P6 carry it. - N4 — this deployment is single-instance and not planned to change, so P2 does not build the `--scale app=2` test. Every claim path in §E is built exactly as specified regardless — the unique index and the CAS equally protect a tick that overruns into the next one, and the lease and its reclaim recover a step whose process died mid-dispatch. §E records what to build first if this deployment is ever scaled. N1 was taken in full, item grants included, which is a reversal of two separate lines in ADMIN_CONTROLS.md §8 rather than one. §8's cut list names "item/gold grants" explicitly and separately from world building, so the new §8a amendment says both out loud rather than letting the grant ride along on the creation reopening. §8's bullet is left standing verbatim — it is the decision that was actually taken — with a pointer to the amendment, and §1's thesis paragraph gets the same pointer. Loot-table changes and arbitrary [set / [get / [add stay permanently excluded, along with the rest of the Tier-H moderation verbs. N3 gives ENGAGEMENT.md §7.1 Q6's "no evaluator yet" an owner: the Event System runner becomes the `kind: 'scheduled'` evaluator, built in that workstream's P2. Nothing in the engagement contract changes. One consequence was derived rather than decided and is flagged for review: POST /admin/events/:id/verify moves to `admin, editor` rather than staying with `start`. A dry run dispatches nothing, and the author who wrote the definition is who should price it against the caps before asking an admin to publish. Verified: every relative link and anchor in the four touched files resolves, including the renamed §N anchor (#n--decisions) and the new #8a. The docs repo has no CI workflow, so the plan's "docs CI link check" was run by hand. Co-Authored-By: Claude --- link/ADMIN_CONTROLS.md | 75 ++++++++++++++++++++ website/ENGAGEMENT.md | 10 ++- website/EVENTS.md | 152 +++++++++++++++++++++++++++++++++++++---- website/EVENTS_PLAN.md | 69 ++++++++++++++----- 4 files changed, 274 insertions(+), 32 deletions(-) diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md index 247d120..18f6fe5 100644 --- a/link/ADMIN_CONTROLS.md +++ b/link/ADMIN_CONTROLS.md @@ -21,6 +21,12 @@ None of these are *moderation*. A staff member who wants to kick a cheater, ban **The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form. +> ⚠ **Amended 2026-09-01 — see [§8a](#8a-amendment--2026-09-01).** That thesis holds for *ad-hoc staff +> action*, and the Event System does not contest it: a GM still does the target-driven parts in-client. +> What was reopened is narrower — declared, cap-bounded, ledgered, idempotent creation and **leased** +> mutation, both owned by an event run and both undone at teardown. `[set` / `[get` / `[add` and +> loot-table changes stay excluded exactly as this paragraph argues. + --- ## 2. How ServUO admin controls actually work @@ -284,6 +290,11 @@ All resolved by source inspection (ServUO checkout at ``). No live-shard ## 8. Decisions — locked 2026-07-12 +> ⚠ **Partly amended on 2026-09-01. Read [§8a](#8a-amendment--2026-09-01) with this section.** Two of +> the exclusions in the first bullet below — world creation, and item/gold grants — were reopened for +> the Event System under conditions that did not exist when this was locked. The bullet is left +> standing verbatim because it is the decision that was actually taken; §8a is what changed and why. + - **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later. - **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`. - **Attribution:** `web:` in `CommandLogging` and the `BanDealer` tag; no core edits. @@ -292,6 +303,70 @@ All resolved by source inspection (ServUO checkout at ``). No live-shard --- +## 8a. Amendment — 2026-09-01 + +**What changed:** §8's blanket exclusion of world writes is narrowed, in **two** places, and only for +the Event System. Everything §8 decided about the moderation write plane — its verbs, its +CoOwner-level authority, its Owner-only shield, its `Bridge.cfg` default-off switch, its attribution +and its logging — stands unchanged. + +**Decided by:** the org lead, 2026-09-01, answering +[`../website/EVENTS.md`](../website/EVENTS.md) §N1. The design of record is `EVENTS.md`; this section +records the reversal against the decision it reverses, so §8 is never read alone. + +### What is now permitted + +| | Permitted | Under what conditions | +|---|---|---| +| **1** | **Event-owned creation** — creatures, "simple" boss variants, oracle NPCs, temporary gates, temporary decoration | Created by an event run, counted against a module-declared cap dimension, recorded in the resource ledger with the run, step and actor that produced it, and **deleted by serial at teardown** on every terminal path — completion, cancellation and abort alike | +| **2** | **Leased mutation** of a plugin-declared allowlist of config values and object properties | Baseline recorded before the write, drift-checked on read-back, and **deadline-enforced by the game itself** — the shard restores baseline when the lease expires whether or not the website ever comes back | +| **3** | **Item and gold grants** | As (1): cap-bounded, ledgered and attributable — but `reversible: 'none'`. A granted item is not clawed back | + +### What stays excluded, permanently + +Unchanged by this amendment and not reopened: **loot-table changes**, and **arbitrary `[set` / `[get` +/ `[add`**. Both are the "change something with no baseline recorded" case, which is the thing §8 was +actually objecting to; no cap makes them safe. `firewall`, `kill`/`res`, `jail` and `set-access-level` +also stay cut — this amendment is about what an *event* may create and borrow, not about restoring the +Tier-H moderation verbs. + +### Why the reversal, and why item grants are called out separately + +**§8's objection was to an unrecorded change with a large blast radius, made from a web form.** It was +locked when the bridge was a read plane with three inbound verbs, none of which were moderation, and +when the only way to express "change the world" would have been a passthrough command string. In that +setting the objection was correct and it would still be correct today for anything shaped that way — +which is exactly why `[set` / `[get` / `[add` remain excluded. + +**The Event System proposes something with four properties that shape did not have**, all of which are +required before P12 ships and none of which are optional: + +1. **Declared, not typed.** A module declares a verb with its params and their types; the browser posts + an action id and a params object. There is no passthrough field and no place a request body can name + a sidecar route. +2. **Cap-bounded.** Every dimension is enforced in a conditional `UPDATE` with the cap in the `WHERE` + clause — a control a stolen admin session cannot talk its way past, because a role check is exactly + what a stolen session has already passed. +3. **Ledgered.** Every created object and every borrowed value is recorded in `event_run_resources` + before it is confirmed, with its run, step and actor, and with a declared revert. +4. **Idempotent on the wire.** A key minted once per step rides core → module → sidecar → plugin, and + the plugin answers a repeat with the original result rather than acting twice — so a lost + acknowledgement cannot double a spawn or a grant. + +**Item grants are named as their own row because they are a second reversal, not a consequence of the +first.** §8's cut list names "item/gold grants" explicitly and separately from world building; reading +the creation reopening as carrying grants along with it would be the silent reversal this amendment +exists to avoid. They are permitted on the same four conditions as creation — with the difference that +they are **not reversible**, which is why the cap and the ledger carry the entire weight for this row +and why the action ships declared as `reversible: 'none'` rather than pretending at a revert. + +**What has not changed:** the module boundary is still not a security boundary, and none of the above +defends against a hostile module. It defends against a compromised session and an operator mistake — +both of which *scheduling* makes larger, because a scheduled change happens while nobody is watching. +That is the reason the caps and the leases matter more here than the role check does. + +--- + ## 9. Where the code goes | File | Responsibility | diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 76b524b..6a38c96 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -1676,7 +1676,7 @@ deferred to this phase on purpose: | | Question | Decision | |---|---|---| | §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle | -| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet | +| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today. **The evaluator was assigned on 2026-09-01** ([EVENTS.md §N3](EVENTS.md#n--decisions)): the Event System runner becomes it, built in that workstream P2 | | — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed | **One namespace was the more invasive of the two options and it is worth stating what it bought and @@ -4078,6 +4078,14 @@ day it ships. caller's — so `kind` means something from the moment it is declarable rather than from the moment it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and the first thing anyone will ask for after the IDOC mail works. + + > **The evaluator now has an owner, decided 2026-09-01** ([`EVENTS.md`](EVENTS.md#n--decisions) + > §N3). The **Event System runner** becomes the `kind: 'scheduled'` evaluator rather than a ninth + > poller being written for it — the runner already materialises due occurrences on a tick, and a + > second timer doing the same shape of work is how a codebase acquires two schedulers that + > disagree. It is built in that workstream's P2. Nothing in this phase's contract changes: a + > `scheduled` declaration means today exactly what it meant on 2026-08-29, and `ctx.events.emit` + > still refuses to fire one. 7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be built if they are powered by game data — say team X members or governors or whatever — thru the uo diff --git a/website/EVENTS.md b/website/EVENTS.md index 735916e..1e8c0b8 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -475,6 +475,15 @@ the fishing contest on Drachenfels is exactly that shape. | 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. | +> **This deployment runs one app instance, and every protection above is built anyway** +> ([§N4](#n--decisions)). The "two instances" column names the *hardest* contender for each row, not +> the only one: the unique index and the CAS equally protect a tick that runs long while the next one +> fires, and the lease and its reclaim are what recover a step whose process died mid-dispatch. Both +> happen with one container. What the single-instance decision changes is the **test**, not the +> design — P2 does not build the `--scale app=2` rig — so the multi-instance property is true by +> construction and unproven by experiment. Scaling this deployment, or adopting a rolling deploy that +> briefly runs two containers, is the trigger to build that rig before anything else. + ### Idempotency ```js @@ -825,15 +834,23 @@ live game world on a schedule. ### Which role, per surface Following the split module-uo already uses — `requireRole('admin','moderator')` for live operations, -`requireRole('admin')` for configuration that can break things: +`requireRole('admin')` for configuration that can break things — **with one deliberate departure, +decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping one is not.** | Surface | Role | | --- | --- | | The calendar, run history, the run console, the diagnostic log | `staff` — the tier gate, nothing added | | Authoring and editing a draft | `admin`, `editor` | -| Publishing a version, starting a run, live controls | `admin`, `moderator` | +| 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 | +> **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. +> Cancelling is incident response, and the incident is "the event is doing something wrong at 2am" — +> it wants the widest. A split that read consistent, with one role owning both buttons, would behave +> badly in exactly the case the moderator role exists for. + > **Keep the check in one function.** The whole authorisation decision — role, enablement, cap, shard > switch — should live behind a single `mayInvoke(user, action, run)` rather than being spread across > route middleware. Not for tidiness: it is what makes an EM-style delegation model a *later* option @@ -925,15 +942,15 @@ no URL moved. | `GET /admin/events` | staff | definitions, state, next occurrence, health | | `POST /admin/events` | admin, editor | create a draft | | `PUT /admin/events/:id` | admin, editor | edit the draft spec | -| `POST /admin/events/:id/publish` | admin, moderator | snapshot a version and go `ready` | +| `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, moderator | **dry run** — dispatch with `verify: true`, report cost against the caps | -| `POST /admin/events/:id/runs` | admin, moderator | start now; optional `rehearsal: true`, optional `scope` | +| `POST /admin/events/:id/verify` | admin, editor | **dry run** — dispatch with `verify: true`, report cost against the caps | +| `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 | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | -| `POST /admin/events/runs/:runId/pause\|resume\|advance\|cancel` | admin, moderator | `cancel` takes `{ cleanup, reason }` | +| `POST /admin/events/runs/:runId/pause\|resume\|advance\|cancel` | admin, moderator | live control of a run in flight; `cancel` takes `{ cleanup, reason }` | | `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | `confirm` resolves a GM cue step | | `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 | @@ -944,6 +961,11 @@ no URL moved. | `GET /public/events/series/:slug` | — | the arc | | `GET /player/events/history` | auth | this account's participation | +> **Publish and start are `admin`; cancel is not** — that asymmetry is deliberate and decided in +> [§N2](#n--decisions). `verify` sits with `admin, editor` rather than with `start`: a dry run +> dispatches nothing, and the author who wrote the definition is exactly who should be able to price +> it against the caps before asking an admin to publish it. + A module registers actions server-side and adds **no routes** for them beyond its option endpoints, which is what keeps the browser from being able to name a transport. @@ -991,11 +1013,12 @@ that only *terminal* rows are eligible. Seventeen PR-sized slices (P0–P16) on an `edge` branch with one cutover, matching the discipline every previous workstream used. Phases 1–10 and 13–16 ship a complete, useful system that schedules, announces, orchestrates and records — **without creating or changing anything in the game world** — -and need no answer to [N1](#n--open-questions). Only P11 and P12 are gated. +and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **§N1 was answered on +2026-09-01 and the gate is lifted.** | Phase | | Repos | | --- | --- | --- | -| **P0** | Design of record; answer §N; amend `ADMIN_CONTROLS.md` §8 | `docs` | +| **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` | | **P1** | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` | | **P2** | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` | | **P3** | The minimal admin surface — **first demo** | `website` | @@ -1006,8 +1029,8 @@ and need no answer to [N1](#n--open-questions). Only P11 and P12 are gated. | **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` | -| **P11** ⚠ | *Needs N1.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` | -| **P12** ⚠ | *Needs N1.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` | +| **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` | +| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` | | **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` | | **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` | | **P15** | Integration Kit chapter 5 — cannot merge before the cutover exists | `integration-kit` | @@ -1022,10 +1045,36 @@ P15 and P16 are not optional if anything ships to `main`; they are the cost of t --- -## N — Open questions +## N — Decisions -Each needs an architectural decision before the phase that depends on it. A recommendation is given -for every one. +**All eleven were settled by the org lead on 2026-09-01**, before any code, which closes the second +half of [`EVENTS_PLAN.md`](EVENTS_PLAN.md) Phase 0 and lifts the ⚠ gate from P11 and P12. Each entry +below keeps the recommendation it was decided against, so the reasoning survives alongside the answer. +**Nine went the way the recommendation argued; two did not** — N2, where starting a run became +`admin`-only, and N4, where the deployment is declared single-instance for good. + +Two answers have consequences outside this document, and both are written where they will be found +rather than only here: + +- **N1** is an amendment to [`../link/ADMIN_CONTROLS.md`](../link/ADMIN_CONTROLS.md) §8, recorded there + as a dated amendment with its reasoning. §8's locked scope is reopened in two places, not one — see + the decision below. +- **N3** closes [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6, whose "no evaluator yet" row is updated in + the same pull request. + +| | Question | Decision | +|---|---|---| +| N1 | Scope of the §8 reopening | **As recommended, including item grants** — §8 is amended in two places | +| N2 | Role split across the four surfaces | **Starting and publishing are `admin` only**; a moderator gets live control of a run already in flight and nothing more | +| N3 | Runner as the `kind: 'scheduled'` evaluator | **Yes** — one scheduler, not two | +| N4 | More than one app instance? | **No, and not later** — single-instance is a recorded assumption; the claim paths stay, the two-instance test is not built | +| N5 | Live console: poll, or core SSE? | **Poll** in v1 | +| N6 | Module-declared risk classes? | **No** — four closed values, core-owned | +| N7 | Plugin-side runtime and phase persistence | **The shard stays stateless about events**; reconciliation-on-reconnect reunites objects with phases | +| N8 | One event invoking another? | **Not as a feature** — composition covers it | +| N9 | Does core know a reward is a reward? | **No** — a reward is an ordinary action with its own cap dimension | +| N10 | Who verifies the lease allowlist? | **The plugin, at boot** — set, read back, restore; a key that does not take drops itself from the catalog | +| N11 | Caps per run, or also per period? | **Per run only** | **N1 · Confirm the scope of the §8 reopening.** The direction is settled; what needs writing down is the boundary. Recommendation: permit two things @@ -1036,6 +1085,27 @@ loot-table changes, and arbitrary `[set` / `[get` / `[add` — which is precisel no baseline recorded". This should be an amendment recorded in `ADMIN_CONTROLS.md`, not a silent reversal. +> **Decided 2026-09-01 — as recommended, item grants included.** +> +> **`ADMIN_CONTROLS.md` §8 is reopened in two places, and the amendment says both out loud.** §8's +> locked scope cut the Tier-H verbs "entirely — not now, not later", and that list names **item/gold +> grants** as well as world creation. Permitting an event to grant an item is therefore not a widening +> of §8's creation cut; it is a second, independent reversal of a decision that was taken deliberately, +> and an amendment that mentioned only the first would be the silent reversal §8 deserves not to get. +> +> **What makes the grant a different proposition from the one §8 refused** is the four properties it +> did not have then, all of which this document requires before P12 ships: it is *declared* by a module +> rather than typed as a command, *cap-bounded* by `uo.rewards` in a conditional `UPDATE` a stolen +> session cannot talk its way past, *ledgered* in `event_run_resources` with the run, step and actor +> that produced it, and *idempotent* on the wire so a lost acknowledgement cannot double it. §8's +> objection was to an unrecorded change with no baseline; a recorded, bounded, attributable one is a +> different argument. It remains `reversible: 'none'` — a granted item is not clawed back — which is +> exactly why the cap and the ledger carry the whole weight here and are non-negotiable. +> +> **Still permanently excluded, and this decision does not touch them:** loot-table changes, and +> arbitrary `[set` / `[get` / `[add`. Those are the "change something with no baseline recorded" case +> and no cap makes them safe. + **N2 · Confirm the role split across the four event surfaces.** Settled in principle — gate on the existing roles, add no permission system — so what is left is the mapping, and it is a judgement call rather than a design one. Recommendation in [§K](#k--security-model): @@ -1043,53 +1113,109 @@ viewing is the staff tier gate; authoring is `admin` + `editor`; publishing, sta are `admin` + `moderator`; anything above `notify` and the action switchboard are `admin` only. The one worth a second look is whether a moderator should be able to start a run at all. +> **Decided 2026-09-01 — no, and the recommendation was changed because of it.** Publishing a version +> and starting a run are **`admin` only**. A moderator keeps **live control of a run already in +> flight** — cancel and abort — and nothing more. +> +> **The asymmetry is the point.** Starting is the act that commits the deployment to everything the +> definition contains, unattended, up to every cap it declares; cancelling is the act that stops it. +> One of those wants the narrowest possible gate and the other wants the widest, because the moderator +> role exists for incident response and the incident here is "the event is doing something wrong at +> 2am". Gating the stop button on the same role as the start button would be the version of this that +> reads consistent and behaves badly. +> +> This is the one place the split departs from the `requireRole('admin','moderator')` / +> `requireRole('admin')` shape module-uo uses, and [§K](#k--security-model)'s table is the normative +> statement of it. + **N3 · Does the event runner also become the `kind: 'scheduled'` trigger evaluator?** Recommendation: yes. It closes ENGAGEMENT §7.1 Q6, and a second timer doing the same shape of work is how a codebase acquires two schedulers that disagree. +> **Decided 2026-09-01 — yes, as recommended.** [`ENGAGEMENT.md`](ENGAGEMENT.md) §7.1 Q6's row is +> updated from "no evaluator yet" in the same pull request as this decision. The evaluator itself is +> built by P2, which is the phase that acquires the tick. + **N4 · Is this deployment ever more than one app instance?** Affects nothing in the design — every claim is already multi-instance safe — but it decides how hard P2's tests must work, and whether the answer is "safe" or merely "documented". `docker compose up --scale app=2` is how the engagement workstream's Phase 13 acceptance walk found the seed race. +> **Decided 2026-09-01 — one instance, and not planned to change.** The `app` service runs as a single +> container; there is no load balancer, no rolling deploy with two containers briefly alive together, +> and no intent to add either. **P2 does not build the two-instance test.** +> +> **Every claim in [§E](#concurrency) stays exactly as specified, and this decision is not a licence to +> simplify them.** They are not only there for a second container: the unique index and the CAS are +> equally what protect a tick that runs long while the next one fires, and the lease and its reclaim are +> what recover a step whose process died mid-dispatch — both of which happen in a single-instance +> deployment. They also cost nothing to keep, being one index and one `WHERE` clause. +> +> **What this actually buys is a smaller P2 and one honest sentence:** the multi-instance property is +> *by construction*, not *proved*. If this deployment is ever scaled, or acquires a rolling deploy, the +> event runner is the component most likely to break and it breaks by duplicating world writes rather +> than by erroring — so scaling is the trigger to build the test that was skipped here, and this +> paragraph is where whoever does that will find out why it does not already exist. + **N5 · Does the live run console poll, or does core gain SSE?** Recommendation: *poll* in v1. Core has no SSE at all and adding one is a larger core change than this needs. If a live channel is ever built it should be core infrastructure with module-uo's two streams migrating onto it, not an event-system side effect. +> **Decided 2026-09-01 — poll, as recommended.** The run console polls; core gains no SSE in this +> workstream. + **N6 · May a module declare its own risk classes?** Recommendation: no. Four closed values, core-owned. A module-defined class is a module choosing which gate it sits behind. Budget *dimensions* are the opposite case and are module-declared, because core cannot name what a game counts. +> **Decided 2026-09-01 — no, as recommended.** Four closed, core-owned risk classes. Budget +> dimensions stay module-declared. + **N7 · Where does the plugin-side runtime live, and does a phase persist across a shard restart?** An asymmetry needing a decision: a spawned creature is an object and persists; a *phase* does not exist on the shard at all. Recommendation: keep the shard **stateless about events** — it owns objects and leases, core owns phases — and make reconciliation-on-reconnect the mechanism that reunites them. A plugin that remembered a phase would be a second event engine. +> **Decided 2026-09-01 — as recommended.** The shard is stateless about events: it owns objects and +> leases, core owns phases, and reconciliation-on-reconnect is what reunites them. + **N8 · Can one event invoke another?** Recommendation: not as a feature. It already works by composition — a second event's trigger condition can be `event.run.completed` — and a direct edge would need cycle detection, a depth bound and a story about cancelling a parent. Series and arcs cover the storyline case without invocation. +> **Decided 2026-09-01 — not as a feature, as recommended.** Composition covers it; there is no +> direct edge, therefore no cycle detection and no depth bound to design. + **N9 · Does core need to know a reward is a reward?** Recommendation: **no**. A reward is an ordinary action, enabled or not on the switchboard like any other, with its own cap dimension — a shard that wants none simply never enables it. An action returning a resource that carries a `memberKey` is enough to answer who received what, and inventing a category would put core back in the business of knowing what a game can give. +> **Decided 2026-09-01 — no, as recommended.** A reward is an ordinary action with its own cap +> dimension. Core learns no reward category. + **N10 · Who verifies the lease allowlist, and how often?** A key that is live-read today can become `static readonly` in a later ServUO release, and the failure is silent — the lease applies and nothing changes. Recommendation: the allowlist ships with the plugin and each key carries a boot-time self-check (set, read back, restore) that drops a key from the advertised catalog if it does not take. Better a capability that disappears loudly than one that lies. +> **Decided 2026-09-01 — as recommended.** The allowlist ships with the plugin and each key +> self-checks at boot, dropping itself from the advertised catalog if the write does not take. + **N11 · Are caps per run only, or also per period?** Recommendation: **per run only**. A cumulative cap that silently exhausts mid-event is a worse failure than one that refuses at authoring time, and with no delegation there is nobody whose longer-term allowance needs tracking. Per-period is a later addition if it is ever wanted. +> **Decided 2026-09-01 — per run only, as recommended.** Per-period caps are a later addition if +> they are ever wanted; nothing in the schema forecloses one. + --- ## O — Recommendations beyond the brief diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 0c56a79..2915575 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -6,11 +6,13 @@ is proved. It re-specifies nothing — where this and `EVENTS.md` disagree, `EVE file is the one with the bug. **Seventeen phases, P0–P16.** Every phase is independently shippable and leaves the site working. -**Two are gated** on the open question `EVENTS.md` §N1 (the `ADMIN_CONTROLS.md` §8 amendment) and are -deliberately late: P11 and P12. Everything else — the engine, the scheduler, the calendar, the -conditions, the caps, the module contract, the ledger, the first wave of UO actions, the integrations, -the authoring UI, the public surface — needs no decision beyond P0. Those fourteen phases reach the -game only to *announce*, over verbs the write plane already carries; nothing in them creates or +**No phase is gated any more.** P11 and P12 hung on `EVENTS.md` §N1 (the `ADMIN_CONTROLS.md` §8 +amendment); §N1 and the other ten **were answered by the org lead on 2026-09-01**, which closes P0 +and lifts the gate. They stay deliberately late all the same — they are the phases that reach into +the world, and they belong after the ledger that makes them safe. Everything else — the engine, the +scheduler, the calendar, the conditions, the caps, the module contract, the ledger, the first wave of +UO actions, the integrations, the authoring UI, the public surface — needed no decision beyond P0. +Those fourteen phases reach the game only to *announce*, over verbs the write plane already carries; nothing in them creates or changes a thing in the world. --- @@ -96,9 +98,9 @@ early on purpose. ### Phase 0 — Design of record (`docs`) -> **Half-done.** The pull request that introduced this file and `EVENTS.md` discharges the first -> half — the design of record exists. What remains is the decisions, and they are the part that -> gates P11 and P12. +> **Complete, in two pull requests.** The first introduced this file and `EVENTS.md`; the second +> recorded the eleven decisions and amended the two documents they reach into. **All of §N1–N11 +> were answered by the org lead on 2026-09-01**, before any code, so nothing below is gated. Land `docs/website/EVENTS.md`, and answer §N1–N11. Two of those answers have consequences outside this document and should be written where they will be found: @@ -113,6 +115,24 @@ document and should be written where they will be found: **Ships:** the design of record, and two documents that stop contradicting each other. **Verify:** `docs` CI link check; every relative link resolves. +> **As answered, 2026-09-01.** Nine of the eleven went the way `EVENTS.md` §N recommended. **Two did +> not, and each changes a phase below.** +> +> - **N2 — starting a run is `admin` only.** A moderator gets live control of a run already in +> flight — cancel and abort — and nothing more. Starting commits the deployment to everything a +> definition contains, unattended; cancelling is incident response, and gating the stop button on +> the same role as the start button would behave badly in exactly the case moderators exist for. +> **P3 and P6 carry this**, and `EVENTS.md` §K's table is the normative statement of it. +> - **N4 — this deployment is single-instance, and not planned to change.** **P2 does not build the +> `--scale app=2` test.** Every claim path is built exactly as specified regardless: the unique +> index and the CAS equally protect a tick that overruns into the next one, and the lease and its +> reclaim recover a step whose process died mid-dispatch. The multi-instance property becomes +> true-by-construction rather than proved; `EVENTS.md` §E records what to build first if this +> deployment is ever scaled or acquires a rolling deploy. +> +> **N1 was taken in full, item grants included** — a reversal of *two* separate lines in +> `ADMIN_CONTROLS.md` §8 rather than one, and the amendment at §8a says both out loud. + --- ### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`) @@ -155,8 +175,11 @@ Also here: `missed` and the grace window, the lease and its reclaim, `concurrenc run params, and `health` as a column separate from `status`. **Ships:** a manually started event that broadcasts, waits, and completes. Demoable over curl. -**Verify:** `npm test`, including a **deliberate two-instance test** — the claim paths are the whole -point of this phase and a single-process test proves nothing about them. +**Verify:** `npm test`. **No two-instance test** — §N4 settled this deployment as single-instance, so +the `--scale app=2` rig the engagement workstream used is not built here. The claim paths are still +built exactly as `EVENTS.md` §E specifies, and they are still the point of the phase: they are what +protects a tick that overruns into the next one, and what recovers a step whose process died +mid-dispatch. Test both in-process. The decision is not licence to drop a CAS. **Two traps, both already paid for once in this codebase.** - **A reclaim must not reset `attempts`.** Engagement Phase 14's defect: a sweep that returned every @@ -173,6 +196,11 @@ point of this phase and a single-process test proves nothing about them. A list with state and next occurrence; a create/edit form; publish; start now; cancel; and a run console showing the phase, the step list with status and attempts, and the log. +**Publish and start now are `admin` only from this phase, not from P6** (§N2). Cancel is `admin` + +`moderator`. The routes get their real gates here even though the switchboard they will eventually +consult does not exist yet — a button that is admin-only later and open now is a gate nobody +notices was missing. + The spec is edited as **structured fields for the parts that exist** (name, description, schedule, phases with their steps) and the step's params as a raw JSON field — a deliberate placeholder that P13 replaces with the schema-driven editor. Say so in the UI, so it does not read as the finished thing. @@ -247,7 +275,10 @@ error, not an outage. enablement, cap, and the shard's own switch. Not for tidiness — it is what keeps an EM-style delegation model a later option rather than a redesign. -The role split from `EVENTS.md` §K is applied to the routes here. +The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and +including §N2's departure from the module-uo shape: **publishing and starting are `admin` only, +while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are +deliberately not the same gate. **Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of anything. @@ -344,9 +375,9 @@ push; the ceiling on `run.failed` proved to exclude a moderator. --- -### Phase 11 ⚠ — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) +### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) -**Gated on §N1.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked. +**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked. - **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a repeat with the original result; a lease deadline timer that restores baseline **without being @@ -366,9 +397,10 @@ proving baseline returns anyway. --- -### Phase 12 ⚠ — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) +### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) -**Gated on §N1.** The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and +**§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below +stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and either owned or borrowed: | Verb | Owned or borrowed | Cap dimension | @@ -493,9 +525,9 @@ flowchart TD P8 --> P9["P9 · UO wave 1
no protocol change"] P5 --> P10["P10 · integrations"] P9 --> P10 - P0 -.->|"§N1"| P11["P11 ⚠ · protocol bump"] + P0 -.->|"§N1 answered"| P11["P11 · protocol bump"] P8 --> P11 - P11 --> P12["P12 ⚠ · UO world verbs"] + P11 --> P12["P12 · UO world verbs"] P7 --> P13["P13 · authoring UI"] P4 --> P13 P10 --> P14["P14 · public + mobile"] @@ -523,7 +555,8 @@ change. This is a real product and it is roughly a third of the work. **Stop after P10** — everything above plus the module seam, the ledger, the first UO actions and every notification channel. The website orchestrates and announces; a GM does the target-driven parts in-client, exactly as `ADMIN_CONTROLS.md` §8 argued they should. **This is the recommended minimum -viable scope**, and it requires no answer to §N1 at all. +viable scope**, and it was the scope that required no answer to §N1 at all. §N1 has since been +answered in full, so this is now a scope choice rather than a way around an open decision. **Stop after P14** — everything, including the world verbs and the public calendar. P15 and P16 are not optional if anything ships to `main`; they are the cost of the `edge` discipline. -- 2.49.1 From 9ba119dc5acce1838ff0a7ceb2acefdbec2aadef Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 23:28:26 -0500 Subject: [PATCH 02/24] =?UTF-8?q?docs(events):=20Phase=201=20as=20built=20?= =?UTF-8?q?=E2=80=94=20schema,=20CRUD=20and=20the=20core=20action=20regist?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs half of RunicGateway/website#. Three files. **BACKEND_DESIGN.md** gains the six event tables, column by column, and the eleven admin routes. Written where the other table groups are, in the same shape, because the argument for a column belongs beside the column. **EVENTS.md** records four things the build settled that §D and §F had left open: - `event_definitions.spec`, the working copy. §D's column list does not name one because §D describes what a PUBLISHED event is made of — but "editing a draft is free; no version exists yet" means the draft has to live somewhere, and it cannot be an `event_versions` row: that table is immutable and a run pins one, so a mutable unpublished row in it would be exactly what versioning exists to prevent. - A param's `example` is REQUIRED, on optional params too, the same rule `registerEventTriggers` makes of a variable's example and for the same reason: it is the authoring form's placeholder, one word at declaration time and unreconstructable afterwards. - The authoring side of dormancy. §F said what happens at DISPATCH; the save path draws the same line one step earlier, in the shape `engagement_rules` established — a saved step may keep an unregistered action, a new step may not add one, and a dormant step blocks the publish rather than the save. - Publish re-validates against the registries as they stand at that moment, not from the save that wrote the spec. Plus two routes the § API surface table did not name — `GET /admin/events/:id` (the list serves a summary; the editor needs the tree) and `GET /admin/events/series` (a form cannot offer a value it cannot enumerate) — and a note stating which of that table's rows Phase 1 deliberately did not build. **EVENTS_PLAN.md** marks Phase 1 complete, names those four settlements, and states the two deliberate absences so a reviewer does not read them as gaps: the live run controls are not stubbed, and core's three `perform()` bodies answer `{ ok: false }` rather than `{ ok: true }` — `ok: true` on an action that did nothing is a recorded world change that did not occur. `api-route-inventory.json` is NOT resynced here. It has been stale since engagement Phase 2 and is 47 routes behind; catching it up in this PR would bury a 13-route change under an unrelated 47. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 182 ++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 43 +++++++-- website/EVENTS_PLAN.md | 32 +++++++ 3 files changed, 252 insertions(+), 5 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 1c91346..aa22c86 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1117,6 +1117,177 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m --- +### The six event tables — the engine's, game-agnostic (events phase 1) + +Design of record: [`EVENTS.md`](EVENTS.md) §D. Nine core tables are specified there; **six land in +Phase 1** — the ones that do not depend on the module contract. `event_action_settings`, +`event_run_budget`, `event_run_resources` and `event_run_participants` arrive with the phases that +give them a writer, rather than as empty tables nothing reads. + +Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id, +a `scope`, a resource kind and a budget dimension are opaque strings core stores and never +interprets. + +#### event_series — the arc +| col | type | notes | +|---|---|---| +| id | INT AUTO_INCREMENT PK | | +| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | | +| description | TEXT NULL | | +| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags | +| created_by | INT NULL FK→users(id) ON DELETE SET NULL | | +| created_at / updated_at | DATETIME | | + +Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one +arrives with the calendar. + +#### event_definitions — the thing that is listed, scheduled and audited +| col | type | notes | +|---|---|---| +| id | INT AUTO_INCREMENT PK | | +| title | VARCHAR(200) NOT NULL | | +| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it | +| summary | VARCHAR(500) NULL | | +| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is | +| image_url | VARCHAR(500) NULL | | +| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case | +| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to | +| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert | +| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below | +| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | | +| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc | +| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once | +| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 | +| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` | +| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | | +| created_at / updated_at | DATETIME | | + +`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter. +`INDEX(series_id, series_order)` — the arc. + +**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.** +"Editing a draft is free; no version exists yet" means the working copy has to live somewhere, and it +cannot be an `event_versions` row: that table is immutable and a run pins one, so a mutable +unpublished row in it would be exactly what versioning exists to prevent. Publishing copies this +column into a version and leaves it standing as the next draft. + +#### event_versions — the immutable snapshot a run pins +| col | type | notes | +|---|---|---| +| id | INT AUTO_INCREMENT PK | | +| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | | +| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 | +| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree | +| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | | +| published_by | INT NULL FK→users(id) ON DELETE SET NULL | | + +**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the +*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what +makes a run reproducible and an audit answerable after the definition has moved on. + +#### event_runs — one occurrence, in one scope +| col | type | notes | +|---|---|---| +| id | BIGINT AUTO_INCREMENT PK | | +| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | | +| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for | +| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence | +| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal | +| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both | +| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it | +| current_phase | VARCHAR(64) NULL | | +| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant | +| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation | +| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params | +| params | JSON NULL | | +| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history | +| started_at / ended_at | DATETIME NULL | | +| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner | +| started_by | INT NULL FK→users(id) ON DELETE SET NULL | | +| last_error | VARCHAR(500) NULL | | +| created_at / updated_at | DATETIME | | + +`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per +occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what +stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers +without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one +occurrence answers with the existing row rather than raising a duplicate-key error a caller has to +interpret. + +`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list +· `INDEX(concurrency_key, status)` the overlap check. + +#### event_run_steps — the work queue +| col | type | notes | +|---|---|---| +| id | BIGINT AUTO_INCREMENT PK | | +| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | | +| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | | +| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted | +| params | JSON NULL | validated against the action's declared params at save | +| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter | +| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows | +| due_at | DATETIME NULL | | +| attempts | INT NOT NULL DEFAULT 0 | | +| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run | +| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat | +| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | | +| last_error | VARCHAR(500) NULL | | +| started_at / finished_at | DATETIME NULL | | +| created_at / updated_at | DATETIME | | + +`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran +into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan · +`INDEX(run_id, phase, seq)` the run console. + +#### event_run_log — "why didn't phase 3 start?" must be a query +| col | type | notes | +|---|---|---| +| id | BIGINT AUTO_INCREMENT PK | | +| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | | +| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | | +| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` | +| phase | VARCHAR(64) NULL | | +| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable | +| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | | + +`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds. + +**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative +audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did* +goes here. They are different questions with different readers and different retention. The writer +**never throws** — a failure to record why something went wrong must not become a second failure on +top of the first. + +**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.** +`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows +are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the +beginning. + +#### The action registry — declared, never stored + +Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables, +deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one +would outlive the module that can perform it. `modules/registries.js` gained +`registerEventActions` in this phase, with core as its first registrant — +`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is +exercised on every boot long before a module uses it. + +The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names +a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the +closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only +iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type +and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog +serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is +naming one by id. + +`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js` +builds its own `api` facade for a module and has no method that delegates to it, so no module can +call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later +phase's. + +--- + ## 4. API contract Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also @@ -1512,6 +1683,17 @@ file a route sits in — that is the property the route manifest freezes. | GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately | | GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term | | GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply | +| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` | +| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders | +| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused | +| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition | +| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins | +| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps | +| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight | +| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` | +| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log | +| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing | +| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase | | — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) | Every admin write logs to `activity_log`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 1e8c0b8..7811318 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -400,8 +400,8 @@ tables carry no module prefix. | Table | Holds | Why a table | | --- | --- | --- | -| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `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, description, ordering. Definitions optionally belong to one. | **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_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_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. | @@ -411,6 +411,14 @@ tables carry no module prefix. | `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_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | +> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it +> follows from "editing a draft is free; no version exists yet" below. A draft's working spec has to +> live somewhere, and it cannot be an `event_versions` row: that table is immutable and a run pins +> one, so a mutable unpublished row in it would be precisely what versioning exists to prevent. +> Publishing copies the column into a version and leaves it standing as the next draft. `series_order` +> is the same kind of addition — `event_series.ordering` places a series among the others, and a +> definition's place *within* its arc is the column an editor drags. + ### Not tables, deliberately - **Phases** — configuration in `event_versions.spec`, materialised as steps when a run starts. A @@ -592,7 +600,11 @@ api.registerEventLeases([{ game words a chess ladder has no use for. - **Params are validated at save *and* at dispatch, against the declared version.** A step stores the `action_version` it was authored against; a bump makes it render a warning in the editor rather - than dispatch a mistyped parameter. + than dispatch a mistyped parameter. A param's `example` is **required**, on the optional params as + well as the required ones — the same rule `registerEventTriggers` makes of a variable's example and + for the same reason. It is the authoring form's placeholder, it is one word at declaration time, + and it is unreconstructable afterwards; a blank box is how an unattended world write comes to be + scheduled with a typo in it. - **Resources are named by the module and owned by core.** `kind` and `ref` are opaque strings core stores verbatim — `ctx.teams.activity.push`'s exact treatment. Core does the remembering; the module does the meaning. @@ -610,7 +622,11 @@ api.registerEventLeases([{ **no concept of "the game being up"** — only `{ ok: false, retry: true }` — because a module with six sidecars cannot answer that question in the singular. - **An action whose module is uninstalled goes dormant, never an error.** A step naming it fails - `terminal` with the module named and the run degrades — never a silent skip. + `terminal` with the module named and the run degrades — never a silent skip. The authoring side + draws the same line one step earlier, in the shape `engagement_rules` established for a dormant + trigger: **a step already in a saved spec may keep an unregistered action and a new step may not + add one**, so an uninstall is never destructive after the fact — and a dormant step blocks the + *publish*, because a version is what a run pins and a run cannot dispatch a verb nobody registers. - **Actions and budgets are their own id spaces.** An action names a verb, a trigger names an event, a budget names a resource dimension. @@ -924,7 +940,7 @@ without stealing an edit. | Edit | Effect | | --- | --- | | Editing a `draft` | Free. No version exists yet. | -| Publishing | Snapshots the whole spec into an immutable `event_versions` row and points `current_version_id` at it. | +| Publishing | Snapshots the whole spec into an immutable `event_versions` row and points `current_version_id` at it. The spec is **re-validated against the registries as they stand at that moment**, not trusted from the save that wrote it: a module uninstalled in between must block the publish rather than produce a run that fails at dispatch with the world half-changed. | | Editing a `ready` definition with no live run | Creates the next version on publish. Future runs use it. | | Editing while a run is live | Creates the next version. **The live run keeps the version it pinned** and is unaffected. The editor says so. | | Changing what a *running* event does | **Not an edit.** The live controls are pause, resume, skip, force-advance and cancel — each logged, each attributable, none mutating a version. Anything more expressive is a cancel and a new run, because a half-executed spec edited mid-flight is neither reproducible nor auditable. | @@ -940,6 +956,7 @@ no URL moved. | Route | Gate | | | --- | --- | --- | | `GET /admin/events` | staff | definitions, state, next occurrence, health | +| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads | | `POST /admin/events` | admin, editor | create a draft | | `PUT /admin/events/:id` | admin, editor | edit the draft spec | | `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` | @@ -955,6 +972,7 @@ no URL moved. | `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 | | `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param | +| `GET /admin/events/series` | staff | the arcs a definition may belong to | | `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps | | `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 | @@ -966,6 +984,21 @@ no URL moved. > dispatches nothing, and the author who wrote the definition is exactly who should be able to price > it against the caps before asking an admin to publish it. +> **Two rows above were added by Phase 1 rather than decided in §N**, and both are derived from +> what the surface needs rather than from a new policy. `GET /admin/events/:id` exists because the +> list route serves a summary and the editor needs the whole authored tree; `GET +> /admin/events/series` exists because a definition carries `series_id` and a form cannot offer a +> value it cannot enumerate. Both are staff reads of data the list route already exposes, so neither +> widens the surface's reach. + +**What Phase 1 built, and what it deliberately did not.** Definitions CRUD, publish, archive, the +version history, the action catalog, the series read and the run reads are live. Every route that +acts on a run *in flight* — pause, resume, advance, cancel, step skip/retry/confirm, cleanup — is +absent rather than stubbed, because nothing is in flight until the runner exists: a control that +answers `200` and does nothing is worse than one that is not there. `verify` and `GET/PUT +/admin/events/actions` are absent for the same kind of reason — there are no caps to price against +and no switchboard to serve until the phase that builds them. + A module registers actions server-side and adds **no routes** for them beyond its option endpoints, which is what keeps the browser from being able to name a transport. diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 2915575..ef78084 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -137,6 +137,38 @@ document and should be written where they will be found: ### Phase 1 — Schema, CRUD and the core action registry (`website` + `docs`) +> **Complete.** `edge` in `website` and `docs`. Six tables, thirteen routes, the action registry with +> core as its first registrant, and 44 tests. **Nothing dispatches** — a run row is created and stays +> `scheduled`, which is this phase's correct answer and is rendered as such. +> +> **Four things the build settled that the plan had left open, each recorded in `EVENTS.md`:** +> +> - **`event_definitions` gained a `spec` column.** §D's column list does not name one, because §D +> describes what a published event is made of. But "editing a draft is free; no version exists yet" +> means the working copy has to live somewhere, and it cannot be an `event_versions` row: that table +> is immutable and a run pins one. Publishing copies the column into a version and leaves it as the +> next draft. +> - **The spec validator must accept its own output**, and a test found it did not. `validate()` adds +> `actionVersion` and `dormant`, then refused them as unknown keys on the next call — which would +> have made the *second* save of any definition, and publish's own re-validation, impossible. Both +> are now accepted and recomputed rather than trusted. +> - **A param's `example` is required**, on optional params too, matching `registerEventTriggers`. It +> is the authoring form's placeholder and there is no other source for one. +> - **Two routes the §API-surface table did not name**: `GET /admin/events/:id` (the list serves a +> summary; the editor needs the tree) and `GET /admin/events/series` (a form cannot offer a value it +> cannot enumerate). Both are staff reads over data the list already exposes. +> +> **Two deliberate absences, both stated so a reviewer does not read them as gaps.** The live run +> controls and `verify` are not stubbed — nothing is in flight until P2, and a control that answers +> `200` and does nothing is worse than one that is not there. And core's three `perform()` bodies +> answer `{ ok: false, retry: false }` rather than `{ ok: true }`: `ok: true` on an action that did +> nothing is a recorded world change that did not occur, which is the exact mistake §F's failure +> default exists to prevent. +> +> `registerEventActions` is on the staging area and reachable **only** by `registerCore()` — the +> loader builds its own `api` facade and has no method that delegates to it, so no module can call it +> yet and `MODULE_API_VERSION` is untouched. P7 adds that facade and makes the bump. + The six tables that do not depend on the module contract: `event_definitions`, `event_series`, `event_versions`, `event_runs`, `event_run_steps`, `event_run_log`. Admin CRUD, publish (which snapshots a version), archive. `router/v1/admin/events.router.js` + `events.controller.js`, models as -- 2.49.1 From 76b2276d362c50da01c39ba4eb739ce7a6464c34 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 06:32:15 -0500 Subject: [PATCH 03/24] docs(events): Phase 2 as built - the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs half of Event System Phase 2. Pairs with `RunicGateway/website` `feat/events-phase-2`. EVENTS.md gains what the runner settled: the parked step (`running` with a NULL lease), the two success-envelope members `await: 'human'` and `holdFor`, the answer for a run whose concurrency key is held, `n` in §L's `retry(n)`, the rule that all three `on_failure` dispositions write the step `failed`, and the health transition on the first retry rather than the eventual failure. EVENTS_PLAN.md marks Phase 2 complete and records the four org-lead decisions and the three things the build settled on its own. Co-Authored-By: Claude --- website/EVENTS.md | 64 +++++++++++++++++++++++++++++++++++++++-- website/EVENTS_PLAN.md | 65 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 7811318..84b2356 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -448,7 +448,7 @@ tables carry no module prefix. | `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. | +| `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). | ### The scheduler @@ -459,6 +459,24 @@ which cannot load module code. Same `setInterval` + `unref()` + `stop()` shape, evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording resources. +**As built in Phase 2, the tick has four legs and one of them is smaller than the above implies.** +Ordered: **reclaim** (release leases whose holder died), **materialise**, **advance**, **drain**, then +a **prune** on its own six-hourly clock. What "materialise" covers today is only the grace window — +the spec validator accepts `kind: 'manual'` alone until Phase 4, so there is no recurrence to expand +and the only occurrences that exist are the ones an admin created. The half that is already real is +the half that already matters: a run whose instant passed while the process was down becomes `missed` +rather than starting late and silently. Phase 4 adds the expansion above it. + +Three numbers govern a step, and they live in the runner rather than in a column because no authoring +surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_MS` (60 000, flat), and +`EVENT_RUN_LEASE_MS` (15 minutes). A step's own lease is not one of them — it is computed from that +action's declared `budgetMs` plus a minute, because a registry that lets an action declare an hour +would otherwise have its steps reclaimed and re-dispatched fifty-nine minutes before they answered. + +**Serial within a phase.** The runner works the lowest-`seq` step of the current phase that is not +terminal, and does nothing with the one after it until that one finishes. This is the only reading +under which `core.wait` means anything, and the only one under which a cue can gate what follows it. + **Two scheduling decisions the calendar forces.** *Schedules are timezone-aware, and the timezone belongs to the event.* Every EM listing is in the @@ -483,6 +501,15 @@ the fishing contest on Drachenfels is exactly that shape. | 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. | +**What happens to the run that loses.** It is **held at `scheduled`**, not failed and not queued +(org lead, 2026-09-02). Every tick re-examines it; if the holder finishes inside the grace window the +run starts, and if it does not the missed sweep makes the run terminal and visible. Failing it +immediately would say the system broke when in fact it correctly declined to overlap two events, and +queueing it indefinitely would let an event whose announcement said 8pm begin at 11pm — the exact +thing `missed` exists to prevent. The reason is written to `last_error` and logged as `run.blocked` +**only when it changes**, because a line per tick for the length of a grace window buries the one +line that matters. + > **This deployment runs one app instance, and every protection above is built anyway** > ([§N4](#n--decisions)). The "two instances" column names the *hardest* contender for each row, not > the only one: the unique index and the CAS equally protect a tick that runs long while the next one @@ -586,6 +613,31 @@ api.registerEventLeases([{ ### What is contract rather than implementation +**Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02). +Both are ordinary envelope members rather than special cases keyed on an action id, so the runner +never names a verb, and a module's own long-running action reaches them through the same door core's +does: + +```js +return { ok: true, await: 'human' } // PARK. The step stays `running` with a NULL lease; + // nothing advances until a human confirms it. +return { ok: true, holdFor: 300 } // FINISH, and delay what follows by 300s. The pause is + // the NEXT step's `due_at`, owned by core. +``` + +`await: 'human'` is what makes the GM cue work, and the NULL lease is load-bearing: the stale reclaim +only ever takes back a lease that is **non-NULL and expired**, so a cue posted on Friday is still +waiting on Monday rather than being re-dispatched every fifteen minutes. `holdFor` is what makes +`core.wait` a no-op at dispatch — a `perform()` that slept would hold its claim for the duration, turn +a five-minute pause into a five-minute lease, and be re-dispatched by the reclaim, so a long enough +wait would never end. It is bounded at seven days. + +**A `holdFor` on the last step of a phase holds the next phase**, rather than meaning nothing. The +later phase's steps do not exist at that moment — they are materialised on entry — so the instant is +carried across the boundary and applied to the new phase's first step. Dropping it would make +"announce, wait five minutes, then the next phase" start the next phase at once, which is a wait that +silently did nothing. + - **Every method answers with an envelope, and no shape a failure can take reads as success.** `registerTeamProvider`'s load-bearing rule, inverted: the team provider's default on refusal is "keep what you have" because staleness is cheap; an action's default is **"nothing happened, @@ -903,6 +955,7 @@ controller stamps it from the session. | Situation | Behaviour | | --- | --- | +| **A step retries at all** | The run goes `degraded` on the FIRST retry, not on the eventual failure — an event whose announcements are landing on the second attempt is having trouble now, and now is when an operator wants to know. `health` is not `status`: the run is still genuinely running (§E). | | **Core restarts mid-run** | Nothing is held in memory. The next tick finds steps in `running` with expired leases, reclaims them *without resetting `attempts`*, and continues. A step whose ack was lost is re-dispatched with the *same* idempotency key. | | **Core is down when a run should start** | Within `grace_seconds` it starts late and the log says so. Past it the run is `missed` — a terminal state a human can see. An event that begins three hours after its announcement is worse than one that visibly did not. | | **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. | @@ -912,7 +965,7 @@ 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 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. | -| **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. | +| **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. | | **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. | @@ -999,6 +1052,13 @@ answers `200` and does nothing is worse than one that is not there. `verify` and /admin/events/actions` are absent for the same kind of reason — there are no caps to price against and no switchboard to serve until the phase that builds them. +**Phase 2 added no routes at all.** It is the runner, and a runner has no surface: a published +definition started through `POST /admin/events/:id/runs` now actually runs, and the run reads that +already existed render it moving. The controls above are still absent, and they are still Phase 3's — +the shipped demo of Phase 2 is a run that announces, waits and completes without anyone touching it, +which is exactly the thing that needs no control. `core.cue`'s confirm is the first of them that has +something to act on, and it arrives with the console that shows the cue. + A module registers actions server-side and adds **no routes** for them beyond its option endpoints, which is what keeps the browser from being able to name a transport. diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index ef78084..6f5187f 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -195,6 +195,60 @@ the same rule. ### Phase 2 — The runner (`website`) +> **Complete.** `edge` in `website`. The eighth poller, the two CAS claims, the lease and its +> reclaim, the grace window, and the three core actions given real bodies. **A published event +> started from the existing run route now announces, waits and completes on its own** — the phase's +> shipped claim, and it adds no routes to do it. +> +> **Four things the org lead settled that the plan and §E had left open** (2026-09-02), each written +> into `EVENTS.md`: +> +> - **A parked step is `running` with a NULL lease.** `event_run_steps.status` has no state for +> "waiting on a human", and adding one would be a table ALTER that `CREATE TABLE IF NOT EXISTS` +> never delivers to an existing deployment. So the reclaim was written to take back only a lease +> that is **non-NULL and expired**, and a NULL one means parked. A cue posted on Friday is still +> waiting on Monday. +> - **Two success-envelope members, not two special cases.** `{ ok: true, await: 'human' }` parks; +> `{ ok: true, holdFor: }` finishes and delays what follows. The runner never names an +> action id, and Phase 7 hands a module the same door. +> - **A run whose concurrency key is held stays `scheduled`** and lets its own grace window decide, +> rather than failing at once or queueing indefinitely. +> - **`n` in §L's `retry(n)` is a runner constant** — `EVENT_STEP_MAX_ATTEMPTS`, 3, with a flat +> 60s backoff — rather than a column or a spec field. +> +> **Three things the build settled on its own, all worth a look:** +> +> - **All three `on_failure` dispositions write the STEP `failed`.** The disposition governs the RUN. +> `skipped` is left for a human's skip control in Phase 3, because a status meaning both "nobody ran +> this" and "this failed and we moved on" makes the console's summary line unreadable. +> - **A live lease is not re-enterable, not even by the process that took it.** The first draft of +> `claimTick` carried an `OR claimed_by = ?` escape for a tick re-entering its own claim — which is +> precisely the overrun this phase's CAS is meant to protect against, since `setInterval` fires +> whether or not the last callback returned. The clause is gone, a `releaseClaim` hands a still- +> in-flight run back at the end of a tick (without it every `core.wait` would become +> `max(wait, leaseMs)`), and an in-process `ticking` guard skips an interval that would overlap. +> - **A wait as the last step of a phase holds the NEXT phase.** The first implementation set the +> following step's `due_at` and stopped there, so a trailing wait — "announce, wait five minutes, +> then phase 2" — silently meant nothing, because the next phase's steps are not materialised until +> the run enters it. The instant is now carried across the boundary. Found by writing the test, and +> the test was re-run against the unfixed code to confirm it fails. +> +> **What "materialise" means here.** The spec validator accepts `kind: 'manual'` alone until Phase 4, +> so there is no recurrence to expand — this leg builds the half that is already real, the grace +> window, and Phase 4 adds the expansion above it. +> +> **Verify, as run.** `npm test` — **1682 tests, 1638 pass, 43 skipped, 1 fail**, and that one is +> `engagementManifest.test.js`, pre-existing and environmental (`engagement-triggers.json` is CRLF in +> a Windows tree under `core.autocrlf=true` while the generator writes LF; content identical, green on +> CI, confirmed still failing with this branch stashed). 39 new tests across `eventRunner.test.js` and +> `eventRunnerSql.test.js`; the Phase 1 test asserting core's placeholders refused is replaced rather +> than deleted, because half of what it proved still holds. `routes:manifest` and `swagger` +> regenerated to a **zero-line diff** — the runner has no surface. `check:modules` clean. +> +> **Trap for anyone running the suite on this machine:** `server/modules/uo` is installed here, so the +> core suite and both generators need an empty `MODULES_DIR`. Without it `routeManifest.test.js` fails +> on a difference that is the module's, not the branch's. + `utils/eventRunner.js`, the eighth poller: same `setInterval` + `unref()` + `stop()` shape as the other seven, wired into `server.js`'s start and shutdown beside `engagementWorker`. @@ -213,6 +267,17 @@ built exactly as `EVENTS.md` §E specifies, and they are still the point of the protects a tick that overruns into the next one, and what recovers a step whose process died mid-dispatch. Test both in-process. The decision is not licence to drop a CAS. +> **As built:** the in-process half is `eventRunner.test.js`, and the statements themselves are proved +> against a real MariaDB in `eventRunnerSql.test.js` — which SKIPS when there is none, so CI stays +> green without a database. That second file exists because of what engagement Phase 4a found: a +> cooldown claim that was green against its stub and always allowed the send against a real server, +> because the connector defaults `foundRows: true` and a no-op UPDATE reports 1 rather than 0. A stub +> can only ever agree with whoever wrote it. Run it with: +> +> ```bash +> DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node --test test/eventRunnerSql.test.js +> ``` + **Two traps, both already paid for once in this codebase.** - **A reclaim must not reset `attempts`.** Engagement Phase 14's defect: a sweep that returned every stale row to its start state made `MAX_ATTEMPTS` unreachable, so the row cycled forever, never -- 2.49.1 From 261cce64231a13d60149cc28c7af7305d35cb7e7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 08:39:52 -0500 Subject: [PATCH 04/24] =?UTF-8?q?docs(events):=20Phase=203=20as=20built=20?= =?UTF-8?q?=E2=80=94=20the=20minimal=20admin=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS.md: the six controls that exist and the two that do not, and why; retry as one control with its two guards; what a cancel does to a parked cue; the run status re-read between steps; the two derived response fields. EVENTS_PLAN.md: Phase 3 complete, the four org-lead decisions, and the three things the build settled — including the mid-batch pause defect and the retry guard that was reading the wrong end of the phase. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- website/EVENTS.md | 65 ++++++++++++++++++++++++++++++++++--- website/EVENTS_PLAN.md | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 84b2356..f913b24 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -473,6 +473,11 @@ surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_M action's declared `budgetMs` plus a minute, because a registry that lets an action declare an hour would otherwise have its steps reclaimed and re-dispatched fifty-nine minutes before they answered. +**A run's status is re-read between steps, not only at the top of a tick** (Phase 3). One tick drains +up to `EVENT_STEPS_PER_TICK` steps from one run, so without this a pause pressed mid-batch would be +answered by dispatching another two dozen steps — which is not a pause. It is one indexed read per +step, against a control whose entire value is that it takes effect at once. + **Serial within a phase.** The runner works the lowest-`seq` step of the current phase that is not terminal, and does nothing with the one after it until that one finishes. This is the only reading under which `core.wait` means anything, and the only one under which a cue can gate what follows it. @@ -851,11 +856,40 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie - **A GM cue step.** A core action whose implementation is "post the instruction, wait for a human to confirm they did it in-client, then advance". No module, no protocol, no world write — and it makes the entire system useful on day one, with a GM doing the target-driven parts in-client exactly as - `ADMIN_CONTROLS.md` §8 argued they should. + `ADMIN_CONTROLS.md` §8 argued they should. **Both halves exist as of Phase 3**: the action parks + the step in Phase 2, the run console's confirm ends it. - **Live controls that are honest.** Pause, resume, skip a step, force a phase advance, cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event is not one of them (see [Versioning](#versioning-and-editing-a-live-event)). +> **Six of those exist as of Phase 3, and two do not — for reasons, not for scope.** Pause, resume, +> cancel, and a step's confirm, skip and retry are built and gated to `admin` + `moderator`. +> **`advance` — force a phase forward — is not**, because it has no honest meaning yet: a phase today +> advances when its steps go terminal, and the per-step skip already does that one step at a time. +> Phase 5 gives a phase an advance *condition*, and that is the first moment "force it anyway" names +> something an operator could predict. **Cancel takes `{ reason }` and not `{ cleanup }`**, because +> the resource ledger a cleanup would work over arrives in Phase 8; a flag that changes nothing is +> the "control that answers 200 and does nothing" this plan has refused twice already. + +> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a +> paused run is paused *at* that step — so re-queueing without resuming would leave the run in +> precisely the state it was already in, with a second button the operator now has to find. +> Splitting them would read as honesty and behave as a trap. The single action re-queues the step and +> resumes the run, and its log line says both. +> +> Two guards make it safe. The step must be `failed`, and it must be **the furthest step its phase has +> reached** — `MAX(seq) WHERE status <> 'pending'`. The near miss is worth recording because the +> obvious rule is the wrong one: "the lowest step that is not settled" looks equivalent and is not, +> because `nextOpenStep` selects `pending` and `running` only and the runner therefore steps *over* a +> failed step. Under that rule a phase whose second step failed-and-skipped and whose fifth then +> failed-and-paused would offer retry on the second, re-queueing a row behind the runner's own cursor +> where it would sit `pending` for ever. +> +> `attempts` returns to zero, and that is not the rule Engagement Phase 14 arrived at being broken. +> That rule is about *sweeps*: an automatic path that reset a counter made the ceiling unreachable and +> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named +> person deciding once is the thing it is unattended from. + **Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is having trouble" is operator information, and the existing shard pages already model exactly this by @@ -966,7 +1000,9 @@ controller stamps it from the session. | **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 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. | | **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 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. | +| **A human retries a step** | Only while the run is `paused`, and only on the furthest step its phase has reached. `attempts` returns to zero and the run resumes in the same action; both facts are in the log line with the actor. | | **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. | ### The ledger's two rules @@ -1020,8 +1056,9 @@ no URL moved. | `GET /admin/events/runs` | staff | run history across definitions | | `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | -| `POST /admin/events/runs/:runId/pause\|resume\|advance\|cancel` | admin, moderator | live control of a run in flight; `cancel` takes `{ cleanup, reason }` | -| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | `confirm` resolves a GM cue step | +| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 | +| `POST /admin/events/runs/:runId/advance` | admin, moderator | force a phase forward — **not built**; it has no honest meaning until Phase 5 gives a phase an advance condition | +| `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 | | `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param | @@ -1059,6 +1096,26 @@ the shipped demo of Phase 2 is a run that announces, waits and completes without which is exactly the thing that needs no control. `core.cue`'s confirm is the first of them that has something to act on, and it arrives with the console that shows the cue. +**Phase 3 added six routes, and they are the live controls.** `pause`, `resume` and `cancel` on a +run; `confirm`, `skip` and `retry` on one of its steps. All six are `admin` + `moderator` — the one +gate in this feature wider than `admin`, and deliberately so (§N2). Every one of them is a +compare-and-set against the status it is allowed to act from rather than a read-then-write: the +runner ticks every fifteen seconds, so a console rendered thirty seconds ago describes a run that has +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: `advance` (no advance condition until Phase +5), `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). + +Two response fields arrived with the console and are worth naming because both are **derived, not +columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST +can say so — a cue nobody notices is a run that never advances while looking perfectly healthy from +the outside. A step carries `parked`, a boolean, because the console has to tell a cue waiting on a +person from a step some process is mid-dispatch on, and it must not do so by being shown +`claimed_by` and `claim_expires_at`: those are the runner's business, and a UI that reasoned about +leases would be a second opinion about who owns a row. + A module registers actions server-side and adds **no routes** for them beyond its option endpoints, which is what keeps the browser from being able to name a transport. diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 6f5187f..6cdc1be 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -289,6 +289,71 @@ mid-dispatch. Test both in-process. The decision is not licence to drop a CAS. ### Phase 3 — The minimal admin surface (`website`) +> **Complete.** `edge` in `website`. Three screens, a nav group and **six live run controls** — the +> routes Phase 1 left absent on purpose because nothing was in flight, and Phase 2 gave something to +> act on. **An admin can now author, publish, schedule, start and watch an event that announces +> things and cues a human, and a moderator can stop one that is going wrong.** This is the first +> phase with a demo. +> +> **Four decisions the org lead settled (2026-09-02), all as recommended:** +> +> - **Six controls, not four and not eight.** `pause`, `resume`, `cancel` on a run; `confirm`, +> `skip`, `retry` on a step. **`advance` is not built** — a phase today advances when its steps go +> terminal, and the per-step skip already does that one step at a time, so a force-advance now would +> silently change meaning under the operator when Phase 5 gives a phase an advance *condition*. +> `cleanup` needs Phase 8's ledger. +> - **Cancel takes `{ reason }`, not `{ cleanup, reason }`.** The flag arrives with the thing it would +> act on. A `cleanup: false` that changes nothing is the "control that answers 200 and does nothing" +> Phases 1 and 2 both refused. +> - **Its own top-level nav group, staff-wide** (`admin`, `editor`, `moderator`) — not admin-only like +> Engagement's. §K makes every read here `staff`, and the moderator's entire power over this feature +> is the run console; hiding it from them would leave the one role that exists for incident response +> unable to see the incident. The narrow gates are on the actions instead, and each button follows +> the route it calls. +> - **The params box is a raw JSON field with the action's declaration rendered beside it.** Both are +> already in the catalog — name, type, required, description, example — so the placeholder is usable +> without reading source, and it is captioned as a placeholder so it does not read as Phase 13's +> schema-driven form. +> +> **Three things the build settled, and the first is a defect in shipped code:** +> +> - **A pause pressed mid-tick did nothing for up to 24 more steps.** `advanceRun` drains up to +> `EVENT_STEPS_PER_TICK` steps from one run inside a single tick and only checked the run's status at +> the top of it — so the whole value of a pause, that it takes effect *now*, was absent. The loop +> re-reads the status between steps (`runsDb.statusOf`, one indexed column by primary key). Found by +> writing the test; the test was re-run against the unfixed code to confirm it fails, and it does. +> - **The retry guard was reading the wrong end of the phase.** The first draft asked for the lowest +> `seq` that is not *settled*, which looks equivalent to "the step the run is stopped at" and is not: +> `nextOpenStep` selects `pending` and `running` only, so the runner steps *over* a `failed` step. +> A phase whose second step failed-and-skipped and whose fifth then failed-and-paused would have +> offered retry on the second, re-queueing a row behind the runner's own cursor where it sits +> `pending` for ever. The rule is now `MAX(seq) WHERE status <> 'pending'` — the furthest the phase +> has reached — and the test that found it is the one that names the case. +> - **Retry and resume are one control, because there is no state in which you would want half of +> it.** Retry is legal only from `paused`, and a paused run is paused *at* that step; re-queueing +> without resuming leaves the run exactly where it was with a second button to find. `attempts` +> returns to zero: the ceiling bounds what the runner does **unattended**, and a named person +> deciding once is the thing it is unattended from. That is not Engagement Phase 14's rule being +> broken — that rule is about automatic *sweeps*. +> +> **Two smaller ones, taken as assumptions rather than asked:** the console polls every 5s while the +> run is non-terminal and stops the moment it is not (§N5, poll not SSE — a run changes on a +> fifteen-second tick and a console is a tab left open for two hours); and `confirm` takes an optional +> note saying what was actually done in-client, which is kept on the step and in the log. +> +> **A cue nobody notices is a run that never advances**, and it looks perfectly healthy from the +> outside — `running`, nothing failed. So `waitingSteps` is on the run LIST as well as the console, +> as a derived count rather than a column, and the list leads with a banner naming every run that is +> waiting on a person. +> +> **Verify, as run.** `npm test` — the pre-existing `engagementManifest.test.js` CRLF failure is the +> only red, exactly as in Phase 2. **42 new tests**: `eventRunControls.test.js` (22, almost all of them +> *refusals* — a control that works from a status it should not have is a staff member changing a live +> world from a stale screen), 8 more in `eventRunnerSql.test.js` proving the four new statements +> against a real MariaDB, 2 more in `eventRunner.test.js`, and `eventAuthoring.test.js` (20) on the +> client. `routes:manifest` and `swagger` regenerated — **six routes added, none moved**. The client +> builds. + `client/src/routes/admin/views/EventsAdmin.jsx`, `EventEditor.jsx`, `EventRun.jsx`, plus the nav rows. A list with state and next occurrence; a create/edit form; publish; start now; cancel; and a run console showing the phase, the step list with status and attempts, and the log. @@ -298,6 +363,14 @@ console showing the phase, the step list with status and attempts, and the log. consult does not exist yet — a button that is admin-only later and open now is a gate nobody notices was missing. +**As built, the control set is six**: `pause`, `resume`, `cancel` on a run and `confirm`, `skip`, +`retry` on a step, all `admin` + `moderator`. Every one of them is a compare-and-set on the status it +may act from, never a read-then-write — the runner ticks every fifteen seconds, so a console rendered +thirty seconds ago describes a run that has 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, and the client models the same guards so a button the server will refuse is not offered +in the first place. + The spec is edited as **structured fields for the parts that exist** (name, description, schedule, phases with their steps) and the step's params as a raw JSON field — a deliberate placeholder that P13 replaces with the schema-driven editor. Say so in the UI, so it does not read as the finished thing. -- 2.49.1 From 9410181560c299eeded3b40edd83289f3e4f0547 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 16:10:50 -0500 Subject: [PATCH 05/24] docs(events): Phase 4 as built - schedule, recurrence and the calendar EVENTS.md: the materialise leg as two halves and why the horizon is what makes the missed sweep meaningful; the recurrence shapes as built, incl. the two DST rules and why nth has no fifth; publishing as the schedule switch and the re-pin; the calendar's run-vs-projection distinction and the reader's-zone/event's-zone split; the four new routes; P1-P4 ticked. EVENTS_PLAN.md: Phase 4 marked complete with the five org-lead decisions, the three the build settled, the test-harness defect the phase introduced and fixed, and the verified numbers. Website: RunicGateway/website#PENDING Co-Authored-By: Claude --- website/EVENTS.md | 111 ++++++++++++++++++++++++++++++++++++----- website/EVENTS_PLAN.md | 61 +++++++++++++++++++--- 2 files changed, 153 insertions(+), 19 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index f913b24..1c1f3d4 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -459,13 +459,27 @@ which cannot load module code. Same `setInterval` + `unref()` + `stop()` shape, evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording resources. -**As built in Phase 2, the tick has four legs and one of them is smaller than the above implies.** -Ordered: **reclaim** (release leases whose holder died), **materialise**, **advance**, **drain**, then -a **prune** on its own six-hourly clock. What "materialise" covers today is only the grace window — -the spec validator accepts `kind: 'manual'` alone until Phase 4, so there is no recurrence to expand -and the only occurrences that exist are the ones an admin created. The half that is already real is -the half that already matters: a run whose instant passed while the process was down becomes `missed` -rather than starting late and silently. Phase 4 adds the expansion above it. +**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died), +**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock. + +**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's +recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon** +(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the +occurrence key — so the tick that already made one makes nothing, which is what lets it run every +fifteen seconds for ever. The second SWEEPS: a run whose instant passed while the process was down +becomes `missed` rather than starting late and silently. + +**The two halves need each other, and the horizon is why.** Expansion looks forward from +`now - grace_seconds` only, so an occurrence nobody ever materialised is never invented +retroactively — waking up after three days down must not manufacture three days of `missed` history +that no operator could have seen or cancelled. It does not have to: because rows exist a fortnight +ahead of their instant, an outage spanning an occurrence finds the row already there and the sweep +marks it honestly. **The horizon is what makes the missed sweep mean anything for a recurring event.** + +**Automatic expansion is at the empty scope** (org lead, 2026-09-02). A fan-out across named scopes +needs a registry of what a scope *is*, which no phase owns yet; inventing one before the module +contract would be a contract the modules were never asked about. An admin's own +`POST /admin/events/:id/runs` still takes any scope. Three numbers govern a step, and they live in the runner rather than in a column because no authoring surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_MS` (60 000, flat), and @@ -495,6 +509,49 @@ The only cron precedent is in the bot, in another process, with no parser in the tree — and a cron string is the one field an operator cannot proofread. Monthly-nth is not padding: the fishing contest on Drachenfels is exactly that shape. +**As built in Phase 4.** `time` is `HH:MM` and `days`/`weekday` are English weekday names rather than +numbers, for the same proofreading reason that rejected cron; `at` is a **local wall clock** +(`YYYY-MM-DDTHH:MM`) in the definition's own zone, never a UTC instant, because the schedule belongs +to the event and the instant is derived at materialisation. `days` is normalised into week order, so +two spellings of one schedule do not show as an edit nobody made in the version history. **`nth` is +`1..4` or `-1` for "last"** (org lead, 2026-09-02): every month has a first through fourth of every +weekday, so the closed set has no absent-occurrence case to define, and `-1` is the shape a +"last Friday" contest actually is — it is not a synonym for `4`. + +There is **no date library in the server's dependency tree** and Phase 4 did not add one. Node ships +the full tzdata behind `Intl.DateTimeFormat`, which is the same database a library would vendor a +copy of and is already what the zone-name check uses. `events/recurrence.js` inverts the +instant-to-wall-clock mapping by search; it is the one place an occurrence is computed, so the +runner's expansion and the calendar's forecast cannot disagree. + +**The two DST rules** (org lead, 2026-09-02), which exist because a weekly 02:30 event in +`Europe/Berlin` is a thing an operator will really author: + +- A **nonexistent** local time — the spring-forward gap — steps forward to the first wall clock that + does exist. 02:30 becomes 03:00, not 03:30: the event happens as close to the authored time as the + calendar allows. +- An **ambiguous** local time — the fall-back hour, which comes round twice — takes the **first**, + at the pre-transition offset. + +Neither rule ever drops an occurrence: a weekly event happens every week. Both are recorded on the +run as `detail.dstAdjusted`, so nobody has to rediscover daylight saving at 3am on the last Sunday in +October. + +**Publishing is the schedule switch, and archiving is how it is turned off.** `ready` is defined as +"a version has been published and the schedule is live", so a second enabled flag would be another +answer to a question `state` already answers, and the two would eventually disagree. The expansion +reads the **published version's** spec, never the definition's working copy: a half-typed recurrence +an author is midway through must not materialise anything. + +**Publishing also re-pins the occurrences that have not started** (org lead, 2026-09-02). Every run of +that definition still `scheduled` with a NULL `started_at` moves to the new version, and the count +comes back on the publish response. A version pin exists to make a run that **has run** reproducible; +a run that has not begun has nothing to reproduce yet. Without this an editor's fix would reach none +of the fortnight already on the calendar, and the only recourse — cancelling each stale occurrence — +is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occurrence`, so the +occurrence would not come back on the new version, it would vanish. A run that **has** started keeps +its pin for ever. + ### Concurrency | Contention | Protection | Not an in-process mutex, because | @@ -829,12 +886,38 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th | Screen | Pattern it reuses | What is new | | --- | --- | --- | -| **Calendar** — month and list view, filtered by category, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. | +| **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. | +**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 +cancel it. A **projection** is arithmetic beyond the materialisation horizon — no row, nothing +committed, nothing to open. The API says which each is (`kind`), and the UI draws a projection dashed +and dimmed, because an operator acting on a forecast as though it were a booking would have been +misled by the screen rather than by the server. **A projection is never emitted for an instant a run +already occupies**, which keeps the fortnight inside the horizon from being drawn twice — and which +also means a **cancelled** occurrence does not reappear as a forecast looking like it is still coming. +Filtering by run `status`, or by a named `scope`, suppresses projections entirely: a forecast has no +status, and automatic expansion happens at the empty scope. + +**The grid's date axis is the reader's timezone; each entry's time is the event's.** §E gives the zone +to the event because every listing this replaces is written in the shard's local zone — but "what is +happening this month" is a question about the month the person reading is living in. So the cell an +event lands in is the reader's date, and the time beside it always carries the event's own zone +(`20:00 Europe/Berlin`), which misreads as nothing. The server returns UTC instants and does not guess +the reader's zone; the client places them. + +**Series are managed on the calendar**, inline, because the calendar is what makes an arc visible in +the first place. Their writes are `admin, editor` rather than `admin`: naming an arc is authoring, and +[§N2](#n--decisions)'s narrow gate is about committing the deployment to a run. A series **delete** is +a real delete — the only one in this feature, where a definition is archived instead. A series pins +nothing and no run references one; `event_definitions.series_id` is `ON DELETE SET NULL`, so its +definitions survive without an arc and re-attaching one is a dropdown. The response says how many were +detached, because that is the entire consequence of the act. + **The phase editor should be a timeline, not a node graph.** A canvas is the obvious thing to reach for and it is wrong here for a reason that comes out of the code: **the condition grammar has no branching.** It is `and`/`or`/`not` over comparisons, bounded at depth 5, deliberately closed, and @@ -1063,6 +1146,10 @@ no URL moved. | `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions | | `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param | | `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 | +| `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 /public/events` | — | the calendar: upcoming and live, by category, scope and series | | `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results | @@ -1169,10 +1256,10 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | Phase | | Repos | | --- | --- | --- | | **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` | -| **P1** | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` | -| **P2** | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` | -| **P3** | The minimal admin surface — **first demo** | `website` | -| **P4** | Schedule, recurrence, timezones, series and the calendar | `website` `docs` | +| **P1** ✓ | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` | +| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` | +| **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` | | **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 6cdc1be..9fc5aca 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -233,7 +233,7 @@ the same rule. > the run enters it. The instant is now carried across the boundary. Found by writing the test, and > the test was re-run against the unfixed code to confirm it fails. > -> **What "materialise" means here.** The spec validator accepts `kind: 'manual'` alone until Phase 4, +> **What "materialise" meant in THIS phase.** The spec validator accepted `kind: 'manual'` alone, > so there is no recurrence to expand — this leg builds the half that is already real, the grace > window, and Phase 4 adds the expansion above it. > @@ -386,11 +386,56 @@ before building further. ### Phase 4 — Schedule, recurrence and the calendar (`website` + `docs`) -The closed recurrence shapes — `once`, `weekly`, `monthly` (nth weekday), `manual` — computed in the -definition's **IANA timezone** and stored as UTC in `scheduled_for`. `event_series` becomes usable: a -definition may belong to a series, and the series has an ordering. - -An admin calendar view (month + list), filtered by state, scope and series. +> **Complete.** `edge` in `website` and `docs`. The closed recurrence shapes — `once`, `weekly`, +> `monthly` (nth weekday), `manual` — computed in the definition's **IANA timezone** and stored as UTC +> in `scheduled_for`; `event_series` is usable and managed inline on the calendar; an admin calendar +> (month + list) filtered by state, scope and series. **An event now happens on its own.** No schema +> change: Phase 1 built every column this needed. +> +> **The decisions the org lead settled (2026-09-02), all as recommended:** +> +> - **A fourteen-day materialisation horizon, with projections beyond it.** Inside it an occurrence is +> a real row an operator can see, cancel and reschedule one at a time; beyond it the calendar +> forecasts from the same arithmetic, so a monthly event is still visible three weeks out. Drawn +> differently on purpose — acting on a forecast as though it were a booking would be the UI's fault. +> - **Automatic expansion is at the EMPTY scope.** A fan-out across named scopes needs a registry of +> what a scope *is*, which no phase owns yet; inventing one here would be a contract the modules were +> never asked about. The admin's own start route still takes any scope. +> - **DST: skip-forward, take-first.** A local time the spring gap swallows moves forward to the first +> one that exists (02:30 becomes 03:00, not 03:30); an hour that happens twice takes the first. Both +> are recorded as `detail.dstAdjusted`. **Neither rule ever drops an occurrence.** +> - **`nth` is 1..4 plus -1 for "last".** There is no fifth, so there is no absent-occurrence case to +> define — every month has a first through fourth of every weekday. `-1` is not a synonym for `4`. +> - **Publishing re-pins the occurrences that have not started.** Asked mid-build, once the horizon +> made it real: on the day an editor fixes a typo there are already fourteen days of rows carrying +> the old spec. A pin makes a run that **has run** reproducible; one that has not begun has nothing +> to reproduce. The alternative was worse than doing nothing — cancelling a stale occurrence leaves +> its slot held in `uq_evrun_occurrence`, so it would not come back on the new version, it would +> vanish. +> +> **Three things the build settled:** +> +> - **`now - grace` is the window start, not `now`.** An occurrence nobody ever materialised is never +> invented retroactively — three days down must not manufacture three days of `missed` history no +> operator could have seen. It does not need to: rows exist a fortnight early, so a real outage finds +> them already there. **The horizon is what makes the missed sweep mean anything for a recurrence.** +> - **Publishing is the schedule switch; archiving turns it off.** `ready` already means "a version has +> been published and the schedule is live", so a second enabled flag would be another answer to a +> question `state` answers. Expansion reads the **published version's** spec, never the working copy. +> - **Series writes are `admin, editor`.** Naming an arc is authoring; §N2's narrow gate is about +> committing the deployment to a run. +> +> **A defect this phase introduced into the test harness, and fixed.** Putting the expansion leg in +> front of `tick()` made `eventRunner.test.js` reach the dead-port pool on every tick — the file passed +> and took minutes. Stubbing `findSchedulable` there returned it to 0.45s. Worth naming because a suite +> that is merely *slow* reads as a suite that is fine. +> +> **Verified:** `npm test` — **1768 tests, 1711 pass, 56 skipped, 1 fail**, that one the pre-existing +> `engagementManifest.test.js` CRLF failure (`edge` before this branch: 1714/1662/51/1). **54 new +> tests**, of which `eventRecurrence.test.js` (17) is the DST fixture set this plan asked for, and 5 in +> `eventRunnerSql.test.js` prove `findSchedulable`, `listInWindow` and `repinScheduled` **against a +> real MariaDB** (32/32 with a database, skipped without). Client: 354 pass. Four routes added, none +> moved; `check:modules` and `check:hosts` clean; the client builds. **Ships:** recurring and scheduled events, and the calendar that replaces the thing this feature exists to replace. @@ -400,7 +445,9 @@ all. **Trap:** the temptation is a cron string. There is no cron parser in the server dependency tree, the only precedent is in the bot (a different process), and a cron expression is the one field an operator -cannot proofread. Closed shapes render as a form. +cannot proofread. Closed shapes render as a form. *Held: `events/recurrence.js` inverts +`Intl.DateTimeFormat` rather than adding a date library, since Node already ships the tzdata one would +vendor.* --- -- 2.49.1 From 957d662a2fad1fc4255fe784b103a0e0a6b98fb0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 22:11:30 -0500 Subject: [PATCH 06/24] docs(events): Phase 5 as built - conditions, phase advancement and the panel EVENTS.md gains the eleventh table, a new SS-E subsection on advance conditions and the two writers a gate has, the diagnosis panel in SS-I, the "a condition never fires" row in SS-L, and the observability rows for the gate table and its three new log kinds. The `advance` route leaves the not-built list; the catalog route now serves triggers. The SS-D count said "nine" over a list of ten from the revision that added `event_series`; with the gate table it is eleven, and it now says so. EVENTS_PLAN.md marks Phase 5 complete in the shape Phases 0-4 use: the four org-lead decisions, the three things the build settled, the MariaDB left-to-right SET evaluation defect that only a real database found, the live walk, and the three defects that walk turned up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- website/EVENTS.md | 132 ++++++++++++++++++++++++++++++++++------ website/EVENTS_PLAN.md | 133 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 236 insertions(+), 29 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 1c1f3d4..331d111 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -381,6 +381,12 @@ A phase advances on `{ after: '30m' }` or `{ on: '', where: **Built in Phase 5, and `engagement/conditions.js` is reused unchanged** — its grammar, its type +> checking against the declaration, its depth and list bounds, and its operator labels. What Phase 5 +> added is where the words are *read*: the same labels that fill the rule editor's dropdowns render +> the diagnosis panel, so `gte` says *"is at least"* in both places because there is only one place +> it is written down. + **A cap is checked before dispatch, and a breach is a refusal, not a failure.** A step that would exceed a cap does not run and does not retry. It is recorded `refused` with the dimension and the numbers, and shown to the author — because "you asked for 40 monsters and this @@ -395,8 +401,9 @@ completion, cancellation and abort alike. ## D — Data model -Nine core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core -tables carry no module prefix. +Eleven core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core +tables carry no module prefix. (The count said "nine" over a list of ten from the revision that added +`event_series` until Phase 5 counted them.) | Table | Holds | Why a table | | --- | --- | --- | @@ -409,6 +416,7 @@ tables carry no module prefix. | `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_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. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | > **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it @@ -462,6 +470,12 @@ resources. **As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died), **materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock. +**A phase advances on every one of its steps going terminal — and, if it authored one, on its GATE +being open as well** (Phase 5). The gate is an *additional* condition and never a replacement: a +phase whose steps are still running is not advanced by a boss that spawned early, and the force +control refuses a phase held by a step for the same reason. A phase with no gate behaves exactly as +every phase did before Phase 5, and carries no `advance` key at all. + **Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon** (`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the @@ -552,6 +566,56 @@ is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occ occurrence would not come back on the new version, it would vanish. A run that **has** started keeps its pin for ever. +### Advance conditions, and the two writers a gate has + +A phase's `advance` is one of exactly two shapes, both validated at save: + +| Shape | Means | Closed by | +| --- | --- | --- | +| `{ after: '30m' }` | thirty minutes from the moment the phase was **entered**, whatever its steps took | the runner's tick, `satisfied_by: 'elapsed'` | +| `{ on: '', where: , count: n }` | `n` firings of that trigger that satisfy the predicate, counted from phase entry | the **emit path**, `satisfied_by: 'condition'` | + +`after` is one integer and one unit from `s m h d`, normalised to its largest whole unit — `'120m'` +is stored as `'2h'` — for the reason `days` is normalised into week order: the spec is diffed between +versions, and two spellings of one delay differing as JSON is a version history reporting edits +nobody made. `count` defaults to 1; `where` is optional and `null` means any firing. + +**The two shapes are closed by two different writers, and that division is the design.** A gate +waiting on three boss spawns is counting things that happen *between* two ticks; fifteen seconds +after the third spawn there is nothing left for a poller to observe, and a tally held in a process's +memory is one a restart silently returns to zero — with the phase then waiting for three more of +something that already happened. So `ctx.events.emit` writes the count where it happens, beside +`engine.dispatch` and with the same posture (never awaited, never allowed to reject), and the tick +reads the row. The increment is **one statement with the threshold inside it**, the argument +`event_run_budget`'s conditional increment makes one phase early: two firings arriving together each +add one and exactly one of them crosses `needed`. + +**The clock and the tally both start at phase ENTRY**, not when the steps finish. `after: '30m'` is +thirty minutes after the phase began however long its dispatches took, and a trigger that fires while +the announce step is still in flight counts. Both are what makes a gate predictable from the authored +spec alone. + +**A gate that never opens is held, and made loud** (org lead, 2026-09-02). There is deliberately no +authored timeout and no disposition to choose: nothing advances a phase but its condition or a human. +What the engine does instead is *say so* — an `on` gate that has waited past `EVENT_PHASE_STALL_MS` +(one hour) takes the run's `health` to **`stalled`**, §E's third value and the first thing in this +system ever to write it, logged once. It has to be loud, because a held run also holds its +concurrency key, so every later occurrence of the same definition goes `missed` behind it. An +`after` gate is never stalled: a phase waiting out six hours it was authored to wait is working, and +health that said otherwise would train an operator to ignore it. + +**Health is a high-water mark, and `setHealth` now enforces it.** Nothing has ever cleared +`degraded` — a run whose announcement landed on the second attempt did have trouble, and that stays +true for the rest of its life — so the write is escalation-only. Without that guard, a step retrying +after a stall would demote `stalled` back to `degraded`, and a run that waited ninety minutes on a +boss that never came would end its life claiming it merely wobbled. + +**A gate on a trigger nothing registers is DORMANT**, exactly as a step naming an unregistered action +is: it saves, so uninstalling a module is not destructive to an author's work, and it refuses to +publish, because a version runs are pinned to must not wait on a trigger that can never fire. Its +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. + ### Concurrency | Contention | Protection | Not an in-process mutex, because | @@ -945,14 +1009,24 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event is not one of them (see [Versioning](#versioning-and-editing-a-live-event)). -> **Six of those exist as of Phase 3, and two do not — for reasons, not for scope.** Pause, resume, -> cancel, and a step's confirm, skip and retry are built and gated to `admin` + `moderator`. -> **`advance` — force a phase forward — is not**, because it has no honest meaning yet: a phase today -> advances when its steps go terminal, and the per-step skip already does that one step at a time. -> Phase 5 gives a phase an advance *condition*, and that is the first moment "force it anyway" names -> something an operator could predict. **Cancel takes `{ reason }` and not `{ cleanup }`**, because -> the resource ledger a cleanup would work over arrives in Phase 8; a flag that changes nothing is -> the "control that answers 200 and does nothing" this plan has refused twice already. +> **Seven of those exist as of Phase 5, and one does not — for a reason, not for scope.** Pause, +> resume, cancel, and a step's confirm, skip and retry arrived in Phase 3, gated to `admin` + +> `moderator`. **`advance` joined them in Phase 5**, which is when it started meaning something: a +> phase used to advance when its steps went terminal and on nothing else, so "force it anyway" named +> no state an operator could be in, and the per-step skip already did that one step at a time. A +> phase with a gate can wait on a boss that will never spawn, and then it names exactly one. +> +> **Its three refusals are the design.** A run that is not `running` is waiting on nothing. A phase +> with no advance condition already advances on its steps, so forcing it would duplicate the runner +> rather than override it. And a phase whose steps have not all gone terminal is held by a *step*, +> not by its gate — the step-level skip is the honest control for that, one step at a time, and a +> force that swept past pending steps would be a cancel of half a phase under a button labelled +> advance. It satisfies the gate and stops; the next tick performs the phase boundary, exactly as it +> does after `resume`, so there is one implementation of what a phase boundary is rather than two. +> +> **Cancel takes `{ reason }` and not `{ cleanup }`**, because the resource ledger a cleanup would +> work over arrives in Phase 8; a flag that changes nothing is the "control that answers 200 and does +> nothing" this plan has refused twice already. > **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a > paused run is paused *at* that step — so re-queueing without resuming would leave the run in @@ -973,6 +1047,21 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie > the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named > person deciding once is the thing it is unattended from. +**The diagnosis panel** (Phase 5) sits above the step list for the same reason the parked cue does: +a phase waiting on a condition is `running` and looks completely healthy, and the one screen an +operator opens to find out why nothing is happening must say so before they have to read a log. It +carries the clause in the condition builder's own words, the tally, how long the phase has waited, +and the **last related firing whether or not it counted** — because *"the boss did spawn, in +Britain"* and *"no boss has spawned"* are different answers that look identical without it. + +**The sentence is rendered on the server**, and this is the one place this feature does not hand the +client a vocabulary to render. `gates[].where` arrives as text. The labels are defined in +`engagement/conditions.js`; a renderer in the browser would be a second implementation of a grammar +the server owns, and the first clause the two spelled differently would meet its operator at two in +the morning. Only the variables the condition **names** are recorded on the gate row and shown — not +the payload — because that row is read back onto an admin screen and a copy of a whole game event's +data would be a second copy of exactly the content `engagement_sends` is careful not to keep. + **Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is having trouble" is operator information, and the existing shard pages already model exactly this by @@ -1081,6 +1170,7 @@ controller stamps it from the session. | **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* | | **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. | | **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. | @@ -1140,10 +1230,10 @@ no URL moved. | `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | | `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 | -| `POST /admin/events/runs/:runId/advance` | admin, moderator | force a phase forward — **not built**; it has no honest meaning until Phase 5 gives a phase an advance condition | +| `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 | +| `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/series` | staff | the arcs a definition may belong to | | `POST /admin/events/series` | admin, editor | create an arc (Phase 4) | @@ -1191,9 +1281,14 @@ 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: `advance` (no advance condition until Phase -5), `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). +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. + +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 +opened is returned rather than only the current phase's — *"how long did phase 2 wait, and what +released it"* is the same question as the live one, asked the morning after. Two response fields arrived with the console and are worth naming because both are **derived, not columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST @@ -1213,7 +1308,8 @@ which is what keeps the browser from being able to name a transport. An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs — and there is a piece of reuse here better than it first looks. The condition grammar already carries **human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*, -`present` is *"is present"*. The same labels render the diagnosis. +`present` is *"is present"*. The same labels render the diagnosis. **Built in Phase 5**, on the +server, from those labels — the caps line arrives with the budgets in Phase 6. ``` Phase 3 — "The Boss" has not started. @@ -1230,6 +1326,8 @@ Phase 3 — "The Boss" has not started. | 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 | | 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` | | Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` | | Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` | @@ -1260,7 +1358,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` | | **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` | +| **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` | | **P8** | The resource ledger, leases and generated cleanup | `website` `docs` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9fc5aca..0dde84a 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -453,16 +453,120 @@ vendor.* ### Phase 5 — Conditions and phase advancement (`website` + `docs`) -Phase advance on `{ after: '30m' }` and on `{ on: '', where: , count: n }`, -reusing `engagement/conditions.js` **unchanged** — its grammar, its type checking against the -declaration, its depth and list bounds, and its operator labels. - -The runner subscribes to the trigger stream it already has; `event_run_log` gains -`condition.evaluated` rows for both outcomes. - -**The diagnosis panel lands here**, and it is the phase's real deliverable: the operator question -*"why didn't phase 3 start?"* answered in the condition builder's own words — `gte` renders as *"is at -least"*, `present` as *"is present"* — with the tally, the elapsed time and the last related event. +> **Complete.** `edge` in `website` and `docs`. Phase advance on `{ after: '30m' }` and on +> `{ on: '', where: , count: n }`, reusing `engagement/conditions.js` +> **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds, +> and its operator labels. `event_run_log` gained `phase.gate`, `condition.evaluated` (written for +> **both** outcomes) and `phase.advanced`. The diagnosis panel is the phase's real deliverable, and +> `POST /admin/events/runs/:runId/advance` — absent since Phase 3 for want of a meaning — arrived +> beside it. One new table, `event_run_phase_gates`. +> +> **The decisions the org lead settled (2026-09-02), all as recommended:** +> +> - **A new table, not a query over the log.** The tally, the entry time and the last related firing +> are a row with an atomic conditional increment, the protection §E gives caps. Deriving them from +> `event_run_log` would have been a JSON predicate no index supports, and it would have made the +> retention sweep load-bearing for whether a phase advances. +> - **A gate that never opens is HELD, and the run goes `stalled`.** No automatic advance, ever, and +> no authored timeout: "what should happen when the world did not cooperate" is a decision an +> operator makes live, not one an author guesses at months earlier. What the engine owes is +> visibility — `EVENT_PHASE_STALL_MS` (1h) takes `health` to §E's third value, the first thing in +> this system ever to write it, logged once. It has to be loud: **a held run keeps its concurrency +> key**, so every later occurrence of that definition goes `missed` behind it. +> - **Force-advance ships here, not in Phase 6.** A gate without an override is a panel that explains +> a problem nobody can act on, and §K already had the gate written (`admin` + `moderator`). +> - **The clock and the tally start at phase ENTRY**, not when the steps finish. `after: '30m'` means +> thirty minutes from the moment the phase began whatever its dispatches took, and a firing during +> the announce counts. Both make a gate predictable from the authored spec alone. +> +> **Three things the build settled:** +> +> - **A gate is an ADDITIONAL condition, never a replacement.** A phase whose steps are still running +> is not advanced by a boss that spawned early, and force-advance refuses a phase held by a step — +> that phase is held by the *step*, and skip is its control, one step at a time. A force that swept +> past pending steps would be a cancel of half a phase under a button labelled advance. +> - **The emit path writes, the tick reads.** A gate waiting on three spawns counts things that +> happen *between* two ticks; fifteen seconds later there is nothing left for a poller to see, and +> a tally in a process's memory is one a restart silently zeroes. So `observe()` sits beside +> `engine.dispatch` in `ctx.events.emit` — a second subscriber rather than a leg of dispatch, +> because a rules lookup that throws must not lose the count and a gate write that throws must not +> lose the mail. +> - **The panel's sentence is rendered on the SERVER.** Everywhere else this feature serves the +> client a vocabulary; here it serves text. The labels live in `engagement/conditions.js`, and a +> renderer in the browser would be a second implementation of a grammar the server owns. Only the +> variables the condition **names** are stored on the gate row — the row is read onto an admin +> screen, and a copy of a whole game event's payload would be a second copy of what +> `engagement_sends` is careful not to keep. +> +> **The defect only a real database found, and it was the phase's own statement.** The conditional +> increment was written `SET tally = tally + 1, … satisfied_at = CASE WHEN tally + 1 >= needed …`, +> which is wrong on MariaDB: **an UPDATE's SET assignments are evaluated left to right, each seeing +> the values already assigned**, so the CASE read the incremented tally and a gate needing two +> firings closed on the first. Every stub agreed with the intent rather than with the server, exactly +> as engagement's cooldown claim did over `foundRows: true`. The increment now comes **last** and the +> order of that SET list is load-bearing; `eventRunnerSql.test.js` is what catches a reorder. +> +> **Two things that had to change underneath.** `setHealth` is now **escalation-only** — health has +> always been a high-water mark here, and without a rank a retry after a stall would demote +> `stalled` back to `degraded`. And the **catalog route serves triggers**: `/admin/engagement/triggers` +> is `adminOnly` while a definition is authored by `admin` *and* `editor`, so pointing the editor at +> it would have left an editor typing a trigger id from memory into a field the save path refuses. +> +> **A leg a stubbing file did not know about, for the third time.** `runs.detail()` gained the gate +> read, and `eventsAdmin.test.js` does not stub `eventPhaseGates.db` — so the run-console test hung +> ten seconds against the dead-port pool and failed with `ECONNREFUSED`, saying nothing whatever +> about the route it was testing. Phase 4's expansion leg did the same to `eventRunner.test.js`, +> where it only made the file slow. **When the runner or a model gains a leg, every file that stubs +> the layer under it needs the stub** — and the symptom is a ten-second test, whether it then fails +> or merely passes. +> +> **Verified:** `npm test` — **1810 tests, 1746 pass, 63 skipped, 1 fail**, that one still the +> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch's changes +> and watching it fail unchanged; `edge` before: 1768/1711/56/1). **+42 is exactly the tests added**, +> and the +7 skipped are the new SQL cases skipping without a database. `eventGates.test.js` (14) covers the +> renderer against the grammar's own labels and the observer's near-miss branch; 10 in +> `eventRunner.test.js`, 5 in `eventRunControls.test.js`, 6 in `eventSpec.test.js`, and **7 in +> `eventRunnerSql.test.js` against a real MariaDB** (39/39 with a database, skipped without) — one of +> which is the left-to-right defect above. Client: **361 pass**, 7 new. One route added, none moved; +> the client builds. +> +> **The live walk, on the local review stack.** A three-phase `Yew Champion Muster` — an `on` gate +> needing two `uo.champ.boss_up` firings `where location contains "Yew"`, then an `after: '10m'` +> gate, then an ungated wind-down — authored, published and started as `navadmin`, with firings sent +> through the real `ctx.events.emit` seam: +> +> - **The save-time refusals named the variable**, live: `"regoin" is not a variable of +> "uo.champ.boss_up"`, `"gt" cannot be applied to a string`, and `1h30m` refused with the grammar +> spelled out. That is the phase's Trap, held on a running server. +> - **A near miss was recorded and did not count.** A boss up in *Britain* left the tally at 0 of 2 +> and put `did not count (location: "Britain (10, 20, 0)")` on the panel — and **only `location`**, +> the one variable the condition names. `spawnName`, `bossName` and `spawnSerial` were in the +> payload and never touched the row. +> - **Two matching firings advanced the phase**, and the next phase opened its own `after` gate with +> a `dueAt` ten minutes out. The log reads: three `condition.evaluated` lines (0 of 2, 1 of 2, 2 of +> 2), `phase.advanced`, `phase.completed`, `phase.gate`, `phase.entered`. +> - **A second run went `stalled`** with `EVENT_PHASE_STALL_MS=45000` — `degraded` first from a +> failing announce, then escalated to `stalled` and logged **once**, which is the escalation-only +> guard working live. +> - **The panel said exactly what §Observability asked for**: *"Phase muster has not started — +> STALLED / waiting on `uo.champ.boss_up` where location contains "Yew" / seen so far 0 of 2 / +> since 9:56:58 PM (6 min) / last related event …"*. +> - **§N2's split held:** `navmod` pressed *Advance phase* and got 200; the refusals answered 409 +> naming what was actually happening — *waiting on step 0 (core.announce), not on its advance +> condition* — and a second force said *already past its advance condition*. +> +> **The walk found three defects, all fixed here:** +> +> 1. **`validate` refused its own output.** The normalised gate carries `dormant`, and the input +> check did not allow it — so a gated definition *saved* and then *failed to publish* over a field +> the validator itself wrote. The rule was already on the page for a step's `actionVersion` and +> `dormant`; the gate just had to follow it. `validate(validate(x)) === validate(x)` is now a test. +> 2. **A forced advance was logged twice** — once by the control with the actor and the reason, then +> again by the tick that acted on the satisfied gate, the less informative one last. `phase.advanced` +> is now written by whoever made the decision, and the tick skips `forced`. +> 3. **A satisfied gate's clock kept running.** `elapsedSeconds` measured to read time, so the panel +> said 139s beside a logged `waitedSeconds` of 121. It now stops at `satisfied_at`: live it answers +> "how long has this been waiting", afterwards "how long did it wait". **Ships:** multi-phase events that advance on what happens in the game rather than only on a clock. **Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel @@ -470,7 +574,11 @@ explains why without a server log. **Trap:** a condition is validated at **save** against the trigger's declaration, with the offending variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that -silently never advances, and the day you find out is the night of the event. +silently never advances, and the day you find out is the night of the event. *Held: `spec.js` calls +`conditions.validate(declaration, where)` at save and re-roots the grammar's own errors at the phase, +so an author fixing five clauses at once can tell which phase each belongs to. A gate naming a +trigger nothing registers is **dormant** on the rule a step's unregistered action already follows — +it saves, and it will not publish.* --- @@ -495,7 +603,8 @@ delegation model a later option rather than a redesign. The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and including §N2's departure from the module-uo shape: **publishing and starting are `admin` only, while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are -deliberately not the same gate. +deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first +named a state an operator could be in.) **Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of anything. -- 2.49.1 From aa8752ef651ca4a6c9f491bb4698ae17e64696fa Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 3 Sep 2026 05:51:19 -0500 Subject: [PATCH 07/24] =?UTF-8?q?docs(events):=20Phase=206=20as=20built=20?= =?UTF-8?q?=E2=80=94=20enablement,=20caps=20and=20mayInvoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS.md §D — the two tables move from "arrive with a later phase" to built, with the two facts that are not obvious from the column list: a missing settings row is the risk-class default rather than "disabled", and a NULL cap is uncapped and still a row, so a MISSING row keeps its one meaning. event_versions gains verified_at/verified_by, and the note on why two mutable columns do not break an immutable table. §E — a new subsection, "Enablement and caps, in front of every dispatch": where the check sits and why, the retry that does not pay twice (and the failure that gets no refund), the partial spend that is unwound, the budget seeded from every phase at creation, the tightest cap, and the one place a demoted user's access deliberately is not re-checked. §K — the amendment. "Nothing above notify" becomes "nothing that changes the world", with the reason: read literally it shipped core.wait disabled. The same line is the role floor. Plus mayInvoke as built — why user and run may be null, why the cap check can write, why the role floor is in the model rather than in middleware, and the example-pricing stand-in until registerEventBudgets lands. §I, §L, § API surface and § Observability — the switchboard and the dry run marked built; the two refusal rows and the held-occurrence row; the three new log kinds and the run's fourth derived field; why the whole-plan cost check is the one finding no other path makes. EVENTS_PLAN.md — Phase 6 complete in the shape Phases 0–5 use: the four org-lead decisions, the two derived calls flagged for review, the three things the build settled, the one thing this phase deliberately cannot demonstrate with a module (registerEventActions is Phase 7's seam), the two defects the live walk found, the verified numbers and the walk itself. Checked by hand — docs has no CI: every anchor resolves, and every route, column, env var and file path named here exists on website#XXX. Diff is 151/23 and 118/2 with no CRLF artefact, verified against the real content diff rather than --numstat alone. Co-Authored-By: Claude Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- website/EVENTS.md | 174 +++++++++++++++++++++++++++++++++++------ website/EVENTS_PLAN.md | 120 +++++++++++++++++++++++++++- 2 files changed, 269 insertions(+), 25 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 331d111..d41ee90 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 0dde84a..0b290d4 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -584,12 +584,123 @@ it saves, and it will not publish.* ### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`) -`event_action_settings` (one row per registered action, everything above `notify` disabled by default) +> **Complete.** `edge` in `website` and `docs`. Two new tables — `event_action_settings` (the +> switchboard) and `event_run_budget` (what a run has spent and the most it may) — plus two columns on +> `event_versions`, `verified_at` and `verified_by`. `GET/PUT /admin/events/actions` and +> `POST /admin/events/:id/verify` are the last routes this feature was missing bar `cleanup`, and +> `events/authorize.js` is the one function §K asked for. +> +> **The four decisions the org lead settled (2026-09-03), all as recommended:** +> +> - **The default-off line falls between `inspect` and `change`, not between `notify` and `inspect`.** +> §K's sentence read literally would have shipped `core.wait` — which is `risk: 'inspect'` — +> disabled, so every published event that waits breaks on a fresh deployment until an admin finds +> the switch. An `inspect` action reads state and writes nothing, so a deployment gains no risk by +> having it on. §K is amended, and **the same line is the role floor**: `change` and `irreversible` +> are the steps only an admin may author. +> - **The tightest cap wins.** `event_action_settings.caps` is per action while `event_run_budget` is +> one row per dimension, so two actions spending `uo.creatures` have to agree on one number, and the +> number a safety limit should settle on is the smaller. It is pinned into the run at creation with +> the action it came from, so the console can say whose switch set it. +> - **A refusal follows the step's `on_failure`, and takes health to `degraded`.** A cap breach or a +> disabled action is not an outage, so it is `refused` rather than `failed` and gets its own log +> kind — but it takes the same **disposition** a failure takes, which means a `change` step's +> default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes. +> Skipping and carrying on would leave a completed run with a hole in it, which nobody reads a log +> for. +> - **The verify gate is enforced for SCHEDULED starts only.** A version must have passed its dry run +> before the scheduler will materialise an occurrence of it unattended; a human pressing *Start now* +> is not gated, because that human **is** the review the gate exists to require. +> +> **Two things derived rather than decided, and both flagged for review.** A dry run **fails** — it +> does not merely warn — when a version names a disabled action or over-spends a cap, which is exactly +> what the scheduled-start gate is for; and the unattended path **does not re-check the starter's +> role**, because demoting an admin at midnight must not silently strand every event they started +> (cancel is the control for a run that should stop). +> +> **Three things the build settled:** +> +> - **The check is after the claim and before the dispatch, and it can write.** After, because the cap +> is held by a conditional `UPDATE` and two ticks that both priced a step before either claimed it +> would both spend. Before, because a refusal means the action does not happen at all. And `spend` +> is a *parameter* of `mayInvoke` rather than a second function, because checking and then spending +> would be two statements with a race between them — the exact race the conditional increment +> removes. +> - **A retry does not pay the cap twice, and a failure does not get a refund.** The spend happens on +> the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance +> through unreliability rather than through effect. The corollary is deliberate — a step that spent +> and then failed for good keeps its spend, because the attempt may have half-run and a refund would +> be core asserting that it did not. (A *partial* spend across dimensions IS unwound: a step refused +> on its second dimension gives the first one back, because a step that did not run must not have +> spent anything.) +> - **`NULL` cap is uncapped and still a row; a MISSING row is a refusal.** Keeping the two distinct +> is what lets the meter count a dimension nothing bounds while a step spending something its own +> run's version never priced still fails closed. +> +> **The one thing this phase deliberately cannot demonstrate with a module.** `registerEventActions` +> is not on the module-facing API yet — that seam is **Phase 7's** — and core's own three actions +> declare no `cost`. So the cap machinery ships with no live consumer, exactly as the plan intends +> ("ships: a system that cannot be made to do an unbounded amount of anything"). It is proved instead +> by unit tests, by a real-MariaDB concurrency test, and by a **rig action added to core for the walk +> and reverted before commit**. +> +> **Two defects the live walk found, and neither could have been caught anywhere else:** +> +> 1. **The run console route dropped the budget.** `runs.detail()` returned it and the client read it, +> but `getRun` builds its response field by field and never listed it — so the meter would have +> rendered empty for ever, with the model test and the client both correct in isolation. **A +> hand-built response shape is a place a new field silently does not arrive**, and only a request +> through the whole stack says so. +> 2. **The role refusal did not agree with itself.** `"Spawn creatures" change the world` — the +> sentence always used the plural verb over a list that is almost always one long, so the case an +> editor actually sees read as a broken sentence rather than as a rule. +> +> **Verified:** `npm test` — **1921 tests, 1847 pass, 73 skipped, 1 fail**, that one still the +> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch and +> watching it fail unchanged; `edge` before: 1810/1746/63/1). **+111 tests**, of which the +10 skipped +> are the new SQL cases skipping without a database. `eventAuthorize.test.js` (27) is `mayInvoke`'s +> four layers and the cap arithmetic; `eventVerify.test.js` (12) the dry run, including the +> whole-plan total; `eventsRoles.test.js` (31) is **the 403 walk**, driving the real router with every +> handler replaced so it measures gates rather than handlers; plus 12 in `eventRunner.test.js`, 19 in +> `eventsAdmin.test.js`, and **10 in `eventRunnerSql.test.js` against a real MariaDB** (49/49 with a +> database, skipped without) — among them the plan's own criterion, two concurrent spends against one +> cap. Client: **362 pass**, 1 new; the client builds. Three routes added, **none moved**. +> +> **The live walk, on the local review stack.** The gate announced itself before anything was +> authored: a leftover Phase 4 definition began logging *"scheduled occurrences held: the published +> version has never been verified"* on the first tick after boot. +> +> - **The board arrived with the right posture.** `core.wait` (`inspect`) enabled, `core.announce` and +> `core.cue` enabled, the rig's `change` action disabled, and every row marked `configured: false` — +> a deployment that has never opened the screen, behaving correctly with no rows at all. +> - **The dimension was discovered by pricing the declared example**, live, and the cap editor +> offered exactly it. A cap naming a dimension the action does not spend was refused by name. +> - **The dry run caught the whole-plan total.** Three steps of 15 under a cap of 30: each legal +> alone, `45 of "core.creatures" across all its steps` refused together, before anything was +> scheduled. +> - **The dry run found a real defect in a leftover definition** — an announce leg `"site"` that no +> module registers — and, being a failure, **recorded nothing**, so the schedule stayed held. Fixing +> and re-verifying lifted the hold on the same tick and the occurrence materialised. +> - **An editor was refused a world-changing step** and allowed an announcing one; an admin saved the +> same body. +> - **The cap bit at dispatch.** Two 15s spent 30 of 30 and the third came back `refused: asks for 15 +> of "core.creatures"; 30 of 30 is already spent this run`, health `degraded`, run `completed` +> under `on_failure: skip`. With `pause`, a disabled action stopped the run at step 0 and left the +> step after it `pending`. +> - **The 403 walk held live** as well as in the harness: `GET` and `PUT /actions` answered 403 to the +> editor and the moderator alike, and 200 to the admin. +> +> **One thing this PR fixes in passing:** the committed OpenAPI spec still carried the pre-Phase-5 +> catalog description — the annotation had been edited without a regenerate. `npm run swagger` here +> picks it up, which is why the spec diff has two deletions in it that are not this phase's. + +`event_action_settings` (one row per action an admin has an opinion about — everything that CHANGES +THE WORLD disabled by default; the line moved off `notify` when this phase built it, see above) and `event_run_budget` with the conditional increment: ```sql UPDATE event_run_budget SET consumed = consumed + ? - WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap + WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap) ``` A breach is `refused` — the step does not run, does not retry, and is surfaced to the author with the @@ -606,6 +717,11 @@ while cancelling and aborting a run in flight are `admin` + `moderator`**. Start deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first named a state an operator could be in.) +*As built, P3 had already put every route gate in place, so what P6 actually added here is the two +new routes' own gates and the one gate that could not live in route middleware at all: the per-STEP +role floor. The route is `admin, editor` and stays that way — **which of the two you have to be +depends on what is in the body**, so it is checked in the model, at save as well as at publish.* + **Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of anything. **Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends; -- 2.49.1 From 426e9bfbfb56dc7dc4bb2ac1c81cffecd8e3ed70 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 3 Sep 2026 14:16:19 -0500 Subject: [PATCH 08/24] 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 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- website/EVENTS.md | 86 ++++++++++++++++++++++++---- website/EVENTS_PLAN.md | 110 ++++++++++++++++++++++++++++++++++++ website/MODULE_API.md | 123 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 308 insertions(+), 11 deletions(-) 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 -- 2.49.1 From 07512d846d2410a9c38c811d0f7e3574e1cf3776 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 3 Sep 2026 21:20:04 -0500 Subject: [PATCH 09/24] docs(events): Phase 8 as built -- the resource ledger, leases and cleanup The docs half of Event System Phase 8. Code: RunicGateway/website#NNN. EVENTS.md Section D's ledger row is corrected in two places and section L gains a new subsection recording what the build settled: - Rule 1 needed a mechanism, because a spawn's ref does not exist until the module answers. What goes in before the dispatch is a PLACEHOLDER keyed by the step's idempotency key, and that is why revert() takes the key at all. - A LEASE does not use it and gets rule 1 in a stronger form: its target is the lease id the step already names, so `core.lease` writes the real row first -- the only moment the two-events-one-target refusal can happen before the world has been written to. - The unique key is held by three statuses and released by three (amended 2026-09-03). "Among non-reverted rows" was written before the six statuses had their meanings; taken literally it makes `drifted` and `orphaned` hold a target for ever, so one bad night would disable a lease permanently with no control able to clear it. - MariaDB has no partial index, so the encoding is a STORED generated column reading `status` ALONE -- TEAMS.md 2.5's correction, because MariaDB refuses ON DELETE SET NULL on a foreign key whose column is a base column of one. - Cleanup is one sweep, not synthetic step rows, and it runs from one place. - What that sweep SELECTS cost two live-walk defects in opposite directions, and the pair is the finding worth keeping: deriving "is there anything to do" from a summary column instead of from the rows stranded a lease outright, and then made the retry bound mean one attempt. Both are recorded with the reasoning. Section F gains the lease's verb and the reconcile bullet; section I's "cancel takes { reason } and not { cleanup }" becomes what shipped; section K's role table puts re-running cleanup in the admin-only row; section L's cancel and cleanup-fails rows are updated; Observability names the six new log kinds; and the API surface table's absent-routes list is now empty. EVENTS_PLAN.md Phase 8 marked complete in the shape Phases 0-7 use: the four org-lead decisions, the four things the build settled, the verified numbers, and the live walk -- including the three defects only it could find and the two properties that needed the process to die. MODULE_API.md 1.10.0 gains `reconcile()` and `ctx.events.reconcile()` IN PLACE rather than a new version. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, which is the rule the Teams workstream arrived at, applied to a module API for the first time. The rule list grows from six to ten: the lease's verb is core's, `until` goes down the wire, revert is idempotent and reverting something that does not exist is a success, revert is also called with the key and an empty list, and reconcile is optional where revert is required. Checked by hand `docs` has no CI, so: every anchor in EVENTS.md resolves (checked by generating the heading slugs and diffing), and every route, identifier, env var and file path named here exists on the website branch -- `core.lease`, `core.options.leases`, `ctx.events.reconcile`, `EVENT_REVERT_MAX_ATTEMPTS`, `event_run_resources`, `live_marker`, `uq_evres_target`, the six log kinds, and the route in both routes.manifest.json and the OpenAPI spec. Diffs are 144/21, 139/16 and 67/9 with NO CRLF artefact, verified by comparing --numstat against the real content diff rather than trusting it. Co-Authored-By: Claude --- website/EVENTS.md | 165 +++++++++++++++++++++++++++++++++++------ website/EVENTS_PLAN.md | 155 ++++++++++++++++++++++++++++++++++---- website/MODULE_API.md | 76 ++++++++++++++++--- 3 files changed, 350 insertions(+), 46 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index ae6f8bf..ce26c24 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -414,7 +414,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | `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` (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_resources` | `run_id`, `step_id` **`SET NULL`**, `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 the rows core still believes are ITS — see the amendment below | **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. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. | | `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. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | @@ -723,9 +723,11 @@ module-uo's `coreApi: "^1.9.0"` continues to resolve. > 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. +> **A lease is declared here and acquired by `core.lease`.** Core owns a lease's duration and its +> conflict check; both live in the resource ledger, which Phase 8 built. Declaring it in 1.10.0 rather +> than waiting is what keeps the module contract one version a module author reads once rather than +> two — and `reconcile` was folded into the same version for the same reason, in place, because a +> protocol owes a bump only once it has landed on `main`. ```js api.registerEventBudgets([ @@ -782,6 +784,16 @@ api.registerEventActions([{ A lease action declares what it can hold and how long it may hold it. Core owns the duration and the conflict check; the module owns reading the current value and writing a new one. +**The verb is core's** (org lead, 2026-09-03, Phase 8). A module declares the lease and never writes +one: an author puts `core.lease` in a step, naming the lease, a value and a number of minutes, and +core reads the baseline, reserves the target, applies the value with its deadline, and restores it at +teardown through the module's own `restore()`. A lease verb per module would be the `maxDurationMs` +bound and the two-events-one-target check re-implemented once per module — advisory everywhere, and +wrong in the first one that forgot. It also means `core.lease` needs no `revert()` of its own: an +`override` row is restored through the lease registry, which is exactly the split this section draws. +`core.lease` is `risk: 'change'`, so it is default-off, admin-only and cap-checked like any module +verb — the first core action for which that is true. + ```js api.registerEventLeases([{ id: 'uo.rate.skillgain', @@ -898,6 +910,16 @@ silently did nothing. - **`scope` is opaque and may be null.** Core passes it through and never interprets it. Core has **no concept of "the game being up"** — only `{ ok: false, retry: true }` — because a module with six sidecars cannot answer that question in the singular. +- **A module says when to reconcile, because core cannot.** §L's reconnect row asks each ledgered + resource's module what is still in force, and the trigger for that question is `ctx.events.reconcile()` + — the module's own call, made when it sees its own reconnect (module-uo already watches `bootId` to + tell a shard restart from a sidecar reconnect). Core has **no concept of the game being up**, which + is the same sentence three bullets down, so the alternative would be core guessing. Core asks once + at its own boot, which is the one reconnect it can see. `reconcile()` is OPTIONAL where `revert` is + required: a module that cannot answer leaves core believing its own ledger, which is the behaviour + before this phase, while a module that created something and cannot undo it has made a promise core + has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }` changes nothing: + **"I do not know" is never read as "it is gone"**. - **An action whose module is uninstalled goes dormant, never an error.** A step naming it fails `terminal` with the module named and the run degrades — never a silent skip. The authoring side draws the same line one step earlier, in the shape `engagement_rules` established for a dormant @@ -1123,9 +1145,12 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie > advance. It satisfies the gate and stops; the next tick performs the phase boundary, exactly as it > does after `resume`, so there is one implementation of what a phase boundary is rather than two. > -> **Cancel takes `{ reason }` and not `{ cleanup }`**, because the resource ledger a cleanup would -> work over arrives in Phase 8; a flag that changes nothing is the "control that answers 200 and does -> nothing" this plan has refused twice already. +> **All eight exist as of Phase 8, and cancel gained its `{ cleanup }`** now that there is a ledger +> for it to work over — a flag that changed nothing would have been the "control that answers 200 and +> does nothing" this plan has refused twice already, which is why it waited. It defaults to TRUE: +> §L makes cancelling *without* cleanup the separate, admin-only, logged action, so the safe direction +> is what a moderator's cancel does without having to know the flag exists. `cleanup` itself is the +> eighth control, and the one gated NARROWER than the rest — see [§K](#k--security-model). > **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a > paused run is paused *at* that step — so re-queueing without resuming would leave the run in @@ -1235,7 +1260,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 **changes the world** (`change`, `irreversible`), and the action switchboard | `admin` only | +| Any step whose action **changes the world** (`change`, `irreversible`), the action switchboard, and **re-running cleanup** | `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. @@ -1332,10 +1357,10 @@ controller stamps it from the session. | **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 run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); the ledger is then swept by the runner's cleanup leg on its next tick — the request does not wait for it, because a cancel pressed at 2am must answer at once rather than after a round trip per resource to a shard that may be why it is being pressed. Cancelling *without* cleanup is a separate, logged, admin-only action (`{ cleanup: false }`), and it leaves the run `cleanup_status: 'incomplete'` with every unreverted row on the console — the truthful value, because the world changes really are still up. **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. | | **A human retries a step** | Only while the run is `paused`, and only on the furthest step its phase has reached. `attempts` returns to zero and the run resumes in the same action; both facts are in the log line with the actor. | -| **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. | +| **Cleanup itself fails** | The run reaches `completed` with `cleanup_status = 'incomplete'`, the unreverted resources listed and a manual retry offered. It does **not** stay `running` — an event whose world changes are still up is a real state, and pretending the event is in progress hides it. The automatic sweep stops after `EVENT_REVERT_MAX_ATTEMPTS` and leaves the rows for a person; `POST …/cleanup` is the only thing that clears the counter, with the actor logged. | ### The ledger's two rules @@ -1349,6 +1374,97 @@ twice. One that never succeeds stays visible until a human acts, because the alt `completed` row over a shard full of orphaned monsters — is the failure that would end this feature's credibility on its first bad night. +### What Phase 8 settled about the ledger + +**Rule 1 needed a mechanism, because a spawn's `ref` does not exist until the module answers.** What +goes in before the dispatch is a PLACEHOLDER keyed by the step's idempotency key — the reserved +`kind: '@step'`, `ref` = that key — and the reported resources are inserted `confirmed` on the answer, +resolving it. A dispatch that never answers leaves the placeholder standing, and cleanup then calls +`revert({ idempotencyKey, resources: [] })`. **That is why [§F](#f--the-module-contract)'s `revert` +takes the key at all**, and it is the case rule 1 exists for: record afterwards instead, and a +timed-out spawn is twelve creatures no cleanup will ever see. The placeholder is written AFTER the +permission check and BEFORE the module is reached — a refused step created nothing and must ledger +nothing. + +**A LEASE does not use the placeholder, and gets rule 1 in a stronger form.** Its target is knowable +before the dispatch — it is the lease id the step names — so `core.lease` writes the real row first. +That is also the only moment the two-events-one-target refusal can happen *before* the world has been +written to; a second run asking for a held lease comes back `refused`, in the same words a cap breach +uses and for the same reason. + +**Recording is idempotent because the database makes it so.** A retry re-sends the same idempotency +key and a module may honestly re-report the same resources; `uq_evres_target` refuses the second +insert and core reads that as "already recorded" rather than as an error — `materialisePhase`'s +INSERT IGNORE posture, one table along. + +**The unique key is held by three statuses and released by three** (amended 2026-09-03). The row +above said *"among non-reverted rows"*, which was written before the six statuses had their meanings; +taken literally it makes `drifted` and `orphaned` hold a target for ever, so one bad night would +disable a lease permanently with no control able to clear it. `pending`, `confirmed` and `reverting` +mean core still believes the resource is this run's, and hold the target. `reverted` gave it back, +`drifted` means somebody else has hold of the value and this run has deliberately let go, `orphaned` +means it vanished — none of the three is a claim, and all three stay LOUD by the other mechanism: +`cleanup_status = 'incomplete'` and a row on the run console, which is what rule 2 actually asks for. +MariaDB has no partial index, so the encoding is a STORED generated column that is NULL for the last +three; it reads `status` ALONE, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose +column is a base column of a stored generated column (error 1901) and `step_id` must stay SET NULL — +the same correction `TEAMS.md` §2.5 had to be given. + +**Cleanup is one sweep, not a set of synthetic steps.** The tempting reading of *"cleanup steps are +generated from the ledger"* is a synthetic phase of real `event_run_steps` rows, so the console's +per-step retry comes free. It is the wrong shape here for a concrete reason: `event_run_resources` +already carries `revert_attempts` and `last_error`, so synthetic steps would put a second retry +counter beside the first and the two would disagree the first time a step reverted three of its four +resources. The manual retry is a route over the ledger — `POST /admin/events/runs/:runId/cleanup` — +which is what the § API surface already promised. + +**It runs from ONE place: a fifth leg of the runner's tick.** Hooking each terminal path instead +would be four call sites, three of them inside a request, and none of them would survive the process +dying mid-teardown. The leg is ordered AFTER advance, so a run that completes in one tick is torn +down in the same one. `cancel` therefore answers at once and does not wait for the teardown — which +is also the right behaviour for a control pressed at two in the morning against a shard that may be +the reason it is being pressed. + +**What that leg SELECTS cost two live-walk defects, in opposite directions, and the pair is the +finding worth keeping.** Keying the scan on `cleanup_status = 'pending'` is the obvious reading of +the column, and it was wrong twice on the same boot: + +- A run whose only resource was a **lease** never went through the ledger's dirty-marking at all — + `core.lease` reserves its own row rather than reporting one — so the column stayed `not_required`, + the leg never looked at the run, and **the lease was never given back**. The scan now includes + `not_required`: a terminal run with an unresolved row has work to do whatever any summary column + says, and treating that combination as work is the fail-safe direction. +- A run whose first sweep FAILED was moved to `incomplete` **by that very sweep**, so it was never + picked up again and `EVENT_REVERT_MAX_ATTEMPTS` quietly meant one attempt rather than three. So + `incomplete` is now written only once there is nothing left to try — it means *"finished with, and + not finished"* — which is also what makes it the honest marker for a run an admin cancelled + **without** cleanup. + +Both are the same mistake: deriving *"is there anything to do"* from a summary column instead of from +the rows that are the fact. Neither was visible to a unit test, because a test that calls the sweep +directly never asks what would have selected the run. + +**And the first fix for the second one made the console lie.** Spending every row's +`revert_attempts` was a tidy way to take a `cleanup: false` run out of a scan bounded by the counter +— and the run page then rendered *"3 attempts"* beside resources nothing had ever tried, which reads +as *"core tried three times and could not"*. Found by opening the page. **A counter that means two +things is a counter a screen cannot render**, so the status column carries that decision instead. + +**Only a human clears `revert_attempts`.** The automatic sweep bounds itself at +`EVENT_REVERT_MAX_ATTEMPTS` and then leaves the rows for a person; the cleanup route resets the +counter and asks again, with the actor on the log line. This is Engagement Phase 14's rule stated a +third time — a sweep that returned every stale row to its start state made the attempt ceiling +unreachable, so the row cycled for ever and was never eligible for any retention sweep — and the +licence a human has here is the same one their step retry has. + +**Drift is a third outcome, not a failure with a flag.** The module did exactly what it was asked and +found somebody else's value in place, so `{ ok: false, drifted: true, current }` produces a `drifted` +row rather than a retry: asking again would find the same thing, and writing anyway would silently +revert an operator's manual fix. And **`orphaned` is only ever reached through reconcile**, never +through a revert — a revert that finds nothing there is a SUCCESS (§L, and what a Rust wipe needs), +whereas a resource the module reports missing is a thing that vanished while nobody was looking. +Those are two different sentences to the operator reading the console the morning after. + --- ## Versioning, and editing a live event @@ -1388,10 +1504,10 @@ no URL moved. | `GET /admin/events/runs` | staff | run history across definitions | | `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | -| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }`, and gains `cleanup` with the ledger in Phase 8 | +| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }` and, since Phase 8, `{ cleanup }` — **defaulting to true**, so §L's *"cancelling WITHOUT cleanup is a separate, logged, admin-only action"* is the flag that has to be asked for. `cleanup: false` is refused to a moderator in the MODEL rather than in middleware, because which of the two roles you have to be depends on what is in the body. Either way the request answers at once: the teardown is the runner's cleanup leg, not this call | | `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 | +| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources (Phase 8). **`admin` rather than admin+moderator, unlike the seven live controls beside it**: this is not incident response, it asks core to write to the world again, which §K puts in the same row as the world-changing actions themselves. Legal on a TERMINAL run only — a run still in flight has a ledger that is still growing. It is the only thing that clears `revert_attempts`, and it answers **200 whatever it found**, for the dry run's reason: some resources may still be out there, and a 4xx would make that indistinguishable from a bad run id | | `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 | @@ -1440,11 +1556,18 @@ 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 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 7 filled the option-source route, and Phase 8 filled the last one. Nothing in this table is +absent any more.** The option-source 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 8 added one route and one body field, and neither moved anything.** `cleanup` is the manual +retry; `cancel`'s `{ cleanup }` is the choice about what a cancelled run leaves behind. A run's +detail gained a fifth and sixth derived field: **`resources`**, the whole ledger with the `@step` +placeholders filtered out — they are core's own bookkeeping and a list of them would read as +resources nobody can name — and **`unresolvedResources`**, counted over the whole ledger *including* +the placeholders, which is why it can exceed the length of the list. That is deliberate: a run whose +answer was lost has something unresolved and nothing to show, and the console says exactly that. **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 @@ -1464,8 +1587,8 @@ a draft would be a claim about a spec that changes under the author's hands. **F 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. +Nothing is absent from this list any more: `advance` left it in Phase 5, `cleanup` and cancel's +`cleanup` flag in Phase 8. > **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 @@ -1523,7 +1646,7 @@ Phase 3 — "The Boss" has not started. | 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` | | Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` | -| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` | +| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources`, plus six `event_run_log` kinds: `resource.recorded`, `resource.orphaned`, `cleanup.reverted`, `cleanup.failed`, `cleanup.swept`, `cleanup.retry`. `resource.recorded` is written at the ANSWER rather than at the placeholder, because a placeholder is a promise and the operator's question is about the world | One caution carried over from the engagement retention work: the run log is high-cardinality and grows per event, so it needs a retention sweep from the start — `engagementRetentionPrune` is the pattern, and the rule it learned is @@ -1555,7 +1678,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | **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` | -| **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` | | **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` | | **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 0ee4536..016e0b8 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -866,23 +866,146 @@ it was carved out of has not been validated, and P9 should be the *second* consu ### Phase 8 — The resource ledger, leases and cleanup (`website` + `docs`) -`event_run_resources` with `lease_until`, the `drifted` status, and -`UNIQUE (owner_module, kind, ref)` among non-reverted rows — which is what makes two events unable to -lease one target, and produces a `refused` step at authoring time rather than a corrupted baseline at -runtime. +> **Complete.** `edge` in `website` and `docs`. One table (`event_run_resources`), one core action +> (`core.lease`) and one option source (`core.options.leases`), one route +> (`POST /admin/events/runs/:runId/cleanup`), one body field (`cancel`'s `{ cleanup }`), and two +> members added to MODULE_API **1.10.0 in place** — `reconcile()` on an action and +> `ctx.events.reconcile()` on the module context. +> +> **The version was amended rather than bumped** (org lead, 2026-09-03). A protocol owes a bump once +> it has landed on `main`; while it is on `edge` it is amended in place — the rule the Teams +> workstream arrived at, applied to a module API for the first time. 1.10.0 has not shipped, so the +> whole module contract reaches an author as one version they read once, which was the argument for +> putting the lease declaration in it a phase early. +> +> **The four decisions the org lead settled (2026-09-03), all as recommended:** +> +> - **A lease is acquired by a new CORE action, `core.lease`.** §F puts the duration bound and the +> two-events-one-target conflict check on core's side of the seam, and a lease verb per module +> would be both of those re-implemented once per module — advisory everywhere, and wrong in the +> first one that forgot. It is `risk: 'change'`, so it is the first core action that is default-off, +> admin-only and cap-checked like any module verb. +> - **Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key.** A spawn's `ref` +> does not exist until the module answers, so there is nothing to write a row about — the row core +> writes beforehand is `kind: '@step'`, `ref` = that key. On the answer the reported resources are +> inserted `confirmed` and the placeholder is resolved; if the answer never comes it stands, and +> cleanup calls `revert({ idempotencyKey, resources: [] })`. **That is why §F's `revert` takes the +> key at all.** A lease skips it and reserves its real target instead, which is the same rule in a +> stronger form. +> - **Cleanup is one sweep over the ledger, not synthetic `event_run_steps` rows.** The step-shaped +> version buys the console's per-step retry for free and costs a second retry counter beside +> `revert_attempts` — two counters that disagree the first time a step reverts three of its four +> resources. The manual retry is the route this plan already promised. +> - **`reconcile` is declared here and TRIGGERED BY THE MODULE**, through `ctx.events.reconcile()`. +> Core has no concept of the game being up (§F), so it cannot decide when to ask; module-uo already +> watches `bootId` to tell a shard restart from a sidecar reconnect, and that is the moment. Core +> asks once at its own boot, which is the one reconnect it can see. +> +> **Four things the build settled:** +> +> - **The unique key is held by three statuses and released by three**, which corrects §D's *"among +> non-reverted rows"* — written before the six statuses had their meanings. Taken literally it makes +> `drifted` and `orphaned` hold a target for ever, so one bad night would disable a lease +> permanently with no control able to clear it. `drifted` means somebody else has hold of the value +> and this run has let go; `orphaned` means it vanished. Neither is a claim, and both stay loud +> through `cleanup_status` and the console instead, which is what rule 2 actually asks for. +> Recorded as a dated amendment in `EVENTS.md`. +> - **MariaDB has no partial index, so the key is a STORED generated column that is NULL once the row +> is no longer ours** — and it reads `status` ALONE. `TEAMS.md` §2.5 had to be corrected on this +> exact shape: MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column +> of a stored generated column (error 1901), and `step_id` must stay SET NULL because a record of +> what was changed in the world has to outlive the row that scheduled it. +> - **Cleanup runs from ONE place: a fifth leg of the runner's tick**, ordered after advance so a run +> that completes in one tick is torn down in the same one. Hooking each terminal path would be four +> call sites, three of them inside a request, and none would survive a process that died +> mid-teardown. It is also why `cancel` answers at once — the right behaviour for a control pressed +> at 2am against a shard that may be the reason. +> - **What that leg SELECTS is the phase's real finding, and it took the live walk twice.** See +> below: the obvious reading of `cleanup_status` stranded a lease outright, and then made +> `EVENT_REVERT_MAX_ATTEMPTS` mean one attempt. +> +> **Verified:** `npm test` — **2025 tests, 1935 pass, 89 skipped, 1 fail**, that one still the +> pre-existing `engagementManifest.test.js` CRLF failure in a file this branch does not touch +> (`edge` before: 1950/1876/73/1). **+75 tests**: 14 in the new `eventLedger.test.js`, 20 in the new +> `eventCleanup.test.js`, 16 in `eventRunnerSql.test.js` (which skip without a database — see +> below), 8 in `eventRunner.test.js`, 9 in `eventRunControls.test.js`, 5 in +> `eventModuleContract.test.js`, 2 in `eventActionRegistry.test.js` and 1 in `eventsRoles.test.js`. +> Client: **362 pass**; the client builds. `npm run routes:manifest` and `npm run swagger` — **one +> route added, none moved.** +> +> **The unique key was proved against a real MariaDB, because nothing else can prove it.** Whether +> multiple NULLs collide in a unique index, whether a STORED generated column is recomputed and +> re-indexed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the +> server and of nothing else. `eventRunnerSql.test.js` gained 14 tests covering all three, plus the +> ledger model's own statements: `DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node +> --test test/eventRunnerSql.test.js` — **63 pass**. The real `schema.sql` was also applied to a fresh +> database *and* to an existing one, because `CREATE TABLE IF NOT EXISTS` next to a generated column +> is where a migration silently does nothing. +> +> **And that file had a latent bug this phase found.** Its model-backed tests required +> `eventRunBudget.db`, whose pool `utils/db` builds at require time from `DB_NAME` — and `utils/db`'s +> own `dotenv.config()` reads `server/.env`. So on a developer's machine those tests were reaching +> that developer's real schema while the fixtures they asserted against were being written into the +> throwaway database next door. They passed only because both tables happened to exist in both. +> `process.env.DB_NAME = DB` before the require fixes it, and the whole run is disposable again. +> +> **The stale-stub trap, for the fourth time in this feature.** Phase 4's expansion leg, Phase 5's +> gate read, Phase 6's settings read and now the ledger write: a new leg under a model needs a stub in +> every file that stubs that layer, and unstubbed it is not a wrong answer — it is a ten-second +> `ECONNREFUSED` against the dead port. One missing stub cost `eventsAdmin.test.js`'s run-detail test +> ten seconds and said nothing about the route it was testing. +> +> ### The live walk, and the three defects only it could find +> +> Driven by a throwaway `rig` module in `website/modules/` — a real module directory scanned by the +> real loader, **deleted before commit** — registering one ledgering action and one lease, with its +> behaviour driven by a JSON file on disk so a revert could be made to fail and then succeed on the +> same boot. 34 assertions, all green at the end. `rig` declared `coreApi: "^1.10.0"` and module-uo's +> `"^1.9.0"` still resolved, on the same boot: the additive claim proved rather than asserted. +> +> **Defect 1 — a lease was never given back at all.** `core.lease` reserves its own ledger row, so it +> never went through the ledger's dirty-marking, so a run holding only a lease kept +> `cleanup_status = 'not_required'` and the cleanup leg — which selected on `pending` — never looked +> at it. Every unit test passed: they call the sweep directly, and a test that calls the sweep never +> asks what would have SELECTED the run. +> +> **Defect 2 — `EVENT_REVERT_MAX_ATTEMPTS` meant one attempt, not three.** The first failing sweep +> moved the run to `incomplete`, which took it out of the leg's own scan for ever. Visible only as +> `revert_attempts` sitting at 1 through half a minute of live ticks; the test that covered the bound +> asserted `<= 3` and was satisfied by 1. **A bound has two halves — it retried, and it stopped — and +> a test that only asserts the ceiling passes against a floor.** Both halves are asserted now. +> +> **Defect 3 — the first fix for defect 2 made the console lie.** Spending every row's +> `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and +> the run page then rendered *"3 attempts"* beside resources nothing had ever tried — which reads as +> *"core tried three times and could not"*. Found by opening the page, exactly like Phase 7's React +> defect. **A counter that means two things is a counter a screen cannot render.** +> +> **What the walk proved, beyond the defects:** three wisps recorded and given back with the run +> reaching `complete`; a failing revert leaving the run `completed` + `incomplete` with the reason on +> each row; the cleanup route rescuing it and a moderator being refused it; the route refusing a run +> still in flight and one that recorded nothing; a lease taken, its baseline and applied value in the +> payload, and a **second run refused it by name**; a GM's mid-event edit producing `drifted` with the +> world left alone, and a later run still able to lease that target; and `cancel { cleanup: false }` +> refused to a moderator, allowed to an admin, and logged. +> +> **The two that needed the process to die**, which is the plan's own verify line. With the module's +> `perform()` hanging: the placeholder existed while the dispatch was in flight, **nothing was named** +> (`unresolvedResources: 1`, an empty list — rule 1 visible in the wild), and after `taskkill` and a +> restart the reclaim re-dispatched the SAME idempotency key, the retry re-used its own placeholder +> rather than writing a second, and everything was given back. Then, with the module reporting one of +> two resources as no longer in force, core's boot-time `reconcileAll()` marked the other `orphaned` +> — never `reverted` — and logged it. +> +> **The console, in all three states.** A clean run: neutral border, *"Everything this run created or +> borrowed has been given back"*, green rows, no button. An unresolved run: amber border, *"3 of these +> are still unresolved"*, *"still out there"*, and **Try cleanup again** — which was clicked, answered +> `200`, and flipped the panel green in place. A drifted lease: *"someone else moved it"*, its +> deadline, its genuine attempt count, and the sentence naming the value that is there now. +> +> **Ships:** the safety property the whole world-write half depends on. Also useful on its own — the +> platform gains a durable record of what it changed. -**Record before confirm.** The step writes a `pending` row, dispatches, and promotes it on the answer. -Recording afterwards makes every object whose acknowledgement was lost invisible to cleanup forever — -so reverting a resource that does not exist must be a *success*. - -Cleanup steps are **generated from the ledger** at teardown, on every terminal path — completion, -cancellation and abort alike. `cleanup_status` is its own column: a run reaches `completed` with -`cleanup_status = 'incomplete'` and stays on the admin screen, rather than being held `running`. - -Reconcile-on-reconnect: the runner asks each ledgered resource's module what is still in force. - -**Ships:** the safety property the whole world-write half depends on. Also useful on its own — the -platform gains a durable record of what it changed. **Verify:** `npm test`; a rig run that kills the process mid-run and confirms cleanup completes on restart; a run whose revert fails and stays visible. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 662d015..f14a8c6 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -35,12 +35,19 @@ module chunk evaluates, which is earlier than any network round trip could answe **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 +`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8). +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. +> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a +> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is +> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module +> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as +> one version they read once, which was the argument for putting the lease declaration here in the +> first place. + **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 @@ -70,15 +77,31 @@ api.registerEventActions([{ 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' + // Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown, + // over the rows this action's `resources` produced — a LIST, so twelve creatures + // are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names + // the ones that did not come back. + async revert({ runId, resources, idempotencyKey }) { return { ok: true } }, + // OPTIONAL, and only on an action that ledgers. "Which of these does the game + // still have?" — asked after something outside core restarted. + async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } }, }]) +// The module says WHEN, because core cannot: core has no concept of the game +// being up. module-uo already watches `bootId` to tell a shard restart from a +// sidecar reconnect, and that is the moment a ledger of live spawns has become a +// claim about a world that no longer exists. +ctx.events.reconcile() + 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 +// A value a run may borrow. The module ships the three callables; the VERB an +// author puts in a step is core's `core.lease`, so the duration bound and the +// two-events-one-target conflict check live in one place. +api.registerEventLeases([{ 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 } }, @@ -109,9 +132,31 @@ field: `.`. 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. +- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the + module never writes one: core reads the baseline, reserves the target in the resource ledger — + which is where "two events cannot hold one target" comes from, as a unique index rather than as a + check — applies the value with the deadline, and restores it at teardown through the module's own + `restore()`. A lease verb per module would be that bound re-implemented once per module, advisory + everywhere, and wrong in the first one that forgot it. +- **`until` goes down the wire, and the game side must honour it without being asked again.** A + module that treats it as advisory has produced a lease that outlives an outage, which is the one + thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is + the fail-safe. +- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core + records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was + lost leaves a row for something that may never have existed — and cleanup will ask about it. A + module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style + monthly wipe needs, and the second reason a lease's restore must be idempotent too. +- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer + case: core knows a dispatch went out under that key and never learned what it made. A module that + can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible + to an operator, which is the correct outcome rather than a silent one. +- **`reconcile` is optional where `revert` is required**, and the asymmetry is the design. A module + that cannot say what the game still has is not broken — core keeps believing its own ledger, which + is the behaviour before this version — whereas one that created something and cannot undo it has + made a promise core has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }` + leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module + reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go. **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 @@ -576,7 +621,7 @@ 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.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 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 @@ -954,7 +999,7 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio **`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 +shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract rather than implementation and belong here: - **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s @@ -975,6 +1020,19 @@ rather than implementation and belong here: 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. +- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D + rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core + wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer + never came back is still something cleanup can act on. The consequence for a module author is the + whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called + with the key and an EMPTY list, meaning *"a command went out under this key and core never learned + what it did"*. Answering that honestly is what makes an unattended world write recoverable; a + module that cannot answer it says so, and the row stays visible to an operator. +- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an + action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an + aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it + runs on every terminal path — completion, cancellation and abort alike — so a module's only job is + to answer `revert` correctly however many times it is asked. **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 -- 2.49.1 From 9cca021c1fadd50ded35a13c46f4d39b178905c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 07:38:50 -0500 Subject: [PATCH 10/24] =?UTF-8?q?docs(events):=20Phase=209=20as=20built=20?= =?UTF-8?q?=E2=80=94=20UO=20wave=201,=20and=20a=20budgetMs=20rule=20the=20?= =?UTF-8?q?phase=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EVENTS_PLAN.md` Phase 9 as built, plus the two findings that are contract rather than facts about UO. - **MODULE_API.md §2.4: `budgetMs` must exceed the timeout of whatever the action talks to.** The dispatcher classifies a budget timeout as `retry` unconditionally and cannot ask the action, so an action whose own client gives up after core's deadline never classifies its own failure and `retry: false` is unreachable. The default (10s) is shorter than module-uo's sidecar client (12s), the wrong way round. - **EVENTS.md §F gains "What Phase 9 settled, as the contract's first module registrant"**: the rule above; that `on_failure` is not a retry policy; that `reconcile()` can answer from a stamp rather than a query; and that a module's reconcile trigger must fire on a live signal, never a replay. The plan block records the four org-lead decisions, the live walk, the three defects it found — two of them data loss — and the Phase 8 defect in core it reproduced and deliberately did not fix here. Co-Authored-By: Claude --- website/EVENTS.md | 31 +++++++++++++++++++ website/EVENTS_PLAN.md | 70 ++++++++++++++++++++++++++++++++++++++++++ website/MODULE_API.md | 14 ++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index ce26c24..703ceef 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -848,6 +848,37 @@ api.registerEventLeases([{ in them, so `core.options.legs` costs nothing new, and it means the seam's first exercise is not a module's. +### What Phase 9 settled, as the contract's first module registrant + +Wave 1 — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post` — is the first time anything but core +registered an action, and three of the four things it found are rules rather than facts about UO. + +- **`budgetMs` must EXCEED the timeout of whatever the action talks to.** The dispatcher classifies a + budget timeout as `retry` unconditionally and does not ask the action — it cannot, the action is + still awaiting a socket. So an action whose own client gives up *after* core's deadline never gets + to classify its own failure, and `retry: false` in its envelope is unreachable. The default + `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, the wrong way round, and every slow + shard produced a retry the module had explicitly refused. Now a rule in `MODULE_API.md` §2.4. +- **There is no per-action "do not retry me", and `on_failure` is not one.** `on_failure` is what + happens AFTER `EVENT_STEP_MAX_ATTEMPTS`, so `skip` — already the `notify` default — does not stop a + broadcast going out three times. The lever is the failure envelope, and an action that must be + attempted once answers `retry: false` to everything. That is a heavier thing to ask of a + declaration than the plan assumed, and it is the reason the rule above is load-bearing rather than + a tuning note. +- **`reconcile()` need not be a query.** A module that knows *when* its resources are lost can answer + from a stamp instead of a round trip: wave 1 records the shard's `bootId` in the resource payload + and reports in force exactly the rows whose stamp still matches. That is correct for the module's + own trigger AND for core's boot sweep — where the game may not have restarted at all, and where + answering "all gone" would abandon live rows. A row with no stamp is reported IN FORCE, because + "I do not know" must never read as "it is gone", which is `reconcileModule`'s own posture stated + from the module's side. +- **A module's `reconcile()` trigger must fire on a LIVE signal, never a replay.** §L says the module + says when; it did not say that a module's event feed may replay history. `module-uo`'s sidecar + reconnect backfills the last several `server.hello` frames, each with a different `bootId`, so every + replayed frame reads as a restart — and an intermediate one compares a resource stamped with the + current boot against a boot that ended hours ago and orphans it. A live resource core will never + take back, lost to a reconnect. Any module wiring this seam owes the same gate. + ### What is contract rather than implementation **Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02). diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 016e0b8..9387115 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1028,6 +1028,76 @@ not** — a retry is a second announcement to everyone online — and there is n wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the declaration says why. +> **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option +> sources, `MODULE_API` unchanged at 1.10.0 — the module registers against the contract Phase 7 +> opened rather than widening it. +> +> **The trap above is real and its stated fix does not work.** `on_failure: 'skip'` is already the +> default for `risk: 'notify'`, and `on_failure` is what happens AFTER `EVENT_STEP_MAX_ATTEMPTS` +> retries — there is no per-action lever meaning "do not retry me". The lever a module has is the +> failure envelope, so `uo.broadcast` answers `retry: false` to everything. **And that alone is not +> enough**: `dispatch.classify()` answers `retry` for a budget timeout unconditionally without asking +> the action, and the default `budgetMs` (10s) is SHORTER than `uoLinkClient`'s own timeout (12s), so +> core's deadline fired first on every slow shard and the refusal was unreachable. All three actions +> declare `budgetMs: 15000`. That is the phase's most portable finding and it is now a contract rule +> in `MODULE_API.md` §2.4 rather than a fact about one module. +> +> **`reconcile()` needed no protocol work.** There is no "list the crier lines" or "list the news +> articles" on the wire, and adding one would be protocol work for a question the module can already +> answer: both live in shard memory, so a restart is definitionally the loss of both. `perform()` +> stamps the shard's `bootId` into the resource payload and `reconcile()` reports in force exactly +> the rows whose stamp still matches — correct for the module's own trigger AND for core's boot +> sweep, where the shard may not have restarted at all and answering "all gone" would abandon live +> rows. A row with no stamp is reported IN FORCE: "I do not know" must never read as "it is gone". +> +> **Four decisions (org lead, 2026-09-04), all as recommended.** `uo.towncrier.post` ships ALONGSIDE +> the existing `towncrier` announce leg rather than replacing it — the leg is post-shaped, one-shot +> and non-reversible, the action is run-scoped, takes lines and a duration directly, and is +> `reversible: 'ledger'`; a budget dimension `uo.broadcasts` bounds the broadcast alone, because the +> keyed verbs replace under their own id and have no runaway to bound; the option sources ship here +> rather than with their Phase 12 consumers; and an event write records `event:` as its actor, +> because no staff member pressed a button and attributing it to one would be a false line in the +> game's own audit trail. +> +> **The walk: a real two-phase event, against ServUO + the sidecar + the website.** All three verbs +> reached the shard; the shard's own audit read `[Bridge][admin] web:event:3692 broadcast`, which is +> the actor decision visible where it was meant to be. The dry run refused the event for asking 2 of +> `uo.broadcasts` against a cap of 1 — before publishing — and passed at 2. The option sources +> resolved from the real atlas: 387 regions, 558 landmarks, 800 creatures, all well inside the 2000 +> bound. Teardown reverted the crier line and the news article. Then the shard was restarted mid-run +> and core logged `orphaned on reconcile {asked: 2, inForce: 0, orphaned: 2}` **31 milliseconds after +> the bootId changed** — the phase's headline property, proved rather than asserted. +> +> **Three defects it found, two of them data loss, none visible to any unit test.** +> +> 1. **A WS reconnect would have orphaned every live resource.** The backfill replays the last +> several `server.hello` frames in order — this rig saw three, each with a different `bootId` — so +> every replayed frame reads as a restart, and the intermediate ones compare a resource stamped +> with the CURRENT boot against a boot that ended hours ago. The row is then `orphaned`: a live +> crier line core will never take down again, lost to nothing worse than the website reconnecting. +> Gated on `!fromBackfill`, which is the rule the engagement fan-out and the SSE broadcast beside +> it already state; the website-was-down case is still covered by core's own boot sweep. +> 2. **The shard explains its refusals and the run log dropped the explanation.** A 403 body reads +> `{"reason":"admin write plane disabled"}`, `legError` looks for `data.message`, and the console +> said "sidecar responded 403". A staff member clicking a button knows what they switched off; an +> event that ran at four in the morning leaves the run log as the only place anyone will learn why. +> 3. **The "not retried" clause explained the wrong thing.** A 403 will not succeed on any attempt, +> so "not retried: a repeat would announce twice" points an operator at a policy decision instead +> of at the switch they have to flip. The clause is now added only where a retry was genuinely +> given up, and 403/404 join the statuses the keyed verbs treat as terminal. +> +> **And one it found in Phase 8's shipped code, left for its own change.** `UNRESOLVED` in +> `eventRunResources.db.js` includes `orphaned`, so the cleanup sweep selects an orphaned row, calls +> `revert()` on it and records it `reverted`. That contradicts §L's own rule — *"a resource the module +> no longer has becomes `orphaned`, never `reverted`"* — and the console then says core put back two +> things that had vanished. It also spends a sidecar round trip per orphaned row on a shard that has +> just restarted. Not fixed here: it is core's file and a Phase 9 PR is the wrong place for it. +> +> **`ci/core-ref.json` moves to a website `edge` sha** for the length of this workstream (org lead). +> `registerEventActions` exists only from 1.10.0, so under the old `main` pin `register()` throws and +> the module does not load at all — the frozen-manifest job would have been red by construction for +> eight phases and proved nothing. Phase 16's cutover re-pins it to `main`. + --- ### Phase 10 — Integrations (`website` + `docs`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index f14a8c6..cbb05d0 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -114,7 +114,10 @@ api.registerEventLeases([{ 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 + timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that + needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its + own transport's timeout** — see §2.4's rule, which exists because the first module to register an + action did not, and its one non-retryable verb was retried anyway. 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 @@ -1008,6 +1011,15 @@ rather than implementation and belong here: 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. +- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The + dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action — + it cannot, the action is still awaiting a socket. So an action whose own client gives up *after* + core's deadline never gets to classify its own failure, and `retry: false` in its envelope is + unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which + is the wrong way round: every slow shard produced a retry the module had explicitly refused. The + rule generalises past that one pairing — an action is the near end of a call with a far end, and + the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it + is attempted once, declares 15000. - **`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 -- 2.49.1 From e12cc0dbb1dc1d3f8f12474c8c1ffb1d5b3db858 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 13:06:06 -0500 Subject: [PATCH 11/24] =?UTF-8?q?docs(events):=20Phase=2010=20as=20built?= =?UTF-8?q?=20=E2=80=94=20the=20integrations,=20and=20a=20ceiling=20per=20?= =?UTF-8?q?firing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five files. `EVENTS.md` §F gains "What Phase 10 settled" and §D/§J are corrected to what was built; `EVENTS_PLAN.md` records the phase as built with its six decisions, the live walk and the three defects it found; `MODULE_API.md` folds two envelope members into 1.10.0 in place; `ENGAGEMENT.md` §8.5's "come back for X" row is resolved; `BACKEND_DESIGN.md` gains the eleventh event table and the two columns Phase 10 put on tables that already existed. The four contract findings, three of which are rules rather than facts about events: - a firing may carry a ceiling of its own and it may only ever NARROW, applied at the send-time G24 gate as meet(declared, emitted). §I promised a rehearsal "ceilinged to staff" and nothing implemented it; - two incomparable ceilings refuse rather than resolve, which is §5.1a rule 3 reused rather than re-argued; - a module reports participants on the success envelope and there is no other door; - a trigger that is not also a STREAM has no push and says nothing about it, so every stream must be a trigger while a trigger need not be a stream. And a trigger's `description` is read by a recipient, not only by an operator: it is the structural projection's `intro` fallback, so a line naming its own audience ends up in that audience's inbox. - [x] AI-assisted: Claude Code (Opus 5). Code: RunicGateway/website#TBD. Co-Authored-By: Claude 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- website/BACKEND_DESIGN.md | 21 +++++-- website/ENGAGEMENT.md | 2 +- website/EVENTS.md | 66 ++++++++++++++++++-- website/EVENTS_PLAN.md | 126 ++++++++++++++++++++++++++++++++++++++ website/MODULE_API.md | 45 +++++++++++++- 5 files changed, 247 insertions(+), 13 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index aa22c86..ec9fa02 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1119,10 +1119,22 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m ### The six event tables — the engine's, game-agnostic (events phase 1) -Design of record: [`EVENTS.md`](EVENTS.md) §D. Nine core tables are specified there; **six land in -Phase 1** — the ones that do not depend on the module contract. `event_action_settings`, -`event_run_budget`, `event_run_resources` and `event_run_participants` arrive with the phases that -give them a writer, rather than as empty tables nothing reads. +Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event +table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are +spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty +tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and +`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in +Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id` +that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score +and a `rank_at` written only when results are published. Core writes it and sources none of it: a +`member_key` → account mapping is one game's, and a module reports both halves on its action's +success envelope. + +Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at` +(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news +pipeline has already announced without either job standing on the other's toes. Everything that +means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means +the one with a NULL `run_id`. Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id, a `scope`, a resource kind and a budget dimension are opaque strings core stores and never @@ -1202,6 +1214,7 @@ makes a run reproducible and an audit answerable after the definition has moved | params | JSON NULL | | | rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history | | started_at / ended_at | DATETIME NULL | | +| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one | | claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner | | started_by | INT NULL FK→users(id) ON DELETE SET NULL | | | last_error | VARCHAR(500) NULL | | diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index 6a38c96..48871a3 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -4333,7 +4333,7 @@ worth confirming in Phase 4 rather than discovering in Phase 11. | **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) | | **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed | | **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap | -| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled | +| **Come back for X** — a scheduled event is starting | `event.run.started`, emitted by the event runner | ✅ **built — `EVENTS_PLAN.md` Phase 10**. It is a trigger AND a stream, which is what gives it push: the push channel is stream-keyed, so the six other `event.` triggers are email and in-app only | email, push, in-app | on the run | | **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed | | **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged | diff --git a/website/EVENTS.md b/website/EVENTS.md index 703ceef..cd2cf0b 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -415,7 +415,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | `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` **`SET NULL`**, `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 the rows core still believes are ITS — see the amendment below | **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. `@step` is a reserved `kind` core owns (rule 1, below); a module reporting one is refused. | -| `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_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `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. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. | | `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. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | @@ -879,6 +879,60 @@ registered an action, and three of the four things it found are rules rather tha current boot against a boot that ended hours ago and orphans it. A live resource core will never take back, lost to a reconnect. Any module wiring this seam owes the same gate. +### What Phase 10 settled, wiring the engine to everything else + +Phase 10 is the integrations — core's own `event.` triggers, `event_run_participants`, results +publication, and `core.announce.post`. Four things it settled, and three of them are rules rather +than facts about events. + +- **A firing may carry a ceiling of its own, and it may only ever NARROW.** A trigger's declared + ceiling is a property of the KIND of event; a rehearsal is a property of the occasion, and the two + are different questions. §I promises an event can be "run for real with announcements ceilinged to + `staff`", and nothing implemented it — a rehearsal of a published event fires exactly the same + trigger as the real thing, so it would have mailed every subscriber. The emit envelope now takes an + optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It is a + MODULE_API change (1.11.0) because the envelope is a module's too, and the alternatives were worse: + a rehearsal that emitted nothing would be a rehearsal of everything except the announcements, and a + parallel set of `event.rehearsal.*` ids would make every operator write each rule twice. +- **Two incomparable ceilings refuse rather than resolve.** `meet('owner', 'staff')` is null — they + have no common descendant, which is the whole argument `modules/ceilings.js` is built on — and the + gate then refuses every rule. That is §5.1a rule 3's posture, reused rather than re-argued, and the + reason a narrowing ceiling could not simply be "the smaller of the two". +- **A module reports participants on the success envelope, beside its resources** (org lead, + 2026-09-04). There is no `ctx.events.participants` and no route: a second write path into a run + core is mid-tick on would be a second thing that can race the claim, for a caller that does not + exist until a module can source attendance at all. Core stores what it is told and sources nothing + — `member_key` is module-opaque and `user_id` is filled in only by whoever knows the link table, + because a core that guessed would be one game's identity model compiled into core. +- **`affectedRows` cannot tell an INSERT from an unchanged upsert here**, and a "was this new" flag + read off it would have counted every idempotent retry as a fresh participant. The connector sends + `CLIENT_FOUND_ROWS`, under which an `ON DUPLICATE KEY UPDATE` that changes nothing answers 1 — + exactly what an insert answers. The recording path counts what it was given instead. Same family as + Engagement Phase 4a's `foundRows` trap, one flag along. + +- **A trigger's `description` is read by a RECIPIENT, not only by an operator.** It is the rule + editor's catalog text and, through the structural projection's `intro` fallback, the body of every + unauthored render — so a line that names its own audience (*"Staff-facing."*) is a line that ends + up in that audience's inbox. Write it as prose the reader can read; who a trigger is for is what + its ceiling says, and the ceiling is the only place that can enforce it. +- **A trigger that is not also a STREAM has no push, and says nothing about it.** Push delivery joins + `notification_subscriptions`, which is only ever written for an id a user could switch push on for + — and `notificationChannelPrefs.catalog` offers the push channel only for registered streams. So a + rule naming `push` on a trigger-only id enqueues a tickle nobody can subscribe to, `publishToUsers` + finds no endpoint, and the send log records it `sent`. The seeded `run.started` rule named push + before the live rig caught it. **`event.run.started` is now a stream as well as a trigger** (org + lead, 2026-09-04) — the same-owner upgrade `news.post` already is — and the other six are not: push + is the channel that says *now*, and the rest are things a player reads when they next look. The + containment runs one way, and that is the rule this settles: **every stream must be a trigger**, or + it is a push toggle for something no rule can fire; a trigger need not be a stream. + +**And one thing the tree corrected about this phase's own plan.** Core does not seed through +`registerEngagementSeeds`: that door is module-facing and requires template keys namespaced +`.`, while core's own bodies live in `engagement/templateSeeds.js` and its rules in +`engagement/coreRules.js` under a per-group one-shot key. Phase 10 uses core's mechanism, which is +also what keeps Engagement Phase 11's rule intact — one key per seed GROUP, so a deployment that has +already stamped the Team and news keys still receives these two. + ### What is contract rather than implementation **Two members of the success envelope mean "succeeded, but not finished"** (org lead, 2026-09-02). @@ -1228,13 +1282,13 @@ rendering last-known state rather than an error. | System | How Events connects | Status | | --- | --- | --- | -| **Engagement** | Core registers triggers under `event.` — `run.scheduled`, `run.started`, `phase.changed`, `run.ending`, `run.completed`, `run.cancelled` public; `run.failed` at `admin`. Events owns **none** of the delivery. | clean fit | +| **Engagement** | Core registers triggers under `event.` — `run.scheduled`, `run.started`, `phase.changed`, `run.ending`, `run.completed`, `run.cancelled` at ceiling `authenticated` / default audience `subscribers`; `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN, so a weekly event is not throttled by last week's. **`run.started` is also a notification STREAM**, and it is the only one: push delivery is stream-keyed, so a trigger that is not one can be mailed and inboxed but never pushed. Events owns **none** of the delivery. **Built in Phase 10**, with two rules seeded — `run.started` and `run.failed` — and both, like every seeded rule, off. | built | | **Scheduled triggers** | The event runner is the natural home for the `kind: 'scheduled'` evaluator ENGAGEMENT §7.1 Q6 deferred. One tick, two consumers, and it closes an open item rather than adding a second timer. | recommended | -| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. | reuse the legs | +| **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. **Built in Phase 10**, on a nullable `announce_jobs.run_id`: a post may now have more than one job, and everything that means "the post's job" — the admin panel, its retry button, `announced_at` — still means the news one. | built | | **Notifications** | Falls out of Engagement — email, in-app inbox, content-free push tickles, per preference, with suppression and the verification gate. Nothing new. | exists | | **Teams** | "This Team's members" is already a registered audience, so a guild-scoped event is a rule's audience segment and needs no event-side feature. | free | -| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. | small | -| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. | by publication | +| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; the surface that reads it is Phase 14's. | table built | +| **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication | | **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module | | **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out | | **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, an `events` capability string. **Prerequisite:** the app hardcodes `api/v1/public/shard/stream` — a module path — and reads `/public/modules` nowhere, so it cannot render a site whose module it does not know. | app fix first | @@ -1592,6 +1646,8 @@ absent any more.** The option-source route answers a refusal with a `200`, which 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 10 added no route at all.** Its whole surface is two more derived fields on a run: `participants`, who took part as a module reported them — `memberKey` module-opaque, `userId` only where the module could link the player, `rank` null until results are published — and `resultsPublishedAt` on the run itself, which is on the run LIST as well, because *"which of last month's events still have no published results"* is a question about a list. A run whose participants are collected and unranked is a real and visible state rather than an error. + **Phase 8 added one route and one body field, and neither moved anything.** `cleanup` is the manual retry; `cancel`'s `{ cleanup }` is the choice about what a cancelled run leaves behind. A run's detail gained a fifth and sixth derived field: **`resources`**, the whole ledger with the `@step` diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9387115..9f109e2 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1118,6 +1118,132 @@ type"* — is resolved in this PR. **Verify:** `npm test`; a mail-catcher rig confirming an event announcement reaches email, in-app and push; the ceiling on `run.failed` proved to exclude a moderator. +> **Built.** Seven `event.` triggers, two seeded rules, `event_run_participants`, +> `core.results.publish`, `core.announce.post`, and a narrowing ceiling on the emit envelope. +> **No route was added and nothing moved** — the whole surface is two more derived fields on a run. +> `ENGAGEMENT.md` §8.5's *"Come back for X — a scheduled event is starting"* row is resolved. +> +> **Six decisions (org lead, 2026-09-04), all as recommended.** A narrowing `ceiling` on the emit +> envelope; participants on the action's success envelope; `core.results.publish` as an ordinary +> step; a nullable `announce_jobs.run_id`; core's own seed mechanism rather than the module-facing +> one; and §J's ceilings with two rules seeded rather than seven. +> +> **The phase's own defect, and it was a promise nothing kept.** §I says a rehearsal "runs for real +> with announcements ceilinged to `staff`". A ceiling is declared on the TRIGGER, and a rehearsal +> fires exactly the same trigger as the real thing — so the moment this phase gave a run something to +> announce, rehearsing a published event would have mailed every subscriber it. The fix is a +> per-firing `ceiling` on the emit envelope, applied at the send-time G24 gate as +> `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than +> resolving to either, which is `segments.js`'s own posture. `events/announce.js` passes `'staff'` +> when `run.rehearsal`, so a rehearsal exercises the announce steps, the rules and the log lines — +> everything except the delivery it must not make. +> +> **Four things the tree corrected about the plan.** +> +> - **Core does not seed through `registerEngagementSeeds`.** That door is module-facing: it requires +> template keys namespaced `.` and rule trigger ids namespaced likewise, while core's own +> bodies live in `engagement/templateSeeds.js` and its rules in `engagement/coreRules.js` under a +> per-group one-shot settings key. Phase 10 uses core's mechanism, with a THIRD key — the rule +> `ENGAGEMENT.md` Phase 11 established, because appending to the Team or news list would seed these +> on fresh installs only and on exactly the upgrades that want them, never. +> - **The ceilings are §J's, not this section's.** The paragraph above reads as though all seven sit +> at `admin`; §J says six are public and only `run.failed` is. §J is right and this is now built +> that way: six at ceiling `authenticated` / default audience `subscribers`, exactly where +> `news.post` sits, and `run.failed` at `admin` on both halves because a failure names the +> deployment's own broken machinery. +> - **The row is `ENGAGEMENT.md` §8.5, not §8.6.** §8.6 is `module-uo`'s trigger catalogue. +> - **Two rules are seeded, not seven.** All seven triggers are DECLARED, so an operator can write a +> rule against any of them; what is seeded is the pair somebody would otherwise build on the first +> day. Seven disabled rows would bury the two that matter, and `event.phase.changed` is the one +> most likely to be switched on by accident and then mail a player four times in an evening. +> +> **Two design notes worth carrying forward.** +> +> - **None of the six public triggers declares a `url` variable, and that is deliberate.** There is no +> public event page until Phase 14 — `App.jsx` mounts nothing under `/site/events` — and `news.post` +> has already paid for this mistake once: its `postUrl` example named a path that did not exist, and +> the template editor previewed a link that was dead in every mail it sent. Phase 14 adds the +> variable alongside the page it points at, which is a version bump. `event.run.failed` is the +> exception because `/admin/events/runs/:runId` exists today. +> - **`startsAtLabel` is a presentational fragment computed at the emitter**, which is +> `ENGAGEMENT.md` §4.6.1 convention 1 rather than a shortcut. `startsAt` is a `datetime` and the +> seam normalises it to an ISO string — right as data, unreadable in a sentence — and a template has +> no logic with which to format one. The zone is the SHARD's, because "8pm" means the shard's +> evening to everyone reading it. `hour12` is set explicitly: left to the `en-GB` locale, midnight +> renders "00:00" while the schedule editor beside it writes "12:00 AM". +> +> **Two traps found in the build, both silent.** +> +> - **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends +> `CLIENT_FOUND_ROWS`, under which an `ON DUPLICATE KEY UPDATE` that changes nothing answers 1 — +> the same as an insert. A `{ inserted }` flag read off it would have reported every idempotent +> retried collect as a fresh participant. `record()` answers nothing instead; the caller already +> knows how many it was given. Third occurrence of this flag's class, after Engagement Phase 4a's +> cooldown. +> - **A session variable is not a transaction.** The obvious ranking — `SET @rk := 0` then +> `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY score DESC` — is wrong here in a way no test +> without a live database would catch: `query()` takes a connection from the pool per call and +> releases it, so the variable is set on one connection and read on whichever the next call gets. +> `ROW_NUMBER() OVER (…)` in a joined derived table needs no session state at all. +> +> **The live walk.** A real rig — MariaDB, the site with no module installed, mailpit as the relay — +> and a real two-phase event. `event.run.started` fired at the transition, one rule matched, and +> **the mail arrived**: *"The Yew Invasion is starting"*, headed with the event's own title, its +> summary beneath it, and the start time reading *"Friday 4 September at 1:54 pm +> (America/New_York)"* rather than an ISO string. The series line was **absent**, which is the +> single-token block convention working: this event belongs to no arc, so its line disappeared +> instead of rendering "Part of .". The unsubscribe link carried `event:1` as its scope. +> +> A rehearsal of the same definition then fired the same trigger, logged +> `{"trigger":"event.run.started","ceiling":"staff","because":"rehearsal"}`, and produced **zero +> outbox rows** where the real run produced three — the server log naming the refusal in as many +> words (*"rule audience exceeds its trigger ceiling - refusing … emitted: staff"*). That is the +> phase's headline safety property, proved rather than asserted, and the contrast with the real run +> is what makes it a ceiling rather than a broken emitter. +> +> A deliberately doomed run then failed on a default-off `core.lease`, and `event.run.failed` reached +> **the administrator's inbox and nothing else** — the player who had received both "starting" +> notices got no failure notice at all. +> +> `core.announce.post` queued a second job against a post that had already been announced: the +> event's job carried `run_id`, `posts.announce_job_id` still pointed at the news job, +> `findByPostId` still returned the news job, and after the event's job rolled up to `done` the +> post's `announced_at` still read the August date it was published on. The option source offered the +> published post and not the draft; the draft was refused terminally by both the dry run and the +> real one. +> +> And the two things no unit test could reach — `rankRun`'s window function and the upsert — were +> run against real MariaDB 11: four participants ranked `340 → 120.5 → 120.5 → -15` with the tie +> broken deterministically, identical on a second call, and a re-reported member updated its score +> and its `user_id` in place while `joined_at` and `rank_at` stayed exactly as they were. +> +> **The live walk's first finding, and it was a channel that reported success while reaching nobody.** The +> seeded `event.run.started` rule named `push`, because §8.5's row and this phase's own Ships line +> both do. On the rig every `event.` id offered only email and in-app on the preferences screen while +> `news.post` offered push — because `notificationChannelPrefs.catalog` grants the push channel only +> to registered STREAMS, `publishToUsers` joins `notification_subscriptions`, and that table is only +> written for a channel a user could switch on. So the tickle went to nobody, every time, and +> `pushChannel.deliver` still answered `ok: true, 'tickle published'`. **`event.run.started` is now a +> stream as well as a trigger** (org lead, 2026-09-04) — one toggle, on the one lifecycle moment +> worth waking a phone for — and the other six stay email and in-app deliberately. +> +> **Its second finding: a trigger's `description` is read by two audiences, and one of them is the +> recipient.** It is the rule editor's catalog text — and, through `projection.project`'s `intro` +> fallback, the body of every unauthored render through `notify.event` or `inapp.event`. So +> `run.failed`'s original line ended *"Staff-facing."*, and those words landed in an administrator's +> own inbox item. All five of the jargon-y ones were rewritten as prose a player can read +> (*"tearing down"*, *"ran to the end of its last phase"*, *"placed on the calendar"*). Who a trigger +> is for is said by its CEILING, which is the only place that can enforce it anyway. Same class as +> Phase 9's *"not retried"* clause and Phase 8's counter: the server was right and the screen was +> not. +> +> **A post may now have more than one announce job, and everything that meant "the post's job" still +> means the news one.** `announce_jobs.run_id` is nullable and `findByPostId` filters +> `run_id IS NULL`, so the post admin panel and its retry button are untouched; `posts.announce_job_id` +> is written only when the post has none, and `announced_at` is not stamped by a run's job — an event +> linking a three-week-old article must not rewrite when that article was announced. + + --- ### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index cbb05d0..a3452d1 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -47,6 +47,13 @@ a human and publish results. > API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as > one version they read once, which was the argument for putting the lease declaration here in the > first place. +> +> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success +> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0, +> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option +> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is +> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional: +> a module that never passes it is emitting exactly what it emitted before. **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 — @@ -273,9 +280,28 @@ 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. +`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost 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 — `ctx.events.reconcile()` (Phase 8) is the one +exception, because only the module knows when the game it talks to has restarted. + +Two members joined it in Phase 10, both on an envelope: + +- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer + `{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records + them against the run, on both success shapes, beside `resources`. `memberKey` is required and + module-opaque; `userId` is optional and is the module's own answer to "is this player a website + account", because core cannot map one and a core that guessed would be one game's identity model + compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches + a world write that already happened. +- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for + THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`, + so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings + meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about + which branch was meant. The case that forced it is core's own — a rehearsal fires the same + lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared + envelope rather than in `events/` because "this particular firing is narrower than the kind + usually is" is a fact any emitter can have. **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. @@ -1020,6 +1046,19 @@ rather than implementation and belong here: rule generalises past that one pairing — an action is the near end of a call with a far end, and the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it is attempted once, declares 15000. +- **A module reports who took part on the envelope, and there is no other door.** `participants` + rides back from `perform()` exactly as `resources` does, on both success shapes — including + `await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is + therefore the only moment its participants can be recorded. There is deliberately no + `ctx.events.participants` and no route: a second write path into a run core is mid-tick on would be + a second thing that can race the step claim. One step may report at most 5000, the same bound the + engagement engine puts on a list of users a caller may assert, and a member reported twice in one + step is recorded once with the duplicate named. +- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An + emitter that already knows this particular firing must not reach as far as the declaration allows + passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing — + and passing something incomparable with the declaration refuses every rule rather than resolving + to either. See §1.1 under 1.10.0. - **`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 -- 2.49.1 From 8b4096dd18f399c55a5381b7d0645ba022e0709d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 14:57:42 -0500 Subject: [PATCH 12/24] =?UTF-8?q?docs(link):=20protocol=206=20as=20built?= =?UTF-8?q?=20=E2=80=94=20a=20guarantee,=20and=20the=20kind=20that=20pays?= =?UTF-8?q?=20for=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `link/v6.md` is the spec of record. It is the first bump that adds a promise rather than data: a command carrying an `idempotencyKey` is executed at most once, and a repeat is answered with the original reply. Also in this PR: - `INTEGRATION.md` — §2 gains v6, the write plane gains a "retrying a command safely" section with the four rules a caller needs, §7 gains 425 and the paragraph on why it is not 409, and the event catalog gains `champ.boss.killed`. - `PLAN.md` — §5.5 records that a per-KILL damage table needs no override even though a per-HIT event does, which is the observation the new kind rests on; §7 documents the key on the inbound frames. - `EVENTS.md` — §A row 7 and two §G capability rows close, and the failure table's "an action succeeds but the ack is lost" loses its "does not exist today". - `EVENTS_PLAN.md` — Phase 11 splits into 11a and 11b, and `installer` leaves the phase: PLAN.md §7.4 made sure no protocol version is hardcoded there, so the bundle gate is version-agnostic and needed no change for 5 either. The live walk's defect is written up in v6.md §6.1 because the naive check confirms it: an active ChampionSpawn registers an unnamed region over its own spawn area, so the innermost region containing a champion boss is guaranteed to have no name — and region registration is deferred, so a lookup at spawn time answers "Britain" while one at the kill does not. CRLF preserved throughout; links and anchors checked by hand, this repo has no CI. Co-Authored-By: Claude --- link/INTEGRATION.md | 110 +++++++++++++- link/PLAN.md | 24 ++- link/v6.md | 331 +++++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 11 +- website/EVENTS_PLAN.md | 118 +++++++++++++-- 5 files changed, 569 insertions(+), 25 deletions(-) create mode 100644 link/v6.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 9f4ab85..4e70515 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -The current version is **5**, shipped on 2026-09-01 in sidecar **v2.1.0** and overlay **v1.1.0** — resolve them as bundle **2026.09.01**, never as "latest of each". The pairing before it was protocol 4, sidecar **v2.0.0** + overlay **v1.0.0**. +The current version is **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each". -- Every response carries an **`X-UOLink-Version: 5`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`. -- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 6`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`. +- **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" } + { "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. @@ -97,6 +97,21 @@ both about ABSENCE rather than presence, because both are easy to read as an err - `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a commission vendor. Neither means "this vendor has no money". +**v6 (Protocol 6)** is the first bump that is mostly about a **guarantee** rather than about data +([`v6.md`](v6.md)). A command may now carry an **`idempotencyKey`**, and the shard promises to +execute a key **at most once**: a repeat is answered with the original reply rather than re-run. That +is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a +command that never applied were the same event as seen from the caller. See §6's write plane for how +to send one, and §7 for the one new status code it introduces. + +It also adds **`champ.boss.killed`**, a champion's defeat with the damage table only the shard ever +sees. Previously this was inferable from `champ.update` losing its `bossUp` alongside a nearby +`mob.killed` — a signal that also fires when a GM resets a spawn and that says nothing about who did +the work. + +Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key +behaves exactly as it did under v5. + **Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you @@ -295,6 +310,7 @@ Champion spawns have no in-game event either, so they're polled (`ChampSweepSeco |------|--------|-------| | `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). | | `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. | +| `champ.boss.killed` (Protocol 6) | `category`, `bossSerial`, `boss`, `bossType`, `map`, `x`,`y`,`z`, `region`, `killer`, `damagers` — plus `serial`, `type`, `level` naming the ALTAR when the kill could be attributed to one | The boss went down. A real event, not a polled diff — see below. | `status` is one of: - **`active`** — running (or, for `sea`, the boss is alive). @@ -322,6 +338,43 @@ Category-specific fields on `champ.update`: The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events. +##### `champ.boss.killed` (Protocol 6) + +The one champion frame that is **not** polled. It fires on the death itself, so unlike everything +above it is an event rather than a difference between two snapshots — which means a first sighting +of it is the thing being reported rather than a baseline to compare against. + +```json +{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar", + "bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain", + "serial":"0x400150E8","type":"Abyss","level":0, + "killer":{"serial":"0x2E0","name":"tester","player":true}, + "damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240}, + {"serial":"0x24C","name":"Darrow","player":true,"damage":120}], + "t":1788551315000} +``` + +- **`serial` means the ALTAR here**, matching `champ.update`, so the two join without a rule about + which of two serials means what. It is **absent** — with `type` and `level` — when the boss could + not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval. + The kill is still reported; it simply arrives without its altar. +- **`damagers` is every player who damaged it, highest first**, each the standard actor object plus + a `damage` total. Totals are summed per player, so nobody appears twice. Entries are included + whether or not the shard still considers them valid for **looting rights** — someone who fought + two thirds of the fight and then died took part in it. Capped at 20. +- **`region` is the nearest NAMED region** and is **absent** in open countryside. It is not the most + specific region containing the boss: an active champion altar registers an unnamed region of its + own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a + name", never "the shard would not say". +- `category` is `champion` or `sea`. There is no `mini` — a `MiniChamp` has no boss. + +**This does not replace `champ.remove`.** A slain `sea` boss still produces one, because it also +leaves the board. A `champion` altar stays on the board and goes to `cooldown` as usual. + +**`damagers` names players and ranks them.** The sidecar serves it verbatim, as it serves +everything; deciding who may see a damage table is the consuming site's job. The website's own +answer is `staff` by default with the kill itself public — see [`v6.md`](v6.md) §4. + #### Guilds (Protocol 2.0) Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board. @@ -878,6 +931,45 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web `origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation by staff in the game client surfaces the same way with `origin:"in-game"`. +### Retrying a command safely — `idempotencyKey` (Protocol 6) + +Any command in this section may carry an **`idempotencyKey`**, and the shard promises to execute a +key **at most once**. A repeat is not re-run: it is answered with the **original reply**, restamped +with the repeat's own correlation id and marked `"replayed": true`. + +```json +POST /admin/broadcast +{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" } + +→ 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 } +→ 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true } +``` + +Note the second reply's `t`: it is the **first** attempt's, because it is the stored answer rather +than a fresh execution. The world write happened once. + +This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement +and a command that never applied are the same event as seen from here, and the only safe policy is +to give up on the announcement rather than risk sending it twice. + +Four rules for a caller: + +- **A key belongs to your unit of work, not to the attempt.** Derive it from something stable — the + website's event runner uses `sha256(runId|stepId)` — so every retry of one action carries the + same key and a different action never collides with it. A fresh value per call satisfies the field + and defeats the entire mechanism. +- **A key is remembered for one hour**, bounded at 4096 keys per shard. Retry inside that window. +- **A repeat that arrives while the original is still in flight** is answered **425 Too Early** + (`{"kind":"bridge.busy"}`). Nothing ran; come back. It is transient by construction. +- **A replayed reply is an ordinary 200.** Treat it exactly as you would have treated the answer you + lost; `replayed` is for your log. + +Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a +human just pressed a button for and can see the result of. + +The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the +second removal of an entry is a no-op the shard is already happy to perform. + ### Help-page (support) queue Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own @@ -1075,6 +1167,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's | 401 | Missing or invalid auth token | | 404 | Not found (unknown account / character / id, or a not-linked account) | | 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | +| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | | 500 | Internal error (e.g. database) | | 503 | Shard not connected — the query needs the live game and it's down | @@ -1082,6 +1175,11 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's `503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist." +`425` vs `409`: both are conflicts of a sort and they want **opposite** responses. A `409` is a +deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will +never help. A `425` is a retry that will succeed on its own. They are deliberately different codes +so a retry loop cannot quietly swallow a mismatched deployment. + --- ## 8. Putting it together @@ -1089,7 +1187,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PLAN.md b/link/PLAN.md index b9ebd1b..7c06674 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -178,10 +178,16 @@ Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanL | Player death | `PlayerDeath` | low | | | Murder | `PlayerMurdered` | low | High-signal for the website. | | Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. | -| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. | +| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. **Protocol 6 uses it for `champ.boss.killed`**: the handler's first act is to decide this is not a champion, by type, which is one `is` check per death. | +| **Damage table at a kill** | `Mobile.DamageEntries`, read inside `CreatureDeath` | — | Not a stream: the accumulated per-attacker totals, readable **only at the death**, since ServUO discards them with the creature. This is what makes a boss kill attributable without a per-hit event. | | Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. | > ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink. +> +> But per-*kill* attribution needs no override at all, and Protocol 6 is where that was noticed: +> `Mobile.DamageEntries` already holds a running total per attacker, and a `CreatureDeath` handler +> reads it before the creature is disposed of. A whole participation signal, for free, at the one +> moment it is worth having. ### 5.6 Progression & activity @@ -295,6 +301,22 @@ Newline-delimited JSON, one object per line, `serial` as the primary key. Every inbound handler marshals to the Core thread before touching world state. +### Any inbound command may carry an `idempotencyKey` (Protocol 6) + +```jsonc +{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"…","idempotencyKey":"5f2c…"} +``` + +The shard executes a key **at most once** and answers a repeat with the original reply, restamped +with the repeat's correlation id and marked `"replayed": true`. That is what makes a world-writing +command safe to retry after a lost acknowledgement, and it is a precondition for every world verb +the event system adds later — see [`v6.md`](v6.md) §2.1. + +The gate sits in `BridgeBoot`'s inbound dispatch rather than in each handler, so it covers every +kind including ones a later protocol adds. A command with no key behaves exactly as it did before. +One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still +in flight, nothing ran, come back. + ### `server.hello` is per-connection, not per-boot The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. diff --git a/link/v6.md b/link/v6.md new file mode 100644 index 0000000..3b94aaf --- /dev/null +++ b/link/v6.md @@ -0,0 +1,331 @@ +# Protocol 6 — A guarantee, and the kind that pays for it + +**Status:** In review on `edge`. Part **a** of two: see §7 for what protocol 6 gains in 11b before it cuts over. +**Date:** 2026-09-04 +**Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`INTEGRATION.md`](INTEGRATION.md) (website API). +**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11. + +--- + +## 1. Why this bump is not like the other five + +Every protocol version so far added **data**: a field, a frame, a board. This one adds a **promise**, +and the data it also carries is along for the ride because the ride is expensive. + +The promise is one sentence: + +> **A command carrying an `idempotencyKey` is executed at most once. A repeat is answered with the +> original reply.** + +Nothing on the wire could say that before, and [`EVENTS.md`](../website/EVENTS.md) §A row 7 records +what that cost: *"a command whose ack is lost is indistinguishable from one that never applied."* +The website's event runner retries a step that did not come back — that is what a runner is for — +so every world-writing verb had to be declared un-retryable, and Phase 9 shipped `uo.broadcast` +answering `retry: false` to *everything*, including a 503 from a shard that was merely restarting. +The reasoning was sound and the trade was real: a lost announcement is cheaper than a doubled one. + +It is not a trade that survives contact with the rest of the workstream. An event that spawns +creatures, grants an item, or holds a lease on a live config value cannot be built on a wire where +"did that apply?" has no answer. §G's capability table says so in as many words: *"Required before +any world write ships."* + +**So the guarantee had to come before the verbs, and this is the version that has nothing else to +do.** Protocol 12's world verbs are the payload; protocol 6 is the floor they stand on. + +### 1.1 Why `champ.boss.killed` rides along + +The same argument v5 §1 made: a bump costs a sidecar release, a republished bundle and an operator +update on every shard, so a field left out costs a *second* bump rather than a follow-up commit. + +`champ.boss.killed` is the one piece of new data the events workstream is known to need and can +state now. §G's capability table had a boss defeat as 🔗 — *inferable* from `champ.update` going +`bossUp` true then false, correlated against a nearby `mob.killed` — and called that +*"workable and slightly fragile"*. It is more fragile than that: `bossUp` also drops when a GM +resets a spawn, when a boss despawns, and after a sidecar reconnect clears the sweep's diff cache. +And the inference is silent about the thing an event most wants to know, which is **who fought it**. + +A phase condition is written over a trigger firing, so a kind nothing declares is a kind no event +can wait on. *"Advance when the boss falls"* is the canonical UO event phase boundary, and it did +not exist. + +--- + +## 2. The shard side + +### 2.1 The idempotency key + +**One new optional field on every inbound command**, and no new command kinds: + +```json +{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"...", + "idempotencyKey":"5f2c…40 hex"} +``` + +The gate is in `BridgeBoot.OnInboundLine`, before the handler is dispatched, so it covers **every** +inbound kind including the ones a later protocol adds — which is the half that is easy to forget. A +command with no key behaves exactly as it did before protocol 6, which is what leaves the admin +screens unchanged: a human pressing a button can see whether the thing happened, and sends none. + +The store is `BridgeIdempotency`. Four rules, each of which is a decision rather than an +implementation detail. + +**1. Reserve on receipt, not on completion.** The key is recorded *before* the handler runs. A +handler that finishes inside its own inbound call can never see a repeat — the Core thread processes +one line at a time — but a handler that defers (a lease that arms a timer, a spawn that waits for a +save) completes long after `OnInboundLine` has returned, and that is precisely the window a lost +acknowledgement opens. Reserving late would leave it uncovered. + +A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, and the caller is +told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work +is happening. + +**2. A key that has begun is never released.** Not even when the handler throws. Releasing it would +let a retry re-run a command that may have applied half of itself, which is the exact failure this +file exists to prevent. A handler that throws stores a `bridge.error` reply instead, so the retry +gets a definite answer and the step fails once rather than looping. + +**3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh +per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the +original id verbatim would leave the call hanging until the reply timeout — the very failure being +answered. The stored reply is re-emitted with its correlation field rewritten and `"replayed": true` +appended; everything else is byte-for-byte the original, including its original `t`. + +`reqId`, `code` and `id` are all recognised, in the order `rpc.rs` tries them. A repeat that +correlates on a *different* field than the original did is refused with a `bridge.error` rather than +answered under an id nobody is waiting on. + +**4. The bound is loud.** TTL one hour, cap 4096 keys, drop-oldest. The hour is not a tuned number: +core's step lease is fifteen minutes and its retry backoff is bounded well inside that, so expiry +should never be the thing that ends a key's life. The cap is the guarantee's **one hole** — an +evicted key's repeat *would* be applied a second time — so an eviction that drops a key still inside +its TTL prints a console warning naming the count. If the promise is ever actually breached, an +operator reads it here rather than discovering a doubled spawn in the world. + +`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated=)`. + +#### 2.1.1 How the reply is captured + +The handlers emit their answers through `BridgeLink.Emit` like everything else, so there is nothing +to return. While a keyed handler runs, `Emit` offers each line to the store, and the store keeps +**only the line the sidecar would correlate with this command** — the one whose `reqId`/`code`/`id` +equals the command's. + +That filter is the point. An `admin.audit` frame emitted alongside the reply is a fact about the +world and must **not** be replayed to a retry; the reply is an answer to a caller and must be. The +capture happens before `Emit`'s `_running` check, too, because a reply the link was too dead to +deliver is exactly the one a retry will come back for. + +A keyed command that produces no correlated reply at all is treated as a defect rather than as a +state to model: it is logged by kind and key, and a definite `bridge.error` is stored so the retry +terminates instead of timing out forever. + +### 2.2 `champ.boss.killed` + +A new kind, emitted from `EventSink.CreatureDeath` in `BridgeChamps`. + +```json +{"t":1788551315,"kind":"champ.boss.killed","category":"champion", + "bossSerial":"0xD8D","boss":"Semidar","bossType":"Semidar", + "map":"Felucca","x":1496,"y":1628,"z":-5, + "serial":"0x400150E8","type":"Abyss","level":0, + "region":"Britain", + "killer":{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true}, + "damagers":[{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true,"damage":100240}, + {"serial":"0x24C","name":"Darrow","acct":"whitlocktech","webId":"14","player":true,"damage":120}]} +``` + +| Field | Notes | +|---|---| +| `category` | `champion` or `sea`. There is no `mini`: a `MiniChamp` has no boss | +| `bossSerial`, `boss`, `bossType` | The creature. `boss` prefers its display name and falls back to its type | +| `serial`, `type`, `level` | **The ALTAR**, present only when the kill could be attributed to one. `serial` means the spawn here, matching `champ.update`, so a consumer joins the two without a rule about which of two serials means what | +| `map`, `x`, `y`, `z`, `region` | Where it fell. `region` is the nearest **named** region and is absent in open countryside — see §6.1 | +| `killer` | The standard actor object, and the last blow only | +| `damagers` | Every player who damaged it, **highest first**, each an actor object plus `damage` | + +**Detection is by TYPE, not by the sweep.** `BaseChampion` — which `BaseSeaChampion` derives from — +so one check covers both families, and a boss that popped and died inside one sweep interval is +still reported. The sweep's `bossSerial → spawnSerial` map supplies only the altar *context*; a kill +it cannot attribute arrives without `serial`, `type` and `level` rather than not arriving. + +**The damage table exists here and nowhere else.** ServUO discards a creature's damage entries with +the creature, so the shard is the only party that ever sees them. Totals are summed per damager +rather than trusted to be one entry each: ServUO folds repeat damage into an existing entry, but an +entry that expired and was re-created leaves two, and a table listing the same player twice reads as +two participants. Entries are reported **whether or not ServUO considers them expired** — expiry +governs looting rights, and someone who fought the first two thirds of a champion fight and then +died took part in it regardless of what they are owed from the corpse. + +Capped at 20 entries so the frame stays one line on the wire. + +--- + +## 3. The sidecar side + +`PROTOCOL_VERSION: u32 = 5` → `6`, and **one behaviour**: `bridge.busy` maps to HTTP **425 Too +Early** in all three responders. + +Everything else is free. The key rides in the command body, which every write endpoint already +passes through verbatim; `champ.boss.killed` lands in `events` and on the feed through the generic +forward path with no arm of its own. There is **no store migration** — nothing gains a column. + +That is the dumb-forwarder property doing its job again (v3 §3), and it is worth naming what it +means here specifically: **the sidecar makes no idempotency promise of its own.** It does not dedupe, +does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which +is the only place it can be — the shard is where the world write happens. + +### 3.1 Why 425 and not 409 + +409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a +client: a version mismatch is a deployment fault nobody should retry, and a busy shard is a retry +that will succeed on its own. Sharing a status would make the difference readable only by inspecting +the body, which is how a retry loop ends up hiding a mismatched deployment. + +425 is what that status is for — a server unwilling to risk processing a request that might be a +replay — and it is unambiguous here because nothing else on this surface uses it. + +A **replayed** reply, by contrast, is an ordinary **200**. The caller must be able to treat it +exactly as it would have treated the answer it lost; `replayed: true` is for the log. + +--- + +## 4. Visibility + +One classification, made on the website in `module-uo`'s `shardVisibility.js`, never in the sidecar. + +| Field / kind | Audience | Why | +|---|---|---| +| `champ.boss.killed` | **`anonymous`**, on the existing `champs` feature | A champion falling is announced in-world and is the content the public board is *for*. Mapping it is required, not optional: rule 2 fails an unmapped kind closed to admin-only | +| `champ.boss.killed` → `damagers` | **`staff`**, configurable | Nested, so one rule covers the whole table | +| `champ.boss.killed` → `killer` | **not listed** | One actor, whose blow everyone present saw, and the same disclosure `mob.killed` has published on the public activity feed since before this framework existed | +| `damagers[].acct` / `.webId` | **admin, locked** | Rule 1, automatically, by suffix — inside the array, exactly as it already works inside a guild roster | + +**The split is the whole point.** A shard announces that its champion fell without publishing a +ranked roll of who was strong enough to fell it. The kill is an event in the world; the damage table +is a performance record of named players that nobody consented to publish. A shard that wants a +public "who slew the champion" board lowers **one** field rule. + +**The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount` +and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into +mail an operator may address to every subscriber, so a name reaching the trigger's data would undo +the field rule one layer up — a distinct hole from the SSE path the rule guards, reachable without +touching the visibility config at all. + +--- + +## 5. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** | +| `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders | +| `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` | +| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` | +| `installer` | **nothing.** See below | +| `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately | + +**`installer` has no work, and `EVENTS_PLAN.md` Phase 11 was wrong to list it.** The plan put it in +the phase *"because of the pairing"*, but `PLAN.md` §7.4 made that unnecessary on purpose: +**no protocol version is hardcoded anywhere in the installer.** `bundle.rs` reads the number out of +the bundle document and `bundle.yml`'s Gate 1 reads both halves out of the released artefacts and +refuses a mismatch. The check is version-agnostic, so it needs no change for 6 exactly as it needed +none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is +released or bundled until the events cutover in any case. + +**The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and +`module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree* +rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing. + +--- + +## 6. Verification + +Unit tests: 571 in `module-uo/server` (571 pass / 0 fail, 9 new), 43 in the sidecar (4 new), 42 in +`module-uo/client`; the C# compiles against the real ServUO 57.4 reference assemblies. `cargo fmt`, +`cargo clippy -D warnings` and `check:imports` clean. + +Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles, 209k +items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol6Probe.cs`. + +| Claim | Evidence | +|---|---| +| a repeat is not re-executed | Three `POST /admin/broadcast`, two under key `K1` and one under `K2`. The shard's own audit trail holds **two** `admin.audit` rows, not three | +| a replay is the ORIGINAL reply | Attempt 2 answered `{"kind":"admin.ok","replayed":true,"reqId":"r-2","t":1788550182074}` — the first attempt's `t`, so it is stored rather than re-run | +| a replay is stamped with the REPEAT's id | The same reply carries `reqId: r-2`, not the `r-1` the first attempt used. Without this the retry hangs until the reply timeout | +| the `id`-correlated plane works too | Two `POST /towncrier` under one key → `{"kind":"towncrier.ok","id":"evt-CRIERKEY","replayed":true}` on the second, correlated on `id` rather than `reqId` | +| a different key still executes | Attempt 3 answered a fresh `r-3` with a new `t`, and produced the second audit row | +| the shard says what it did | `[Bridge] idempotency: replaying the original reply for key K1-… (admin.broadcast)` | +| `champ.boss.killed` fires for a real champion | A real `ChampionSpawn` driven through its own `SpawnChampion()`, damaged from two seeded players and killed: `boss: Semidar`, `category: champion` | +| the altar is attributed | `serial: 0x400150E8`, `type: Abyss`, `level: 0` — from the sweep's map, so the kill names the altar and not only the creature | +| the damage table ranks and SUMS | `[("tester", 100240), ("Darrow", 120)]` — descending, and `tester`'s 240 registered damage plus the 100000 killing blow folded into one entry rather than two | +| the region resolves | `region: "Britain"` — see §6.1, this is the one that took four rig cycles | +| the website's half agrees, on the real bytes | The captured frame fed through `module-uo`: trigger `uo.champ.boss_killed` with `location: "Felucca 1496, 1628 (Britain)"`, `damagerCount: 2`; projected `damagers` **absent** for `anonymous` and `player`, present for `staff`, and `acct` stripped from every actor at every rung below admin | +| the sidecar needed no change beyond the constant and one status | `GET /health` → `"protocol":6`; the key and the new kind both arrived through the generic paths | + +**One thing the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat can +never arrive while the original is still running, because the Core thread processes one inbound line +at a time — so the state is unreachable on a live shard until a handler defers. It is implemented, +`BridgeIdempotency.Hold`/`Complete` are the door a deferring handler uses, and the sidecar's 425 +mapping is unit-tested on all three responders. **Phase 11b's leases are the first thing that can +actually produce it**, and proving it belongs in that walk rather than being claimed here. + +### 6.1 The rig trap: the innermost region has no name + +Recorded because it is the sort of defect that ships, and because the naive check *confirms* it. + +The first emitter read `boss.Region` and a champion killed in the middle of Britain produced a frame +with **no region at all**. Three things were true at once: + +- An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its spawn area, constructed with + a **null name** and with the town region as its `Parent`. So the most specific region containing a + champion boss is, by construction, the one region on the map guaranteed to have no name. +- `Mobile.Region` never returns null: with no cached region it falls back to the map's **unnamed + default region**. So the failure looks identical to "outdoors, nowhere in particular". +- `ChampionSpawn.SpawnChampion` places its boss at `Z - 15`, which was a plausible-looking second + explanation and a red herring. It cost a rig cycle. + +And the reason it survived a first look: **region registration is deferred**, so a lookup taken +immediately after the altar is placed still answers `"Britain"` and one taken at the kill twenty +seconds later does not. The probe printed `region "Britain"` at spawn on every run, including the +ones whose frame carried nothing. A read at spawn time would have confirmed the bug into the design. + +The fix is general rather than a special case for champions: walk outward to the nearest **named** +ancestor. A house region, a dungeon sub-region and a guarded-zone overlay are all anonymous children +of somewhere a player would name. The map's own default region terminates the walk with its +parentless empty name, so open countryside answers null — and the field is then **omitted**, because +a consumer reading `region: ""` cannot tell "nowhere in particular" from "the shard would not say". + +### 6.2 Two smaller ones + +- **`deploy.ps1` overwrites `Bridge.cfg`,** so every deploy strips the scaffolding flags — + `Protocol6ProbeOnStart` and, here, `AdminWriteEnabled` as well. The probe then silently does + nothing on the next boot. The scaffolding README warns about this and it still cost a cycle. +- **A running ServUO holds `Scripts.dll`,** so an offline `msbuild` fails with `MSB3027` rather than + a compile error. Stop the shard, build, restart — and read the boot log's `0 Error(s)` to confirm + the new code is live rather than trusting a clean boot, which is what + `servuo-plugin-build-and-run` already records. + +--- + +## 7. What 11b adds to protocol 6 + +Phase 11 is split. **This document covers 11a**; 11b adds lease deadlines and the run-scoped +participation ledger to the *same* protocol version, amended in place rather than bumped to 7 — +6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule is that +a protocol owes a bump once it has shipped and is amended in place before that. + +Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side +of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle +CI never sees either half until the cutover, by which time 6 means one thing. It would not be +tolerable on `main`, and this paragraph exists so nobody discovers that the hard way. + +What 11b will add here: + +- `lease.apply` / `lease.release` / `lease.list`, with a deadline the shard honours **without being + asked again** and a compare-and-set restore that reports `drifted` rather than overwriting a GM's + deliberate change. +- `participation.open` / `participation.snapshot` / `participation.close`, keyed by character serial + to match `module-uo`'s existing `memberKey`, and **persisted in the world save** — the Bridge's + first persisted state, so a tally survives a restart mid-event. +- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`. diff --git a/website/EVENTS.md b/website/EVENTS.md index cd2cf0b..4f8079e 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -272,7 +272,7 @@ What is genuinely not there, stated as what the codebase says rather than as ass | 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. | | 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. | | 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. | -| 7 | **The command path is not durable and has no idempotency.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. | +| 7 | ~~**The command path is not durable and has no idempotency.**~~ **Closed by protocol 6** (Phase 11a). `rpc.rs` is unchanged and still an in-memory pending map with a flat timeout — the replay was never the sidecar's to do. A command may now carry an `idempotencyKey`, and the SHARD executes a key at most once, answering a repeat with the original reply restamped for the retry's correlation id. Which is the only place the promise can live: the shard is where the world write happens. See [`../link/v6.md`](../link/v6.md) §2.1. | | 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. | | 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. | | 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. | @@ -1094,7 +1094,7 @@ a capability exists. | Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. | | Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. | | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | -| Detect a boss defeated | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. | +| Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. | | **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. | | Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | | Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | @@ -1106,7 +1106,7 @@ a capability exists. | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | | Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | | Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. | -| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** | +| Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. | | Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. | | Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. | @@ -1434,7 +1434,7 @@ controller stamps it from the session. | **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. | | **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. | | **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** | -| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* | +| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe*, and as of protocol 6 (Phase 11a) it exists: proved on the rig with three broadcasts under two keys producing **two** rows in the shard's own audit trail. It has one bound worth knowing — the plugin remembers a key for an hour, capped at 4096, and says so loudly if the cap ever evicts a live one. | | **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. | @@ -1768,7 +1768,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | **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` | -| **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` | +| **P11a** | *N1 answered.* Protocol 6: the idempotency key, and `champ.boss.killed` | `servuo-plugins` `link` `module-uo` `docs` | +| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` | | **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` | | **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` | | **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9f109e2..acc2cd8 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1025,7 +1025,7 @@ site, cued to a GM, completed, recorded. **Trap worth writing into the action declarations.** These three verbs have *different* idempotency. `towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is not** — a retry is a second announcement to everyone online — and there is no idempotency key on the -wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the +wire until P11a. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the declaration says why. > **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option @@ -1246,25 +1246,117 @@ push; the ceiling on `run.failed` proved to exclude a moderator. --- -### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) +### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `docs`) -**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked. +**§N1 answered 2026-09-01 — no longer gated.** A protocol bump, and the shape TEAMS Phase 1 already walked. -- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a - repeat with the original result; a lease deadline timer that restores baseline **without being - asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind. +**Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos, +each with a different thing to prove on a live ServUO, is one review and one walk too many. They land +as **one protocol version**: 11a bumps to 6, and 11b amends 6 **in place** on `edge` — the same rule +the org lead set for `MODULE_API_VERSION` on 2026-09-03, and it applies because 6 will not reach +`main` until the events cutover. The bundle CI therefore never sees two numbers. + +**`installer` is NOT in this phase, and the line above that said so was wrong.** `PLAN.md` §7.4 made +sure of it deliberately: no protocol version is hardcoded anywhere in the installer. `bundle.rs` +reads the number out of the bundle document, and `bundle.yml`'s Gate 1 reads both halves out of the +released artefacts and refuses a mismatch — version-agnostic, so it needed no change for 5 either. +And `link`/`servuo-plugins` are on `edge`, so nothing is released or bundled until the cutover. + +#### Phase 11a — the idempotency key, and `champ.boss.killed` + +- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers + a repeat with the original result; `champ.boss.killed` as a first-class kind. - **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to compose. -- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served. -- **`installer`:** joins the phase because of the pairing above. +- **Sidecar:** `PROTOCOL_VERSION` bumped; the key carried; the new kind stored and served. +- **`module-uo`:** the key sent on every event-driven write, `uo.broadcast` made retryable, and the + new kind mapped for visibility and declared as a trigger. - **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`. -**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both -are safety properties, not features. +**Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the +precondition every world verb in Phase 12 is waiting on. **Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new -code is live; a deliberate duplicate command; a lease whose website is killed before it expires, -proving baseline returns anyway. +code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than +against the reply. + +> **Built.** `PROTOCOL_VERSION` and `overlay.toml` both **6**, in the same pair of PRs. The whole +> sidecar change is one constant and one status mapping; the whole guarantee lives on the shard, +> which is the only place it can, because the shard is where the world write happens. +> +> **Seven decisions (org lead, 2026-09-04), all as recommended.** The 11a/11b split; participation +> keyed by character serial; the ledger persisted in the world save; leases as machinery plus one +> proven key; reserve-on-receipt with `bridge.busy` for an in-flight repeat; a TTL-plus-cap with a +> loud eviction; and top damagers at a `staff` ceiling. The last three are 11a's. +> +> **What the phase bought immediately:** `uo.broadcast` stopped being un-retryable. Phase 9 shipped it +> answering `retry: false` to *everything* including a 503 from a restarting shard, with a comment +> naming the line that would change. This is that line. It now defers to `sidecarFailure` — the same +> helper its two siblings already used — so the hand-rolled variant that forced every outcome +> terminal is gone rather than merely re-tuned. +> +> **One verb was less idempotent than its own `id` made it look.** Both keyed verbs post under a +> run-scoped id and a repeat REPLACES, which is why Phase 9 called them safe to retry. But +> `news.add` with `announce: true` makes the criers proclaim the article's title on every post, so a +> retry replaced the article silently and proclaimed it **again**. The key stops the second +> proclamation. Read in the plugin, not inferred: `BridgeNews` removes the old entry, inserts the +> new one, and then announces. +> +> **The live walk found one defect, and the naive check confirms it.** A champion killed in the +> middle of Britain emitted a frame with **no region**. An active `ChampionSpawn` registers a +> `ChampionSpawnRegion` over its spawn area with a **null name** and the town region as its parent, +> so the most specific region containing a champion boss is the one region on the map guaranteed to +> be nameless — and `Mobile.Region` hides that by falling back to the map's unnamed default rather +> than to null. Region registration is deferred, too, so a lookup at spawn time answers `"Britain"` +> and one at the kill does not: the probe printed `"Britain"` on every run, including the ones whose +> frame carried nothing. Fixed by walking outward to the nearest NAMED ancestor, which is the general +> answer — house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of +> somewhere a player would name. +> +> **Two findings that are rules rather than facts about events:** +> +> - **A trigger is not a stream, and in this module they are disjoint sets.** Push delivery is keyed +> on the subscription id, which core's catalog grants only to registered STREAMS. `module-uo`'s +> stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids (`uo.champ.started`, …) share +> no member, so **no** engagement rule in this module can push — the tickle resolves to zero +> endpoints while the send log records success. That is the Phase 10 defect, pre-existing here in +> twenty rules. This phase does not fix them; it declines to add a twenty-first, so its rule ships +> `['email','inapp']` with a comment saying why. **Flagged for the org lead as its own change.** +> - **A new rule needs a new seed GROUP, never an appended one.** `triggers-v1` is stamped once under +> a settings guard, so a twenty-seventh entry would reach fresh installs and nothing else. +> `champ-boss-killed-v1` is its own group — the same remedy core applied in Phase 10, and the third +> application of Engagement Phase 11's seed-key finding. +> +> **What the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat cannot +> arrive while the original runs — the Core thread takes one inbound line at a time — so the state is +> unreachable on a live shard until a handler defers. Implemented, with `Hold`/`Complete` as the door, +> and unit-tested at the sidecar's mapping on all three responders. **11b's leases are the first +> thing that can produce it**, and proving it belongs in that walk. +> +> See [`../link/v6.md`](../link/v6.md) for the spec of record. + +#### Phase 11b — lease deadlines and the participation ledger + +- **Plugin:** a lease deadline timer that restores baseline **without being asked**, with + compare-and-set restore reporting `drifted`; a run-scoped participation ledger. +- **Leases land as machinery plus ONE proven key** (org lead): the generic registry, the deadline + timer, CAS restore and `lease.list` reconcile, proved end to end against one verified live-read + `Config.Get` key. The curated allowlist and its boot-time self-check stay in Phase 12, where the + rest of the lease surface is. +- **The participation ledger counts presence in a declared area plus kill credit inside it, keyed by + character serial** — matching `module-uo`'s existing Teams `memberKey`, so one module speaks one + member vocabulary. The plugin computes the score; core stores an opaque number it never interprets. +- **The ledger is persisted in the world save**, which makes it the Bridge's first persisted state + ever. A run spans hours and a restart mid-event is realistic; an in-memory tally would regress the + score after one, and the only ways to paper over that are a high-water rule in core (which must + stay game-agnostic) or a per-run offset in the module. +- **Protocol 6 is amended in place**, not bumped to 7. See the split note above. + +**Ships:** the game side can put the world back on its own, and can say who took part. +**Verify:** a lease whose website is killed before it expires, proving baseline returns anyway; a +deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a tally that survives +a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes +reachable. --- @@ -1396,7 +1488,7 @@ flowchart TD P8 --> P9["P9 · UO wave 1
no protocol change"] P5 --> P10["P10 · integrations"] P9 --> P10 - P0 -.->|"§N1 answered"| P11["P11 · protocol bump"] + P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"] P8 --> P11 P11 --> P12["P12 · UO world verbs"] P7 --> P13["P13 · authoring UI"] -- 2.49.1 From eabaf2635f770fa6c9ade493a7176c600fb87b9a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 19:32:11 -0500 Subject: [PATCH 13/24] docs(link): protocol 6 part b -- leases, participation, and what the walk found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six files. `v6.md` gains sections 7-10: one version with two halves, the lease plane, the participation ledger, and 11b's verification. Three corrections to what the plan and the design of record assumed. Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's correction: a lease's ledger row had no reconcile path anywhere, because the step that made it names `core.lease` and that is core's own action. `website` joins. EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two patterns. Measured on 57.4: of the 158 non-Bridge sites in `Scripts/`, roughly eight are read live. The allowlist is not a curated subset of a large pool, it is nearly the whole of what exists. And `Config.Set` has exactly one caller in the entire tree, so on a stock shard a GM cannot drift a configuration lease even deliberately -- which is why proving `drifted` needed a scaffolding verb. §G's "participation attribution is now the largest piece of new UO work" closes, and the live-config-lease row goes to built-with-one-key. §10.1 records the defect the phase's own deferral found in 11a's shipped code: `bridge.busy` answered 200 instead of 425 because the frame carried two `kind` fields and parsers take the last. Unreachable in 11a by construction; produced on the first collision here. Co-Authored-By: Claude --- link/INTEGRATION.md | 92 +++++++++++- link/PLAN.md | 35 +++++ link/v6.md | 329 +++++++++++++++++++++++++++++++++++++++-- website/EVENTS.md | 39 ++++- website/EVENTS_PLAN.md | 60 ++++++++ website/MODULE_API.md | 14 +- 6 files changed, 546 insertions(+), 23 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 4e70515..3a054cd 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -69,6 +69,12 @@ Pin the version you built against and compare it to the header (or `/health.prot **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. +**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying +an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds +`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six +endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an +operator has not switched it on. See [`v6.md`](v6.md). + **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` / `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that @@ -970,6 +976,89 @@ human just pressed a button for and can see the result of. The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the second removal of an entry is a no-op the shard is already happy to perform. +### The event plane — leases (Protocol 6) + +**Off by default.** Every endpoint below answers **403** unless the operator has set +`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch: +enabling admin writes is consenting to staff moderation from a screen a human is looking at, and +enabling this is consenting to your world being changed and watched on a schedule, unattended. + +A **lease** is a live configuration value held at a new setting for a bounded time. The shard +restores the baseline when the deadline passes **whether or not you are ever heard from again** — +so the worst case is a world back at baseline early, never one stuck changed indefinitely. + +```json +GET /lease +→ 200 { "kind":"lease.list.ok", "leases":[ + { "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float", + "min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] } + +POST /lease +{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000, + "untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" } +→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 } + +POST /lease/release +{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" } +→ 200 { "kind":"lease.ok", "released":true, "current":"1000" } +``` + +Five things a caller needs: + +- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take + effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply + cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and + "any config key" is not offered. +- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline + is measured against two clocks, and a shard whose clock runs fast would restore your lease the + moment it took it. +- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed + values at the other end; the text is so a compare-and-set is comparing what you sent. +- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you + held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an + error: the mechanism did its job, and only a human can decide what should happen next. +- **`held` means the shard still has a record of the lease, not that the value is still overridden.** + A lease whose deadline has fired stays listed with `expired: true` until you release it, so a + reconcile in that window does not read a working backstop as a lost resource. A shard restart, by + contrast, reverts every lease and clears the record — `held: false` is how you learn that. + +### The event plane — participation (Protocol 6) + +A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed +by **character serial**. The shard computes a score and you store it; the components ride along so +you can explain it. + +```json +POST /participation +{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 } +→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false } + +POST /participation/99/snapshot +{ "idempotencyKey":"…" } +→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5, + "participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17", + "seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000", + "firstMs":1788550182074, "lastMs":1788553782074 } ] } + +POST /participation/99/close +→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 } +``` + +- **The area is a point and a radius, not a region name.** The most specific region containing an + event is routinely anonymous on a UO map — an active champion spawn registers a nameless region + over its own area — so a region-named venue would be undeclarable for exactly the places events + happen. +- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended + run the shard walks its members across game ticks rather than in one call, so a repeat arriving + mid-walk is answered **425**. Come back. +- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run + the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being + counted for it either way. +- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally + says so rather than quietly being short. +- **`webId`** is present only where the character's game account is linked to a website user. Most + characters carry none; treat its absence as ordinary. + ### Help-page (support) queue Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own @@ -1165,7 +1254,8 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's | 200 | OK | | 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | | 401 | Missing or invalid auth token | -| 404 | Not found (unknown account / character / id, or a not-linked account) | +| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard | +| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) | | 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | | 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | diff --git a/link/PLAN.md b/link/PLAN.md index 7c06674..c72498a 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -317,6 +317,41 @@ kind including ones a later protocol adds. A command with no key behaves exactly One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still in flight, nothing ran, come back. +### The event plane, and the Bridge's first persisted state (Protocol 6) + +```jsonc +{"kind":"lease.apply","key":"PlayerCaps.SkillCap","value":"1200","holdMs":600000,"runId":"77"} +{"kind":"participation.open","runId":"99","map":"Felucca","x":1496,"y":1628,"radius":40} +``` + +Six commands behind one new gate, `Bridge.EventsEnabled`, default off and deliberately not the admin +write plane's switch. Two facts about ServUO shaped both halves and are worth recording here rather +than only in the spec. + +**`Server/Config.cs` is a real runtime store, and almost nothing reads it live.** `Config.Set` +mutates the in-memory entry table and `Config.Load()` is guarded by `_Initialized`, so a Set survives +every later Get — but of the **158** non-Bridge `Config.Get` call sites in `Scripts/`, roughly +**eight** are read at the call site. The rest are cached at type initialisation, where a lease +applies cleanly and does nothing at all. That is why the lease catalog is a verified allowlist and +never "any config key". + +Two consequences, both deliberate: **nothing calls `Config.Save()`**, so a lease never reaches disk +and a shard restart is a free restore; and **`Config.Set` has exactly one caller in the whole tree** +(`Server/ScriptCompiler.cs`), so on a stock shard no GM can drift a configuration lease even +deliberately — the compare-and-set is still required for Phase 12's object-property leases, and +proving it needs the scaffolding driver. + +**The participation ledger is the first thing this plugin has ever persisted.** A run spans hours and +a restart mid-event is ordinary, so an in-memory tally would silently regress every attendee's score. +`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the +world save — no persistence *item*, so no world object and nothing for a GM to delete by accident. +The hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and +`Initialize()` is too late. + +**And the first handler that defers.** `participation.snapshot` walks a large run's members across +Core ticks rather than in one inbound call, which makes it the first handler to complete after +`OnInboundLine` returned — and therefore the first that can actually produce `bridge.busy`. + ### `server.hello` is per-connection, not per-boot The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. diff --git a/link/v6.md b/link/v6.md index 3b94aaf..24b6739 100644 --- a/link/v6.md +++ b/link/v6.md @@ -1,7 +1,7 @@ # Protocol 6 — A guarantee, and the kind that pays for it -**Status:** In review on `edge`. Part **a** of two: see §7 for what protocol 6 gains in 11b before it cuts over. -**Date:** 2026-09-04 +**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7. +**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b) **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11. @@ -175,6 +175,25 @@ means here specifically: **the sidecar makes no idempotency promise of its own.* does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which is the only place it can be — the shard is where the world write happens. +11b adds six routes and no new machinery. `event_call` is `admin_call` without the required +`actor`: an event verb's author is a **run**, which the body carries as `runId`, and demanding a +human name for something no human is doing would have the runner inventing one. + +| Route | Command | Note | +|---|---|---| +| `GET /lease` | `lease.list` | The whole catalog with current values. One read serves both `read()` and `inForce()` on the website's side | +| `POST /lease` | `lease.apply` | | +| `POST /lease/release` | `lease.release` | A drifted answer is a **200** — see §8.3 | +| `POST /participation` | `participation.open` | | +| `POST /participation/:runId/snapshot` | `participation.snapshot` | **A POST for a read**, because it carries the caller's `idempotencyKey` and can be refused as a repeat in flight. A read that can legitimately answer 425 is not a GET | +| `POST /participation/:runId/close` | `participation.close` | | + +`respond_event` is the fourth responder, and it exists for two mappings the generic one gets wrong: +a drifted lease is a 200 rather than a reason-sniffed 400, and the event plane being **switched off** +is a 403 rather than a 400 — `Bridge.EventsEnabled` is an operator's deliberate refusal, and telling +the website it sent a bad request would send an administrator hunting a bug in a step that is +written correctly. + ### 3.1 Why 425 and not 409 409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a @@ -206,6 +225,17 @@ ranked roll of who was strong enough to fell it. The kill is an event in the wor is a performance record of named players that nobody consented to publish. A shard that wants a public "who slew the champion" board lowers **one** field rule. +**11b's two frames are deliberately unmapped.** `lease.applied` and `lease.expired` are operational +records of the WEBSITE changing this shard's configuration — which key, from what to what, on whose +run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an +unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs; +mapping them would mean choosing a feature an operator could then widen, and there is no rung below +admin these belong on. Same reasoning as `account.login.result` in v5. + +The participation ledger emits no stream frame at all. A tally is read on request, not broadcast: +a live feed of who is standing where would be a location tracker, and the ledger's whole justification +is that it answers one bounded question about one run. + **The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount` and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into mail an operator may address to every subscriber, so a name reaching the trigger's data would undo @@ -221,7 +251,8 @@ touching the visibility config at all. | `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** | | `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders | | `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` | -| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` | +| `website` | **11b only.** `core.lease` gains a `reconcile()` and `registerEventLeases` gains an optional `inForce()` — see §8.4 | +| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` · `MODULE_API.md` (11b) | | `installer` | **nothing.** See below | | `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately | @@ -233,6 +264,11 @@ refuses a mismatch. The check is version-agnostic, so it needs no change for 6 e none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is released or bundled until the events cutover in any case. +**`EVENTS_PLAN.md` was wrong about the repo count in the other direction too.** Phase 11 is four +repos for 11a and **five** for 11b: a lease's ledger row had no reconcile path anywhere, because the +step that made it names `core.lease`, and that is core's own action rather than a module's. There is +nowhere on that path a module could hang an answer, so core had to grow one. §8.4. + **The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and `module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree* rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing. @@ -308,24 +344,285 @@ a consumer reading `region: ""` cannot tell "nowhere in particular" from "the sh --- -## 7. What 11b adds to protocol 6 +## 7. One version, two halves -Phase 11 is split. **This document covers 11a**; 11b adds lease deadlines and the run-scoped -participation ledger to the *same* protocol version, amended in place rather than bumped to 7 — -6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule is that -a protocol owes a bump once it has shipped and is amended in place before that. +Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping +to 7 — 6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule +is that a protocol owes a bump once it has shipped and is amended in place before that. Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle CI never sees either half until the cutover, by which time 6 means one thing. It would not be tolerable on `main`, and this paragraph exists so nobody discovers that the hard way. -What 11b will add here: +Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise. -- `lease.apply` / `lease.release` / `lease.list`, with a deadline the shard honours **without being - asked again** and a compare-and-set restore that reports `drifted` rather than overwriting a GM's - deliberate change. -- `participation.open` / `participation.snapshot` / `participation.close`, keyed by character serial - to match `module-uo`'s existing `memberKey`, and **persisted in the world save** — the Bridge's - first persisted state, so a tally survives a restart mid-event. -- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`. +--- + +## 8. The lease plane (11b) + +> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is +> held. +> +> — `EVENTS.md`, *Leases: the primitive underneath everything* + +Three commands, one new configuration gate, and two properties that are the whole reason the +framing is worth having. + +### 8.1 `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled` + +**Its own switch, default off** (org lead, 2026-09-04). Enabling the admin plane is an operator +consenting to staff moderation driven from the website — a human pressing kick or ban on a screen +they are looking at. A lease and a participation ledger are the website changing and watching the +world on a **schedule**, unattended, at four in the morning. Those are different consents and one +switch cannot honestly express both; an operator who wanted the first and got the second would be +right to be angry. + +### 8.2 The commands + +```json +{"kind":"lease.apply","reqId":"r-7","key":"PlayerCaps.SkillCap","value":"1200", + "holdMs":600000,"untilMs":1788567000000,"runId":"77","idempotencyKey":"…"} +``` + +| Command | Answers | | +|---|---|---| +| `lease.list` | `lease.list.ok` | Every allowlisted key: `current`, `default`, `min`/`max`, and where held, `baseline` / `applied` / `untilMs` / `runId` / `expired` | +| `lease.apply` | `lease.ok` | `baseline`, `applied`, `untilMs` | +| `lease.release` | `lease.ok` or `lease.drifted` | Compare-and-set | + +Three shapes are worth stating because the obvious alternative is subtly wrong in each. + +**`holdMs` is authoritative and `untilMs` is for display.** An absolute deadline computed on the +website and honoured on the shard is a deadline measured against **two clocks**, and a shard running +ten minutes fast would restore a ten-minute lease the instant it took it. A duration is immune. The +absolute time still crosses, because a console that can say when the hold ends in terms the +operator's own clock agrees with is worth one field. + +**Values cross as TEXT, whatever the lease's declared type**, and comparison is done on the parsed +values. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a parser and two +different strings to a compare-and-set, and a drift check that compared formatted numbers would +report drift on a value nobody had touched — refusing to restore, leaving the world changed, and +blaming an innocent operator. + +**A lease held longer than the shard's ceiling is REFUSED, never clamped.** A clamp would quietly +give the website a shorter lease than it believes it has, and the website is the half that schedules +the restore; the two would then disagree about when the world comes back. `Bridge.LeaseMaxDurationSec` +is the shard's independent bound rather than a mirror of core's — it exists for the case where the +website is wrong, and being loud about it is the entire value. + +### 8.3 The two mechanisms, and one thing a stock shard cannot do + +**The deadline lives on the shard.** A lease arms a timer, and when it passes the shard restores +baseline **whether or not the website is ever heard from again**. Core drives the normal restore; +this is the backstop. It inverts the naive design, where restoration depends on core dispatching a +cleanup step and therefore fails *open* if core dies mid-event. A lease fails *safe*, and the worst +case is a world back at baseline early rather than one stuck changed indefinitely. The shard emits +`lease.expired` so the website learns what happened without being asked. + +**Restore is compare-and-set, never a blind write.** If the current value is not what the event +applied, somebody moved it deliberately: answer `lease.drifted` with the current value, leave the +world alone, and let an operator decide. A **200**, not a 409 — the shard did exactly what it was +asked, and 409 is the version gate's with the opposite disposition. Blindly restoring would silently +revert a staff member's change, which is the one failure that would make operators distrust the +whole feature. + +> **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`, +> for `Compiler.Dynamic`. There is no in-game command, no gump and no console verb that writes a +> config key. + +So on a stock shard a GM **cannot drift a configuration lease even deliberately**. The mechanism is +still correct and still required — Phase 12's object-property leases are trivially driftable, and a +shard with custom scripts may well write config at runtime — but proving it needs the `configset` +verb in `tools/scaffolding/BridgeRigDriver.cs`, which exists for exactly that reason. + +**A lease is memory-only, and that is a decision.** `Config.Set` mutates the in-memory entry table; +`Config.Load()` is guarded by `_Initialized` and so runs once at boot, which is what makes a Set +survive every later Get. **Nothing ever calls `Config.Save()`**, so a shard restart is a *free* +restore — the strongest fail-safe available, at no cost. It is also why `lease.list` reports an empty +hand after a restart, which is precisely what lets the website's reconcile notice the lease is gone. + +A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's original +default marker too, because the entry compares against the value it was loaded with. Restoring a key +that was `@`-defaulted in a cfg file leaves it `@`-defaulted. + +### 8.4 The reconcile hole, and `inForce()` + +**A lease's ledger row had no reconcile path at all, and nothing failed to say so.** `cleanup.js` +resolves a resource to the action of the step that made it, and for a lease that action is +`core.lease` — a **core** action, on a path a module cannot register anything on. So every `override` +row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped stayed +in the ledger as live until teardown went looking for a baseline nobody was holding. + +11b closes it in two pieces, both in `website`: + +- `core.lease` gains a `reconcile()`. +- `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any + record of this hold?"* + +It is deliberately not `read()` plus a comparison. A value that differs from what the run applied is +**drift**, which teardown must deliver through `restore()` so the row lands `drifted` with the +current value beside it; a reconcile that inferred absence from a changed value would orphan the row +first and destroy that signal — telling the operator the lease vanished rather than that somebody +moved it. Only an explicit `{ ok: true, held: false }` takes a row out; a throw, a timeout, an +unrecognised shape and a lease with no `inForce()` all leave it alone. + +`MODULE_API_VERSION` stays **1.10.0**, amended in place, by the same rule §7 states for the protocol. + +### 8.5 The catalog is short, and shorter than `EVENTS.md` expected + +§D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type +initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes +effect at once. Measured on 57.4 the split is not near even: of the **158** non-Bridge call sites in +`Scripts/`, roughly **eight** are live reads. + +11b ships **one** key: `PlayerCaps.SkillCap`, read live inside `CharacterCreation.cs`'s per-character +path and divided by ten to give the per-skill cap. It is both live and observable, which is what +"proven" has to mean here — the failure the allowlist exists to prevent is a key that applies +cleanly and changes nothing at all. Phase 12 adds the rest, with the boot-time self-check that drops +a key from the advertised catalog if it does not take. + +--- + +## 9. The participation ledger (11b) + +§G rates participation attribution as the largest remaining piece of new UO work, and says why +nothing composed out of the existing streams stands in for it: `region.enter` plus `mob.killed` is +loosely composable and **not trustworthy enough to publish results on**. Nothing scopes a kill or an +arrival to a run, nothing separates a passer-by from an attendee, and nothing survives a relog. + +| Command | Answers | | +|---|---|---| +| `participation.open` | `participation.ok` | `{ runId, map, x, y, radius, holdMs }` | +| `participation.snapshot` | `participation.snapshot.ok` | The tally, resolved to names and accounts | +| `participation.close` | `participation.ok` | Stops counting; the tally stays readable through the grace window | + +**The area is a map, a point and a radius** (org lead, 2026-09-04). Not a region name: §6.1 above +established that the most specific region containing an event is routinely **anonymous**, so a +region-named area would be undeclarable for exactly the venues events use. Not a rectangle either — +an author picks the spot the event happens at, not two opposite corners of it. + +**Members are keyed by character serial**, matching `module-uo`'s existing Teams `memberKey`, so one +module speaks one member vocabulary and a participant joins to a roster without a translation table. +A player who attends on two characters is two members, which is the answer Teams already gives. + +**The shard computes the score and core stores an opaque number it never interprets.** *"A minute +present plus five a kill"* is a sentence about Ultima Online, and the sentence has to live on the +Ultima Online side of the seam. The components ride along in the frame anyway, because a results +table that can say "forty minutes and three kills" beside a number is one an operator can defend +when a player argues with it. + +Two shapes that look like details and are not: + +- **Presence accrues in SECONDS, not sample counts.** A count would have to be multiplied by the + sweep interval to mean anything, and the interval is a config key an operator may change halfway + through a five-hour run — silently rewriting the first half of the tally. The kill weight is frozen + per run at open for the same reason. +- **Kill credit goes to every damager standing in the area, not to the killer.** A last hit is a poor + description of who fought something: the player who held it for four minutes and died to it took + part more than the one who landed the blow that finished it. The area test is applied to the + **damager**, so someone shooting in from outside is not attending and someone who has since walked + away accrues nothing more. Entries are summed per damager for the reason `champ.boss.killed`'s + table is (§2.2): an expired-and-recreated entry leaves two. + +### 9.1 The Bridge's first persisted state + +Nothing in this bridge has ever persisted anything. A ledger has to: a run spans hours and a restart +mid-event is an ordinary Tuesday, and an in-memory tally would silently regress every attendee's +score to whatever they earned after the restart. The only ways to paper over that from the other side +are a high-water rule in core — which must stay game-agnostic and cannot have one — or a per-run +offset in the module, which is the same bug with more moving parts. + +`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the +world save, rather than a persistence **item**: no world object, no serial, nothing for a GM to find +and delete by accident, and a wipe of custom items leaves the ledger intact. The save and load hooks +are attached **unconditionally**, before the enabled gate is consulted — an operator who switches the +plane off for an afternoon must not come back to a truncated file where a run's tally used to be. + +Bounds, all `Bridge.*` keys: eight runs counted at once, 2000 members per run, a 300-tile ceiling on +the area, and a 24-hour grace window after a run closes. A member the cap turns away is **counted** +and the count rides on every snapshot: a truncated tally that says it is truncated is usable, and one +that does not is a leaderboard with people missing from it for no stated reason. + +### 9.2 The first handler that defers + +`participation.snapshot` resolves every member serial to a mobile and an account, so a well-attended +run is hundreds of world lookups in one inbound call — exactly the work the Core thread must not be +handed in one piece. Above `Bridge.ParticipationSnapshotChunk` members it walks in chunks across +ticks, using `BridgeIdempotency.Hold` / `Complete`. + +That makes it the first handler in the bridge to complete **after** its inbound call returns, and +therefore the first that can genuinely answer `bridge.busy`. 11a built that door and had nothing to +walk through it. + +--- + +## 10. Verification (11b) + +Unit tests: **588** in `module-uo/server` (17 new), **42** in `module-uo/client`, **2003** in +`website/server` (5 new), **47** in the sidecar (4 new). `cargo fmt`, `cargo clippy -D warnings` and +`check:imports` clean; the C# compiles against the real ServUO 57.4 reference assemblies with the +shard stopped. + +The rig: a real ServUO with a seeded world, the **release** Rust sidecar, and +`tools/scaffolding/BridgeRigDriver.cs` (`configset` / `configread` / `partprobe`) plus +`BridgeParticipationProbe.cs`. + +| Claim | Evidence | +|---|---| +| a lease applies, and the change is visible through the shard's own reader | `lease.apply` → `{"baseline":"1000","applied":"1200"}`; an independent `Config.Get` from a different class, long after every type initialiser, read `1200` | +| **the shard restores baseline with nobody asking** | A 20-second lease, and then silence. `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, and `lease.expired` on the feed with `restored: true` | +| an expired lease still yields a verdict to teardown | `lease.list` reported `held: true, expired: true, restored: true`; the later `lease.release` answered `alreadyRestored: true` rather than an error | +| **a mid-lease GM edit produces `drifted`, and the world is LEFT ALONE** | `configset PlayerCaps.SkillCap 1350` under a live lease → `lease.release` answered `{"kind":"lease.drifted","current":"1350"}` at **200**, and a read afterwards still showed `1350` | +| a restart reverts a config lease, and the catalog says so | After a restart: `held: false`, `current: "1000"` — which is what makes `inForce()` correct | +| kill credit is per damager, inside the area | Two seeded players damaging one creature at the venue: both credited `kills: 1`, `score: 5.0` | +| **the tally survives a shard restart mid-run** | `save`, `shutdown`, boot: `[Bridge] participation: 1 run(s) restored from the world save`, and the snapshot returned both members with identical `firstMs` | +| **the first live `bridge.busy`** | Two concurrent `POST /participation/:runId/snapshot` under one key, chunk size 1: the first answered 200 with the tally, the second **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` | +| a deferred key replays like any other | A third request under the same key answered `replayed: true` under its own `reqId` and the **first attempt's** `t` | + +### 10.1 The defect the deferral found, in 11a's own code + +`bridge.busy` came back **200**, not 425, the first time anything produced it. + +`BridgeIdempotency.Busy` built its frame with `BridgeJson.Begin("bridge.busy")` — which writes +`"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)` naming the command +that was in flight. **The object carried two `kind` fields, and every JSON parser worth the name +takes the last.** The sidecar matched on `bridge.busy` to decide the 425, read +`participation.snapshot` instead, and answered an ordinary 200 with a body saying nothing had +happened — the single worst of the three possible answers, since a retry loop would treat it as +success. + +It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never +arrive mid-flight, so the arm was unreachable on a live shard, and the sidecar test that covers the +mapping was — correctly — feeding it a frame built by hand. The first deferring handler produced it +on its first collision. Renamed to `busyKind`. + +This is the argument for the phase's ordering, stated as a fact rather than a hope: 11a said +*"11b's leases are the first thing that can actually produce it, and proving it belongs in that +walk"*, and the walk found a real bug in shipped code. + +### 10.2 What the rig could not drive: presence + +The participation ledger counts two things and only one of them is reachable headlessly. + +**Presence needs a connected client.** The sweep credits online players — `NetState != null` — which +is the correct test and not one a probe should loosen: a character parked in Britain and logged out +for eight hours did not attend anything, and a ledger that said otherwise would put people at the top +of a leaderboard for being AFK. There is no way to produce a NetState short of writing a client, and +ClassicUO cannot be driven from this machine. + +**Kill credit needs none**, so the whole of the credit path — the damager filter, the per-damager +fold, the area test on the damager, the member cap — ran exactly as it would in a fight, and the +accrual, persistence, snapshot, chunking and replay paths were driven by it. The one line the walk +did not exercise is `member.Seconds += seconds`, and it is named here rather than left to be assumed. + +### 10.3 Two rig traps + +- **`Core.Kill` does not save the world.** The rig driver's `shutdown` verb is a clean shutdown, which + is the only kind that *emits* — and it emits `server.shutdown` without writing a save. The first + restart test therefore reloaded an **empty** `Participation.bin` and looked exactly like a + persistence bug. `save` then `shutdown` is the sequence; the file's length is the check. +- **A probe that means to produce two damagers must not kill with the first blow.** An opening 40 + damage on a Mongbat (around thirty hit points) killed it where it stood, so the second damager never + landed a hit, `DamageEntries` held one name, and the ledger correctly credited one player. It read + as a plugin crediting only the killer. Scaled to the creature's `HitsMax`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 4f8079e..eb9c0b0 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -205,6 +205,18 @@ live-read — never "any config key". A module must not advertise a lease it can because *"the setting applied and nothing happened"* is the worst failure this feature has. That is a testable obligation, and the test is mechanical: for each key in the catalog, apply, observe, restore. +> **Measured in Phase 11b, and it is far more lopsided than "two patterns" suggests.** Of the 158 +> non-Bridge `Config.Get` call sites in `Scripts/` on ServUO 57.4, roughly **eight** are read live. +> The rest are cached at type initialisation. So the allowlist is not a curated subset of a large +> pool — it is nearly the whole of what is available, and the catalog Phase 12 inherits will be +> short for reasons no amount of care can change. +> +> A second measurement, which decides how `drifted` gets tested at all: **`Config.Set` has exactly +> one caller in the whole tree** (`Server/ScriptCompiler.cs`). No in-game command, gump or console +> verb writes a config key, so on a stock shard a GM cannot drift a *configuration* lease even +> deliberately. The compare-and-set is still required — Phase 12's object-property leases are +> trivially driftable — but the config half of it is proved with scaffolding, not by a GM. + Beyond configuration the same pattern covers any per-object property whose current value is readable before it is written — an existing spawner's `Amount` / `MinDelay` / `MaxDelay`, a named creature's stats, a `SeasonalEventSystem` entry's status. Loot stays excluded for the reason it always was: it @@ -807,6 +819,10 @@ api.registerEventLeases([{ // not an error: answer { ok: false, drifted: true, current }. return { ok: true } }, + // Optional (Phase 11b). "Does the game side still have any record of this + // hold?" — a DIFFERENT question from `read`, and the only thing that takes a + // lease's ledger row out at reconcile. + async inForce() { return { ok: true, held: true } }, }]) ``` @@ -829,6 +845,16 @@ api.registerEventLeases([{ 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. +- **`inForce()` is the fourth, it is optional, and it is not `read()` with a comparison** + (Phase 11b). It answers *"does the game side still have any record of this hold?"*, which none of + the other three do — and it had to exist because a lease's ledger row has no reconcile path + otherwise: the step that made it names `core.lease`, which is core's own action, so there is + nowhere a module could hang the answer. A value that DIFFERS from what the run applied is drift, + which `restore()` reports so the row lands `drifted` with the current value beside it; inferring + absence from a changed value would orphan the row first and destroy that signal. Only an explicit + `{ ok: true, held: false }` takes a row out; a throw, a timeout and a lease with no `inForce()` + all leave the ledger alone. It matters most for exactly the case core could not see before: a + config lease is memory-only on the shard, so a restart reverts it *and* clears the record. - **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 @@ -1095,13 +1121,13 @@ a capability exists. | Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. | | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | | Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. | -| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, and **not trustworthy enough to publish results on**: nothing scopes a kill or an arrival to a run, nothing separates a passer-by from an attendee, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. | +| **Participation attribution** | ✅ built | Protocol 6 part b. Presence in a declared area plus kill credit inside it, keyed by character serial, **persisted in the world save** so a restart mid-event does not lose it. The area is a map, a point and a radius rather than a region name — the most specific region containing an event is routinely anonymous. Kill credit goes to every damager standing in the area, not to the killer: a last hit is a poor description of who fought something. | | Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | | Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | | Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. | | Named, hued creatures from an allowlist | 🔧 📡 | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. | | "Simple" boss variants | 🔧 📡 | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. | -| **Lease a live config value** — rates, toggles, caps | 🔧 📡 | `Server/Config.cs` is a runtime typed key-value store, so this works — **for keys read live**. The plugin ships a verified allowlist, because a lease on one of the `static readonly` keys applies cleanly and does nothing. The single most transferable action in the whole set. | +| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. Phase 12 adds the rest with the boot-time self-check. | | Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. | | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | | Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | @@ -1973,10 +1999,13 @@ the platform's largest missing safety property. Today the moderation write plane happened and nothing about what it produced. It is also what makes the reward audit answerable — who received what, from which step, in which run. -**Participation attribution is now the largest piece of new UO work.** -With points cut, spawning is no longer the hard part — attribution is. Results, profile history, the +**Participation attribution was the largest piece of new UO work.** ✅ *Built in Phase 11b.* +With points cut, spawning was never the hard part — attribution was. Results, profile history, the calendar's "what happened" and any future recognition all rest on trustworthy "who took part", and UO -gives no run-scoped attribution that can be synthesised from the website side. +gives no run-scoped attribution that can be synthesised from the website side. So the shard counts it: +presence in a declared area plus kill credit inside it, keyed by character serial, and **persisted in +the world save** — which made it the Bridge's first persisted state, because a run spans hours and an +in-memory tally would regress every attendee's score after one restart. **The GM cue step makes the system useful before any protocol change.** "Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index acc2cd8..a4518c6 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1358,6 +1358,66 @@ deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes reachable. +> **Built.** All four verifications passed on a real ServUO with the release sidecar. Protocol 6 +> amended in place; `MODULE_API_VERSION` amended in place at 1.10.0. See +> [`../link/v6.md`](../link/v6.md) §§7–10. +> +> **It is FIVE repos, not four, and the plan was wrong about this in the opposite direction from +> 11a.** A lease's ledger row had no reconcile path anywhere, and nothing failed to say so: +> `cleanup.js` resolves a resource to the action of the step that made it, and for a lease that +> action is `core.lease` — a CORE action, on a path a module cannot register anything on. So every +> `override` row came back `unanswered` for the life of the run, and a lease the shard had quietly +> dropped stayed in the ledger as live until teardown went hunting a baseline nobody was holding. +> `website` joins the phase: `core.lease` gains a `reconcile()` and `registerEventLeases` gains an +> optional **`inForce()`**. Deliberately not `read()` plus a comparison — a changed value is DRIFT, +> which teardown must report so the row lands `drifted`, and inferring absence from it would orphan +> the row first and tell the operator the lease vanished rather than that somebody moved it. +> +> **Ten decisions (org lead, 2026-09-04), all as recommended.** The five-repo correction and +> `inForce()`; `PlayerCaps.SkillCap` as the one proven key; a scaffolding write verb to make +> `drifted` reachable at all; leases memory-only, so a restart is a free restore; a separate +> `Bridge.EventsEnabled` gate rather than `AdminWriteEnabled`; map + point + radius for the area; +> presence-plus-weighted-kills for the score; the shard-side bounds and grace window; chunking +> `participation.snapshot` as the thing that defers; and no `MODULE_API` bump. +> +> **The catalog is far shorter than §D expected, and the measurement is the finding.** §D frames the +> 258 `Config.Get` call sites as splitting into two patterns. Measured: of the **158** non-Bridge +> sites in `Scripts/`, roughly **eight** are read live. The allowlist is not a curated subset of a +> large pool — it is nearly the whole of what exists. And **`Config.Set` has exactly one caller in +> the entire tree** (`Server/ScriptCompiler.cs`), so no in-game command, gump or console verb writes +> a config key: on a stock shard a GM cannot drift a *configuration* lease even deliberately, which +> is why proving `drifted` needed a `configset` verb in the rig driver. +> +> **The walk found a defect in 11a's shipped code, which is the argument for the ordering.** +> `bridge.busy` came back **200**, not 425, the first time anything produced it: +> `BridgeIdempotency.Busy` built its frame with `Begin("bridge.busy")` and then appended a diagnostic +> `.Str("kind", prior.Kind)`, so the object carried **two `kind` fields** and every JSON parser takes +> the last. The sidecar read `participation.snapshot`, matched nothing, and answered a 200 whose body +> said nothing had happened — the worst of the three possible answers, because a retry loop reads it +> as success. Unreachable in 11a by construction, and the first deferring handler produced it on its +> first collision. Renamed `busyKind`. +> +> **One resource in `module-uo` must NOT reconcile by boot stamp, and it is this one.** Every other +> resource wave 1 ships is stamped with the shard boot that created it, because a crier line and a +> news article live in shard memory and a restart is definitionally the loss of both. The +> participation ledger is written into the world save *specifically* so it survives a restart, so +> the stamp would orphan the one resource the phase went to the trouble of persisting. It asks +> instead, and only a 404 takes a row out. +> +> **What the rig could not drive: presence.** The sweep credits online players (`NetState != null`), +> which is the correct test and not one a probe should loosen — a character parked in Britain and +> logged out for eight hours did not attend anything. There is no way to produce a NetState short of +> writing a client. Kill credit needs none, so the credit path, the accrual, the persistence, the +> chunking and the replay were all driven; the one unexercised line is the presence accrual itself, +> and it is named rather than assumed. +> +> **Two rig traps, both of which faked a defect.** `Core.Kill` does **not** save the world, so the +> first restart test reloaded an empty `Participation.bin` and looked exactly like a persistence bug +> — `save` then `shutdown` is the sequence. And a probe that means to produce two damagers must not +> kill with the first blow: 40 damage on a Mongbat killed it where it stood, the second damager never +> landed a hit, and the ledger correctly credited one player while reading as a plugin that credits +> only the killer. + --- ### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index a3452d1..4fd9c63 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -114,6 +114,7 @@ api.registerEventLeases([{ async read() { return { ok: true, value: 1.0 } }, async apply(v, until) { return { ok: true } }, async restore(baseline, { expected }) { return { ok: true } }, + async inForce() { return { ok: true, held: true } }, // optional }]) ``` @@ -129,6 +130,17 @@ field: 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`. +- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering + `{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything + else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone. + Core needs it because a reconcile after an outage asks *"does the game side still have any record + of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run + applied is drift, which `restore()` reports so the row lands `drifted` with the current value + beside it, and a reconcile that inferred absence from a changed value would orphan the row first + and tell the operator the lease vanished rather than that somebody moved it. The two questions + have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's + step names `core.lease`, which is core's own action, so there is nowhere else a module could hang + the answer. - **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 @@ -652,7 +664,7 @@ api.registerAudiences([{ id, label, params, ceiling, resolve }]) // api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 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.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) -- 2.49.1 From c53cf9fce5b6c1e5e0318b2213e1ad754526bf39 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 01:52:34 -0500 Subject: [PATCH 14/24] docs(link): protocol 7 -- what an event owns, and the split that carries it `link/v7.md` is the spec of record: one command family for five verbs, the persisted ownership registry and why it is forced rather than chosen, the oracle that reuses ServUO's own dialogue vocabulary without its command-scripting field, and the decoration index. `EVENTS_PLAN.md` splits Phase 12 into 12a and 12b (org lead, 2026-09-07), on the line section G already draws between what an event OWNS and what it BORROWS. Ten verbs, four repos and a protocol bump is one review and one walk too many, and the halves prove different things. One protocol version across both, the 11a/11b shape. Three corrections the survey forced on `EVENTS.md`: The config lease catalog does NOT grow in 12b, and the plan's promise of "the rest of the allowlist" was written before anyone counted. Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites; 82 sit outside a field declaration, but all but four of those are inside a `Configure()` or a static constructor and are cached at boot exactly as the field initialisers are. The live, event-useful reads are `PlayerCaps.SkillCap` (11b shipped it) and vendor bribe decay. So 12b's lease work is object-property leases. Section G calls the seasonal toggle "a nine-value enum". `EventStatus` has THREE values; it is `EventType` that has nine entries. The verb is a three-state toggle over nine named events, which is a different form to author and to cap. Section G costed the reopening at one protocol bump. It is two: the idempotency guarantee had to land before the verbs that depend on it, which is v6's own argument. `README.md` gains rows for v6 and v7. v6's was missing -- the index has been one protocol behind since 11a. Refs: EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- README.md | 2 + link/v7.md | 295 +++++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 18 +-- website/EVENTS_PLAN.md | 120 +++++++++++++++-- 4 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 link/v7.md diff --git a/README.md b/README.md index 0edb670..d057676 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ particular game; a module is what makes it a site *for* one. | [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework | | [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) | | [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**, shipped 2026-09-01 as bundle 2026.09.01 (sidecar v2.1.0 + overlay v1.1.0) | +| [v6.md](link/v6.md) | Protocol 6 — idempotent commands, config leases with a shard-side deadline, and the run-scoped participation ledger. On `edge`; reaches `main` at the events cutover | +| [v7.md](link/v7.md) | Protocol 7 — the world verbs an event OWNS: creatures, bosses, oracle NPCs, temporary gates, decoration, and the persisted ownership registry behind them. On `edge` | | [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) | | [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge | | [PLAN.md](link/PLAN.md) | uo-link build plan | diff --git a/link/v7.md b/link/v7.md new file mode 100644 index 0000000..bf8e800 --- /dev/null +++ b/link/v7.md @@ -0,0 +1,295 @@ +# Protocol 7 — What an event owns + +**Status:** In review on `edge`. **12a and 12b land as one protocol version** — see §8. +**Date:** 2026-09-07 (12a) +**Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`v6.md`](v6.md) (6.0 idempotency, leases, participation), [`INTEGRATION.md`](INTEGRATION.md) (website API). +**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 12. + +--- + +## 1. The payload protocol 6 was the floor for + +[`v6.md`](v6.md) §1 said it plainly: *"Protocol 12's world verbs are the payload; protocol 6 is the +floor they stand on."* This is that payload. + +Five verbs an event author sees — creatures, an enhanced "boss", an oracle NPC, a temporary gate, +decoration — and every one of them is the same sentence underneath: + +> **An object exists in the world, and this run owns it.** + +Ownership is the whole of why [`EVENTS.md`](../website/EVENTS.md) §G can exclude arbitrary `[add` +and `[set` and then permit all of this in the same table. The rule it draws is: + +> An event may **own** what it creates and **borrow** what it changes, and may never change +> something with no baseline recorded. + +12a is the first half. 12b is the second. + +--- + +## 2. One command family, not five + +| Command | Answers | | +|---|---|---| +| `world.spawn` | `world.ok` | Places `count` of `what` at a map/x/y; replies with the serials | +| `world.despawn` | `world.ok` | `removed` / `gone` / `refused`, per serial | +| `world.owned` | `world.owned.ok` | What the run still owns, pruning dead rows as it walks | + +Plus one unsolicited frame, `world.expired`, when the shard collects something on its own. + +**`what` is a field, not five kinds** (org lead, 2026-09-07). The per-verb differences — a boss's +multipliers, an oracle's lines, a gate's destination and deadline — ride alongside the discriminator +rather than justifying their own command. That buys one ledger shape, one teardown path and one +reconcile instead of five near-identical ones across three repos, and it costs nothing an author can +see: `module-uo` still declares **five actions**, because five is what they are. The discriminator +is a wire detail, and a dropdown of `what` would be a worse authoring form than five named steps. + +```json +{"kind":"world.spawn","reqId":"r-4","runId":"77","idempotencyKey":"…", + "what":"creature","map":"Felucca","x":1496,"y":1628, + "count":8,"type":"Orc","name":"Rotting Orc","hue":1157,"spread":6} +``` + +```json +{"kind":"world.ok","reqId":"r-4","action":"spawn","runId":"77","what":"creature", + "placed":8,"serials":["0x40001A2B","…"],"owned":8} +``` + +Serials cross as `"0x…"` text, matching every other serial this bridge writes. + +--- + +## 3. The ownership registry, and why it is persisted + +**This is the design decision of the phase, and it was forced rather than chosen.** + +Start from what is already known. A spawned creature is a **world object**, so unlike a town-crier +line it *survives a restart*. That alone settles one question: `reconcileByBootId` — the trick +Phase 9 uses for the crier and the news gump, where a changed `bootId` **is** proof the thing is +gone — is wrong here. It would report gone the one class of resource still standing in Britain. +Reconcile has to ask. + +The harder question is where the record of *which run owns which serial* lives, and the two obvious +answers are both wrong: + +- **In memory on the shard.** Lost in the restart the creatures themselves survive. The website then + holds serials the shard will not vouch for, and thirty orcs stand in Britain until somebody + deletes them by hand. +- **Only in the website's ledger.** Then it is not held on the shard at all, and `world.despawn` + would have to delete whatever serial it was handed. *"An event never touches a creature it did not + create"* — the sentence the boss verb is built on — would be an intention with no mechanism behind + it, and a bug in a step or a stolen sidecar token would be a delete-anything primitive. + +So the Bridge gains its **second** persisted file, `Saves/Bridge/Owned.bin`, beside 11b's +`Participation.bin`. Same mechanism (`Server.Persistence` plus `EventSink.WorldSave`), same rule +about attaching the hooks in `Configure()` rather than `Initialize()` — `EventSink.WorldLoad` fires +*inside* `World.Load()` — and the same unconditional attachment ahead of the enabled gate, so an +operator who switches the plane off for an afternoon does not come back to a truncated registry and +a world full of creatures nothing admits to owning. + +### 3.1 The registry and the objects cannot get out of step + +Worth stating because a reader will reach for it as an objection: if the registry were lost while +the objects survived, `world.owned` would answer with an empty hand and the website would orphan +live creatures. + +It cannot happen. The registry is written by `EventSink.WorldSave` — **the same save** that writes +the objects it describes. They are saved together and lost together. That is what makes "anything +the shard does not list is gone" a safe reading rather than a hopeful one, and it is why +`world.owned` on a run the shard has never heard of is an **empty list and a 200**, not a 404: +*"this run owns nothing"* and *"I have never heard of this run"* are the same fact once the registry +is the only record of ownership. + +### 3.2 Three answers to a despawn, and only one is a failure + +| | Meaning | +|---|---| +| `removed` | Owned by this run, found, deleted. | +| `gone` | Owned by this run and already absent. **A success.** | +| `refused` | Not this run's to delete. The row stays; nothing is touched. | + +**`gone` being a success is not a technicality.** Players kill event creatures — that is what +spawning them is *for*. §L already settles it (*"gone, and that is fine"* is a successful revert), +and a run that ended `incomplete` because its event worked would be a report nobody could read. + +`refused` is the only answer here that means somebody asked for something they should not have, and +it is reported back to core as a per-row `failed` so the row lands unresolved **with a reason** +rather than being quietly marked reverted. Nothing will ever delete it through this path. + +--- + +## 4. The oracle is ours, and ServUO's own dialogue engine is why + +ServUO ships a complete dialogue system in `XmlSpawner2.XmlDialog`, and its `SpeechEntry` is the +evidence that the shape this verb wants is right rather than invented: + +| `XmlDialog.SpeechEntry` | This verb | +|---|---| +| `Text` | the line | +| `Keywords`, comma-separated | the keywords | +| an entry with **no** keywords fires automatically | the greeting | +| `defProximityRange = 3` | `Bridge.EventsOracleGreetRange` | +| `LockConversation` | a per-player cooldown | +| **`Action` — XmlSpawner's command-scripting language** | **nothing** | + +That last row is the whole reason not to build **on** it. Routing authored dialogue through +XmlDialog would leave an arbitrary-command field one field away from an event author on a web form: +the `[set` §G excludes, arriving through the back door, in a subsystem this overlay does not own and +an operator can switch off. + +What the verb actually needs are two native virtuals on `Server.Mobile`: + +- **`OnMovement(m, oldLocation)`**, delivered to **every** mobile in range. The `HandlesOnMovement` + filter applies only to *Items* — `Server/Mobile.cs:3369` against `:3375` — so a greeting hook is + free. The old location is compared as well as the new one, which makes it fire once per approach + rather than on every step of a passer-by. +- **`HandlesOnSpeech(from)` → `OnSpeech(e)`** (`Server/Mobile.cs:5150`), the keyword hook. + +Nothing executable crosses the wire: keywords and text. + +**The oracle cannot be killed, moved or looted.** `CanBeDamaged()` is false as `TownCrier`'s is, and +it is `Blessed`, `Frozen` and `CantWalk`. An event NPC a player can drag out of the venue is one +that stops being where the run's ledger says it is, and teardown deleting something that has +wandered two screens away is worse than it not moving. + +**Keywords are lower-cased on the website side, not at match time.** The comparison on the shard is +then ordinal, between two strings already in the same case — culture-aware casing at match time +would make an oracle answer differently on a Turkish shard. + +--- + +## 5. Decoration comes from the shard's own decoration files + +`Data/Decoration/**/*.cfg` names every item type the shard already uses as scenery, with its item +id. The atlas build indexes them, so the authoring dropdown is derived from the operator's own tree, +resolves with the shard down, and is *decoration* by the shard's own definition rather than by ours. + +Two measurements shaped the wire: + +- **120 files, nested two deep** (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`), so + the read is recursive. A flat read would index a fraction of it while looking like it worked — + the failure being a dropdown quietly missing whole expansions rather than an error anyone notices. +- **313 distinct types, and the item id is not decoration.** `Static` alone accounts for **5031 of + the placements under 1992 different graphics**, because for that class the graphic *is* the + identity: a bare `new Static()` is never the switch or the paving stone the author picked. 131 of + the 313 types carry more than one id (a door has one per facing). + +So `world.spawn` carries an optional **`itemId`** for decoration, resolved on the website from the +atlas row rather than typed by an author — which also means the verb places only what this shard's +decoration files actually name, a tighter boundary than "any item that is not a container". + +**The id is never applied to a `BaseAddon`.** An addon is a group of components and its own `ItemID` +is not what a player sees; writing a graphic over it would leave a stone oven rendering as one +arbitrary tile of itself. Addons construct with the right appearance already. + +**Containers are refused outright.** Deleting one at teardown would delete whatever a player had +left inside it. Everything else here is safe to delete because the event made it; a container's +contents are not. + +--- + +## 6. Ceilings refuse, they do not clamp + +`Bridge.EventsMaxCreatures` (30), `EventsMaxBosses` (4), `EventsMaxNpcs` (5), `EventsMaxDecor` (60), +`EventsMaxGateMinutes` (240), `EventsMaxOwnedPerRun` (200), `EventsMaxSpread` (40), +`EventsMaxBossMultiplier` (10.0), and the oracle's own five. Defaults are the EM Program's published +quotas, because they are the only numbers anyone has defended in public. + +These are the shard's **independent** bounds, not mirrors of the module's budget dimensions — +`Bridge.LeaseMaxDurationSec`'s argument from [`v6.md`](v6.md) §8.2 unchanged: the bound exists for +the case where the website is wrong, and being loud about it is its whole value. A refusal is a +**400**, and permanently so: retrying *"you asked for 80 creatures and this shard places 30"* gets +the same answer forever, so a module that classified it as transient would put a run in a loop +against a limit that will never move. + +`EventsMaxOwnedPerRun` is the one that is not a per-call ceiling. The others bound one request; that +one bounds a run calling a verb in a loop, which is the shape a runaway schedule actually takes. + +**The gate deadline lives on the shard**, for [`v6.md`](v6.md) §8.3's reason about leases: a gate the +shard closes by itself closes whether or not the website is ever heard from again. A run whose +engine died leaves a world that comes back early rather than one stuck open. And it crosses as a +**duration** (`holdMs`), never an absolute time — an absolute deadline is measured against two +clocks, and a shard ten minutes fast would collect the gate the instant it opened. + +**`Bridge.EventsEnabled` gates all of it** (org lead, 2026-09-07). Spawning creatures and opening +gates is the same consent 11b introduced that switch for — unattended, scheduled world change — not +a third one. A switch per verb family is a setting nobody can reason about. + +--- + +## 7. Visibility + +`world.expired` is **deliberately unmapped**, exactly as `lease.applied` and `lease.expired` are and +for the reason [`v6.md`](v6.md) §4 gives: it is an operational record of the *website* changing this +shard's world, and rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail +of the site's own writes belongs. Mapping it would mean choosing a feature an operator could then +widen, and there is no rung below admin it belongs on. + +**A spawn emits no stream frame at all**, and the asymmetry is deliberate. A reply correlated on +`reqId` already tells the website everything it needs, and eight creatures placed in one call would +otherwise be eight frames announcing what the caller already knows. `world.expired` exists precisely +because it is the *only* thing here that happens without the website asking. + +--- + +## 8. One version, two halves + +Phase 12 is split. **12a bumps `PROTOCOL_VERSION` to 7; 12b amends 7 in place** on `edge` — the +11a/11b shape, and it applies for the same reason: 7 will not reach `main` until the events cutover, +so the bundle CI never sees two numbers. + +It carries the same hazard [`v6.md`](v6.md) §7 states, and it is worth restating rather than +assuming anyone will follow the link: **an overlay and a sidecar both declaring 7 are interchangeable +only within one side of the 12b merge.** That is tolerable for one reason and no other — nothing is +released from `edge`. It would not be tolerable on `main`. + +The three declaration sites, all bumped in 12a: `link/sidecar/src/main.rs` +(`PROTOCOL_VERSION`), `servuo-plugins/overlay.toml` (`protocol`), and the website's admin-managed +`uoLinkConfig`. `installer` has none, for the reason Phase 11 records. + +**12b adds** object-property leases and their boot-time self-check, the seasonal-event toggle, a +world save, and the item grant. + +--- + +## 9. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | `BridgeWorld.cs` (registry, three handlers, the sweep), `BridgeOracle.cs`, the `EventsMax*` keys, `overlay.toml` → 7 | +| `link` | `POST /world`, `GET /world/:runId`, `POST /world/:runId/despawn`; `PROTOCOL_VERSION` → 7 | +| `module-uo` | Five actions, five budget dimensions, the decoration option source and the atlas index behind it | +| `docs` | This file; `EVENTS.md` §G; `EVENTS_PLAN.md` Phase 12 | + +`MODULE_API_VERSION` does **not** move. Budget dimensions and option sources are things a module +*declares* through an API that already exists; nothing in the contract core reads has changed. + +--- + +## 10. Verification + +The whole rig: a run that spawns one of each of the five, a restart mid-run proving the ownership +registry survives it and reconcile still answers, a creature killed by a player proving `gone` is an +ordinary teardown outcome, and a despawn **refused** for a serial the run does not own. + +### 10.1 What the build already found + +**An action must be performable from its own required params, and `uo.npc.place` was not.** Both +ends refuse an oracle with neither a greeting nor a line — it would stand there in silence — but +both fields were declared optional, so the verb could not be performed from its own required set and +no authoring form could render it as valid either. A cross-field *"at least one of these"* rule is +the wrong shape for a declaration core reads as data. The greeting is now `required`, which says the +same thing in the contract itself; the `perform()` check remains, for the field holding nothing but +spaces. + +It was caught by `module-uo`'s existing dry-run sweep — a test that performs every action with only +its required params — which is a better argument for that test than anything written about it when +it shipped. + +**The creature option source answered with something unusable.** `uo.options.creatures` shipped in +Phase 9, before anything consumed it, carrying the atlas **slug**: unique, stable, and not a thing +the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is +not one. The atlas's `name` **is** the raw type token from the spawn files, so the fix was to stop +discarding the half that works. Safe to change because 12a is the source's first consumer — the file +said so when it shipped. diff --git a/website/EVENTS.md b/website/EVENTS.md index eb9c0b0..af52dd9 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1122,22 +1122,24 @@ a capability exists. | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | | Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. | | **Participation attribution** | ✅ built | Protocol 6 part b. Presence in a declared area plus kill credit inside it, keyed by character serial, **persisted in the world save** so a restart mid-event does not lose it. The area is a map, a point and a radius rather than a region name — the most specific region containing an event is routinely anonymous. Kill credit goes to every damager standing in the area, not to the killer: a last hit is a poor description of who fought something. | -| Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | -| Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | -| Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. | -| Named, hued creatures from an allowlist | 🔧 📡 | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. | -| "Simple" boss variants | 🔧 📡 | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. | +| Oracle NPC with scripted dialogue | ✅ **built (protocol 7)** | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. Phase 12a: a greeting on approach plus keyword rows, on `Mobile.OnMovement` and `Mobile.OnSpeech`. Built rather than layered on `XmlSpawner2.XmlDialog`, which implements exactly this vocabulary **and an `Action` command-scripting field** — the `[set` this table excludes, one field away from an author. | +| Temporary gate to a venue | ✅ **built (protocol 7)** | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. Phase 12a; the deadline is the SHARD's, so a gate closes whether or not the website is heard from again — and it crosses as a duration, never an absolute time. | +| Temporary decoration lockdown | ✅ **built (protocol 7)** | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. Phase 12a places it from the shard's OWN `Data/Decoration/**/*.cfg` vocabulary, carrying the item id: `Static` alone accounts for 5031 placements under **1992 different graphics**, so a bare type name places the wrong thing. Containers are refused — teardown would delete what a player left inside. | +| Named, hued creatures from an allowlist | ✅ **built (protocol 7)** | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. Phase 12a; the ledger is a PERSISTED shard-side registry, because a spawned creature survives the restart that proves a crier line gone. | +| "Simple" boss variants | ✅ **built (protocol 7)** | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. Phase 12a: an allowlisted type plus hits/damage/stat multipliers, each bounded. | | **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. Phase 12 adds the rest with the boot-time self-check. | | Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. | | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | -| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | +| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status`, already persisted across saves. **Corrected in Phase 12a's survey:** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. So the verb is a three-state toggle over nine named events — a different form to author and to cap than one nine-way choice. Phase 12b. | | Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. | | Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. | | Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. | | Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. | -**What the reopening actually costs.** Nine new plugin verbs, one protocol bump carrying an -idempotency key and a lease deadline, and a participation ledger. Bounded work, and every verb is +**What the reopening actually costs.** Nine new plugin verbs and **two** protocol bumps: 6 +for the idempotency key, the lease deadline and the participation ledger, and 7 for the world +verbs themselves. (One bump was the estimate; the guarantee had to land before the verbs that +depend on it, which is [`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is cap-bounded, ledgered and attributable by construction. **None of it is `[add`.** The distinction that makes it defensible: an event may **own** what it creates and **borrow** what it changes — and a borrowed value carries a deadline the game itself enforces. What it may never do is change something diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index a4518c6..5f8a4ad 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1423,26 +1423,122 @@ reachable. ### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) **§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below -stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and -either owned or borrowed: +stands. + +**Split into 12a and 12b (org lead, 2026-09-07)**, on the line §G already draws: what an event +**owns** and what it **borrows**. Ten verbs, four repos and a protocol bump is one review and one +walk too many, and the two halves prove different things — 12a proves a run can put things in the +world and get all of them back, 12b proves it can change something it did not create and give that +back unchanged. They land as **one protocol version**: 12a bumps to **7**, 12b amends 7 **in place** +on `edge`. That is the 11a/11b shape, and it carries the same hazard §7 of `v6.md` states — an +overlay and a sidecar both declaring 7 are interchangeable only within one side of the 12b merge — +tolerable for the same single reason and no other: nothing is released from `edge`, so the bundle CI +never sees two meanings of 7. + +`installer` is not in this phase, for the reason Phase 11 records: no protocol version is hardcoded +anywhere in it. + +#### Phase 12a — what an event OWNS + +Five verbs that put something in the world, ledger its serial, and delete it at teardown. + +| Verb | Cap dimension | Author's action | +| --- | --- | --- | +| Named, hued creatures from the atlas's ~800 constructible types | `uo.creatures` | `uo.creature.spawn` | +| "Simple" boss variants, as event-owned creature templates | `uo.bosses` | `uo.boss.spawn` | +| Oracle NPCs with scripted dialogue | `uo.npcs` | `uo.npc.place` | +| Temporary gates | `uo.gate.minutes` | `uo.gate.open` | +| Temporary decoration | `uo.decor` | `uo.decor.place` | + +**One command family, five author verbs** (org lead, 2026-09-07). Every row above ends in "an object +exists and this run owns it", so the wire carries `world.spawn` / `world.despawn` / `world.owned` +with a `what` discriminator, and the per-verb differences — a boss's stat multipliers, an oracle's +lines, a gate's target and deadline — are **fields rather than kinds**. One ledger shape, one +teardown path, one reconcile, instead of five near-identical ones in three repos. An *author* still +sees five verbs, because five is what they are: the discriminator is a wire detail, and a dropdown of +`what` would be a worse form than five clearly-named steps. + +**The caps are the module's, never core's** (org lead, 2026-09-07). `uo.creatures` and the rest are +declared by `module-uo` through `registerEventBudgets`, exactly as `uo.broadcasts` already is; core +meters whatever dimensions a module declares and holds no UO knowledge — which is the whole of what +§F means by game-agnostic. The shard additionally carries its own `Bridge.EventsMax*` ceilings and +**refuses rather than clamps**, on `Bridge.LeaseMaxDurationSec`'s argument from 11b unchanged: the +shard's bound exists for the case where the website is wrong, and being loud about it is its value. + +**Ownership is persisted, and that is forced rather than chosen.** A spawned creature lives in the +world save, so unlike a crier line it *survives* a restart — which already means +`reconcileByBootId` is wrong here, for the reason it was wrong for the participation ledger, and +reconcile has to ask. But the record of *which run owns which serial* has nowhere else to live. Held +in memory it is lost in the restart the creatures survive, orphaning them. Held only in the website's +ledger it is not held on the shard at all, so `world.despawn` would delete whatever serial it was +handed — and "an event never touches a creature it did not create" is the sentence the boss verb is +built on. So the Bridge gains its **second** persisted file, beside `Participation.bin`. + +**The oracle is our own, and ServUO's own dialogue engine is the reason for both halves of that.** +`XmlSpawner2.XmlDialog` already implements exactly the vocabulary this verb wants — `Text` plus a +comma-separated `Keywords` list, an entry with no keywords being the greeting, a proximity range, a +per-player conversation lock — which is evidence the shape is right rather than invented. It is +also the reason not to build **on** it: `SpeechEntry` carries an `Action` string, XmlSpawner's +command-scripting language, and routing authored dialogue through XmlDialog would leave an +arbitrary-command field one step from an event author. That is the `[set` §G excludes, arriving by +the back door, in a subsystem we do not own and a shard can switch off. `Mobile.OnMovement` +(delivered to **every** mobile in range — the `HandlesOnMovement` filter applies only to Items, +`Server/Mobile.cs:3369` against `:3375`) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals +and are the whole of what the verb needs. + +**Decoration comes from the shard's own decoration files.** `Data/Decoration/**/*.cfg` names every +item type the shard already uses as decoration, with its item id (`LargeCrate 0x0E3C`). The atlas +build indexes them, so the dropdown is derived from the operator's own tree and resolves with the +shard down — and the list is "decoration" by the shard's own definition rather than by our taste. +The plugin validates the type independently, because it cannot trust the website. + +**Ships:** an event can populate a venue — creatures, a boss, an oracle, a gate to reach it and +decoration around it — every piece cap-bounded, ledgered by serial, and gone at teardown. +**Verify:** the whole rig. A run that spawns one of each of the five; a restart mid-run proving the +ownership registry survives it and reconcile still answers; a creature killed by a player proving +"gone" is an ordinary teardown outcome and not a failure; and a despawn **refused** for a serial the +run does not own. + +#### Phase 12b — what it BORROWS, and the one-shots | Verb | Owned or borrowed | Cap dimension | | --- | --- | --- | -| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` | -| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` | -| Oracle NPCs with scripted dialogue | owned | `uo.npcs` | -| Temporary gates | owned, with a deadline | `uo.gate.minutes` | -| Temporary decoration lockdown | owned | `uo.decor` | -| A live config value | **borrowed** — a lease | — | | A property on an existing object | **borrowed** — a lease | — | +| A live config value | **borrowed** — a lease | — | | Seasonal-event toggle | borrowed | — | | World save | neither — a one-shot | — | | Item grant | owned, `reversible: 'none'` | `uo.rewards` | -**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split -between live reads and values cached at type initialisation, and a lease on the second kind applies -cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the -advertised catalog if it does not take — a capability that disappears loudly beats one that lies. +**The config lease catalog does not grow, and the promise of "the rest of the allowlist" was written +before anyone counted.** Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites in +`Scripts/`; 82 sit outside a field declaration, but all but four of *those* are inside a +`Configure()` or a static constructor, and so are cached at boot exactly as the field initialisers +are. The genuinely live, event-useful reads are `PlayerCaps.SkillCap` — which 11b already shipped +— and `Vendors.BribeDecayMinTime`/`MaxTime`, which is vendor bribe decay and which no event would +plausibly lease. (`Staff.*` in `GMbody.cs` is live, and is staff-body cosmetics.) So **12b's lease +work is object-property leases**, and the config half of the catalog is finished at one key. + +**The boot-time self-check ships anyway** (org lead, 2026-09-07). On a stock shard it guards a +one-key catalog, which is not why it exists: it exists for the operator whose *own* scripts read +config live, and it is the mechanism that keeps a capability that lies out of the advertised catalog. +A key that sets, reads back and restores at boot stays; one that does not drops itself and says so. +§D and §G of `EVENTS.md` carry the measurement, so nobody re-plans against "258 call sites, two +patterns". + +**An object-property lease names its target by an allowlist of (type, property) pairs, addressed by +serial** (org lead, 2026-09-07). The plugin ships the catalog — `Spawner.Amount` / `MinDelay` / +`MaxDelay` to start — and refuses any serial whose type is not in it. That mirrors the config +catalog exactly, and keeps §G's "a curated allowlist the plugin ships" true of both halves of the +lease plane; the alternative, a property name and a serial taken on trust, is `[set` with extra +steps. It also finally puts compare-and-set in front of a real hand: a spawner is trivially drifted +with `[props`, which is the test 11b could run only with scaffolding, because `Config.Set` has one +caller in the whole tree. + +**§G describes the seasonal toggle wrongly, and 12b corrects it.** It calls +`SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum". `EventStatus` has **three** values +— `Inactive`, `Active`, `Seasonal` — and it is `EventType` that has nine entries. The verb is a +three-state toggle over nine named events, which is a different form to author and a different one +to cap. **Ships:** the invasion. **Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown -- 2.49.1 From 4169a0df837e3bd3c9ed68ec12fd33ed8fcf1b00 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 08:08:35 -0500 Subject: [PATCH 15/24] docs(link): protocol 7 part b -- what an event borrows, and the one-shots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `link/v7.md` gains §11-§14: the two targeted lease planes, the two one-shots, the routes, and what the build found in already-merged code. `EVENTS.md` §G's five part-b rows are marked built, three of them carrying a correction. `MODULE_API.md` records the three contract members 12b amends into 1.10.0. `EVENTS_PLAN.md` has Phase 12b as built. THE DESIGN POINT, WRITTEN DOWN A borrowed value whose home is the world save does not come back on a restart the way a config value does. 11b's fail-safe -- a lease that never reaches disk makes a restart a free restore -- depends entirely on the leased value being memory-only too, and for a spawner property or a seasonal status it is not: a restart preserves the CHANGE and destroys only the timer that would have undone it. So those two planes' holds are persisted and their deadlines re-armed, and the config plane's still are not. The same argument, applied where its premise is false. FIVE CORRECTIONS TO EVENTS.md - `Spawner.Amount` does not exist. The property is `MaxCount`, and `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds. - The seasonal toggle is not "small and safe". Safe, yes -- ServUO does it to itself from a staff gump -- but `OnStatusChange()` generates or removes world content for six of the eight permitted types. - It is a THREE-value enum over nine named events, not a nine-value enum. (Caught in 12a's survey; the row is corrected here now it is built.) - `TreasuresOfTokuno` is excluded, because `IsActive()` reads its own `DropEra` rather than `Status`. A lease on it applies cleanly, reads back, restores cleanly and changes nothing -- §N10's "capability that lies", and the one instance no runtime probe can catch. - The grant row said failure aborts rather than retries. Protocol 6 changed that: an idempotency key means a repeat is answered by the original reply, so a retried grant cannot be one winner receiving two. And the config-lease row is closed at one key. Counted on ServUO 57.4: 156 non-Bridge `Config.Get` call sites, 82 outside a field declaration, all but four of those inside a `Configure()` or static constructor and cached at boot anyway. The self-check ships regardless -- it exists for the operator whose OWN scripts read config live. MODULE_API 1.10.0, AMENDED IN PLACE `target` on a lease declaration, `values` on a string lease, and `searchable` + `{ q }` on option sources. Amended rather than bumped for the reason every phase since P10 has: 1.10.0 has never reached `main`, so no deployment can tell the difference, and the cutover is what publishes the whole of it. The `target` entry records why this extends `core.lease` rather than giving the module a lease verb of its own: §F settled that in Phase 8, and half its objection no longer holds (the two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the row) while the other half -- `maxDurationMs` re-implemented per module -- still does. §14 records the protocol-pin defect 11a and 12a both shipped, and why the test that guards it passed anyway: it asserts the three declarations agree with each other, which all three being equally stale satisfies. CHECKS `docs` has no CI. Every relative link in the new sections was resolved by hand; the files are CRLF in the working tree and each diff is content-sized (`--numstat` matches the real change), so nothing carries the `\r\r\n` full-file rewrite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- link/v7.md | 286 ++++++++++++++++++++++++++++++++++++++++- website/EVENTS.md | 31 +++-- website/EVENTS_PLAN.md | 72 +++++++++++ website/MODULE_API.md | 81 +++++++++++- 4 files changed, 450 insertions(+), 20 deletions(-) diff --git a/link/v7.md b/link/v7.md index bf8e800..1993a94 100644 --- a/link/v7.md +++ b/link/v7.md @@ -1,7 +1,7 @@ # Protocol 7 — What an event owns **Status:** In review on `edge`. **12a and 12b land as one protocol version** — see §8. -**Date:** 2026-09-07 (12a) +**Date:** 2026-09-07 (12a and 12b) **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`v6.md`](v6.md) (6.0 idempotency, leases, participation), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 12. @@ -248,8 +248,8 @@ The three declaration sites, all bumped in 12a: `link/sidecar/src/main.rs` (`PROTOCOL_VERSION`), `servuo-plugins/overlay.toml` (`protocol`), and the website's admin-managed `uoLinkConfig`. `installer` has none, for the reason Phase 11 records. -**12b adds** object-property leases and their boot-time self-check, the seasonal-event toggle, a -world save, and the item grant. +**12b amends 7 in place** with object-property leases and their boot-time self-check, the +seasonal-event toggle, a world save and the item grant. It is specified in §11 to §14 below. --- @@ -262,8 +262,27 @@ world save, and the item grant. | `module-uo` | Five actions, five budget dimensions, the decoration option source and the atlas index behind it | | `docs` | This file; `EVENTS.md` §G; `EVENTS_PLAN.md` Phase 12 | -`MODULE_API_VERSION` does **not** move. Budget dimensions and option sources are things a module -*declares* through an API that already exists; nothing in the contract core reads has changed. +**Part b adds to every row of it, and adds a fifth.** `servuo-plugins`: `BridgeLeaseTargets.cs`, +`BridgeOneShots.cs`, the persisted `Leases.bin` and the boot self-check. `link`: `target` on the +lease family, `GET /items`, `POST /items/grant`, `POST /world/save`. `module-uo`: five targeted +leases, two actions, `uo.rewards`, three option sources, the atlas's `unique_id`, **and the +protocol pin 11a and 12a both missed** (§14). `docs`: this file, `EVENTS.md` §D/§G, +`MODULE_API.md`. + +**`website` is in part b and was not in part a.** A targeted lease is a shape `core.lease` did not +have: the declaration gains a `target` and a `values` set, the four callables gain the target, and +the reservation ref becomes `#` so the two-events-one-target index bites at the +granularity the world actually has. Extending core rather than giving the module its own lease verb +is what `EVENTS.md` §F decided in Phase 8 — *"the verb is core's"* — and the alternative would have +re-implemented `maxDurationMs` per module, advisory everywhere and wrong in the first one that +forgot. + +`MODULE_API_VERSION` does **not** move — in part a because budget dimensions and option sources are +things a module *declares* through an API that already exists, and in part b because **1.10.0 is +amended in place**, the shape every phase since P10 has used while the workstream sits on `edge`. +Part b really does change the contract (targeted leases, lease value sets, searchable option +sources); nothing is released from `edge`, so the version is amended rather than bumped, and the +cutover is what publishes it. --- @@ -293,3 +312,260 @@ the shard can build, because a creature is constructed from a ServUO class name not one. The atlas's `name` **is** the raw type token from the spawn files, so the fix was to stop discarding the half that works. Safe to change because 12a is the source's first consumer — the file said so when it shipped. + +--- + +## 11. Part b — what an event BORROWS + +12a was the first half of §G's rule. This is the second: **an event may borrow what it changes, and a +borrowed value carries a deadline the game itself enforces.** + +Two planes, and both of them are **targeted** — a lease names a key *and* the thing it applies to. +Every lease before protocol 7 named a single value (a config key, a rate), so the key *was* the +target. `Spawner.MaxCount` is not that: it is one capability over thousands of spawners, and two runs +turning up two different spawners must both be allowed while two runs turning up the same one must +not. + +| Plane | Key | Target | Holds | +|---|---|---|---| +| Object property | `Spawner.MaxCount` | a spawner | how many it keeps alive | +| Object property | `Spawner.MinDelay` / `Spawner.MaxDelay` | a spawner | its respawn window, in seconds | +| Object property | `Spawner.Running` | a spawner | whether it runs at all | +| Seasonal | `Seasonal.Status` | an `EventType` | `Inactive` / `Active` / `Seasonal` | + +The wire is the **same `lease.apply` / `lease.release` / `lease.list` family** protocol 6 introduced, +with a `target` field. One deadline, one compare-and-set, one grace window, one set of counters — +three planes rather than three protocols. + +### 11.1 A lease here must be PERSISTED, and the config plane's must not + +[`v6.md`](v6.md) states the config lease's fail-safe plainly: *a lease that never reaches disk means +a shard restart is a free restore.* That argument depends entirely on the leased value being +memory-only too, and here it is not. + +- A spawner is an `Item`. It is in the **world save**. +- A seasonal entry is written to `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. + +So a restart does not put either of them back. It puts the **change** back and throws away the +deadline timer that was going to undo it — leaving the world at the leased value with nothing on the +shard remembering that it is borrowed. That is the exact failure the lease framing exists to make +impossible, so the hold is persisted, in the Bridge's **third** save file (`Saves/Bridge/Leases.bin`, +beside `Participation.bin` and `Owned.bin`) and written by the same `EventSink.WorldSave` that writes +what it describes. + +**A deadline that passed while the shard was down fires at once**, rather than being dropped or +extended. The promise the website was given is "back at baseline by then"; a shard that was off for +the whole hold has not kept it, and restoring immediately is the only reading of that promise still +available. Extending it would silently turn a two-hour lease into however long the outage was. + +Config holds are still **not** written down, and the asymmetry is the point rather than an +inconsistency: the same argument, applied to planes where its premise is false. + +### 11.2 A target is a serial or a UniqueId, and both are needed + +- A **serial** is what `[props` shows a GM and what a rig can type. +- An **`XmlSpawner.UniqueId`** is what the shard's own `Spawns/*.xml` carry and what the live spawner + keeps. + +The second is not a convenience. A dropdown built from serials is **impossible**: serials are +assigned when the world is built and nothing off the shard knows them, so a lease addressable only by +serial could have no authoring list at all — it would be the free-text box the option-source contract +exists to replace. The atlas already read `` and discarded it; Phase 12b keeps it. + +The UniqueId lookup is a scan of `World.Items`, and stays one: it runs once per lease apply, which is +a rare human-scheduled operation, and a cache would be a second copy of the world to keep correct +across `[add` and deletion. + +### 11.3 The allowlist is checked against the object's own type + +A serial is a number a caller chooses. The only thing standing between `Spawner.MaxCount` and any +item on the shard is the check that the object found **is** one of the types the entry names — so +that check reads the object's own type rather than anything the caller sent, and it is +`IsInstanceOfType` rather than equality so a shard's own subclass of `Spawner` is leasable. + +Properties are read and written by reflection, bounded three ways: the (type, property) pair must be +in the shipped catalog, the property must carry `CommandProperty` (ServUO's own marker for *a staff +member may set this*, so this plane can never reach further into an object than `[set` could), and +its CLR type must be the one the entry knows how to render. Reflection rather than a hand-written +switch is what lets the boot self-check actually verify a pair; a switch would compile happily +against a property ServUO had renamed. + +**`Spawner` and `XmlSpawner` share all four property names**, which is a fact about this tree rather +than a convenience: the shard's own spawn files load as XmlSpawners while `[add spawner` makes the +native one, so a catalog naming only one of them would work until the day it did not. + +### 11.4 The boot self-check, and what it cannot do + +[`EVENTS.md`](../website/EVENTS.md) §N10: *the allowlist ships with the plugin and each key +self-checks at boot, dropping itself from the advertised catalog if the write does not take.* Better +a capability that disappears loudly than one that lies. + +**A config key is probed live** — written, read back, restored, all in one synchronous call — because +there is exactly one of it. + +**A property cannot be**, and that is a property of the thing rather than a shortcut: there are +thousands of instances and no canonical one, so probing would mean picking somebody's spawner at boot +and writing to it. What is verified instead is everything verifiable without touching the world: the +type still resolves, the property still exists on it, it is still public and settable, it still +carries `CommandProperty`, and its CLR type is still the one this plane can render. That is exactly +the failure N10 was written for — a property a later ServUO renamed or made read-only — caught at +boot rather than at 3am inside an unattended run. + +**And one failure no probe can catch is excluded by name.** `TreasuresOfTokuno` is left out of the +seasonal catalog because `SeasonalEventEntry.IsActive()` special-cases it and reads +`TreasuresOfTokuno.DropEra` rather than `Status`. Setting its status writes a field nothing consults: +the write succeeds, the value reads back, a compare-and-set restore passes, and every mechanism in +the plane reports a working lease over a capability that does nothing at all. It is N10's "capability +that lies" in its purest form, and the only way to find it is to read the source. + +### 11.5 What the seasonal toggle actually costs + +[`EVENTS.md`](../website/EVENTS.md) §G called this *"small and safe"* and got two things wrong. + +**It is a three-value enum over nine named events, not a nine-value enum.** `EventStatus` has three +values (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. That is a +different form to author and a different one to cap. (Corrected in 12a's survey; built here.) + +**And it is not small.** `SeasonalEventEntry.Status`'s setter fires `OnStatusChange()`, which calls a +`CheckEnabled()` that generates or removes world content for six of the eight permitted types — +Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. It is *safe*: ServUO does +exactly this to itself from a staff gump. But an author scheduling one is scheduling more than a flag +flip, and the label says so. + +### 11.6 `lease.list` answers the catalog and one row + +A targeted key has no single `current` — `Spawner.MaxCount` is worth something different on every +spawner — so a catalog walk cannot fill one in, while the website's `read()` needs exactly one value +for exactly one target before it applies anything. So the frame narrows: naming a `key` and a +`target` answers that row with its value. + +The frame also carries **`holds`**: every lease the shard is actually holding, whatever key or target +it is on. A catalog walk can enumerate the *keys* but never the *holds* on a targeted one — there is +no list of spawners to walk — so without it a reconcile after an outage would have no way to ask +"what are you still holding?". `inForce()` reads that. + +`current` is **omitted rather than defaulted** when it means nothing (a targeted row listed with no +target), and an unresolvable target answers `unreadable` with a reason. Sending `""` would make the +website record an empty baseline and later try to restore it. + +### 11.7 A vanished target is a success, not a failure + +Somebody deletes the spawner mid-run. There is nothing to restore and nothing owed, so the release +answers `targetGone: true` and the website records the row **reverted**. It is 12a's `gone` in the +lease plane's vocabulary, and for the same reason: reported as a failure it would sit in the ledger +unresolved for ever, over an object that no longer exists, retried by every sweep. + +Drift is unchanged and still not an error: the shard compared, declined to overwrite somebody's +deliberate change, and says so. + +--- + +## 12. The one-shots: neither owned nor borrowed + +Two verbs that cannot be taken back. Nothing is ledgered, because there is nothing core could come +back for. + +### 12.1 The item grant, and who receives it + +`ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both — +deliberately as **two** reversals, because permitting an event to create a creature says nothing +about permitting it to hand out loot. The four properties that make this a different proposition from +the one §8 refused: it is **declared** (an allowlist, never a free-text type reaching +`Activator.CreateInstance`), **bounded** (`EventsMaxGrantPerRun`, `EventsMaxGrantStack`, both +refusing rather than clamping), **attributable** (the run id rides on every grant), and **idempotent**. + +**The recipients are not sent, and that is the interesting decision.** A grant needs a list of +people, and the website has one in `event_run_participants` — but a module cannot read core's tables, +so the alternative was a new core surface handing participants to a module's `perform()`. It is not +needed: **the shard already has the list**, in protocol 6 part b's run-scoped participation ledger, +keyed by the same character serials the website's `member_key` holds. So the grant names a run and +the shard resolves who was there — no new core surface, no participant list crossing the wire twice, +and no window in which the two disagree. + +Two answers that look alike and are not: + +- **A run with no ledger open** is a `404`. The caller named something that does not exist here. +- **A run whose ledger is open and empty** is a `200` with `granted: 0`. An event nobody attended + still happened, and retrying against a ledger that will be just as empty next time would pause a + run for ever. + +**It is retryable, and protocol 6 is why.** [`EVENTS.md`](../website/EVENTS.md) §G called a grant +un-retryable because a lost acknowledgement and a grant that never applied were the same event — +exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` +closes it: a repeat is answered by the original reply, so a retried grant cannot be one winner +receiving two. §G was written before 11a and is corrected here. + +A non-stackable item in quantity is refused at **both** ends: five cloaks would be five items, five +chances to overflow a backpack halfway through with no way to say which half landed. An undeliverable +grant is **deleted rather than dropped** — `AddItem` failing on a full backpack would otherwise leave +the item in the world at (0,0), and an event that quietly littered the map with undeliverable rewards +would be worse than one that reported a miss. + +### 12.2 The world save + +`ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a phase +boundary — the point in an event after which what has happened is safe from a crash — and +`world.save.before` / `world.save.after` have been on the event stream since protocol 2, so the +acknowledgement it needs already exists. The reply says only that the save was **started**; a caller +that needs the completion watches the stream it is already connected to. + +**A save stops the world, so it is rate-limited rather than capped.** +`Bridge.EventsMinSaveIntervalSec` refuses a save that comes too soon after the last one — counting +ServUO's *own* autosave as the last one, because an event save thirty seconds after the hourly one is +the same freeze twice and the shard is the only half that can see both. **Refused, never queued:** a +queued save would land at a moment nobody chose, in the middle of whatever the next step is doing. + +It is the one refusal on this plane that waiting fixes, so it is a **429** rather than the 400 every +other refusal is — which keeps it out of the module's permanent-status set and makes a phase boundary +retried rather than abandoned. + +--- + +## 13. Routes and commands added by part b + +| Route | Command | Notes | +|---|---|---| +| `GET /lease?key=&target=` | `lease.list` | narrows to one row and fills `current`; always carries `holds` | +| `POST /lease` | `lease.apply` | `target` added | +| `POST /lease/release` | `lease.release` | `target` added; may answer `targetGone` | +| `GET /items` | `item.catalog` | the shard's grant allowlist and its bounds | +| `POST /items/grant` | `item.grant` | names a run, never a recipient list | +| `POST /world/save` | `world.save` | 429 when it comes too soon | + +New shard config: `Bridge.EventsMaxGrantPerRun` (200), `Bridge.EventsMaxGrantStack` (1000), +`Bridge.EventsMinSaveIntervalSec` (300). + +--- + +## 14. What part b found in already-merged code + +**The website's protocol pin never left 5.** `uo_link_config.protocol` reaches the sidecar as +`X-UOLink-Version` on every REST call, and the sidecar answers an exact mismatch with a `409`. Phase +11a took the wire to 6 and 12a took it to 7; **neither moved the pin**, in either of the two places +module-uo declares it (`db/schema.sql`'s `CREATE`/`MODIFY` defaults and `DEFAULT_PROTOCOL` in +`uoLinkConfig.model.js`). Every sidecar call on a real deployment would have been refused — the whole +event plane dead, loudly, for a reason nobody would look there for. + +It survived two phases because **both live walks set the column by hand while standing the rig up**, +which is exactly what makes a migration nobody runs invisible. 12b carries all three sites to 7. + +The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts +the three declarations agree **with each other**. That is a real check — they drifted apart once, in +the bug `DEFAULT_PROTOCOL` was introduced to fix — but *all three being equally stale passes it*, and +nothing in that repo can anchor it to the wire, which lives in `link` and `servuo-plugins`. The thing +that actually pairs them is the installer's bundle check at deploy time. Bumping the pin in the same +change as the emitters remains the discipline; no test replaces it. + +**A search term could not reach an option source.** `resolveOptionSource(id)` took no argument and +every source answered a flat list bounded at 2,000 entries. The spawner target is the first source +with more than that — 6,707 spawn points — so a flat list would have dropped two thirds of the world +and said nothing about which two thirds, which is precisely the failure 12a named for decoration. +`resolve({ q })` is additive: every source is passed a term and none is required to read one, and a +`searchable` flag says which do so the form renders a typeahead rather than a select that appears to +filter and does not. + +**`Spawner.Amount` does not exist.** [`EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) named it; on +ServUO 57.4 the property is `MaxCount`, and `MinDelay`/`MaxDelay` are `TimeSpan` rather than numbers +— so the wire carries seconds and the shard converts. Seconds rather than minutes because the spawn +files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot +express five seconds cannot express the shard's own data. diff --git a/website/EVENTS.md b/website/EVENTS.md index af52dd9..2e96a6d 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1127,23 +1127,28 @@ a capability exists. | Temporary decoration lockdown | ✅ **built (protocol 7)** | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. Phase 12a places it from the shard's OWN `Data/Decoration/**/*.cfg` vocabulary, carrying the item id: `Static` alone accounts for 5031 placements under **1992 different graphics**, so a bare type name places the wrong thing. Containers are refused — teardown would delete what a player left inside. | | Named, hued creatures from an allowlist | ✅ **built (protocol 7)** | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. Phase 12a; the ledger is a PERSISTED shard-side registry, because a spawned creature survives the restart that proves a crier line gone. | | "Simple" boss variants | ✅ **built (protocol 7)** | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. Phase 12a: an allowlisted type plus hits/damage/stat multipliers, each bounded. | -| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. Phase 12 adds the rest with the boot-time self-check. | -| Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. | -| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | -| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status`, already persisted across saves. **Corrected in Phase 12a's survey:** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. So the verb is a three-state toggle over nine named events — a different form to author and to cap than one nine-way choice. Phase 12b. | -| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. | +| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. **Phase 12b closed it at that one key and built the self-check anyway.** Counted on ServUO 57.4: of 156 non-Bridge `Config.Get` call sites, 82 sit outside a field declaration and all but four of *those* are inside a `Configure()` or static constructor, cached at boot exactly as the field initialisers are. The genuinely live, event-useful reads are this key and vendor bribe decay, which no event would lease. The self-check ships regardless — it exists for the operator whose OWN scripts read config live, and it is what keeps a capability that lies out of the catalog. | +| Lease a property on an existing object | ✅ **built (protocol 7)** | Practical, and an earlier revision was wrong to rule it out. Phase 12b: an allowlist of (type, property) pairs — `MaxCount`, `MinDelay`, `MaxDelay`, `Running`, on both `Spawner` and `XmlSpawner`, which share all four names — addressed by a serial or by an `XmlSpawner.UniqueId`. **`Spawner.Amount` does not exist**; the plan named it and the property is `MaxCount`. The hold is PERSISTED, unlike a config lease's: a spawner is in the world save, so a restart preserves the change and destroys only the timer that would undo it. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state, and this is the first lease a GM can actually drift. | +| Grant an event item | ✅ **built (protocol 7)** | An ordinary action, not a special contract member. Admin-gated, capped by `uo.rewards`, ledgered, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. Phase 12b resolves the RECIPIENTS on the shard, from protocol 6's participation ledger, keyed by the same serials core stores as `member_key`: the website has the list too, but reaching it would have meant a core surface handing a module core's own participants. **This row said failure aborts rather than retries; protocol 6 changed that** — an idempotency key means a retried grant is answered by the original reply, so it is retryable like every other verb. | +| Toggle a ServUO seasonal event | ✅ **built (protocol 7)** | **Corrected twice.** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries — so the verb is a three-state toggle over named events. And it is not "small and safe": the setter fires `OnStatusChange()`, which generates or removes world content for six of them. Safe (ServUO does it to itself from a staff gump), but not small. **Eight of the nine**: `TreasuresOfTokuno` is excluded because `IsActive()` reads its own `DropEra` rather than `Status`, so a lease on it would apply cleanly, read back, restore cleanly and do nothing — §N10's "capability that lies", and the one instance no runtime probe can catch. | +| Trigger a world save | ✅ **built (protocol 7)** | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built until Phase 12b. `AutoSave.Save()`; the `world.save.*` events it emits are already streamed, so the reply says only that the save STARTED. A save stops the world, so it is RATE-LIMITED rather than capped — `Bridge.EventsMinSaveIntervalSec`, counting ServUO's own autosave as the last one, refusing rather than queueing, and answering 429 because it is the one refusal on this plane that waiting fixes. | | Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. | | Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. | | Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. | -**What the reopening actually costs.** Nine new plugin verbs and **two** protocol bumps: 6 -for the idempotency key, the lease deadline and the participation ledger, and 7 for the world -verbs themselves. (One bump was the estimate; the guarantee had to land before the verbs that -depend on it, which is [`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is -cap-bounded, ledgered and attributable by construction. **None of it is `[add`.** The distinction that -makes it defensible: an event may **own** what it creates and **borrow** what it changes — and a -borrowed value carries a deadline the game itself enforces. What it may never do is change something -with no baseline recorded, which is exactly what `[set` is. +**What the reopening actually cost.** Two protocol bumps — 6 for the idempotency key, the lease +deadline and the participation ledger, 7 for the world verbs and the two borrowed planes. (One bump +was the estimate; the guarantee had to land before the verbs that depend on it, which is +[`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is cap-bounded, +ledgered and attributable by construction. **None of it is `[add`.** The distinction that makes it +defensible: an event may **own** what it creates and **borrow** what it changes — and a borrowed +value carries a deadline the game itself enforces. What it may never do is change something with no +baseline recorded, which is exactly what `[set` is. + +**One thing the survey changed about "borrow".** A borrowed value whose home is the world save — an +object property, a seasonal status — does not come back on a restart the way a config value does, so +its HOLD is persisted on the shard and its deadline re-armed at load. The fail-safe still holds; it +just has to be written down for the planes where a restart is not itself the restore. --- diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 5f8a4ad..9bbd2fb 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1440,6 +1440,8 @@ anywhere in it. #### Phase 12a — what an event OWNS +**Built and merged to `edge`** — `servuo-plugins#23`, `link#38`, `Module-uo#31`, `docs#221`. + Five verbs that put something in the world, ledger its serial, and delete it at teardown. | Verb | Cap dimension | Author's action | @@ -1540,6 +1542,76 @@ caller in the whole tree. three-state toggle over nine named events, which is a different form to author and a different one to cap. +**Built** — `website#—`, `servuo-plugins#—`, `link#—`, `Module-uo#—`, `docs#—`, all onto `edge`. +Spec of record: [`../link/v7.md`](../link/v7.md) §11–§14. + +**It is FIVE repos, and `website` is the one 12a did not need** (org lead, 2026-09-07: *"you can do +the 5 repos and no API bump since it is still on edge"*). A targeted lease is a shape `core.lease` +did not have. Every lease before it named a single value, so the lease id WAS the target and none of +the four callables took one; `Spawner.MaxCount` is one capability over thousands of spawners, and a +reservation on the id alone would let one run turning up one spawner refuse every other run every +other spawner. So the declaration gains a `target`, the callables gain it, and the ledger ref becomes +`#` — which puts the two-events-one-target refusal at the granularity the world +actually has, and leaves it coming from the same unique index it always did. + +**Extending core rather than giving the module a lease verb of its own is what §F already decided** +(Phase 8, *"the verb is core's"*): a lease verb per module would re-implement `maxDurationMs` and the +conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half of +that objection no longer holds — the target check comes free from the index whichever verb reserves +the row — and the other half still does. **`MODULE_API_VERSION` stays 1.10.0, amended in place**, the +shape every phase since P10 has used while this workstream sits on `edge`. + +Two more contract members came with it, both forced by this phase rather than chosen: **`values` on a +`string` lease** (the seasonal status is a three-value enum and nothing bounded `string`, so its only +check was the game side's — a refusal arriving unattended, mid-run) and **searchable option sources** +(see below). + +**The spawner dropdown was the phase's one genuine blocker, and the answer was a core change** (org +lead, 2026-09-07). `resolveOptionSource(id)` took no argument and every source answered a flat list +bounded at 2,000. This tree has **6,707 spawn points**, so a flat list would have dropped two thirds +of the world and said nothing about which two thirds — the exact failure 12a named for decoration, +arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to +read one, and a `searchable` flag says which do. The atlas also keeps `` again (parser +version 4), because it is the only name for one particular spawner that exists off the shard — a +serial is assigned when the world is built, so a lease addressable only by serial could have had no +dropdown at all. + +**Three plan assumptions the tree disproved.** + +- **`Spawner.Amount` does not exist.** The property is `MaxCount`, and `MinDelay`/`MaxDelay` are + `TimeSpan` rather than numbers, so the wire carries seconds. `Spawner` and `XmlSpawner` share all + four names, which is why one catalog covers both — and why a catalog naming only one of them would + have worked until the day it did not. +- **A property lease's hold must be PERSISTED**, and the config lease's must not. 11b's fail-safe is + *"a lease that never reaches disk means a restart is a free restore"*, which depends on the leased + value being memory-only too. A spawner is in the world save and a seasonal status is in + `Saves/Misc/SeasonalEvents.bin`, so a restart preserves the CHANGE and destroys only the timer that + would undo it. The Bridge gains its **third** save file, written by the same `EventSink.WorldSave` + as what it describes; a deadline that passed while the shard was down fires at once. +- **The seasonal toggle is not "small and safe", and one of the nine lies.** `OnStatusChange()` + generates or removes world content for six of them — safe, since ServUO does it to itself from a + staff gump, but not small. And `TreasuresOfTokuno` is excluded: `IsActive()` reads its own + `DropEra` rather than `Status`, so a lease on it applies cleanly, reads back, restores cleanly and + does nothing at all. That is §N10's "capability that lies" in its purest form and the one instance + no runtime probe can catch, so it is excluded by name at both ends. + +**Who receives a grant is answered on the SHARD.** The website has the list in +`event_run_participants`, but a module cannot read core's tables, so the alternative was a new core +surface handing participants to a module's `perform()`. It is not needed: protocol 6 part b's +participation ledger already holds them, keyed by the same character serials core stores as +`member_key`. A run with no ledger open is a 404; a run whose ledger is open and empty is a 200 with +`granted: 0`, because an event nobody attended still happened. And **the grant is retryable** — §G +called it un-retryable before protocol 6 existed, and an idempotency key means a repeat is answered +by the original reply. + +**One defect in already-merged code, and it would have broken everything.** The website's protocol +pin never left **5**: `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` and an exact +mismatch is a `409`, so on any real deployment every sidecar call would have been refused from Phase +11a onward. It survived two phases because both live walks set the column by hand while standing the +rig up. 12b carries all three declaration sites to 7. The test that guards them asserts they agree +WITH EACH OTHER, which is a real check they once failed — but all three being equally stale passes +it, and nothing in `module-uo` can anchor it to the wire. + **Ships:** the invasion. **Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted` diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 4fd9c63..290eaa7 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -103,6 +103,12 @@ ctx.events.reconcile() api.registerEventOptionSources([{ id: 'uo.options.creatures', label: 'Creatures', async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] }, +}, { + // A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and + // requires it of none, so a resolver that ignores it is unchanged; `searchable` + // is what tells the authoring form to render a typeahead rather than a select. + id: 'uo.options.spawners', label: 'Spawners', searchable: true, + async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) }, }]) // A value a run may borrow. The module ships the three callables; the VERB an @@ -115,6 +121,28 @@ api.registerEventLeases([{ async apply(v, until) { return { ok: true } }, async restore(baseline, { expected }) { return { ok: true } }, async inForce() { return { ok: true, held: true } }, // optional +}, { + // A TARGETED lease: one capability over many things. Core adds the target to + // the reservation ref (`#`) so two runs may hold the same key + // on two different objects, and hands it to all four callables. + id: 'uo.spawner.maxcount', label: 'Spawner: how many at once', + type: 'int', min: 0, max: 100, maxDurationMs: 43200000, + target: { label: 'Which spawner', source: 'uo.options.spawners' }, + async read({ target }) { return { ok: true, value: '3' } }, + async apply(v, until, { target }) { return { ok: true } }, + async restore(baseline, { expected, target }) { return { ok: true } }, + async inForce({ target }) { return { ok: true, held: true } }, +}, { + // A string lease may close its value set. `min`/`max` bound the numeric types + // and nothing bounded `string`, so without this the only check on the value is + // the game side's -- a refusal arriving unattended, mid-run, rather than on the + // authoring form. + id: 'uo.seasonal.status', label: 'Seasonal event status', + type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000, + target: { label: 'Which seasonal event', source: 'uo.options.seasonal' }, + async read({ target }) { return { ok: true, value: 'Inactive' } }, + async apply(v, until, { target }) { return { ok: true } }, + async restore(baseline, { expected, target }) { return { ok: true } }, }]) ``` @@ -133,6 +161,23 @@ field: - **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering `{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone. +- **A lease that declares a `target` is a family of values, and core changes what it reserves.** + Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a + config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands + of spawners and reserving the id would let one run turning up one spawner refuse every other run + every other spawner. With one, the ref is `#`, the two-events-one-target index + bites at the granularity the world actually has, and the target reaches all four callables. + **Core refuses a targeted lease with no target and an untargeted one with a target**, both + `retry: false`: the second attempt has the same params. `target.source` names an option source + for the authoring form, and is not resolved by core at registration — a source registered by a + module that boots later must not make this one throw. +- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the + numeric types; a set on an int lease would be a second bound beside them with no rule about + which wins, so it is refused. +- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this + existed behaves identically. Declare `searchable: true` when the term actually narrows the + answer — the form reads that to decide between a typeahead and a select, and inferring it from a + truncated list would read correctly right up until a small deployment's list happened to fit. Core needs it because a reconcile after an outage asks *"does the game side still have any record of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run applied is drift, which `restore()` reports so the row lands `drifted` with the current value @@ -315,6 +360,38 @@ Two members joined it in Phase 10, both on an envelope: envelope rather than in `events/` because "this particular firing is narrower than the kind usually is" is a fact any emitter can have. +Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended +into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there +is no deployment that could tell the difference, and the events cutover is what publishes the +whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose +and stays so until the cutover re-pins `ci/core-ref.json`. + +- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a + FAMILY of values rather than a single value, and core reserves `#` rather than + the id — so two runs may hold the same key on two different objects while two runs holding one + object still collide on the unique index. The target reaches `read`, `apply`, `restore` and + `inForce`. Every lease before this named one value, so the id *was* the target and none of the + four needed an argument; a property does not have that shape. + + **The verb stays core's**, which is the whole reason this is an extension rather than a lease + verb of the module's own. §F settled that in Phase 8: a lease verb per module would + re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and + wrong in the first one that forgot. Half of that objection no longer holds — the + two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the + row — and the other half still does. +- **`values` on a `string` lease.** The closed set an author may choose from, checked by + `core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded + `string`, so the only check on a string lease's value was the game side's — a refusal arriving + unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside + `min`/`max` would be a second bound with no rule about which wins. +- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose + catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the + argument answers exactly as it did before this existed, which is what makes it additive. The + first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the + 2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says + nothing about which part. `searchable` is declared rather than inferred, because inferring it + from a truncated answer reads correctly right up until a small deployment's list happens to fit. + **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 @@ -664,8 +741,8 @@ api.registerAudiences([{ id, label, params, ceiling, resolve }]) // api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0 api.registerEventBudgets([{ id, label, unit }]) // 1.10.0 -api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 -api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0 +api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 +api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` -- 2.49.1 From 303a52a2a068bd1b864ca50d76389f30ff776a9d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 16:35:04 -0500 Subject: [PATCH 16/24] docs(website): the authoring UI proper, and the one route it needed (Phase 13) EVENTS.md I gains what the phase settled -- the rule by which a form gives way to the JSON box, why the condition builder is the engagement one rather than a second one shaped like it, and the live cap meter as a fifth affordance beside the dry run it must not be mistaken for. The screens table's two unfinished rows are marked built; rehearsal is marked reachable, which it had not been since Phase 10 shipped it. The API surface table gains POST /admin/events/price, and a paragraph on why it is a route rather than arithmetic in the browser: cost() is a server function of params, and the dry run both dispatches and records, neither of which belongs behind a keystroke. EVENTS_PLAN.md carries Phase 13 as built, with the four decisions, the two defects it closes in already-merged code, and the six things the build settled. BACKEND_DESIGN.md's /events/* rows are Phase 1's surface and have not moved since; a note says so and points at EVENTS.md as canonical rather than re-listing eleven routes that file already owns. Checks: docs has no CI. Every relative link and in-page anchor in the new sections was resolved by hand; the files are CRLF in the working tree and each diff is content-sized (8/0, 64/5, 67/0), so none carries the \r\r\n full-file rewrite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/BACKEND_DESIGN.md | 8 +++++ website/EVENTS.md | 69 ++++++++++++++++++++++++++++++++++++--- website/EVENTS_PLAN.md | 67 +++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ec9fa02..a05f4a3 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1709,6 +1709,14 @@ file a route sits in — that is the property the route manifest freezes. | GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase | | — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) | +> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it. +> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5), +> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup` +> (Phase 8) and the cap meter (Phase 13) are not listed here. **[`EVENTS.md`](EVENTS.md) § API +> surface is the canonical table** and carries every one of them with the reasoning for its gate; +> re-listing them here would be a second copy of a contract that file owns, and the copy that +> drifts is always the second one. + Every admin write logs to `activity_log`. ### The SPA HTML shell (`app.js` → `utils/htmlShell.js`) diff --git a/website/EVENTS.md b/website/EVENTS.md index 2e96a6d..84a574f 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1190,8 +1190,8 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th | Screen | Pattern it reuses | What is new | | --- | --- | --- | | **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. | +| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. | +| **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. **Built in Phase 13**, with the JSON box kept as a per-step escape hatch — see below. | | **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. | @@ -1232,13 +1232,63 @@ advance condition, its cap draw and its failure policy — says exactly what the the registration: the action's `label`, its params' names, its option sources' labels, its budget dimensions' labels. `check:modules` already fails core's build on a UO identifier. +### What Phase 13 settled, replacing the two placeholders + +Phase 3 shipped a step's params and a phase's `where` as raw JSON boxes, each captioned as a +placeholder for this phase. Both are forms now, and three things about how they give way are +decisions rather than mechanics. + +**A form gives way to JSON on the condition builder's own rule.** A value the editor cannot +round-trip is SHOWN rather than silently rewritten — which is why the two placeholders were always +the same problem. Flattening `A and (B or C)` into `A and B and C` changes which firings release a +phase; dropping a param the action does not declare changes what a step does. Neither is visible in +a save that otherwise looks clean. Three things force a step into JSON, and the screen names which: +the action is dormant, so there is no declaration to draw; the step carries a param the action does +not declare, which the save refuses BY NAME and which is exactly what the author needs to read; or a +value no single control can hold. Otherwise the author's own choice of editor stands. + +**A `where` the builder cannot draw is posted back unchanged.** The builder offers the flat half of +the grammar — one `and`/`or` over a list of comparisons — and a nested tree opens read-only with its +JSON showing and one honest choice: leave it, or clear it and start again. The alternative to posting +it back verbatim is deleting an author's predicate because this screen could not render it. + +**It is the engagement builder, not a second one shaped like it.** The grammar behind a phase gate +IS the engagement condition grammar: `engagement/conditions.js` validates it at save, and the +diagnosis panel's sentence is rendered on the server from the same labels. `conditionRowsFrom`, +`conditionsFromRows` and `coerceLiteral` are imported, not reimplemented. The coercion is the half +worth naming: every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }` against an +`int` variable is refused — rightly — by a server whose error the author would then be reading as a +complaint about JSON rather than about what they typed. + +**A boolean param is a three-value select and an empty field removes its key.** A checkbox cannot +say *"not set"*, which for an optional boolean is a real third state — the action's own default — +and would post `false` for every param nobody touched. And `checkParams` reads `undefined`, `null` +and `''` alike, so clearing a required field is answered *"is required"* rather than with a type +complaint about an empty string. + +**The searchable source got its consumer here.** Phase 12b shipped `q` and `searchable` and nothing +sent a term — so the one screen that picks a spawner was picking from a 2,000-entry truncation of a +6,707-entry world, with nothing on it saying so. Which control is drawn follows the source's own +`searchable`, never the length of the answer: inferring it from a short list reads correctly right +up until the same source is a dropdown on one shard and a search box on another. + ### Four affordances worth building in from the start - **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 - — the run is real, the world changes are real, no player is told. +- **The live cap meter.** *(Built, Phase 13.)* What the plan in the author's hands would spend, and + what this deployment allows, updated while they type. **Deliberately not a lighter dry run**: it + dispatches nothing, so it knows nothing a module knows — whether the landmark exists, whether the + shard is up — and the screen says so under the numbers. What it answers is the half core can + answer alone, which is the half a debounce may ask on every keystroke. It says what it does not + know, too: a step core could not price makes every total an under-count, and an author reading a + number smaller than what will happen is worse off than one reading no number at all. +- **Rehearsal.** *(Built, Phase 10; reachable, Phase 13.)* Run for real with announcements ceilinged + to `staff`. Free from the ceilings lattice — the run is real, the world changes are real, no player + is told. The narrowing ceiling on the emit envelope arrived in Phase 10 and the checkbox that asks + for it in Phase 13, which is longer than it should have been: an affordance nothing can reach is an + affordance nobody has. - **A GM cue step.** A core action whose implementation is "post the instruction, wait for a human to confirm they did it in-client, then advance". No module, no protocol, no world write — and it makes the entire system useful on day one, with a GM doing the target-driven parts in-client exactly as @@ -1618,7 +1668,8 @@ no URL moved. | `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** (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` | +| `POST /admin/events/price` | admin, editor | **the live cap meter** (Phase 13) — price the spec IN THE BODY against the per-run caps. Dispatches nothing and records nothing, which is what separates it from the dry run and what makes it safe to call on a debounce while an author types; the spec is in the body because the plan being priced is unsaved between keystrokes. It therefore knows nothing a module knows — whether the landmark exists, whether the shard is up — and the screen says so under the numbers. A step it cannot price is reported in `unpriced` rather than counted as free; an **undeclared dimension is still counted**, because the action really will try to spend it and it is the enforcement rather than the amount that is missing. Over the caps is a `200`, for `verify`'s reason | +| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` — and optional `params`, which **Phase 13 was the first thing to send**: the concurrency key is a `{placeholder}` template rendered from the run's own params, so a UI posting an empty body rendered the same key for every manual run and had the second refused as an overlap with the first | | `GET /admin/events/runs` | staff | run history across definitions | | `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | @@ -1710,6 +1761,14 @@ asks for 45 and you allow 30" indistinguishable from "you sent a bad event id". Nothing is absent from this list any more: `advance` left it in Phase 5, `cleanup` and cancel's `cleanup` flag in Phase 8. +**Phase 13 added the one route this table did not anticipate**, and it is worth saying why it is a +route rather than arithmetic in the browser. A module's `cost()` is a function of params evaluated on +the server and nowhere else (§F), so a meter has nothing to add up until something asks. The dry run +is the wrong thing to ask twice over: it dispatches every step through the module — and through it to +a sidecar and a game tick — and a pass against a published version is RECORDED, which is the stamp +§K's unattended-start gate reads. Neither belongs behind a keystroke. `POST /admin/events/price` +does neither, which is the whole of its design. + > **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, diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9bbd2fb..6a2bb91 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1621,6 +1621,73 @@ rather than a silent revert of their change. ### Phase 13 — The authoring UI proper (`website`) +> **Complete.** `edge` in `website` and `docs`. The two raw JSON boxes Phase 3 shipped as explicit +> placeholders are forms; the meter, the searchable dropdown and rehearsal are reachable. **One +> route added** — `POST /admin/events/price` — and `MODULE_API_VERSION` is untouched: every schema +> the form renders was already in the catalog. +> +> **Four decisions the org lead settled (2026-09-07), all as recommended:** +> +> - **The meter gets a route of its own rather than riding the dry run.** A module's `cost()` is a +> server function of params, so nothing can be totted up in the browser — and the dry run is the +> wrong call to make on a debounce twice over: it dispatches every step through the module (and +> through it to a sidecar and a game tick), and a pass against a published version is RECORDED, +> which is the stamp §K's unattended-start gate reads. `POST /admin/events/price` dispatches +> nothing and records nothing, takes the spec in the body because the plan being priced is unsaved +> between keystrokes, and is `admin, editor` for `verify`'s reason. +> - **The advance-condition builder is in scope**, though the phase sentence did not name it. P13 is +> the last authoring phase, so *"a later phase"* was this one or none. It is the engagement builder +> — the same `conditionRowsFrom`/`conditionsFromRows`, not a second one shaped like it — because +> the grammar behind a phase gate IS the engagement grammar, validated on the server by +> `engagement/conditions.js` and rendered into the diagnosis panel's sentence by the same labels. +> - **Start now opens a dialog carrying `rehearsal`, `scope` and `params`.** All three have been on +> the route since Phase 10 and this screen posted `{}`. +> - **The searchable option source gets its consumer**, the JSON box stays as a per-step escape +> hatch, and the timeline gains the per-phase cap draw §I asks it for. +> +> **Two defects this phase closes in already-merged code, and neither was visible from a test:** +> +> - **12b's searchable sources had no consumer.** The server half shipped — `q` on +> `/catalog/options/:sourceId`, `searchable` on the answer — and the only UI that reads a source +> never sent a term. The spawner list is 6,707 entries against `MAX_OPTIONS`' 2,000, so the one +> screen that picks a spawner was picking from a truncation of two thirds of the world, with +> nothing on it saying so. +> - **An event whose concurrency key names a `{placeholder}` could not be started correctly by +> hand.** The key is rendered from the run's own params; a start posting `{}` rendered the same key +> every time, so the second manual run was refused as an overlap with the first — the failure +> looking exactly like the safety feature working. +> +> **What the build settled.** +> +> - **A form gives way to the JSON box on the CONDITION BUILDER's rule, not on a new one.** A value +> the editor cannot round-trip is shown rather than silently rewritten: dropping a param the action +> does not declare and flattening `A and (B or C)` into `A and B and C` are the same mistake, a +> save that looks clean and means something else. Three things force it — a dormant action, an +> undeclared param, a value no single control can hold — and the screen names which. +> - **A step core cannot price is reported, never counted as free.** The three ways that happens all +> make the total an under-count, and an author trusting a number smaller than what will happen is +> worse off than one with no number. An undeclared dimension is the one that is still *counted*: +> the action really will try to spend it — the step is refused at dispatch for exactly that — so +> the amount is true and it is the enforcement that is missing. +> - **A boolean param is a three-value select, not a checkbox.** A checkbox cannot say *"not set"*, +> and for an optional boolean that is a real third state: the action's own default. A checkbox +> would have posted `false` for every param nobody touched. +> - **An empty field removes the key rather than posting `""`.** `checkParams` reads `undefined`, +> `null` and `''` alike, so a required param left blank comes back as *"is required"* — the error +> the author needs — instead of a type complaint about an empty string. +> - **A half-typed number is kept as typed.** `coerceLiteral` is borrowed from the engagement builder +> unchanged, and its rule matters here for the same reason: turning `-` into `NaN` mid-keystroke +> either posts a value nobody wrote or makes a negative impossible to enter. +> - **The meter debounces AND counts generations.** Requests 400ms apart do not necessarily answer in +> that order, and an older answer landing last leaves the meter describing a plan that has already +> changed — stale in the one direction that matters, with nothing on screen to say so. A failure +> leaves the last answer standing, dimmed, rather than blanking it: the plan is still saveable, +> dry-runnable and publishable without a meter. +> +> **Verify, as run.** Server `npm test` (the new `eventPrice.test.js`, 13 tests, plus the 403 walk's +> new row); client `npm test` — **380 pass, 0 fail**, 18 of them new; the client builds. +> `routes:manifest` and `swagger` regenerated — one route added, none moved. + Replaces P3's placeholders. The phase timeline (**not** a node graph — the condition grammar has no branching and a canvas would advertise power the engine does not have); the step editor rendering each action's declared params, with option-source dropdowns; the live cap meter; dry run; rehearsal. -- 2.49.1 From 9aee5920af5957548d2947ac0543cec43eaa94ff Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 06:18:55 -0500 Subject: [PATCH 17/24] docs(website): the public surface, and the flag that decides what reaches it (Phase 14a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS.md gains what the phase settled -- a new §I section on the public surface, the four API surface rows filled in, `listed` in §D, and the §J rows for player profiles and mobile. EVENTS_PLAN.md records the 14a/14b split and 14a as built. BACKEND_DESIGN.md points at the two tiers the Phase 14a reads live on. MODULE_API.md records core's own capability list beside a module's -- the same word, a separate list, and why. Two things this phase corrected in the document rather than in code. "Venue" was never a field. §I's screens table and the API surface table had both described one since the first revision; there has never been a column, a spec key, an input on Phase 13's form, or a string anywhere in either repo. Rather than add a field on the way past to a public page, both descriptions dropped it. And the six public triggers' missing url variable, which this document has carried as a promise since Phase 10, is now kept: `eventUrl`, carrying `?run=`, arriving with the page it points at. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/BACKEND_DESIGN.md | 15 +++-- website/EVENTS.md | 122 +++++++++++++++++++++++++++++++++----- website/EVENTS_PLAN.md | 51 +++++++++++++--- website/MODULE_API.md | 11 ++++ 4 files changed, 172 insertions(+), 27 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index a05f4a3..b04bf24 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1712,10 +1712,17 @@ file a route sits in — that is the property the route manifest freezes. > **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it. > The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5), > the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup` -> (Phase 8) and the cap meter (Phase 13) are not listed here. **[`EVENTS.md`](EVENTS.md) § API -> surface is the canonical table** and carries every one of them with the reasoning for its gate; -> re-listing them here would be a second copy of a contract that file owns, and the copy that -> drifts is always the second one. +> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here. +> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them +> with the reasoning for its gate; re-listing them here would be a second copy of a contract that +> file owns, and the copy that drifts is always the second one. +> +> The Phase 14a reads are the only ones outside this tier: `GET /public/events`, +> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and +> `GET /player/events/history` on the self-service one. What makes something visible there is +> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than +> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404, +> indistinguishable from a slug that never existed. Every admin write logs to `activity_log`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 84a574f..b453a62 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -419,7 +419,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | Table | Holds | Why a table | | --- | --- | --- | -| `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_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, `listed` (Phase 14a), 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. **`listed` is announcement, not permission** — see below. | | `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`, 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. | @@ -431,6 +431,15 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | `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. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | +> **`listed` is Phase 14a's one addition to this table**, and it is deliberately not a second answer +> to what `state` answers. `state` says whether an event is SCHEDULABLE; `listed` says whether it is +> ANNOUNCED. The two came apart the moment there was a public surface at all, because publishing is +> what makes a definition runnable — so without the column a surprise invasion would have to be +> advertised a fortnight in advance in order to be allowed to happen. It defaults to 1, so every +> definition keeps the behaviour it had while the only reader was staff, and unlisting is the +> deliberate act. It hides the definition, its runs and its projections from the public surfaces and +> from a participant's own history; it hides nothing from staff. +> > **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it > follows from "editing a draft is free; no version exists yet" below. A draft's working spec has to > live somewhere, and it cannot be an `event_versions` row: that table is immutable and a run pins @@ -1189,8 +1198,8 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th | Screen | Pattern it reuses | What is new | | --- | --- | --- | -| **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. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. | +| **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. **The public half is Phase 14a's and is a LIST, not a grid** — the same data answering a different question; see below. | +| **Definition editor** — basics, storyline, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. **Finished in Phase 13**, which added the per-phase cap draw, the advance-condition builder and the start dialog — `rehearsal`, `scope` and `params` have been on the start route since Phase 10 and the screen posted none of them. An event whose concurrency key names a `{placeholder}` therefore could not be started correctly by hand at all: the key is rendered from the run's own params, so every manual run rendered the same one and the second was refused as an overlap with the first. | | **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. **Built in Phase 13**, with the JSON box kept as a per-step escape hatch — see below. | | **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. | @@ -1354,10 +1363,83 @@ the morning. Only the variables the condition **names** are recorded on the gate the payload — because that row is read back onto an admin screen and a copy of a whole game event's data would be a second copy of exactly the content `engagement_sends` is careful not to keep. -**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and -published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is -having trouble" is operator information, and the existing shard pages already model exactly this by -rendering last-known state rather than an error. +### What Phase 14a settled about the public surface + +An upcoming-events calendar with series and arcs, a live-status page, and published results — +**built**, at `/site/events`, `/site/events/:slug` and `/site/events/series/:slug`, over the three +`/public/events` routes and `GET /player/events/history`. + +**A visitor's calendar is a list; an operator's is a grid.** That is not a styling choice made twice. +An operator's question is *"what does this month look like"* — coverage, clashes, the gap on the third +weekend — which a grid answers and a list answers by making them count. A visitor's question is +*"what is on, and when is the next one"*, which a chronological list answers at a glance. The +projection/run distinction survives into the public list unchanged, dashed and labelled *expected*, +for the visitor's version of the operator's reason: past the horizon nothing has been committed to, +and a page drawing a forecast identically to a booking would be promising on the server's behalf. + +**What is public is `listed` AND `ready` AND not a rehearsal, and all three are in SQL.** `listed` is +a new column on `event_definitions`, defaulting to 1, and it exists because publishing is what makes +a definition RUNNABLE: without it, a surprise invasion would have to be advertised a fortnight in +advance in order to be allowed to happen. It is **not** a second answer to the question `state` +answers — `state` says schedulable, `listed` says announced — and an unlisted event still schedules, +still runs, and is still on the admin calendar. Filtering in JavaScript after the read would work +exactly as well, right up until the first caller that forgot; a draft, an archived definition and an +unlisted one all answer 404, indistinguishable from a slug that never existed. + +**The public shapes are a projection, and the projection is the security boundary.** Nothing is +spread; a field reaches a public entry because a line in `eventPublic.model.js` put it there. The day +somebody adds a column to `event_runs` — a claim token, an operator's note, a last error — a +`{ ...run }` would publish it in the release after the one anybody reviewed. Three things are +deliberately absent from every public shape: + +- **The spec.** Phases, steps, actions and their params are the plan for changing a live world. A + visitor is told what is happening and when, and the **label** of the phase while it is happening — + resolved from the version the run PINNED, so an edit since does not relabel a run in flight. A + phase the pinned spec does not name renders nothing rather than a slug. +- **Health, cleanup, claims and errors.** A run with `health: 'degraded'` renders as running, which + is the tolerance this section already required — "the shard is having trouble" is operator + information, and the shard pages already model exactly this by rendering last-known state. +- **`member_key`.** It is the game's own identifier for a character and it is module-opaque, so core + cannot say what publishing one would disclose. A results row shows whatever display name the module + put in `meta`, or nothing. + +**`failed` and `missed` are both published as `cancelled`, and `paused` as `live`.** To a visitor the +first two are one event — it was on the calendar and it did not happen — while the difference between +them is entirely about the deployment: `failed` names broken machinery and `missed` names a process +that was down when the schedule came round. Publishing either word would tell a stranger something +true about the server and nothing about the event. `paused` is the same argument in the other +direction: an operator holding a run for two minutes is not a state a public page should render, and +one that said "paused" would invite a question whose answer is internal. + +**The six public triggers gained `eventUrl` here, which is the version bump this file promised.** +Until Phase 14a there was no page, so they declared no url at all — `news.post` had already paid for +that mistake once, previewing a link in the template editor that was dead in every mail it sent. The +variable carries `?run=`, and the query string is the whole reason it is a run url rather than an +event url: the page lives at the definition's slug, so a weekly event has one stable address that +survives a retitle, but every one of those triggers is about one OCCURRENCE. It is **optional**, and +`email.button` drops itself when its url interpolates to nothing — so an unlisted event still mails +correctly, with the block absent rather than rendering an inert grey label. `event.run.failed` gains +nothing: an admin reading that the machinery broke wants the console, not the storyline. + +**Core publishes an `events` capability string**, in the `version` block that `GET /public/version` +returns and `GET /public/status` embeds. It is the same idea as a module's `capabilities` on +`/public/modules` and deliberately the same word — but a separate list, because core is not a module +and a client must be able to tell *"this backend has events"* from *"a module called core is +installed"*. **The value is in what is absent**: a backend released before Events omits the key +entirely, which is a distinction probing `/public/events` cannot make, where "not built" and +"temporarily down" look alike. + +**A series with no listed events is a 404 rather than an empty page.** An arc is a label on its +definitions and nothing else, so a page for an empty one would publish the single fact that an +operator has named something they have not announced. + +> **A venue was never a field, and Phase 14a stopped saying it was.** §I's screens table and the API +> surface table had both described a venue since the first revision — the definition editor's +> sections, and what an event page shows. There has never been one: not a column, not a key in +> `spec`, not an input on P13's form, not a string anywhere in either repo. Rather than add a field +> on the way past to a public page, both descriptions dropped it; the storyline already carries the +> where. (The word still appears in §G and §B2 as ordinary English for a place in the world, which is +> what it always meant there.) --- @@ -1370,11 +1452,11 @@ rendering last-known state rather than an error. | **News** | Events does not write posts — `ctx.posts` is read-only to modules and the CMS is core's. A core action `core.announce.post` links an existing post to a run and enqueues it through `announce_jobs`, so the town crier and Discord both come free as already-registered legs with retry and classification. **Built in Phase 10**, on a nullable `announce_jobs.run_id`: a post may now have more than one job, and everything that means "the post's job" — the admin panel, its retry button, `announced_at` — still means the news one. | built | | **Notifications** | Falls out of Engagement — email, in-app inbox, content-free push tickles, per preference, with suppression and the verification gate. Nothing new. | exists | | **Teams** | "This Team's members" is already a registered audience, so a guild-scoped event is a rule's audience segment and needs no event-side feature. | free | -| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; the surface that reads it is Phase 14's. | table built | +| **Player profiles** | `event_run_participants` joined on `user_id`, `member_key` for the unlinked. Retention follows the engagement retention sweep's pattern — participants outlive the run log. Table and write path built in Phase 10; **the surface that reads it was built in Phase 14a** — `GET /player/events/history` and `/account/events`, self-scoped, with the calendar's own two exclusions so attending an unannounced event does not disclose that it exists. The `member_key` is not published even there: the caller is a player reading their own page, and the key would add a module-opaque string nothing on it can render. | built | | **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication | | **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module | | **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out | -| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, an `events` capability string. **Prerequisite:** the app hardcodes `api/v1/public/shard/stream` — a module path — and reads `/public/modules` nowhere, so it cannot render a site whose module it does not know. | app fix first | +| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half is Phase 14b**, with its prerequisite: the app hardcodes `api/v1/public/shard/stream` — a module path — reads `/public/modules` nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry renders and every one of them 404s. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates the group on a capability string rather than on a module id. | server built, app is 14b | | **Admin audit** | `ctx.activity.log` for every edit, publish and manual transition. The runtime record is `event_run_log`, because `activity_log.detail` is `TEXT` and cannot be queried by phase or step. | exists | --- @@ -1685,10 +1767,10 @@ no URL moved. | `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 (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 | -| `GET /player/events/history` | auth | this account's participation | +| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. Defaults to now through 31 days out and the window may span at most 92 — the anonymous surface is the one with no login in front of it | +| `GET /public/events/:slug` | — | **one event** (Phase 14a): storyline, arc, what is live, what is next, what happened recently, and a results table once one is published. Takes an optional `?run=`, which is what an announcement's link carries, so a mail about last Friday's occurrence does not open next Friday's; a run belonging to some other event is **ignored rather than refused**, because a stale link in a months-old mail should land on the event it was about. A draft, an archived definition and an unlisted one all answer 404 | +| `GET /public/events/series/:slug` | — | **the arc** (Phase 14a). A series with no listed events is a 404, not an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced | +| `GET /player/events/history` | auth | **this account's participation** (Phase 14a) — the run, when it was, the score a module reported, and the rank once results were published (null until then, which is a real state rather than an error). Self-scoped on the session with **no id parameter**, deliberately: a route that took one would be a middleware mistake away from publishing who attended what. Keyset-paged on the participation row's id. It obeys the calendar's two exclusions, so attending an unannounced event does not disclose that it exists | > **Publish and start are `admin`; cancel is not** — that asymmetry is deliberate and decided in > [§N2](#n--decisions). `verify` sits with `admin, editor` rather than with `start`: a dry run @@ -1769,6 +1851,17 @@ a sidecar and a game tick — and a pass against a published version is RECORDED §K's unattended-start gate reads. Neither belongs behind a keystroke. `POST /admin/events/price` does neither, which is the whole of its design. +**Phase 14a added four routes and no admin route at all**, which is the shape a public phase has: the +three `/public/events` reads and `GET /player/events/history`. Nothing moved. The one change to an +existing answer is a field — `listed` on a definition, in both directions on the admin routes, because +the switch that decides whether an event is announced is edited where the event is authored. + +**They are a projection rather than a filtered admin shape, and that is the security property.** Every +other reader of these tables is staff; these are read by nobody at all, so nothing is spread and a +field is published because a line put it there. Building the public answer by DELETING fields from an +operator's is the direction that fails silently — a column added to `event_runs` next year would ride +out through a spread the day it was added. + > **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, @@ -1864,7 +1957,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` | | **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` | | **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` | -| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` | +| **P14a** ✓ | Public calendar, event pages, arcs, player history, the `events` capability | `website` `docs` | +| **P14b** | The app's events screens — and the module-path fix, which is its prerequisite | `android-app` `docs` | | **P15** | Integration Kit chapter 5 — cannot merge before the cutover exists | `integration-kit` | | **P16** | Acceptance walk against released artefacts, then `edge` → `main` | all | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 6a2bb91..05a375c 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1165,6 +1165,7 @@ push; the ceiling on `run.failed` proved to exclude a moderator. > the template editor previewed a link that was dead in every mail it sent. Phase 14 adds the > variable alongside the page it points at, which is a version bump. `event.run.failed` is the > exception because `/admin/events/runs/:runId` exists today. +> **Done in 14a**, as `eventUrl`, carrying `?run=` — and the six went to version 2. > - **`startsAtLabel` is a presentational fragment computed at the emitter**, which is > `ENGAGEMENT.md` §4.6.1 convention 1 rather than a shortcut. `startsAt` is a `datetime` and the > seam normalises it to an ISO string — right as data, unreadable in a sentence — and a template has @@ -1699,19 +1700,51 @@ at any point. --- -### Phase 14 — Public, player and mobile (`website` + `android-app` + `docs`) +### Phase 14 — Public, player and mobile — SPLIT into 14a and 14b + +Split by the org lead on 2026-09-07, on the repo line: the app cannot be walked against a page that +does not exist yet, so 14b's emulator walk is only meaningful once 14a has merged. The same argument +12a/12b was split on. + +#### Phase 14a — the public and player surface (`website` + `docs`) — **built** `GET /public/events`, `/public/events/:slug`, `/public/events/series/:slug`, -`/player/events/history`, and an `events` capability string. +`/player/events/history`, and an `events` capability string. `website#196`, `docs#224`. -**One prerequisite fix, in this phase and not after it.** The Android app hardcodes -`api/v1/public/shard/stream` — a *module* path — and reads `/public/modules` nowhere, so it cannot -render a site whose module it has never heard of. That is a small fix and it blocks the app seeing -events at all. +Four org-lead decisions, all taken up front: split the phase; add a **`listed`** flag rather than +letting `state` mean both schedulable and announced; put the capability string in the **`version` +block** rather than publishing core as a pseudo-module; and **drop "venue"** from this document +rather than adding the field §I had described since the first revision and nothing had ever built. -**Ships:** the public calendar, event pages with their storyline and results, participation history, -and the app. -**Verify:** the client and app suites; an emulator walk. +`listed` is the one that changes the shape of the work. Publishing is what makes a definition +runnable, so without a separate flag a surprise event would have to be advertised in order to be +allowed to happen. It is a column, a switch in P13's editor, and three SQL predicates — never a +filter applied after a read, which works exactly as well until the first caller that forgets. + +The six public `event.` triggers gained **`eventUrl`** here, which is the version bump this plan +promised (1 → 2). It carries `?run=`, because the page lives at the definition's slug and every one +of those triggers is about one occurrence. `notify.event-started` gained the button to go with it, +at `seedVersion` 2. + +**Ships:** the public calendar, event pages with their storyline and results, and participation +history. +**Verify:** both suites; a browser walk of the calendar, an event page, an arc and the history. + +#### Phase 14b — the app (`android-app` + `docs`) + +The app's events screens, and **one prerequisite fix that is in this phase and not after it.** The +Android app hardcodes `api/v1/public/shard/stream` — a *module* path — reads `/public/modules` +nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", +which `canSee` treats as visible. On a site with no `uo` module installed that route 404s, so every +shard entry in the drawer renders and every one of them 404s. Failing open is right *within* a module +that exists and wrong for one that is not installed; the fix gates the group on a **capability +string** — `shard`, which module-uo declares — rather than on a module id, and gates the new events +entry on core's `events`. + +`docs/android/PLAN.md` gains the milestone before the code is written. + +**Ships:** the calendar, event pages and participation history in the app. +**Verify:** the app suite; an emulator walk. --- diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 290eaa7..8bf08ca 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -1422,6 +1422,17 @@ Four fields, in the loader's scan order (§4.2). What is *not* there is the desi string. A client must treat an unknown capability as absent and must not infer a route from one — the mount prefixes are `module.json`'s business (§2.3), not the capability list's. +**Core publishes a capability list of its own, and it is deliberately not this one.** Since events +Phase 14a, `GET /public/version` — and `GET /public/status`, which embeds the same block — carries a +`capabilities` array naming what CORE serves beyond the baseline every backend has. It is the same +idea and the same word so that a client feature-detects one way rather than two, and a **separate +list** because core is not a module: publishing core here as a pseudo-module would leave a client +unable to tell *"this backend has events"* from *"a module called core happens to be installed"*, +which is exactly the distinction this endpoint exists to make. The value in core's list is in what is +absent — a backend released before a capability existed omits the key entirely, which is how a client +tells an older site from one that simply has nothing to show. The same rule applies to both: an +unknown string is absent, and no route may be inferred from one. + The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route -- 2.49.1 From ed311a2e8d07006c2d0c924314718b59521a3760 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 11:59:23 -0500 Subject: [PATCH 18/24] docs(website): the three defects the walk found, and the mount that answered one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS.md §I records that participation history is mounted twice and why -- the third screen to need the notificationPaths.js mapping, for the reason the first two did -- and the three defects the live walk found with both suites green on either side of it. EVENTS_PLAN.md says the same in one paragraph under Phase 14a's verify line. Two of the three were one mistake in two files: a split reading a STATUS where it should read a clock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/EVENTS.md | 19 +++++++++++++++++++ website/EVENTS_PLAN.md | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/website/EVENTS.md b/website/EVENTS.md index b453a62..3d31b18 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1433,6 +1433,25 @@ entirely, which is a distinction probing `/public/events` cannot make, where "no definitions and nothing else, so a page for an empty one would publish the single fact that an operator has named something they have not announced. +**Participation history is mounted twice, and it is the third screen to need that.** +`GET /player/events/history` is behind `requireAuth` alone and self-scoped on `req.user.id` — staff +are a superset of players, so a staff member has an attendance record like anyone else. The WEB is +what disagrees: `RequirePlayer` sends anyone who is not a `player` out of `/account`, so a single +mount there is a screen the reviewing admin can never open. The notifications pair hit this exact +wall in engagement Phase 7 and answered it with two routes, one pair of components and one mapping +in `lib/notificationPaths.js`; `eventHistoryPath` joins `inboxPath` and `notificationSettingsPath` +there rather than starting a second file with the same comment at the top of it. The staff path is +`/admin/events/mine`, in the Events section of the sidebar, and it is the one row in that group +with no `roles` — every account has a participation history and the server scopes it to the caller. + +> **Three defects, and the live walk is the only thing that found any of them.** Both suites were +> green before it and after each fix. Two were the same mistake in different files — a split reading +> a STATUS where it should read a clock: the calendar told a visitor an event four days away *did not +> happen* (it had been cancelled, and the tense of that word follows the clock, not the status), and +> the event page filed that same future occurrence under *previously*. The third was the mount above. +> A cancelled occurrence still belongs under what is coming, because *"next Friday is off"* is +> exactly what somebody checking a calendar came to find out. + > **A venue was never a field, and Phase 14a stopped saying it was.** §I's screens table and the API > surface table had both described a venue since the first revision — the definition editor's > sections, and what an event page shows. There has never been one: not a column, not a key in diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 05a375c..25cacda 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1730,6 +1730,12 @@ at `seedVersion` 2. history. **Verify:** both suites; a browser walk of the calendar, an event page, an arc and the history. +**The walk found three defects and the suites found none of them**, which is the argument for the +walk in one line. Two were one mistake in two files — a split reading a status where it should read +a clock — and the third was a screen the reviewing admin could not open at all, because +`RequirePlayer` guards `/account` and the route behind it is role-agnostic. Engagement Phase 7 had +already solved that one; the fix is its `notificationPaths.js` mapping gaining a third entry. + #### Phase 14b — the app (`android-app` + `docs`) The app's events screens, and **one prerequisite fix that is in this phase and not after it.** The -- 2.49.1 From 0a61915e508e23861c4c7a21734e5deb8b57044a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 13:09:02 -0500 Subject: [PATCH 19/24] docs(android): the app's events screens, and the module rows that were never gated (Phase 14b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events Phase 14b, docs half. `docs/android/PLAN.md` gains **M13** — written before the code, as the plan requires — and `EVENTS_PLAN.md` and `EVENTS.md` record 14b as built. M13 records the six org-lead decisions, the two defects the milestone fixes, and the three the emulator walk found. The prerequisite fix is the larger half of the milestone: the app could not tell a module that is not installed from a lookup that failed, so on a site with no `uo` module every shard row rendered and every one of them 404'd. **Absence of an answer is not an answer of absence** — a successful module list that omits the string hides the rows, a failed read keeps the last answer the host gave, and a host that has never answered leaves the gate open. Capability and feature are two gates that compose and answer different questions. A second, older defect is recorded and fixed: the website path → route table had been wrong since the module-system cutover on 2026-08-12, because core's `NAV` is eight rows rather than sixteen and the nine shard rows moved to `/uo/*`. Two tests in the app's suite had been passing vacuously since that day. §5 gains the capability gate beside the feature gate, with the two failure directions spelled out; §6.1 gains the three public event reads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- android/PLAN.md | 179 +++++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 2 +- website/EVENTS_PLAN.md | 52 +++++++++--- 3 files changed, 221 insertions(+), 12 deletions(-) diff --git a/android/PLAN.md b/android/PLAN.md index 0d7102c..a8a1c30 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -661,6 +661,8 @@ not rank). | Contact | everyone | `/public/contact` | | **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | +| **Events** | everyone, *if core serves events* | `/public/events`, `/public/events/:slug`, `/public/events/series/:slug` (M13) | +| **My Events** | signed-in, any role | `/player/events/history` (M13) | | Sign in / Sign out | toggles on session | `/auth/mobile/*` | Guidelines: @@ -672,6 +674,15 @@ Guidelines: the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app shows everything: the server gates regardless, and a nav that flickers in on every load is worse than a link that briefly `403`s. +- **Nor is it the only gate on a MODULE's surfaces (M13).** A feature flag says whether a shard + publishes a surface to this viewer; it cannot say whether the module that owns it is installed at + all. `GET /public/modules` and core's own `capabilities` in the `version` block answer that, per + host rather than per viewer, and an entry may declare a capability alongside its feature. The two + compose and both must pass. **The two failure directions differ, and deliberately:** an unknown + *feature* answer shows everything, because the server gates every call regardless; an unknown + *capability* answer shows everything too, but a **known** one that does not name the capability + hides the rows — absence of an answer is not an answer of absence. Without that distinction a site + with no `uo` module renders five shard rows that each `404`. - Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public groups and a "Sign in" affordance. - The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still @@ -692,6 +703,11 @@ Guidelines: - **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses). - **Wiki** — list/categories/tags/detail as above. - **Contact** — `POST /public/contact` (rate-limited; handle 429/502). +- **Events (M13)** — `GET /public/events` (the calendar; entries are a materialised `run` or a + `projected` forecast and say which), `GET /public/events/:slug` (one event, `?run=` selecting + which occurrence a results table is about), `GET /public/events/series/:slug` (an arc). Gated by + core's `events` capability, not by a module's. A draft, an archived and an *unlisted* definition + all answer `404`, indistinguishable from a slug that never existed. ### 6.2 Public shard (live) - Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the @@ -1128,6 +1144,169 @@ push, and Play (M6–M8) follow the designed app. rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was discarding three 98–100%-covered resolvers. **477 tests green.** +14. **M13 — Events in the app** (post-v1; scoped 2026-09-08). The website's Event System reached its + public surface in events Phase 14a (website#196 / docs#224): a public calendar, event pages, arcs + and participation history, plus an `events` capability string in the `version` block. This + milestone is **events Phase 14b** — the app's half — and the phase was split on the repo line for + the reason 12a/12b was: the app cannot be walked against a page that does not exist yet. + + **Design of record: [`../website/EVENTS.md`](../website/EVENTS.md)** §I and its API surface table, + and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 14b. The contract is normative + there; this entry records what the app does about it. + + **No backend work.** All four routes are live on the website's `edge`: + `GET /public/events`, `/public/events/:slug` (with `?run=`), `/public/events/series/:slug`, and + `GET /player/events/history`. `MODULE_API_VERSION` is untouched — nothing about the module + contract changes here. + + #### The prerequisite, which is in this milestone and not after it + + **The app cannot tell a module that is not installed from a lookup that failed, and it renders a + broken drawer because of it.** `ShardFeaturesRepository.refresh()` maps *every* failure to `null` + and `canSee(null, …)` answers `true`, so on a site with no `uo` module — which is any site running + a different game — `GET /public/shard/features` 404s, all five shard rows render, and every one of + them 404s when tapped. Failing open is right *within* a module that is installed (an admin may + switch a surface off between two refreshes, and the server gates every call regardless) and wrong + for one that is not there at all. + + Three decisions, taken by the org lead on 2026-09-08: + + - **Absence of an answer is not an answer of absence.** A *successful* read of + `GET /public/modules` that does not name a capability **hides** the rows that need it — that is + an answer. A read that *failed* keeps the last answer this host gave, and when the host has + never answered the gate stays open and the drawer renders as it does today. The bug is that the + current code collapses those three into one `null`. + - **The capability gate composes with the feature gate; it does not replace it.** They answer + different questions and both must pass. `capability` asks *is this module installed at all*, and + is per-host — it changes when an operator installs or removes a module. `feature` asks *does this + shard publish this surface to this viewer*, and is per-viewer — it changes on sign-in. That is + also why the two refresh on different clocks: capabilities with the appearance (per host), + features with the session (per viewer). + - **One capability gates all five shard rows: `shard`.** module-uo also declares `atlas`, `market`, + `champs`, `guilds`, `governors`, `houses` and `cliloc`, and gating each row on its own string + would be a second, worse copy of what `feature` already decides. The two new events rows gate on + core's own `events`. + + Core's list and a module's are **separate lists**, deliberately, and the app reads both: + `capabilities` in the `version` block of `GET /public/status` for core, and `GET /public/modules` + for the installed modules. See [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.9 — an + unknown string is treated as absent, and **no route may be inferred from one**. + + #### The website path → route table was stale, and that is a second live defect + + Found while scoping this milestone, and **fixed here** rather than logged (org lead, 2026-09-08). + [`NavPaths.kt`](#) quotes the website's `export const NAV` verbatim as sixteen rows. Since the + module-system cutover on 2026-08-12 core's `NAV` is **eight** rows and the nine shard rows belong + to module-uo, which registers them at `/uo/shard`, `/uo/champs`, `/uo/guilds`, `/uo/governors`, + `/uo/houses`, `/uo/rules`, `/uo/atlas`, `/uo/leaderboards` and `/uo/market` — core mounts a + module's public pages at `//`. Three things follow, all of them true of the shipped app: + + - a nav override an admin sets on any shard row is **ignored**, because the merge intersects with + the app's own menu through a table that no longer resolves those paths; + - an added link to a shard page **hands off to a Custom Tab** instead of opening natively, and + `/uo` alone resolves to a CMS page that does not exist; + - `WEB_ROUTE_ORDER` is the number line a stored `order` is compared against, and a sixteen-row + line against a nav that now numbers core 0–7 and appends the module's rows after it puts a + partly-overridden drawer in the wrong order. `/site/events` is a new core row at index 2, so + every row after News shifts. + + **The nine `/uo/*` paths are hardcoded, and documented as coupling to one module** (org lead). The + alternative — deriving `//…` from the module list — infers a route from a capability, which + §2.9 forbids, and would hardcode the same path shape less visibly. A site running a different game + module maps none of the nine and its links hand off to the browser, which is the correct answer. + + #### What ships + + Four screens, one per 14a surface, and the arc is included because the event page links to it — + omitting it would make that link a dead end or a hand-off out of a native screen: + + | Screen | Route | Reads | + | --- | --- | --- | + | **Events** (the calendar) | `events` | `GET /public/events` | + | **Event** | `events/{slug}?run={run}` | `GET /public/events/:slug` | + | **Arc** | `events/series/{slug}` | `GET /public/events/series/:slug` | + | **My Events** | `account/events` | `GET /player/events/history` | + + - **The calendar is a list, and a projection is drawn differently from a run.** Both are §I's + rules, one tier along: an entry past the materialisation horizon is arithmetic with nothing + committed to it, so it is labelled rather than drawn as a booking. + - **The day heading is the reader's, the time beside it is the event's.** The split is §I's and it + is the one thing about event times that is easy to get wrong. A player in Berlin told an American + shard's event is at 02:00 has been told something true and useless. + - **The status word follows the clock, not the status.** The server publishes `failed` and `missed` + as `cancelled`; *did not happen* is right for a past occurrence and false for a future one, and a + run four days out that an operator called off is the common case. This is 14a's own defect, and + the app must not re-introduce it in Kotlin. + - **`?run=` is carried, not dropped.** The page lives at the definition's slug so a weekly event + has one address, and the occurrence is in the query. + + **My Events is one drawer row at `SIGNED_IN`, not two** (org lead). The website mounts + participation history twice — `/account/events` and `/admin/events/mine` — only because + `RequirePlayer` guards `/account` and the route behind it is role-agnostic. The app's drawer has no + such wall: `GET /player/events/history` is `requireAuth` alone and self-scoped on the caller's own + id, so one row serves players and staff alike. It is deliberately **not** at `PLAYER` access, which + would imply event history is game-linked data; it is not, and a shard with no game module still has + events. + + #### An inbox link to an event opens the app, not the browser + + **Departing from the recommendation** (org lead, 2026-09-08). Engagement Phase 8's contract is that + an inbox item's `url` is site-relative and hands off to a Custom Tab. Phase 14a gave the six public + `event.` triggers an `eventUrl` of exactly the form `/site/events/?run=`, so an inbox row + about an event now has a native destination and should use it. + + The mechanism is the one that already exists — `resolveWebPath` — rather than a second one. That + forces one rule to change, and it is worth stating because it is the exact trap the rule was + written to avoid: **a path carrying a query hands off**, because no app route took one and a + native match would silently drop what the admin wrote. The event page is the first app route that + takes a query, so the rule becomes *a query hands off, except `run` on an event page*. Anything + else in the query string, and any fragment, still hands off untouched. + + - **Excluded**, in the same class as M10's, M11's and M12's exclusions: the events **authoring** + surface. The app does not gain an event editor, a run console or the cap meter; Phase 13 built + those for the web and they are admin *configuration*, which the app is a consumer of and not an + editor for. + + **`account/events`, not `events/mine`**, and the reason is a bug one tier along: `events/mine` and + `events/{slug}` are both two segments, and a static-versus-argument race between two NavHost + patterns is exactly what events Phase 13 found in React Router, where a static `events/new` + outranked `events/:id` and made creating an event impossible for seven phases. Under `account/` + there is no dynamic sibling and no race to lose. + + **Verified:** the app suite (**570 tests, 0 failures**), and an emulator walk against a local + website running the 14a code, at three rungs — anonymous, player and admin. + + #### The walk found three defects, and 563 green tests found none of them + + The rig's own site had **no `uo` module installed**, which made it the exact case the prerequisite + fix is for, and is why two of the three were visible at all. + + - **The three player game-data rows were not gated.** `My characters`, `My vendors` and `My houses` + read `/player/shard/*` — the same module's *player* mount — and rendered on a backend with no + module, answering *"This content couldn't be found"* with a Retry button. The gate had been + applied to the five public shard rows only. The test that was meant to catch this asked whether + every row **with a `feature`** declared the capability, which is true and insufficient: these + three carry no feature, because they are gated by ownership rather than by the visibility + framework. It now asks by **route**. + - **`score` is `DECIMAL(18,4)`, and declaring it an integer breaks the whole response.** The first + history row the walk read scored `318.5`. kotlinx does not round a fraction into a `Long` — it + refuses the entire body, so a `200` reached the screen as *"Something went wrong on the server."* + The same field is on the public results table, so **any** event whose module scores fractionally + would have broken the event page for every anonymous visitor. + - **A drawer route's view model outlives a sign-out, and this one is per-account.** + `navigateTopLevel` saves and restores back-stack state, so the entry keeps its `ViewModelStore` + and a view model that loads only in `init` never runs again. Signing out of the admin and back in + as a player showed the **player the admin's participation history**, with no request made at all. + It now keys on the signed-in account id, and the shell sends a signed-out caller home rather than + letting the screen ask and render a 401. + + What the walk **confirmed working**: every shard row absent on a module-less backend at all three + rungs while Events remained; a cancelled occurrence three days out reading *Cancelled* rather than + *Did not happen*, on both the calendar and the event page; projections labelled; the event's own + zone beside the reader's own day; an arc; `?run=` surviving the NavHost pattern to reach the server; + an inbox event link opening the app natively while a forum link still opened a Custom Tab; and + participation history self-scoped, proved by two accounts rather than asserted. + ### Deferred (not a milestone) - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is diff --git a/website/EVENTS.md b/website/EVENTS.md index 3d31b18..ae84234 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1475,7 +1475,7 @@ with no `roles` — every account has a participation history and the server sco | **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication | | **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module | | **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out | -| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half is Phase 14b**, with its prerequisite: the app hardcodes `api/v1/public/shard/stream` — a module path — reads `/public/modules` nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry renders and every one of them 404s. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates the group on a capability string rather than on a module id. | server built, app is 14b | +| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half was built in Phase 14b** (`android-app` M13), with its prerequisite: the app read `/public/modules` nowhere, and `ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry rendered and every one of them 404d. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates on a capability string rather than on a module id, and its governing rule is that **absence of an answer is not an answer of absence** — a successful module list that omits the string hides the rows, a failed read keeps the last answer, and a host that never answered leaves the gate open. Core's `events` gates the two event rows. The app's four screens are the calendar, an event page (carrying `?run=`), an arc and participation history — the last as **one** row for any signed-in account, because the route is `requireAuth` alone and self-scoped and only the web's own `/account` guard needed a second mount. | built | | **Admin audit** | `ctx.activity.log` for every edit, publish and manual transition. The runtime record is `event_run_log`, because `activity_log.detail` is `TEXT` and cannot be queried by phase or step. | exists | --- diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 25cacda..4954d95 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1736,21 +1736,51 @@ a clock — and the third was a screen the reviewing admin could not open at all `RequirePlayer` guards `/account` and the route behind it is role-agnostic. Engagement Phase 7 had already solved that one; the fix is its `notificationPaths.js` mapping gaining a third entry. -#### Phase 14b — the app (`android-app` + `docs`) +#### Phase 14b — the app (`android-app` + `docs`) — **built** -The app's events screens, and **one prerequisite fix that is in this phase and not after it.** The -Android app hardcodes `api/v1/public/shard/stream` — a *module* path — reads `/public/modules` -nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", -which `canSee` treats as visible. On a site with no `uo` module installed that route 404s, so every -shard entry in the drawer renders and every one of them 404s. Failing open is right *within* a module -that exists and wrong for one that is not installed; the fix gates the group on a **capability -string** — `shard`, which module-uo declares — rather than on a module id, and gates the new events -entry on core's `events`. +`android-app#44`, `docs#225`. **TWO repos**, and `MODULE_API_VERSION` is untouched. Recorded as +**M13** in `docs/android/PLAN.md`, which gained the milestone before the code was written. -`docs/android/PLAN.md` gains the milestone before the code is written. +The app's events screens — calendar, event page, arc, participation history — and **the prerequisite +fix, which is in this phase and not after it.** The app read `/public/modules` nowhere, and +`ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` +treats as visible. On a site with no `uo` module that route 404s, so every shard entry rendered and +every one of them 404d. Failing open is right *within* a module that exists and wrong for one that is +not installed. + +Six org-lead decisions. Four up front — the failure direction, the screen scope, where history lives, +and native deep links; and two on the scope of the second defect below. **One departed from the +recommendation:** an inbox link to `/site/events/?run=` **opens the app** rather than a +Custom Tab, reusing `resolveWebPath` rather than adding a second link-routing mechanism. That forces +the one rule that function was written to enforce — *a query hands off* — to gain an exception, and it +is exactly one key on exactly one path. + +**Absence of an answer is not an answer of absence**, which is the distinction the whole fix rests on: +a *successful* `/public/modules` read that does not name `shard` **hides** the rows; a *failed* read +keeps the last answer the host gave; a host that has never answered leaves the gate open. The old code +collapsed all three into one `null`. Capability and feature are a second and third gate that compose: +capability asks *is the module installed* (per host), feature asks *does this shard publish it to this +viewer* (per viewer). + +**A second, older defect was found while scoping and fixed here** (org lead): the app's website +path → route table had been wrong since the module-system cutover on 2026-08-12. It quoted a +sixteen-row `NAV`; core's is now **eight** and the nine shard rows moved to `/uo/*`, because core +mounts a module's pages at `//`. Three live consequences: a nav override on any shard row +was ignored, an added link to a shard page handed off to a browser instead of opening natively, and +the sort-key number line was wrong. Two tests in the existing suite had been passing **vacuously** +since that day — they asserted a section came out empty, and it did, because the item never joined it. **Ships:** the calendar, event pages and participation history in the app. -**Verify:** the app suite; an emulator walk. +**Verify:** the app suite (**570 tests, 0 failures**); an emulator walk at three rungs. + +**The walk found three defects and the suite found none of them.** The rig's own site had no `uo` +module, which is what made two of them visible. The three player game-data rows read `/player/shard/*` +and were not gated, so they rendered and 404d — and the test meant to catch that asked whether every +row *with a `feature`* declared the capability, which those three do not have. `score` is +`DECIMAL(18,4)` and was declared an integer, so one `318.5` made kotlinx refuse the **entire** body and +a 200 rendered as a server error — latent on the public results table for every visitor. And a drawer +route's view model outlives a sign-out, so signing in as a second account showed it the first +account's participation history with no request made at all. See `docs/android/PLAN.md` M13. --- -- 2.49.1 From 5d48e7f256f3edfbb9f6ecb897a152b17899b4be Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 18:11:39 -0500 Subject: [PATCH 20/24] docs(events): what the integration kit's fifth chapter settled (Phase 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §F's Integration Kit paragraph gains what building it produced, and §H loses an envelope member that does not exist. **It is three chapters, not one.** The book taught a read-only data path end to end and never told anyone to build a command path, so a chapter 5 teaching a module to send an idempotency key would have addressed it to a sidecar with nowhere to put it. Chapters 3 and 4 each gain one section, both skippable. **Two defects, both found by running the template through core's real registry and real dispatcher rather than by writing prose:** * **An idempotency key belongs on a command, never on a question.** A read carrying one is answered by an at-most-once store with the FIRST read's reply, forever — the lease applied correctly and the module could no longer see it. * **§H named a `detail` member on an envelope and `classify()` has never read one.** The sentence §H was making is right and its example was wrong: a revert of something gone is `{ ok: true }`. Corrected in place, with the finding recorded in §F. That second one has a consequence outside this PR: **`module-uo` took §H at its word twice.** `uo.item.grant` answers `detail: { granted, missed, why }` and `uo.world.save` answers `detail: { started: true }`, and neither reaches a screen or the ledger. The grant is the one that matters — which recipients did not receive the item is reported nowhere else. Recorded here rather than fixed; the fix is a Module-uo change and is the org lead's call. Pairs with Integration-kit#10, which is red on `checkCoreApi` by design and merges in the P16 cutover with its pin move. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/EVENTS.md | 42 +++++++++++++++++++++++++++++++++++++++++- website/EVENTS_PLAN.md | 10 ++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index ae84234..85da4e9 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1110,6 +1110,46 @@ a second module's author will get wrong are the envelope's failure default, the passthrough, recording a resource *before* confirming it, and under-declaring `cost`. All four are one paragraph each and all four are invisible until an outage. +> **Built in Phase 15** (`Integration-kit#10`), held unmerged until the cutover by the same mechanism +> Teams phase 11 was: `checkCoreApi` asserts EQUALITY between `template/module.json`'s `coreApi` and +> the `MODULE_API_VERSION` of the core `ci/core-ref.json` pins, so a template declaring `^1.10.0` +> against a `main` still on 1.9.0 is red on purpose from the day the branch opens. The pin move is +> the kit's leg of P16. +> +> **It is three chapters, not one.** The book as it stood taught a read-only data path end to end — +> a sidecar that listens and stores, a plugin that never blocks the game thread, a module that reads +> its own tables. Nothing in it told anyone to build a **command** path, so a chapter 5 teaching a +> module to send an idempotency key would have been addressing it to a sidecar with nowhere to put +> it. Chapter 3 gains §2a (request/reply correlation, the at-most-once store belonging where the +> state is, and a deadline sent as a duration rather than an absolute time) and chapter 4 gains *"A +> command that changes the world runs at most once"* (the key store persisted in the world save, the +> ownership registry, and the expiry this side arms and re-arms at load). Both open by saying they +> are skippable until you want chapter 5. +> +> **The template ships one of each of the four declarations**, and the phase's value came from +> running them through core's real registry and real `events/dispatch.js` classifier at `edge` rather +> than from the prose. That found two things: +> +> - **An idempotency key belongs on a command and never on a question.** A read that carries one is +> answered by an at-most-once store with the FIRST read's reply, forever — so a lease applied +> correctly, the game changed correctly, and the module could no longer see either: `read()` +> reported the pre-run baseline and `inForce()` said nothing was held. The narrower rule that falls +> out is worth stating with the others: a key is for a write whose repetition would be a second +> EFFECT, and a write that merely SETS a value to X is idempotent by its own nature. +> - **§H names a `detail` member on an envelope and core has never read one** — see the correction +> below. + +> **`detail` is not an envelope member.** The Rust wipes row in [§H](#h--rust--oxide-compatibility) +> says the revert contract must accept `{ ok: true, detail: 'resource no longer exists' }`, and +> `events/dispatch.js`'s `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`, `resources` +> and `participants` — nothing else. A reason under any other name is dropped in silence and the +> operator sees `" refused"`. The sentence §H was making is right and its example is +> wrong: a revert of something that no longer exists is a success, and it is spelled `{ ok: true }`. +> **`module-uo` took §H at its word twice** — `uo.item.grant` answers +> `detail: { granted, missed, why }` and `uo.world.save` answers `detail: { started: true }`, and +> neither reaches a screen or the ledger. The grant one is the one that matters: which recipients did +> not receive the item is reported nowhere else. + --- ## G — UO implementation plan: the gap list @@ -1183,7 +1223,7 @@ more than one. Nothing here contradicts it. | Participation | The hard part | Substantially easier — hooks carry attacker and victim. | | **Rewards** | An item into a backpack. `reversible: 'none'` — once given it is gone. | A kit, a permission group, currency via an economics plugin, a cosmetic. **Several of those are revocable**, so a Rust reward may be `reversible: 'override'` — a weekend VIP group is a lease with a deadline, not a gift. Core sees the difference as one enum value it never interprets. | | Several servers | One shard | `run.scope` is in the run's unique key, so one definition fans out to six servers without colliding with itself. Caps are per-run, so a fan-out to six servers is six separate budgets rather than one shared pool. | -| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept `{ ok: true, detail: 'resource no longer exists' }` — "gone, and that is fine" is a successful revert. A wipe also resets leased values to their defaults, a second reason restore must be idempotent. | +| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept "gone, and that is fine" as a successful revert, spelled `{ ok: true }` — **not** `{ ok: true, detail: ... }`, which this row said until Phase 15 and which `classify()` has never read (see [§F](#f--the-module-contract)). A wipe also resets leased values to their defaults, a second reason restore must be idempotent. | | Identity | In-game `[link` code | Steam — still `rust-dryrun` finding 1's open gap. Events neither closes it nor depends on it: `event_run_participants` carries a module-opaque `member_key`. | **The agnosticism is provable, not merely asserted.** Make `event_definitions.owner_module` nullable diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 4954d95..a411377 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1796,6 +1796,16 @@ confirming it, and under-declaring `cost`. **Like Teams Phase 11, this cannot merge until the cutover exists** — the kit is pinned to a `main` sha, and the contract it teaches is not on `main` until then. +> **Built** as `Integration-kit#10` + `docs#226`, red on `checkCoreApi` by design. **It is three +> chapters, not one, and the template gains code.** The book taught a read-only data path end to end +> and never told anyone to build a command path, so chapters 3 and 4 gain one section each (§2a; *"A +> command that changes the world runs at most once"*), both skippable until you want chapter 5. The +> template ships one budget, one option source, one lease and one ledgering action plus +> `server/sidecarClient.js` — named for the filename `noGameConnection.test.js` already anticipated, +> with a real timeout, a real key passthrough and a simulated transport in one replaceable function. +> Running those through core's REAL registry and dispatcher at `edge` is what found the two defects +> §F now records; the prose found neither. + --- ### Phase 16 — Acceptance walk and cutover -- 2.49.1 From ec55e66720d7e96f6819deaa20d4d7fd10d04031 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 18:50:04 -0500 Subject: [PATCH 21/24] docs(events): `detail` is a real envelope member now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the finding recorded a commit ago: §H told a module the revert contract accepts a `detail`, `classify()` had never read one, and `module-uo` had been answering one since Phase 12b — so `uo.item.grant`'s report of which recipients missed out was written into nothing. Fixed in `website#197` by making the member real rather than by deleting the reporting, because §H's sentence was right and only its example was wrong. * **§1.1, 1.10.0** gains `detail` as a third envelope member beside Phase 10's two: optional, on both SUCCESS shapes, carried and never interpreted, objects only, 4KB, dropped-and-logged rather than failing the step. * **§2.4** gains the contract rule — core reads no key out of it, because a switch on known keys anywhere in core would be core learning one module's vocabulary. * **EVENTS.md §F** records the fix, including the half that is easy to miss: the run console's `describeLogLine` default returns a kind WORD, so the new line would have rendered as the literal string "step.detail" — the channel existing and showing nothing. * **§H's wipes row** no longer claims `detail` is unread. MODULE_API stays 1.10.0, amended in place — still on `edge`. The failure channel is unchanged and is still `error` alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/EVENTS.md | 43 ++++++++++++++++++++++++++++++++----------- website/MODULE_API.md | 25 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 85da4e9..42d291e 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1139,16 +1139,37 @@ one paragraph each and all four are invisible until an outage. > - **§H names a `detail` member on an envelope and core has never read one** — see the correction > below. -> **`detail` is not an envelope member.** The Rust wipes row in [§H](#h--rust--oxide-compatibility) -> says the revert contract must accept `{ ok: true, detail: 'resource no longer exists' }`, and -> `events/dispatch.js`'s `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`, `resources` -> and `participants` — nothing else. A reason under any other name is dropped in silence and the -> operator sees `" refused"`. The sentence §H was making is right and its example is -> wrong: a revert of something that no longer exists is a success, and it is spelled `{ ok: true }`. -> **`module-uo` took §H at its word twice** — `uo.item.grant` answers -> `detail: { granted, missed, why }` and `uo.world.save` answers `detail: { started: true }`, and -> neither reaches a screen or the ledger. The grant one is the one that matters: which recipients did -> not receive the item is reported nowhere else. +> **`detail` was not an envelope member, and now it is** (`website#197`). §H's Rust wipes row told a +> module the revert contract accepts `{ ok: true, detail: 'resource no longer exists' }`; +> `events/dispatch.js`'s `classify()` read `ok`, `retry`, `error`, `await`, `holdFor`, `resources` +> and `participants` and had never read a `detail`. **`module-uo` took §H at its word twice** — +> `uo.item.grant` answers `detail: { granted, missed, why }` and `uo.world.save` answers +> `detail: { started: true }` — and both were writing into nothing. The grant is the one that +> mattered: a grant reaches the players a run's participation ledger holds, and *which of them missed +> out* is knowable only to the module, so an operator saw a step `done` and never learned four of +> twelve got nothing. +> +> **Fixed by making the member real rather than by deleting the reporting**, because §H's sentence +> was right and only its example was wrong. `detail` is now optional on both SUCCESS shapes, +> **carried and never interpreted** — nothing in the dispatcher, the runner or the browser reads a +> key out of it — and the runner writes it as a `step.detail` run-log line. Its own log kind rather +> than a field on `resource.recorded`, because the grant that forced it ledgers nothing +> (`reversible: 'none'`) and reports no participants, so it would have had nowhere to ride. +> +> Two things about the fix are worth keeping. **Anything wrong with a `detail` is dropped and logged, +> never a failure** — a step that did what it was asked must not be re-run because its module's +> commentary was malformed, which is a world write repeated for a log line. And **the console's +> renderer was half the fix**: `describeLogLine`'s default returns a kind *word*, so a `step.detail` +> row falling through would have rendered as the literal string `step.detail` — the channel existing +> and showing nothing, which is the failure it was built to fix. It renders the module's keys +> generically; a switch on known keys would be the browser learning one module's vocabulary. +> **`module-uo` needed no change**: the code it already shipped started working. The failure channel +> is unchanged and is still `error` alone. + +> **A revert of something gone is spelled `{ ok: true }`**, and §H said `{ ok: true, detail: ... }` +> until Phase 15. `detail` is a real member now, but it is a *diagnostic line*, not the way a revert +> reports success — the success is `ok`, and a module that put its answer only in `detail` would be +> reverting nothing. --- @@ -1223,7 +1244,7 @@ more than one. Nothing here contradicts it. | Participation | The hard part | Substantially easier — hooks carry attacker and victim. | | **Rewards** | An item into a backpack. `reversible: 'none'` — once given it is gone. | A kit, a permission group, currency via an economics plugin, a cosmetic. **Several of those are revocable**, so a Rust reward may be `reversible: 'override'` — a weekend VIP group is a lease with a deadline, not a gift. Core sees the difference as one enum value it never interprets. | | Several servers | One shard | `run.scope` is in the run's unique key, so one definition fans out to six servers without colliding with itself. Caps are per-run, so a fan-out to six servers is six separate budgets rather than one shared pool. | -| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept "gone, and that is fine" as a successful revert, spelled `{ ok: true }` — **not** `{ ok: true, detail: ... }`, which this row said until Phase 15 and which `classify()` has never read (see [§F](#f--the-module-contract)). A wipe also resets leased values to their defaults, a second reason restore must be idempotent. | +| Wipes | Never | Monthly, and a wipe invalidates every ledgered resource for that server at once. The revert contract must accept "gone, and that is fine" as a successful revert, spelled `{ ok: true }`. This row said `{ ok: true, detail: 'resource no longer exists' }` until Phase 15, when `detail` turned out to be a member nothing read; it is a real one now, but it is a diagnostic line beside the answer and never the answer itself (see [§F](#f--the-module-contract)). A wipe also resets leased values to their defaults, a second reason restore must be idempotent. | | Identity | In-game `[link` code | Steam — still `rust-dryrun` finding 1's open gap. Events neither closes it nor depends on it: `event_run_participants` carries a module-opaque `member_key`. | **The agnosticism is provable, not merely asserted.** Make `event_definitions.owner_module` nullable diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 8bf08ca..9ef276e 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -225,6 +225,24 @@ field: leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go. +**A third joined in Phase 15, also on an envelope:** + +- **`detail` on an action's SUCCESS envelope** (`EVENTS.md` §F). An optional object a module may + answer with, carried to the run log as a `step.detail` line and **never interpreted by core** — + nothing reads a key out of it in the dispatcher, the runner or the browser. It exists because a + module knows things about its own verb core cannot compute and had no other way to say them: + `uo.item.grant` reaches the players a run's participation ledger holds, and *which of them missed + out* was reported nowhere at all. On both success shapes, like `resources` and `participants`, + because `await: 'human'` is a success and a cue's confirm finishes the step without a second + dispatch. + + Objects only, 4KB of serialised JSON, dropped rather than truncated, and **anything wrong with it + is dropped and logged rather than failing the step** — a step that did what it was asked must not + be re-run because its module's commentary was malformed, which would be a world write repeated for + a log line. It is additive and optional: a module that never answers one is behaving exactly as + before. **Found by writing the integration kit's chapter 5** (`EVENTS_PLAN.md` Phase 15), whose + template made the same mistake `module-uo` had — see §F. + **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. @@ -1135,6 +1153,13 @@ rather than implementation and belong here: rule generalises past that one pairing — an action is the near end of a call with a far end, and the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it is attempted once, declares 15000. +- **A module's `detail` is carried and never read.** An optional object on either success shape, + bounded at the dispatcher and written to the run log verbatim beside the action id. Core reads no + key out of it — a switch on known keys anywhere in core would be core learning one module's + vocabulary, which is the thing this whole contract exists to prevent. It is the answer to *"what + actually happened"* for a verb whose answer is neither a resource nor a participant, and before + Phase 15 there was no such answer: `EVENTS.md` §H named the member, `classify()` had never read + one, and a module that used it wrote into nothing. - **A module reports who took part on the envelope, and there is no other door.** `participants` rides back from `perform()` exactly as `resources` does, on both success shapes — including `await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is -- 2.49.1 From 66472870379e976b59f7efe6dcee9d2dc806cf48 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 9 Sep 2026 08:31:06 -0500 Subject: [PATCH 22/24] docs(events): the acceptance walk, and the three contracts it moved (Phase 16a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 16 is split into 16a (the walk), 16b (the cutover) and 16c (runicgateway.com + .profile), because the phase as written asked for a walk "against released artefacts" BEFORE the cutover and all three component repos release on push to `main`. The walk therefore runs against artefacts built from `edge` the way a release builds them, and 16b re-verifies against the real bundle. `EVENTS_PLAN.md` gains the 16a record: the rig, all three deliberate failures passing, the six defects, the one finding withdrawn, and what each fix was verified against. Three contracts move, each because the walk proved the built thing did not match the written one: **`link/v6.md` — a refusal does not spend its key.** Rule 2 had two cases, throw and return, and needed a third: a handler that ran to completion and deliberately refused did nothing, so freezing that refusal as the key's answer made a refusal that WAITING FIXES impossible to retry past. The section now carries the case `uo.world.save` found it with, and the rule the release rests on — do not answer `*.error` after changing the world. `[bridge status` gains `refused=`. **`website/MODULE_API.md` — `revert`'s `idempotencyKey` identifies a dispatch; it is not a key to send on the undo.** The paragraph explained what the key is FOR and never said what it is not, and `module-uo` read it the other way: every despawn went out under the key its spawn had used, so a store that keys on the key alone answered the undo with the DO's reply and teardown became a no-op that reported success. **`website/EVENTS.md` §I — the public calendar matches a run that OVERLAPS the window.** The row promised "upcoming, live and recent" and the built route served only the first, because it read the start instant and a live run has already started. The default window now reaches back so "recent" has somewhere to live, and projections are forecast from now rather than into that tail. Pairs with `website#`, `Module-uo#` and `servuo-plugins#`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- link/v6.md | 31 ++++++++++++++++++---- website/EVENTS.md | 2 +- website/EVENTS_PLAN.md | 59 ++++++++++++++++++++++++++++++++++++++++++ website/MODULE_API.md | 10 +++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/link/v6.md b/link/v6.md index 24b6739..de0fdf4 100644 --- a/link/v6.md +++ b/link/v6.md @@ -80,10 +80,31 @@ A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, a told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work is happening. -**2. A key that has begun is never released.** Not even when the handler throws. Releasing it would -let a retry re-run a command that may have applied half of itself, which is the exact failure this -file exists to prevent. A handler that throws stores a `bridge.error` reply instead, so the retry -gets a definite answer and the step fails once rather than looping. +**2. A key that has begun is never released — except on a refusal.** Not when the handler throws. +Releasing it would let a retry re-run a command that may have applied half of itself, which is the +exact failure this file exists to prevent. A handler that throws stores a `bridge.error` reply +instead, so the retry gets a definite answer and the step fails once rather than looping. + +**A REFUSAL is the third case**, added by the Phase 16 acceptance walk and amending protocol 7 in +place. A handler that ran to completion and answered `*.error` did not do anything — every refusal +on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that +failed and left the value alone. Remembering it froze the answer for ever, so a refusal that +*waiting fixes* could never be retried past. `uo.world.save` is the case that found it: the shard +saves at most every 300 seconds, the module documents that as "the one refusal on this plane that +waiting fixes", and six attempts over four minutes all replayed one frozen sentence — "the last save +was 227 seconds ago" — because the number was the first reply's, not the clock's. A step's key is +one value for the life of the step, so the operator's retry control could not escape it either. + +So a refusal releases the key: nothing happened, and the caller may ask again. The refusal is still +**emitted** to the caller, which is what ends that attempt; it is simply not remembered as the key's +answer. A refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a handler +family added later is covered without extending a list. `bridge.error` is excluded deliberately — +that is the reply the shard writes when a handler THREW, which is the case whose key must be kept. + +**This puts a rule on handlers, and it is the rule the release rests on: do not answer `*.error` +after changing the world.** Report a partial change in an `ok` reply, as `item.grant` does with +`granted`/`missed` and `world.despawn` with `removed`/`gone`/`refused`. The shard cannot verify +"nothing happened"; it takes the `.error` kind as the claim. **3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the @@ -102,7 +123,7 @@ evicted key's repeat *would* be applied a second time — so an eviction that dr its TTL prints a console warning naming the count. If the promise is ever actually breached, an operator reads it here rather than discovering a doubled spawn in the world. -`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated=)`. +`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated= refused=)`. #### 2.1.1 How the reply is captured diff --git a/website/EVENTS.md b/website/EVENTS.md index 42d291e..6a44a9f 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1847,7 +1847,7 @@ no URL moved. | `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 (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time | -| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. Defaults to now through 31 days out and the window may span at most 92 — the anonymous surface is the one with no login in front of it | +| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. A run is an INTERVAL, not an instant: an entry is in the window when the run OVERLAPS it, so one that began before the window and has not ended is still "what is on" (Phase 16a — reading the start instant alone made this route serve only the first of its three words, while the event's own page said `live`). Defaults to seven days back through 31 days out — the tail is where "recent" lives — and the window may span at most 92; the anonymous surface is the one with no login in front of it. Projections are forecast from NOW, never into the tail, since a slot the runner has already passed did not happen | | `GET /public/events/:slug` | — | **one event** (Phase 14a): storyline, arc, what is live, what is next, what happened recently, and a results table once one is published. Takes an optional `?run=`, which is what an announcement's link carries, so a mail about last Friday's occurrence does not open next Friday's; a run belonging to some other event is **ignored rather than refused**, because a stale link in a months-old mail should land on the event it was about. A draft, an archived definition and an unlisted one all answer 404 | | `GET /public/events/series/:slug` | — | **the arc** (Phase 14a). A series with no listed events is a 404, not an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced | | `GET /player/events/history` | auth | **this account's participation** (Phase 14a) — the run, when it was, the score a module reported, and the rank once results were published (null until then, which is a real state rather than an error). Self-scoped on the session with **no id parameter**, deliberately: a route that took one would be a middleware mistake away from publishing who attended what. Keyset-paged on the participation row's id. It obeys the calendar's two exclusions, so attending an unannounced event does not disclose that it exists | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index a411377..fc9e7e8 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1820,6 +1820,65 @@ website, emulator — running a real multi-phase event, including three delibera Then `edge` → `main`, in the order every previous cutover used: the protocol side first, the module, core, docs, then the kit's re-pin and `runicgateway.com`. +> **Split into 16a (the walk) and 16b (the cutover)** (org lead, 2026-09-09), on the same argument +> 12a/12b and 14a/14b were split on. The two sentences above cannot both hold: `link`, +> `servuo-plugins` and `Module-uo` all release on push to **`main`**, so no released artefact +> carrying events can exist until after the cutover. Engagement Phase 13 met the same wall and +> resolved it the other way, cutting over first and walking from `main`. Here the walk goes first +> against artefacts built from `edge` exactly the way a release builds them, because every walk in +> this workstream has found defects and a defect found on `edge` is a reviewed PR rather than a +> hotfix to `main`. **16b re-verifies against the real released bundle** — install, boot, run one +> event — so the delivery path is still proved, just second. A third leg, **16c**, carries +> `runicgateway.com` and `.profile`. +> +> **16a WALKED, and it is four repos** — `Module-uo`, `website`, `servuo-plugins`, `docs`. The whole +> rig: real ServUO 57.4 (208k items, 42k mobiles) → a `cargo --release` sidecar on protocol 7 → core +> with the module installed from a release-shaped bundle → the Android app on an emulator. The +> overlay was deployed from a tarball built the way CI builds one, into a tree with `Scripts/Custom/ +> Bridge` and `Saves/Bridge` deleted first, so it was a first install rather than an upgrade. +> +> **All three deliberate failures pass.** (1) A mid-run process kill landed mid-TEARDOWN — sharper +> than mid-step, since a phase executes in about a second — with the run `completed`, cleanup +> `pending`, a lease half-returned and 21 world objects up: teardown resumed on restart and all 15 +> steps still read `attempts = 1`, so nothing re-executed. (2) The sidecar killed during a phase gate +> left the run `degraded` rather than failed, `core.lease` retrying with a reason, and the four world +> writes behind it **parked at `attempts = 0`**; the shard reconnected on its own. (3) A cap of 5 +> against a step asking for 12 answered `refused` — its own status — with `code: "cap"` and *"asks +> for 12 of `uo.creatures`; 0 of 5 is already spent this run"*, and the dry run had already refused +> it in the author's own words. +> +> **Six defects, all in code already merged to `edge`, and the suites were green on either side of +> every one.** Two were blocking or worse. **The spawn atlas could not import on a stock ServUO +> tree** — a case-sensitive JS dedupe against an `..._ai_ci` PRIMARY KEY, four colliding decoration +> spellings in ServUO's own files, and the whole transaction lost; with no atlas every option source +> answers empty and no world verb can be authored at all. **Teardown of all five world verbs was a +> no-op that reported success** — `revertOwned` sent the despawn under the step's key, which is the +> key the SPAWN used, so the shard replayed the spawn's reply and `OnDespawn` never ran; the ledger +> read `reverted` while the shard held all 21 objects, and the same despawn under a fresh key removed +> every one. Then: **the public calendar served neither live nor recent runs** though §I promises all +> three, so the site said `live` on one page and showed nothing on the other; **a resource left +> `reverting` by a crash was never reclaimed**, and the manual cleanup route answered 200 while doing +> nothing, which stranded a lease and blocked the NEXT run of the same event; **a transient refusal +> under an idempotency key was permanent**, because the shard's store had no case for a handler that +> ran and deliberately did nothing; and **three facts every announcement computes were declared by no +> trigger** and silently dropped. +> +> One reported defect was **withdrawn**: `skip` refusing a `failed` step is not a dead end, because +> `resume` carries a run past any settled step — the route's own docs say so and the rig confirmed +> it. The runner claims only `pending` steps, so `failed` and `refused` are both settled. +> +> Every fix is verified against the rig, not only against tests: the atlas imports 309 decor types +> and 6,455 points; a full four-phase run's teardown leaves the shard owning **0**; a lease stranded +> by a real crash is reclaimed in one sweep and `cleanup_status` reaches `complete`; the same save +> key 25 seconds apart answers "15 seconds ago" then "40 seconds ago"; and `/site/events` shows a +> live run as **Happening now** beside recent ones, in the browser and in the app. Each new test was +> confirmed to FAIL without its fix. +> +> **`Module-uo`'s `revert` no longer forwards core's key at all** — `MODULE_API.md` now says why that +> key identifies a lost dispatch rather than addressing the undo. **Protocol 7 is amended in place**: +> a refusal releases its key, with the rule that pays for it written down — *do not answer `*.error` +> after changing the world*. + **Two documents that are cutover-window work by construction.** - **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until the cutover lands. Same 12a/12b split the engagement workstream needed. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 9ef276e..9a9be64 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -1193,6 +1193,16 @@ rather than implementation and belong here: with the key and an EMPTY list, meaning *"a command went out under this key and core never learned what it did"*. Answering that honestly is what makes an unattended world write recoverable; a module that cannot answer it says so, and the row stays visible to an operator. +- **That key IDENTIFIES a dispatch; it is not a key to send on the undo.** It names the command core + lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of + the reverting command is a different thing entirely, and on a game whose at-most-once store keys on + the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and + answered with the original reply. `module-uo` made exactly this mistake: teardown of all five world + verbs was a no-op that reported success, because every despawn carried the key its spawn had gone + out under. Found by the Phase 16 acceptance walk, with the ledger reading `reverted` and the shard + still holding every object. A command that undoes needs a key of its own or none at all; a repeated + undo is usually harmless by construction ("already gone" is a success), which is what makes *none* + the right answer more often than not. - **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it -- 2.49.1 From 1a7d2da4ddae326650cab6bd6c15cb7666ea5ab5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 9 Sep 2026 20:10:47 -0500 Subject: [PATCH 23/24] docs(events): the cutover, the outage it survived, and the defect it exposed (Phase 16b) `EVENTS_PLAN.md` gains the 16b record: the six steps and why core lands before the module, the four decisions taken, the releases cut, and the re-verify against artefacts an operator would actually download. Three things in it are worth more than the chronology. A job's log IS readable on this Gitea, through the web route rather than the API. Every earlier phase diagnosed CI by reproducing jobs locally on the belief that logs were unreachable; reading one turned four red jobs into four known causes in about ten minutes. Three were the ten-minute Cloudflare outage in the middle of the window and a runner that could not resolve sh.rustup.rs -- and because neither release pushed its tag before dying, the orphaned-tag failure mode did not occur and a plain workflow_dispatch recovered both. The seventh defect of this phase: `server-tests` had been red on every events PR since Phase 10, always the same single test, and the workstream merged over it eight times. `announce.js` asked for `hour12: true`, which is not the same request as a 12-hour clock -- for a locale whose default cycle is h23, Node 20 resolves it to h11 and midnight renders "0:00 am", while Node 22+ resolves it to h12. Same ICU on both sides, so it is V8's ECMA-402 behaviour and not locale data; the image ships node:20-alpine and a dev machine is newer, so it rendered correctly for everyone who reviewed it and wrongly for every real recipient. The rule is now written down: `hour12` is a request about a locale's preference, `hourCycle` is a request about the clock -- ask for the clock. And the re-verify itself: the released installer resolved bundle 2026.09.10, verified both checksums, did a first install into a stock 57.4 tree, the overlay compiled 0/0 -- which no release had ever been asked to prove -- the shard came up with the events plane on and dialed the sidecar, the whole protocol-7 event plane answered, and an event published on released `main` ran to `completed` with its results published and its finished run visible on /site/events two minutes later. That last line is 16a's calendar fix holding on `main`. The module's own install through core's https installer is named as the one leg still owed: it cannot run until Module-uo#34 cuts the release it fetches. Diff is 102/1, content only -- no CRLF rewrite (checked against --numstat). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/EVENTS_PLAN.md | 103 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index fc9e7e8..4a077de 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1879,9 +1879,110 @@ core, docs, then the kit's re-pin and `runicgateway.com`. > a refusal releases its key, with the rule that pays for it written down — *do not answer `*.error` > after changing the world*. +> **16b CUT OVER (2026-09-09/10) — six steps, and core before the module.** The protocol pair +> (`link#40` + `servuo-plugins#26`) is ONE step, not two: `bundle.yml`'s Gate 1 reads the protocol +> number out of both released artefacts and refuses a pair that disagrees, so whichever lands first +> leaves a compose that cannot run. Then core (`website#199`), the module carrying its own re-pin +> (`Module-uo#34`), the app (`Android-app#46`), the kit's re-pin (`Integration-kit#11`), and docs. +> +> **Core lands before the module**, which departs from the sentence above and matches what the +> engagement cutover actually did: `Module-uo`'s `ci/core-ref.json` has to name a website `main` sha +> carrying MODULE_API 1.10.0, and that sha does not exist until core has landed. Four decisions, all +> as recommended (org lead, 2026-09-09): that order; the app merges with **no `v*` tag**, so no APK +> was cut; the re-verify walks the whole delivery path; and **`edge` stays standing** in every repo +> rather than being deleted as the module-system cutover deleted its own. +> +> Releases cut: sidecar **v2.2.0**, overlay **v1.2.0**, bundle **2026.09.10 (protocol 7)**. `website` +> never releases. `MODULE_API_VERSION` and `EVENTS.md` are untouched by this leg — the cutover moves +> no contract. +> +> **`Integration-kit#10` had been merged early**, on 2026-09-08, though it was written to be held — +> so the kit's `main` was red on `checkCoreApi` for two days. That is what step 5 closes, and it is +> the reason the re-pin is a repair rather than only a date. +> +> #### The outage, and what it did not break +> +> Gitea was unreachable for about ten minutes in the middle of the window (Cloudflare 1033/530) and +> killed **both** release runs. `link`'s built every binary and wrote `SHA256SUMS`, then died pushing +> the tag: `fatal: unable to access … The requested URL returned error: 530`. `servuo-plugins`' died +> inside `Set up job` after 11m52s with no step ever executing — which is why that job's log route +> answers 500 while its predecessor's serves fine: **there is no log blob, and that absence is +> evidence.** No tag was pushed either time, so the orphaned-tag failure mode did not occur, and +> re-running both by `workflow_dispatch` published them. The first to land left the pair mismatched +> and compose run 102 failed exactly as the PRs predicted; the second dispatched it again and 103 +> composed. `link`'s `rust-gates` reds on three earlier PRs were `curl: (6) Could not resolve host: +> sh.rustup.rs` inside the runner — infrastructure, not code, on all four counts. +> +> **A job's log IS readable on this instance, through the web route rather than the API:** +> `/{owner}/{repo}/actions/runs//jobs//logs` with an API token, served as `text/plain`; step +> statuses come from the UI's own POST endpoint with a `_csrf` cookie. Every earlier phase diagnosed +> CI by reproducing jobs locally, on the belief that logs were unreachable. They are not, and reading +> one is what turned four red X's into four known causes in about ten minutes. +> +> #### A seventh defect, red on every events PR since Phase 10 +> +> `website`'s `server-tests` job had been failing since `#192` — eight PRs, every one reporting +> `# fail 1`, always **the same single test**, so nothing else was ever hiding behind it. The +> workstream merged over it eight times. +> +> `events/announce.js` asked `Intl.DateTimeFormat('en-GB', { …, hour12: true })`, and **that is not +> the same request as a 12-hour clock.** For a locale whose default cycle is h23 — `en-GB` is one — +> Node 20 resolves `hour12: true` to **`h11`**, whose hours run 0–11, so midnight renders `0:00 am`; +> Node 22 and later resolve it to `h12` and it renders `12:00 am`. **Same ICU (78.2) on both sides**, +> so this is V8's ECMA-402 behaviour and not locale data — no amount of matching the runner's locale +> would have found it. +> +> The image ships `node:20-alpine` and CI runs Node 20, while a dev machine is newer. So the mail +> every real recipient got said **"0:00 am"** beside a schedule editor saying "12:00 AM" — one +> instant, two spellings, the exact contradiction that option was added to prevent — and it rendered +> correctly in front of everyone who reviewed it. Fixed to `hourCycle: 'h12'` (`website#200`), which +> is the form `recurrence.js` had already adopted for the mirror-image case (`h23` **rather than** +> `hour12: false`); `announce.js` was the last `hour12` in either repo. +> +> **The rule: `hour12` is a request about a locale's preference, `hourCycle` is a request about the +> clock. Ask for the clock.** And the test now says so out loud, because it can only fail on Node 20: +> a green run on a dev machine is not evidence, and CI is what holds that line. +> +> #### The re-verify, from artefacts an operator would download +> +> This is the leg 16a could not do — a locally built bundle cannot go through core's module installer, +> which is https-only with a host allowlist. +> +> | | | +> |---|---| +> | installer | released `v0.1.1` binary, checksum matched against the release's own `SHA256SUMS` | +> | bundle | resolved **2026.09.10, protocol 7**; both component checksums verified by the installer | +> | overlay sync | a **first install** into a stock 57.4 tree — `add=30 change=1 unchanged=0` | +> | script build | `0 Warning(s) 0 Error(s)` — the released overlay compiles on a stock tree, which no release had ever been asked to prove | +> | shard boot | `[Bridge] enabled=True … adminWrite=True … events=True`, then `connected to 127.0.0.1:7788` | +> | sidecar | `server.hello` for **208,568 items / 42,871 mobiles**; `x-uolink-version: 7` | +> | event plane | `lease.list.ok` (config **and** targeted property leases), `item.catalog.ok` with its bounds, `GET /world/` an empty list rather than a 404 | +> | core | released `main` on a throwaway database, `capabilities: ["events"]` on `/public/version` | +> | one event | published, run, **`completed` / `health: ok`**, results published | +> | the page | `/site/events` reads *"Everything scheduled, live and recently finished"* and lists a run that finished two minutes earlier | +> +> The last row is 16a's calendar fix holding on `main`: before it, a run that had already started or +> finished was absent and the page rendered `entries: []`. +> +> **A fresh `Bridge.cfg` still ships `EventsEnabled=false` and `AdminWriteEnabled=false`** — the +> operator's real first-boot state, and the released config confirms it rather than a working tree's. +> +> One thing checked and deliberately **not** reported as a defect: a **cancelled** run appears on the +> public calendar. It is meant to. The entry carries its own `status`, and the page renders a past +> cancelled run as **"Did not happen"** — the honest label, not a silent omission. +> +> **The one leg still owed is the module's own install.** `Module-uo#34` cuts the module release, and +> until that release exists there is no bundle for core's installer to fetch — the whole point of the +> leg is that the module arrives over https from a Gitea release rather than being copied into +> `MODULES_DIR` by hand. Everything above stands without it: the shard half is entirely released +> artefacts, and core is released `main`. Run it as `POST /admin/modules/install` against the +> published module tarball once step 3 lands, on the rig described here. + **Two documents that are cutover-window work by construction.** - **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until - the cutover lands. Same 12a/12b split the engagement workstream needed. + the cutover lands. Same 12a/12b split the engagement workstream needed. **16b landed it**, so both + of these are now unblocked: `main` carries the engine, the module and the app, and the bundle triple + the site quotes is sidecar **v2.2.0** / overlay **v1.2.0** / bundle **2026.09.10**. - **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new subsystem is. -- 2.49.1 From bb75910a8f7ca7766db911200d80d435c7a70330 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 9 Sep 2026 21:06:34 -0500 Subject: [PATCH 24/24] docs(events): the module leg of the re-verify, and the two defects it found The leg the first commit named as still owed is walked. Module-uo#34 cut v1.2.1, so the module could arrive the way an operator's does: core fetched the release MANIFEST over https from the allowlisted host, verified its sha256, and mounted it; the four values the installer printed then produced `status: connected`, `pluginConnected: true`, `protocol: 7`. Released core, released module, released sidecar, released overlay. That rig confirmed both Phase 16a fixes in the shipped artefacts rather than in a working tree -- the atlas imports off a stock tree, and a world verb's teardown leaves the shard answering `owned: [], pruned: 0`, which is the check the no-op teardown hid behind. It also found two more defects, both in the released bundle (Module-uo#35). The aggregator discarded the UniqueId, so `uo.options.spawners` was empty and no Phase 12b property lease was authorable at all -- while `PARSER_VERSION = 4`'s own note said a point keeps that field and named Phase 12b as the reason. And a landmark option value named 23 places at once: 558 landmarks under 320 distinct `facet/name`, resolved by `.find()`, so an author who picked "Entrance - Destard" got Blighted Grove with a successful run and no warning. Both are recorded as one class, because that is the useful part: an option source that answers empty, or answers with a value that does not identify one thing, disables a feature silently. Nothing errors; the form simply cannot express the thing, and a test that checks the parser, or the query, or the column in isolation passes throughout. Diff is 45/6, content only -- no CRLF rewrite (checked against --numstat). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- website/EVENTS_PLAN.md | 51 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 4a077de..4deda0b 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1971,12 +1971,51 @@ core, docs, then the kit's re-pin and `runicgateway.com`. > public calendar. It is meant to. The entry carries its own `status`, and the page renders a past > cancelled run as **"Did not happen"** — the honest label, not a silent omission. > -> **The one leg still owed is the module's own install.** `Module-uo#34` cuts the module release, and -> until that release exists there is no bundle for core's installer to fetch — the whole point of the -> leg is that the module arrives over https from a Gitea release rather than being copied into -> `MODULES_DIR` by hand. Everything above stands without it: the shard half is entirely released -> artefacts, and core is released `main`. Run it as `POST /admin/modules/install` against the -> published module tarball once step 3 lands, on the rig described here. +> **The module's own install was walked too**, once step 3 cut `Module-uo` **v1.2.1**. The module +> arrived the way an operator's would: `POST /admin/modules` naming the release's **manifest** (not +> its tarball — core answers a tarball with *"the install manifest is larger than 262144 bytes"*, +> which is the size guard doing its job), core fetched the artifact over https from the allowlisted +> host, verified its `sha256`, and mounted it on the next boot with 12 event actions and 27 triggers. +> Then `PUT /admin/uo-link/config` with the four values the installer printed answered +> **`status: connected`, `pluginConnected: true`, `protocol: 7`** — released core, released module, +> released sidecar, released overlay, all four talking. +> +> On that rig the two Phase 16a fixes were confirmed in the shipped artefacts rather than in a working +> tree: the atlas **imported off a stock tree** (309 decor types, 6,455 points, 800 creatures, 558 +> landmarks, 387 regions, 25 champions — the import that used to die at 313), and a world verb ran and +> **tore down for real** — three orcs spawned, ledger `reverted` ×3, `cleanup: complete`, and the shard +> itself answering `world.owned → owned: [], pruned: 0`. That last check is the one 16a's no-op +> teardown hid behind. The enablement gate and the cap behaved as specified on the way past: the dry +> run refused the action before it was enabled, then priced it `uo.creatures 3 of 10`. +> +> **And the leg found two more defects, both in the released bundle and neither visible to any test** +> (`Module-uo#35`). +> +> **The aggregator discarded the `UniqueId`, so no Phase 12b property lease was authorable at all.** +> All 6,455 spawn points imported with `unique_id` NULL; `listSpawners` filters +> `unique_id IS NOT NULL`, so `uo.options.spawners` — the only source those leases have — was an empty +> dropdown with nothing to explain itself. Every part of the path was right except one line: the files +> carry ``, `parsePoints` returns it, the column exists, the insert passes it. `buildAtlas` +> rebuilds each point from an explicit field list and the field was not on it. **`PARSER_VERSION = 4`'s +> own note says a point keeps its `UniqueId` and names Phase 12b as the reason** — that bump exists to +> re-read trees for this field, and the field was dropped one function later. The intent shipped as a +> comment. Fixing it needs `PARSER_VERSION` 5 as well, because the tree's hashes have not changed — +> only what is kept from them — so nothing would re-read an existing install. +> +> **A landmark option value named 23 places at once.** 558 landmarks, 320 distinct `facet/name`: +> `Trammel/Entrance` is Blighted Grove, Covetous, Deceit, Despise, Destard and 18 more, and +> `landmarkPoint` resolves with `.find()`. So 22 of the 23 were unreachable and an author who picked +> "Entrance — Destard" got Blighted Grove, with a successful run and no warning. **The group was +> already the disambiguator** — shown in the dropdown, left out of the value. Now `facet/group/name`, +> distinct across all 558, with the two-part read kept as a fallback because a published version is +> immutable and those stored values are the authored record. A three-part value whose group is gone +> refuses rather than falling back: it asked for one place. +> +> Both are the same failure shape as 16a's blocking defect and worth naming as a class: **an option +> source that answers empty, or answers with a value that does not identify one thing, disables a +> feature silently.** Nothing errors, the form simply cannot express the thing — and a test that +> checks the parser, or the query, or the column in isolation passes throughout. The atlas fixture had +> no `` in it at all until this phase, which is why a green suite said nothing for two. **Two documents that are cutover-window work by construction.** - **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until -- 2.49.1