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:

  1. ThreatEngine - turns a signal ({ layer, weight, reason }) into a decaying score per member, and a tier (none/low/medium/high/critical).
  2. PunishmentEngine - turns a tier into an action, after checking RoleAnalyzer for 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 to PunishmentEngine - 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, a Set<guildId>) - frame.enableLayer(name, guildId) / frame.disableLayer(name, guildId), the panel, and !gf status all 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.prefix is 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:userId rather than userId alone - 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.config itself - 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.