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>
33 KiB
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 (the read/event plane) and 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. 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/[addand 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 —
BaseCommandsubclasses inCommands/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.newevent and removals aspage.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 (anadmin.banon 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 theadmin.auditevent.- Bidirectional audit (§5.5): built and live-verified.
patches/commandlogging-event.patch(addsCommandLogging.OnWrite) +patches/BridgeModerationAudit.cs(the subscriber) forward in-game bans/kicks/broadcasts to the site asadmin.audit(origin:"in-game"). A boot-time probe confirmed a genuine[bcastand 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.cspolls the queue (PageSweepSeconds, default 5s) →page.new/page.updated/page.closed; inboundpages.snapshot/page.respond/page.close; sidecarGET /pages+POST /pages/{id}/respond|close;INTEGRATION.md§4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, bothpage.newemits, respond, close (→ page removed),page.closedemit, 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:
- Phase 1 — Account & session moderation (Tier A).
admin.kick,admin.ban(timed + indefinite),admin.unban, plusadmin.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. - 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.
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:
- Audit —
CommandLogging.WriteLine(from, …)and theSetBanTags(from, …)"BanDealer" tag record who did it. - Authorization — e.g.
KickCommandrefuses unlessfrom.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.WriteLinecannot be reused for web actions — it dereferencesfrom.NetState/from.Account/from.AccessLevel(Scripts/Commands/Logging.cs:93-103) and there is no staffMobile. So web actions do not land inLogs/Commands/; the console line plus theadmin.auditstream 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 (SetBanTagswants aMobile from; we passnullfor the Mobile and set the tag ourselves — no core edit), - echoes it back in an
admin.auditevent (§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 unsolicitedadmin.auditbroadcast 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 sameadmin.auditshape, 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.
{ "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
WriteLineevent toScripts/Commands/Logging.cs(a 1-lineAction<Mobile,string>raised inWriteLine). This is a stock file, so it ships as apatches/diff — the same mechanism Phase 7'sPlayerVendorSalealready established, and arguably the correct universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emitsadmin.audit. - Broadcasts and other arg-bearing simple commands need no patch — the existing
EventSink.Commandhook 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)
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
reqId— correlation id, echoed on the reply (as inBridgeRequests).actor— required. The website staff user. Rejected if absent.- Target —
account(offline-capable verbs) orserial(online mobiles), resolved with the sameResolveSerial/Accounts.GetAccounthelpersBridgeRequestsuses. reason— recorded in the audit trail.
Reply shape (shard → sidecar → website)
{ "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:
{ "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)
actorrequired and non-empty.- Target floor: refuse any target with
AccessLevel >= CoOwner(AdminAccessFloorinBridge.cfg, defaultCoOwner→ only the Owner/CoOwners are shielded). reasonlength cap;durationSecclamp (min/max);broadcasttext length cap.- Master switch
AdminWriteEnabledinBridge.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.
Mobile.Squelchedpersists — confirmed durable. Serialized unconditionally (Server/Mobile.cs:6489write) 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 gatesOnSaid(: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 inWorld. Phase 3 mute is therefore durable and offline-capable out of the box.- Kicking all sessions — settled. Enumerate
NetState.Instances(Server/Network/NetState.cs:583, aReadOnlyCollection<NetState>), filter onns.Account == acct(:574), andDispose()each. This is strictly better than walking the account's characters'NetState: a client sitting at character-select has aNetStatewith anAccountbut no mobile, and only theInstancessweep catches it.admin.kickand the live-session cleanup inadmin.banboth use this. - In-game capture of resolved bans/kicks (was the ★ risk). Traced through the dispatch path — settled in §5.5.
[ban/[kickdo raiseEventSink.Command, but at type-time without the target. The complete capture point is a 1-line event added toScripts/Commands/Logging.cs:86, shipped as apatches/diff. Broadcasts need no patch. - Ban attribution — pass
nullfor theMobile fromand setweb:<actor>as theBanDealertag ourselves. No core edit. - Broadcast + town-crier — keep both; they differ (instant system line vs. looping crier) and both are cheap.
- 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 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 inBridge.cfg. - Attribution:
web:<actor>inCommandLoggingand theBanDealertag; 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 §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:
- 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.
- Cap-bounded. Every dimension is enforced in a conditional
UPDATEwith the cap in theWHEREclause — a control a stolen admin session cannot talk its way past, because a role check is exactly what a stolen session has already passed. - Ledgered. Every created object and every borrowed value is recorded in
event_run_resourcesbefore it is confirmed, with its run, step and actor, and with a declared revert. - 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.