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

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 / [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 commandsCommandSystem.Register("Save", AccessLevel.Administrator, handler). The bridge already uses this for [bridge (BridgeBoot.cs:44, Administrator-gated).
  • Generic/target commandsBaseCommand 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 [KickKickCommand, 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) [BanKickCommand(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 ResponseEntryMessageSentGump.

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. AuditCommandLogging.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.

{ "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) (→ RedirectProcessfrom.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)

{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
  "account": "griefer42", "durationSec": 604800, "reason": "harassment" }
  • reqId — correlation id, echoed on the reply (as in BridgeRequests).
  • actorrequired. 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)

{ "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)

  • 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 floorCoOwner (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 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 §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.