Files
docs/link/ADMIN_CONTROLS.md
wtclaude cc6775e20b 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>
2026-09-01 20:52:37 -05:00

384 lines
33 KiB
Markdown

# Administrative Controls — Research & Integration Plan
**Status:** Research + design. No code written yet.
**Date:** 2026-07-12
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard.
---
## 1. The question
The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:
| Verb | File | What it does |
|------|------|--------------|
| `ping` | `BridgeBoot.cs:139` | Liveness echo. |
| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. |
| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. |
None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.
**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
Four mechanisms, all of which the bridge must respect or reuse.
### 2.1 The AccessLevel ladder
`Server/Mobile.cs:431`:
```
Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner
```
Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem.
### 2.2 The command system
Two registration styles:
- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated).
- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not.
### 2.3 Command logging (the existing audit trail)
Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.
### 2.4 Account model (the moderation state)
`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here:
| Lever | API | Notes |
|-------|-----|-------|
| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` |
| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) |
| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags |
| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` |
| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account |
| Young status | `acct.Young` | `:471` |
Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident.
---
## 3. Candidate controls
Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.
### 3.1 Session control (target online)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Kick** | `[Kick``KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. |
| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. |
| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. |
### 3.2 Account moderation (works offline)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Ban (indefinite)** | `[Ban``KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. |
| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. |
| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | |
| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). |
| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. |
| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. |
| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. |
### 3.3 Player actions (target online)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. |
| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. |
| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. |
| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. |
| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. |
| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. |
### 3.4 Support: the help-page queue ★
`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry``MessageSentGump`.
This is the single **best** tie-in and deserves its own slice of work:
- **Stream** new pages as a `page.new` event and removals as `page.closed`.
- **Snapshot** the open queue over REST (`GET /pages`).
- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response.
- **Close / assign** a page.
It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.
### 3.5 Broadcast & messaging
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. |
| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. |
| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). |
### 3.6 World / server operations
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. |
| **Background save** | `[BGSave` | | **B** | Non-blocking variant. |
| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. |
| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. |
---
## 4. Roadmap (decided)
> **Build status (2026-07-13):** Phase 1 is **built and live-verified end-to-end**, branch `feature/admin-controls`.
> - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO.
> - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token.
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.
>
> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page.
>
> **Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope.** Phase 3 (below) is **not planned** (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view).
**Wire in, in order:**
1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
3. ~~**Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save.~~ **Not planned** (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2.
Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).
**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.
---
## 5. Authorization & attribution (decided)
Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for:
1. **Audit**`CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*.
2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin.
The resolved model:
**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.
**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target.
**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`).
**Attribution is an explicit `web:<actor>` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard:
- logs it to the **server console** as `[Bridge][admin] web:<actor> <action> …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)*
- stores `web:<actor>` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit),
- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree.
**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.
### 5.5 Bidirectional audit — one moderation picture, both origins
The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions:
- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:<user>"`.
- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client*`[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":"<staff account/name>"`.
The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.
```json
{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
"actor": "GreyBeard", "target": "griefer42", "reason": null,
"durationSec": 604800, "t": 1783720195626 }
```
**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong.
The genuine subtlety is *when* it fires and *with what*:
| Verb shape | Example | What `EventSink.Command` carries | Complete? |
|------------|---------|----------------------------------|-----------|
| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured |
| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** |
For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect``Process``from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`).
**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook:
- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action<Mobile,string>` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`.
- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them.
---
## 6. Protocol design
Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers.
### Request shape (website → sidecar → shard)
```json
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
```
- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`).
- `actor`**required.** The website staff user. Rejected if absent.
- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses.
- `reason` — recorded in the audit trail.
### Reply shape (shard → sidecar → website)
```json
{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }
```
Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400.
### Audit event (shard → website, unsolicited)
Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too:
```json
{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
"target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }
```
`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client.
### Verbs for Phase 1
| kind | target | required fields | shard action |
|------|--------|-----------------|--------------|
| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) |
| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions |
| `admin.unban` | `account` | `actor` | clear ban |
| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` |
Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web:<actor> …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`.
### Caps / defense-in-depth (mirroring town-crier)
- `actor` required and non-empty.
- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded).
- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap.
- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard.
---
## 7. Verification log (all resolved)
All resolved by source inspection (ServUO checkout at `<servuo>`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.
1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591`*"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box.
2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection<NetState>`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this.
3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch.
4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:<actor>` as the `BanDealer` tag ourselves. No core edit.
5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap.
6. **Access floor**`CoOwner` (Owner-only shield). See §5.
**Nothing in §7 remains open — the plan is implementation-ready.**
---
## 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:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5).
- **Help-page queue:** confirmed, lands as **Phase 2**.
---
## 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 |
|------|----------------|
| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. |
| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). |
| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action<Mobile,string>` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. |
| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. |
| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. |
| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. |
| `INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. |
| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. |
The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else.