DOCS/PROTOCOL_LAYERS.MD
Protocol Layers
GlassFrame Protocol splits every reaction into two kinds, and keeping them separate is the core design decision of the whole library.
Containment vs. punishment
Containment is a guild-wide, reversible, time-critical measure a layer takes on its own the moment it detects trouble - raising verification level during a join spike, reverting a role that just gained Administrator. These can't wait for a shared decision pipeline; a raid in progress needs an answer in milliseconds, not after a round trip through scoring.
Punishment is anything aimed at one member - timeout, kick, ban, quarantine. Every layer funnels this through the same two pieces instead of deciding on its own:
ThreatEngine- turns a signal ({ layer, weight, reason }) into a decaying score per member, and a tier (none/low/medium/high/critical).PunishmentEngine- turns a tier into an action, after checkingRoleAnalyzerfor hierarchy and trust, and checking for an already-open case on that member so a second signal a moment later merges into the first incident instead of firing again.
This is what makes the protocol "smart" rather than reflexive: a moderator account that trips one soft signal doesn't get banned, a member the bot has no hierarchy over doesn't get a failed API call retried on every message, and twenty raid joins in ten seconds produce one aggregated log line instead of twenty separate ones.
The four layers
| Layer | Listens to | Containment it can do alone | Signals it reports |
|---|---|---|---|
basicSecurity |
messageCreate |
deletes the offending message | spam rate, duplicate flood, mention spam, an @everyone/@here guard, scam/phishing/raid-recruitment language (incl. an invite-link + raid-language combo check), suspicious links (via PhishingDatabase) |
antiRaid |
guildMemberAdd |
raises verification level, ends lockdown automatically | fast join-rate spikes, alt-account scoring, and creation-time cluster correlation (see below) |
antiNuke |
guildAuditLogEntryCreate, plus messageCreate only when webhook-abuse watching is on |
reverts a dangerous permission grant, deletes an abusive webhook | destructive-action bursts per executor across channels/roles/bans/kicks/webhooks/emoji/stickers, plus two alert-only watchdogs*: a member handed an already-dangerous role, and @everyone's channel permissions overwritten |
aiModeration |
messageCreate (independently of basicSecurity) |
- | AI-confirmed category + confidence for gray-zone messages |
* Those two watchdogs post straight to the log channel
(logger.log({ level: "alert", ... })) rather than calling
punishmentEngine.report() - unlike everything else in this table, they
never touch a member's ThreatEngine score and can't trigger an automatic
action on their own. Same pattern as the existing vanity/identity-change
watchdog: flag for a human, don't act.
Each layer extends src/core/Layer.js. Its event listeners attach
exactly once, ever (attach()), rather than on every enable/disable -
Discord's gateway has no concept of a listener scoped to one guild, so the
listener always fires and each handler's first real check is
if (!this.isEnabled(guildId)) return;. Enabling or disabling a layer is
per-guild: layer.enable(guildId) / layer.disable(guildId) track a
Set<guildId> (enabledGuilds), not a single boolean - see
docs/STATE.md for the full per-guild state and persistence model.
basicSecurity and aiModeration both listen to messageCreate
independently rather than one calling into the other - either can be
enabled without the other, and if both happen to flag the same message,
PunishmentEngine's per-member case debounce (see above) merges the two
signals into one incident rather than double-punishing.
Evasion-resistant detections
Two additions specifically target attacks designed to slip past the straightforward rate-counters above:
- Join clustering (
AntiRaidLayer._clusterScore) - a bulk-registered account farm can trickle members in slowly enough that the join-rate counter never trips. Clustering instead asks whether several recent joiners' accounts were all created within the same narrow window (antiRaid.cluster), which doesn't depend on how fast they join at all. - Webhook message flooding (
AntiNukeLayer._handleWebhookMessage) - a webhook message carries no guild member, so a "create a webhook, then spam through it" nuke is invisible to every per-member spam check in the library. AntiNuke remembers who created a webhook (recentWebhooks) for a short window and, if that webhook then floods messages (antiNuke.webhookAbuse), deletes the webhook and reports the original creator toPunishmentEngine- the only path in the library that reaches a member through something other than a direct per-member signal.
Both feed the same ThreatEngine / PunishmentEngine pipeline as
everything else; they're evasion-resistant in what they watch for, not in
how they respond.
Why AntiNuke also runs a role audit
RoleAnalyzer.scanRoles() (role name vs. permission mismatch detection)
runs automatically once for a server the moment AntiNuke is armed for
that server, and again on demand via !gf scan. It reports flags, never
actions - a human decides what, if anything, to do about a role that looks
like impersonation bait or quietly holds dangerous permissions.
Adding a layer without editing the library
frame.registerLayer(name, layerInstance) is the supported way to do this
now - no need to edit GlassFrame.js's this.layers map by hand.
layerInstance must extend core/Layer: implement attach() (register
your event listener(s) once - it's called exactly once, at registration),
gate all real work behind this.isEnabled(guildId), and report signals via
this.frame.punishmentEngine.report({ guild, member, layer, weight, reason }).
Per-guild enable/disable, state persistence, and !gf status/!gf metrics
all pick it up automatically, since they iterate frame.layers rather than
a fixed list. It does not get a button on the 5-button panel
automatically (that stays fixed at exactly 5) - toggle it with
frame.enableLayer(name, guildId) in code, or build your own command.
const Layer = require("glassframe-protocol/dist/src/core/Layer");
class LinkAgeLayer extends Layer {
constructor(frame) { super("linkAge", frame); }
attach() {
this._listen(this.client, "messageCreate", (message) => this._handle(message).catch(() => {}));
}
async _handle(message) {
if (!message.guild || message.author.bot) return;
if (!this.isEnabled(message.guild.id)) return;
// ... your detection logic, then:
// await this.frame.punishmentEngine.report({ guild: message.guild, member: message.member, layer: "linkAge", weight: 40, reason: "..." });
}
}
frame.registerLayer("linkAge", new LinkAgeLayer(frame));
Custom punishment actions
frame.registerAction(name, handler) adds an action beyond the built-in
ban/kick/timeout/quarantine, so config.punishment.ladder can reference it
by name. handler is async (member, record) => {} and runs through the
same bounded-concurrency action queue as the built-in ones.
frame.registerAction("addMutedRole", async (member) => {
const role = member.guild.roles.cache.find((r) => r.name === "Muted");
if (role) await member.roles.add(role);
});
config: { punishment: { ladder: { medium: "addMutedRole" } } }
A note on the require path above: only dist/ ships to npm (see
package.json's files field) - glassframe-protocol/src/... 404s after a
real npm install, even though it's what you'll see if you're reading
this from inside a cloned copy of the repo itself. dist/src/... mirrors
src/'s structure exactly, so the rest of the path stays the same.
Events
GlassFrame, ThreatEngine, and PunishmentEngine are all
EventEmitters. None of this is required - GlassFrame runs fine with zero
listeners attached - but each is a hook point for a custom dashboard, DM
alerts, or external logging without touching library code.
| Emitter | Event | Fires when |
|---|---|---|
frame |
warning |
A layer or the control panel hits a non-fatal error - a failed Discord API call, a role the bot can't edit. Payload: { layer, message }. |
frame |
layerToggled |
Any layer is armed or disarmed, from code, the panel, or a command. Payload: { layer, guildId, enabled }. |
frame.threatEngine |
signal |
Any layer reports a signal, before PunishmentEngine decides what to do with it. Payload: { guildId, userId, layer, reason, weight, score, tier }. |
frame.punishmentEngine |
case |
After PunishmentEngine executes (or logs/alerts instead of executing) an action. Payload is the case record: { tier, score, trust, action, reasons, note?, error? }. |
frame |
layerRegistered |
A layer is added via frame.registerLayer(). Payload: { layer }. |
frame.punishmentEngine.on("case", (record) => {
if (record.action === "ban") notifyModTeam(record);
});
Scope: shared vs. per-guild
One GlassFrame instance can serve every guild the bot is in. As of
v2.0.0, everything that should be per-guild actually is - see
docs/STATE.md for the full model. Two things remain intentionally
shared across every guild the process serves, plus one deliberate exception
built for exactly that reason (see the note at the end of this section).
Per-guild
- Which layers are enabled (
Layer.enabledGuilds, aSet<guildId>) -frame.enableLayer(name, guildId)/frame.disableLayer(name, guildId), the panel, and!gf statusall operate on one guild only.getStatus(guildId)genuinely respects the guild it's passed. - The whitelist (
Map<guildId, Set<userId>>) -frame.addToWhitelist(guildId, userId),frame.removeFromWhitelist(guildId, userId),frame.isWhitelisted(guildId, userId). - The command prefix (v2.1.0+) -
frame.getPrefix(guildId)/frame.setPrefix(guildId, prefix), or!gf prefix set/reset.config.prefixis the fallback for any guild that hasn't set its own. - Threat scores and signal history (
ThreatEngine) - Open cases (
PunishmentEngine) - Join-rate tracking and active lockdowns (
AntiRaidLayer) - Audit-log burst tracking and the webhook watch list (
AntiNukeLayer) - Basic Security's spam-rate buffer, now keyed by
guildId:userIdrather thanuserIdalone - the same user active in two servers no longer shares one rate-limit window.
Still shared across every guild
- The phishing blocklist (
!gf phishing add/remove/list) - one list for the whole bot process, not per guild. options.configitself - one merged object for the whole process, not settable per guild.
If a server needs genuinely different thresholds rather than just a
different set of active layers, that's not built in yet - run one
GlassFrame instance per server for now.
The one deliberate exception: !gf engine is a bot-wide dashboard
by design, not an oversight - it exists specifically to show a bot owner
everything across every server at once (busiest servers, AI usage,
aggregate performance, a live cross-server activity feed). It's gated
entirely differently from everything else on this page: by options.owners
(a Discord user ID allowlist checked in code, nothing a server admin can
grant), not by Manage Server permission, and it's deliberately left out of
!gf help. See docs/ENGINE.md.
State persistence works. options.stateStore accepts a
MemoryStateStore (default, lost on restart) or JSONFileStateStore (a
single JSON file, restart-safe). As of v2.0.0 every layer toggle and
whitelist change - and as of v2.1.0, every prefix change too - is saved per
guild and restored automatically at startup and whenever the bot joins a
new guild. See docs/STATE.md for the full
model, including frame.ready and how to write a custom store.