docs(events): Phase 0 — the eleven decisions, and the two documents they reach into

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 20:52:37 -05:00
parent cda5325ab4
commit cc6775e20b
4 changed files with 274 additions and 32 deletions

View File

@@ -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 (P0P16) on an `edge` branch with one cutover, matching the discipline every
previous workstream used. Phases 110 and 1316 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