Refusable events fail closed; chat is one of them
Status: Accepted (2026-10-28)
Context
Some events let the gamemode refuse a player's action: a handler returns false and the action does not happen. If an error or a timeout in a handler counted as "not refused", a bug in a mode that forbids, for example, entering team vehicles would silently lift the ban. The same contract decides what happens to an ordinary chat line, which a resource may filter, answer or replace, and which must not escape a filter that crashed.
Events reach resources through the server's event stream (ADR 0048); the reliable event lane (ADR 0007) carries chat in both directions.
Decision
The refusable events are
vehicle_start_enter,vehicle_jack,vehicle_control,trailer_attach,winch_attach,player_remote_enter,player_detonate_satchels,player_stealth_kill,player_chatandplayer_connecting. The return value of every other event is ignored.The event is refused if any handler returns
false, raises an error or runs past the callback time limit, including a handler that catches the time-limit error with its ownpcall.nil,trueand any other value let the action through. All handlers run in any case, so several resources (a filter and a logger, say) coexist. An event sourced at a vehicle that an earlier handler already destroyed is not raised, and a refusable one is refused.player_connectingcan give a reason. A handler refuses withreturn false, "reason"; the first reason given wins, and the player sees it. A failed or timed-out handler gives no reason, and its error text never reaches the player.Chat goes through
player_chat. The flow for aClientChatline:- the server acknowledges it on the reliable lane at once; only the next in-order line, within the per-connection flood limit, goes further;
- it sanitizes the text once, before Lua or any broadcast sees it: UTF-8 up to the first NUL with invalid sequences replaced, control and format characters removed (so embedded line breaks or direction overrides cannot spoof chat lines), surrounding whitespace trimmed. An empty result is dropped: no event, no broadcast;
- it raises
player_chat(player, text), sourced at the sender, sodlmp.roothandlers receive it; - unless the event is refused, it broadcasts the line to everyone as a
ChatMessageattributed to the sender, in the sender's current color.
A returned string does not rewrite the line. A resource that wants other text, or other recipients, refuses the event and sends its own line with
player:send_chat(recipients, text)or replies withplayer:send_message(text).player_chatis not tied to the player's life cycle: it fires before the first spawn and while the player is dead; the resource decides its own policy.Commands keep their own contract. A command handler returns
trueto consume the command; a handler error neither consumes the command nor blocks lower-priority handlers.
Consequences
- A bug in a handler can wrongly forbid an action, but it can never lift a ban the mode depends on. A chat line swallowed by a crashed filter is visible in the server log.
- The polarity of chat matches every other refusable event: a handler that forgets to
returnlets the chat through, which is the ordinary behavior. - Chat filtering, replies and redirection need no wire change;
ClientChatandChatMessageare enough.
Alternatives considered
- Fail open: an error or timeout does not refuse. Rejected: a handler bug removes the mode's restriction.
- Fail open for chat only. Rejected: one rule for all refusable events is simpler, and a lost chat line is visible in the log.
truesuppresses the chat broadcast, astrueconsumes a command. Rejected: a chat filter refuses a line rather than consuming it, and every refusable event usesfalsefor that.- Stopping at the first handler that refuses. Rejected: chat is an event, not a named lookup like a command; every subscriber should see it.
- Rejecting a chat line with invalid UTF-8 instead of replacing the bad bytes. Rejected: a lossy decode keeps the readable part of the message; removing control characters is needed in any case.
