What counts as a server session
Last updated
May be stale
This page was last updated ago, on 3 October 2026. Check what it says against the code before relying on it.
Status: Accepted · 2026-09-03 · charted by #3431
A session is one world-load lifetime, and cityrp.session
(gamemode/core/libraries/sv_session.lua) is the only thing allowed to answer "which one is this".
It is referenced inside the database by an internal session key — the ledger's int unsigned
autoincrement — and it refuses to answer at all until that row lands. Everything below is the
reasoning a future reader will otherwise have to reconstruct.
The vocabulary this ADR uses — session, process, internal session key, external
session key — is defined in GLOSSARY.md and deliberately not redefined here.
The design that implements it is Phase one; the route that
produced it is the charted decisions.
The decision
A session is a world-load lifetime, not a process lifetime
The note that started this effort asked for an identifier "provably tied to the real process lifetime". That wording is wrong, and following it would have introduced a defect rather than fixed one.
The consumer with a live exploit behind it is refund_ents, and what it actually cares about is
the world having been wiped. A changelevel keeps the srcds process and the Lua state alive
while destroying every entity, so a PID-based marker would have newly broken the case that today's
accidental os.time()-at-file-load happens to get right. The two concepts are named apart in the
glossary for the same reason: conflating them is how the original bug was made.
The derivation point is the load of the library file itself — the
cityrp.session = cityrp.session or {} guard, during sh_init.lua's includeCore("libraries")
pass. Not GM:Initialize: the library pass runs earlier, is immune to footgun #3's hook
suppression, and is untouched by every manual hot-reload vector in the repo
(#3432). cityrp.session therefore
uses no hook to decide what a session is, which also sidesteps the repo-wide hazard that a
hook.Add("Initialize") returning non-nil suppresses GM:Initialize and takes the database
connection with it.
Identity splits in two, and only one of them is a key
- Internal session key —
session_id,int unsigned NOT NULL AUTO_INCREMENT. Every foreign key, every lookup, every in-database reference to a session. - External session key — a 64-character random token,
char(64), minted once per session by MySQL. It is a credential held for future server-to-server authentication. Never a join key, never a lookup key, and not exposed to Lua at all in phase one.
The type is not incidental. tmysql4 marshals INT as a Lua number but BIGINT as a Lua
string, while an INSERT's lastid is always a number — so a BIGINT key would be a number on
the write path and a string on the read path, and == would be false everywhere the value came
back out of the database. That asymmetry is invisible to any test that exercises the library alone
(#3442). varchar is worse, and
crash_container.container_id varchar(40) — an integer autoincrement stored as text, which works
only because every comparison happened inside SQL — is the live precedent this design refuses to
inherit.
Result-set values are compared with cityrp.session.Is / Matches, never with a bare ==.
The Lua type of a session id depends on three things that all live elsewhere and all change
silently: the column declaration, the SQL expression, and NULL. CAST(x AS SIGNED) is the trap
worth naming outright — it yields BIGINT, so casting a column to make it numeric in SQL makes it
non-numeric in Lua. None of the three produces a lint error or a stack trace; all three produce
"comparison false → entity unowned → refund it", on the contraband path. This is footgun #9's
shape, where every consumer carried its own copy of a check and each was correct in isolation.
The library is fail-closed, and that is the API rather than a safety net
Charting ruled synchronous availability non-negotiable, on the grounds that libraries load long before the database connects. That premise is dead. Once the autoincrement became the internal key in every referencing column and token generation moved into MySQL, nothing a consumer needs exists synchronously (#3437).
Two facts make this unavoidable rather than merely chosen:
hook.Run("DBConnected")dispatches synchronously, while the sessionINSERT's callback cannot land until a later frame. NoDBConnectedhandler can ever observe a ready session, whatever the hook order. Every current consumer of session identity moves ontoOnReady.DB:Connectnever calls its callback on a failed connection, so a database down at boot means the library never even attempts itsINSERT. "Permanently unready" has two causes, and the bounded retry covers only one.
OnReady(name, fn) is the only sanctioned wait. A bare predicate forces every consumer to
hand-roll the "did I already miss it?" branch, and hand-rolled waits are the failure this whole
effort exists to stop. Get() errors when unready rather than returning nil, because nil
interpolates as the literal string nil through an FRun %s.
The cost is real and is accepted. While unready, refund_ents early-returns from Track: the
contraband is untracked, and the player loses it on the next wipe. The alternative is minting a
track row against a session that does not exist — permanently unclaimable player property and a
dangling foreign key — which is strictly worse. The server also keeps serving while permanently
unready: refusing players over a ledger row is a bigger outage than the one it prevents, and the
library does not self-changelevel, because a bounce does not fix a down database and would make
cityrp.session the arbiter of server availability for a failure it merely noticed first.
Consumers ask cityrp.session; they do not roll their own
Boot identity had been reinvented at least six times in four incompatible representations before
this ADR (#3434): an epoch integer
at plugin file scope (refund_ents), a server_stats autoincrement reached by correlated subquery
(container_code), a settings.last_start_<server> row (db.lua), and a one-second heartbeat file
(sv_logs.lua). Four of the sites are wrong today and all of them fail the same way — the marker is
derived at Lua file scope, so a hot reload advances it without the world being wiped.
The lesson that earns the ADR entry is narrower than "we built a library": server_stats was
already structurally what this effort asks for, and failed anyway, because it had no Lua-side owner.
A table is not an API.
Two rules follow, and both are about restraint:
- Expose derived state the library already holds; do not derive new state for a hypothetical
consumer.
IsFirstLoad()ships because the boot-versus-hot-reload distinction is the library's own reload guard. The token accessor does not ship, because an accessor with no callers invites using a credential as an identifier.processIddoes not ship, because both of its imagined consumers turned out to be uptime readouts that branch on nothing. - Table names are hardcoded; configuration never names a table.
(#3450) A numbered migration
cannot declare a foreign key against a table a server can rename. The alternative — teaching the
migration runner to read
cityrpserver— is ruled out in the other direction: it makes the DDL history non-reproducible, and the migration set's whole value is that it is a literal, replayable record. The hazard itself is AGENTS.md footgun #24.
A repo-wide grep is not an absence proof
Twice in this effort a table or key looked dead to a repo-wide grep and turned out to have a reader
outside the repository. settings.last_start_<server> is read by the changelogs service in WebEnv,
so its key name, epoch value and overwrite-in-place semantics are a live cross-service contract and
the write stays. crash_container is named by the GDPR subject-access export and the ACP backup
manifest, so its table name and its owner / json / done columns must not move — which is why
that migration renames only the two columns nothing outside the repo reads.
The consumer list for anything schema-shaped is therefore: this repo, WebEnv, the ACP, the forums,
the panel. server_stats and currentstats were checked against all of them before being dropped.
Consequences
- One
changelevelis the difference between a refundable loss and a permanent one, socityrp.sessionowns the teardown flag (IsClosing()) rather than each consumer keeping a private one. This rests on undocumented engine behaviour:GM:ShutDown's ordering against entity teardown is documented nowhere, the evidence that it runs first is circumstantial, and if it is wrong the guard is a silent no-op that deletes every outstanding contraband row on the next map change. Phase one therefore carries a mandatory acceptance test — spawn a tracked entity,changelevel, assert the row survives. The fallback, if it fails, is droppingCallOnRemovefor explicitUntrackcalls; that is the worse design, which is exactly why it is the fallback. refund_entskeeps itsLLServer == "build"decision insideTrackand does not acquire anot_serversmeta condition. It publishescityrp.contrackto eight call sites outside the plugin, none of them nil-guarded, so excluding the plugin would make all eight throw. That hazard — a plugin publishing a global consumed by non-plugin code — is AGENTS.md footgun #25.- The library is server-only. Nothing in the use-case list is client-side, and a networked session id would be a net message designed for no current consumer.
gamemode/core/plugins/referrals/sv_session.luaalready exists and is unrelated. The library isgamemode/core/libraries/sv_session.lua; the two are told apart by path only.- The session ledger is append-only. Foreign keys against it are written
ON DELETE RESTRICT ON UPDATE RESTRICTexplicitly rather than left to InnoDB's default, because a cascade here would silently destroy unclaimable player property. - Phase two (player sessions) and phase three (the lost and found) hang off this decision but do not change it. "Session" names three distinct things across the effort — session, player session, job interval — and the glossary keeps them apart so the overloading does not spread.