From cc6775e20b841041f2a9583f90c1e0c78a617ccc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 20:52:37 -0500 Subject: [PATCH] =?UTF-8?q?docs(events):=20Phase=200=20=E2=80=94=20the=20e?= =?UTF-8?q?leven=20decisions,=20and=20the=20two=20documents=20they=20reach?= =?UTF-8?q?=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.