Accountability, safety, and world-build hooks

A public, user-programmable world faces abuse surfaces a fresh database lacks — the things LambdaCore grew over years. DjangoMOO’s default dataset provides a set of engine and default-level primitives for them, alongside ergonomic seams that themed content packs build on. Each is generically useful to any default game, not tied to one world.

The account is the unit of identity

Identity has three conceptual layers: the account (durable, staff-visible), the avatar (the in-world $player object), and any persona a player has “become”. The account is the Player row — its pk is a stable id distinct from any display name. Moderation keys to the account, so a name change or a recycled avatar never breaks traceability, and a ban targets the human rather than a discardable avatar. Resolve between the two with moo.sdk.account_for() and moo.sdk.avatars_of().

An account carries a status (active, guest, suspended, banned), an optional registered_identity (a durable identifier bound at registration), and a suspended_until deadline. A bound registered_identity is unique per site, so one human’s identifier cannot be claimed by two accounts — which matters because a ban blacklists by identity and must key to exactly one account.

Provenance is always on

Every message published to a player carries a server-computed provenance triple (origin, verb, owner), recorded always — independent of the recipient’s @paranoid setting, which becomes the view of an ever-present record rather than a switch that turns recording on. The triple is read from the in-memory caller stack, so it stays a tag-and-id on the hot path; a full caller-stack capture for a report happens only on demand via moo.sdk.capture_provenance_stack().

Sanctioned output also carries a structural kind tag (say/emote/system/ persona) applied server-side, so a user-authored line cannot present as a system line or as another actor. The system kind requires a wizard initiator, making it unforgeable from an ordinary user’s verb even though the emitting primitive (moo.sdk.notify()) runs as a wizard-owned verb.

Flooding, sanctions, and onboarding

A per-account broadcast budget (moo.sdk.broadcast_allowed(), tuned by MOO_BROADCAST_RATE_LIMIT or the System Object broadcast_rate_limit knob) drops a runaway verb’s broadcast lines while never counting a player’s own output — the target is flooding, not verbosity.

Staff sanctions sit above @gag and @eject: a reversible moo.sdk.suspend() and a scarring moo.sdk.ban() that blacklists the account’s durable identity so the same human cannot simply re-register. Both key to the account and neither can target staff; the SSH login path consults moo.sdk.account_login_blocked(), which also clears a suspension whose deadline has passed so a lapsed account does not read as suspended forever.

Onboarding runs guest → registered: moo.sdk.provision_guest() creates a non-persistent $guest who can explore and talk but not build or own, and moo.sdk.register() binds a durable identity through a pluggable verifier (MOO_REGISTRATION_VERIFIER) before build rights are granted (moo.sdk.require_registered()). The built-in verifier is deliberately permissive — it normalizes the identity but does not prove the registrant controls it — so a deployment that exposes @register and relies on bans holding must point MOO_REGISTRATION_VERIFIER at a real verifier (email round-trip, SSO) first.

Consequential actions (create, recycle, destroy, owner/permission change, sanctions) are recorded to an append-only audit log via moo.sdk.record_action(); only player-initiated actions are logged, so bootstrap and system activity never floods it. The log is enforced append-only — a row cannot be modified or deleted through the ORM (the attempt raises AppendOnlyError), so the trail stays tamper-evident. Staff query it with @auditlog.

Recovery and the escape guarantee

@recycle is a reversible soft-recycle: the object is hidden from the site-scoped manager but keeps its id and inbound references, so @restore can bring it back; @destroy remains for permanent removal, and moo.sdk.sweep_recycled() purges anything left past the retention window. A regular builder’s @restore is scoped to their own recycled objects (a wizard may restore any), so naming another builder’s object neither reveals it nor forces a permission error.

home is an engine escape guarantee: moo.sdk.send_home() forces the move to the player’s home (or $player_start), bypassing the destination’s accept/locks/verbs so a player can never be trapped by a room owner. moo.sdk.check_room_connectivity() is the build-time guard that flags a dead-end or one-way room before players fall into it.

World-build hooks

Themed rooms compose their rendering without copying core verbs, through overridable $room hooks: look_description(), show_compass(), hide_from_contents(obj), and tell_contents_extra(). A lattice or overworld room can resolve exits by a function over position with the procedural_exit() hook, which routes computed moves through the standard exit messaging/GMCP path so they behave identically to stored exits. Quest, achievement, and tutorial systems observe gameplay through the on_player_action(player, action, data) hook the common player verbs emit after they succeed. Data-generated worlds resolve objects by a stable external key in O(1) with moo.sdk.get_or_create_by_key().

Compute bounds

Each verb task is bounded by the Celery wall-clock kill and, more finely, by a per-task loop budget (MOO_TICK_BUDGET): a runaway loop aborts with TickLimitError before it burns the whole time budget. The budget is deliberately generous — a legitimate sweep over a large world should never trip it.

Object quota (already enforced)

Object quota needs no new work: create() raises QuotaError and decrements the owner’s ownership_quota, and recycling refunds it (a soft-recycle refunds once, and a later hard-destroy of an already-recycled object does not double-refund). A future measured (byte) quota in addition to the object count is a possible later refinement, not a requirement.