promotional bannermobile promotional banner

Fast Guild Invite - Revived

A WoW Classic AddOn to help recruit new members into your guild.
Back to Files

FastGuildInvite-v2.13.3

File nameFastGuildInvite-FastGuildInvite-v2.13.3.zip
Uploader
PmptastyPmptasty
Uploaded
Sep 14, 2026
Downloads
998
Size
7.4 MB
Flavors
RetailMoP ClassicClassic TBCClassic
File ID
8882989
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 11.2.7
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.15.9

What's new

<FGI> FastGuildInvite

[v2.13.3] (2026-09-14) — a capped level band is cut where the players are, not in the middle

Changed — a level band that comes back full is cut at the level the sample is piled on, so a max-level realm reaches 90-90 in one split instead of seven

Field report (Spacedoc, Discord, 2026-09-14), retail: "if you have 15 people on levels 1-89 and 50+ on level 90 ... it is around 6-8 [splits]". Traced through the arithmetic halving it was exactly seven: 1-90 → 46-90 → 68-90 → 79-90 → 85-90 → 88-90 → 89-90 → 90-90, every one a capped /who returning the same level-90s again, plus the seven low halves each of those left — fifteen level-only queries before the race/class fan-out that actually finds anyone could begin. The reporter's count was right and the answer given on Discord ("three, maybe five") was not.

The cut now comes from the rows. The 50 rows a capped query returned are a sample of who is standing in the band, and fn.levelSplitPoint(a, b, rows) cuts at the sample's upper median level when that level holds at least half the sample — a pile — and halves the band as before when it does not. For the reporter's realm the median is 90 and the first cut is 1-89 / 90-90: the 1-89 half returns the levellers in one uncapped /who, and the seven splits are one. The upper median rather than the lower so a sample split evenly between 89 and 90 cuts between them; a pile sitting on the band's floor is isolated the mirror way (1-1 / 2-90). Rows outside the band are ignored and no usable row at all means the old middle. No setting, no new tag, nothing for a user to configure — the same algorithm, cutting in a better place.

The pile guard is measured, not cautious, and it is the part worth reading. Nobody documents which 50 a live /who returns when more match, so Tests/zz_level_split_point_spec.lua (29 examples) does what the Discord thread could not: a fake /who server over four synthetic realms, the whole scan drained through the real registered callback under both cut rules, under three server orderings (shuffled, lowest-50-first, highest-50-first), and every /who counted. An unguarded median was worse than halving on an evenly spread realm whenever the server hands back the lowest 50 — 37 /who against 23 — because the sample then describes only the bottom slice of the band and its median peels the band off four levels at a time. With the guard the spread realm is the old halving query for query under every ordering (asserted), and the piled realms come in at or under it everywhere:

realm server returns halving this
retail-shaped 1-90, 40 levellers + 1,500 at cap shuffled 32 20
lowest 50 first 31 21
highest 50 first 31 19
classic-shaped 1-60, 120 levellers + 600 at cap shuffled 29 23
lowest / highest first 30 24
evenly spread 1-90, 400 players any 23 23
starter pile 1-90, 300 at 1-10 + 40 above shuffled / lowest / highest 70 70 / 64 / 68

The progress denominator is untouched: a band of n levels is priced n·leaf + (n−1) and two halves of n levels price to the same number wherever the boundary falls, so the conservation the subdivision spec asserts holds by identity — pinned at an off-centre cut. The descending-sweep setting is applied after the cut as before.

Docs — the scan's "how a full search narrows" is now described where the features are

README.md never said how a search gets past the 50-result cap at all; the HTML said it in one bullet. Both now carry the four-step narrowing (level, race, class, zone), that its order is a setting, and that a full level band is cut where its players are. The HTML's guild-integration line also lists MoP Classic, which it had left out.

[v2.13.2] (2026-09-14) — every officer online announced the same leaver, the scan still reached players inside instances, a grouped welcome short-changed the last joiner, and on Classic a recruit's reply never reached their record

Fixed — with several officers running FGI, a member leaving was announced to officer chat once per officer

Field report (Vishiswaz, TBC): "more than 1 person online with fgi in officer chat causes multiple o chat messages for people leaving the guild" — two identical <FGI> Player Koje-Dreamscythe has been blacklisted. Reason - Left the guild lines, one from each officer's client, a second apart. Reported as a regression, and it is one from the reporter's side: v2.13.0 made the auto-blacklist route work on Classic for the first time, so the duplication it always had became visible.

Every client running FGI is in the same OnMemberLeft callback for the same leaver, and every one of them called fn:blackList, which posts the officer line unconditionally. The entry landing on every client is right — it is the same entry, and DeltaSync reconciles it — but the line has to have one sender. fn:blackList takes a fourth argument, auto; both departure routes in Modules/FGI_GuildEvents.lua pass it, and an automatic add posts the officer line only from the elected announcer (Recruiter:CanPostAutoNotice, the announcer election under a new name — the same list Q4 gave the welcomer, and for the same reason: it is who speaks for the guild, and every client computes it from the same policy and roster). A person's own /fgibl or Blacklist-tab add is unchanged — one person, one line. KNOWN COST, and it is the same shape as the welcomer's: a guild running no policy at all still falls open, so several officers with FGI and no announcer list still each post. Falling closed there would silence the line for every guild that never configured a policy, and in the commonest case — one officer running FGI — falling open is one line and correct.

Found beside it: the officer line was worded from the UI-language override (L) while the chat print next to it used the client locale. Both the add and the removal line now go through fn.chatLoc(), the rule every other line into somebody else's chat frame follows.

Tests/guild_events_spec.lua pins that both routes pass the flag; Tests/zz_functions_spec.lua (new block, six examples) drives the real fn:blackList: a manual add posts whatever the election says, an automatic add posts only when elected, the entry lands either way, no election module means post, the setting off means silence, and the wording comes from the client locale.

Fixed — the list of places that count as an instance is generated from the client's own data, for every flavour, and the zone step no longer runs a /who into one

Field report (Vishiswaz, TBC): "fgi still searching in instances" with z-"The Botanica" on screen. The operator's reading of it, verbatim, which is the whole of the fix: "filter out anyone in a dungeon/instance/raid/etc from the who results so you don't invite someone in a protected state" — and on where the data comes from: "you have to get that data for EVERY flavor of the game and maintain it for EVERY flavor of the game. it's a pain, but we have to do it as the API isn't working well."

The quiet-zone set was never complete, on any flavour. It was 45 hand-typed area ids (fewer instances than vanilla alone has) unioned with a walk of the client's Dungeon-typed maps — a walk that finds nothing on Classic Era, which files its dungeons as ordinary Zone maps, and that on every flavour depends on a map-type flag meaning something it does not quite mean. So a /who row standing in an instance the set did not name passed the gate, was queued, and was invited.

The set is now generated. FGI_InstanceAreas.lua is every top-level area of every instanced map in the four flavours' own DB2 tables (Map.InstanceType ~= 0 joined to AreaTable, plus the area each instanced map names as its own — Classic Era's Onyxia's Lair row says it is on Kalimdor and only the raid map's own pointer catches it), produced by tools/instance-areas.lua from wago.tools exports and regenerated per patch; the directions are in the tool. 511 ids across Classic Era (49), TBC (66), MoP (173) and retail (489). fn.getStaticAreas resolves this client's ids through C_Map.GetAreaInfo, which is what makes it right in every locale, and counts what resolved; the Dump window's Areas button prints the count. The Dungeon-map walk stays as a second source, unioned as before.

Each id names the flavours that silence it, and that is not decoration. A name is not the same place in every game: Zul'Aman is the raid on TBC and MoP and, since Midnight, a whole levelling zone on retail; area 206 is a "Westfall" inside the Deadmines on Classic Era and Utgarde Keep on MoP and retail. The generator refuses to silence any name that is also an overworld ZONE in that flavour — a top-level area with sub-areas, on a WORLD map — and prints every refusal so the decision is auditable. What a world map is took four cuts, each learned from a wrong one: any top-level overworld row (dropped the outdoor doorstep rows of Blackrock Depths, Onyxia's Lair, The Violet Hold, so the very dungeons the list exists for fell out); any open map (retail's Death Knight campaign phase is one open map holding one "Scarlet Monastery" with sub-areas, which made the real one scannable); two or more zones (made Deepholm, one zone on its own map, a non-world, so a scenario copy silenced the real zone); a loading screen (retail's story phases of Icecrown Citadel, Firelands and Ny'alotha have one, so the raids fell out). A world is an open map holding two or more zones, or one zone plus a loading screen with no dungeon or raid of that name — a single-zone map named after a raid is that raid's story phase; a single-zone map only a scenario copies is the real place. KNOWN COST: a recruit standing on an instance's outdoor doorstep is silenced with the instance (they are about to be inside anyway); on retail Eye of Azshara, Tol Barad, Ashran and Wintergrasp share their name with a real zone and are therefore not silenced there; and Torghast is refused because its lobby is an open map of that name, so its runs are scannable.

And the zone step reads the results through the same gate. The fan-out over a capped query's zones is still the sample — the zones the 50 rows were standing in, as it has always been — but the sample is now filtered by IsInQuietZone, the predicate the rows themselves go through, so an instance a row stood in never gets a query of its own. That is the z-"The Botanica" line.

The instance list is baked in; the Quiet Zones toggle is the user's list only. The operator, verbatim: "the 'built in instance list' is NOT part of the quiet zone configuration, it's baked in." Until now IsInQuietZone read DB.global.quietZones over the whole union, so switching the toggle off let players inside dungeons through and searched their instances. fn.getAreas now tags each name "instance" or "custom"; an instance is skipped unconditionally, a custom zone while the toggle is on, and a name on both lists is an instance (the user cannot opt a dungeon back in by adding it to their own list). The Quiet Zones tab's master-switch tooltip is rewritten to say so (enUS; the 30 other locales carry the old key and fall back to English for that one tooltip), the Dump window's Areas readout marks the user's entries (your list), and fn.isInstanceArea is the public form of the question. Pinned in zz_add_new_player_spec (an instance is skipped with the toggle off, and stays one when added to the user's list), zz_who_subdivision_spec (no query into an instance with the toggle off; a custom zone is searched again once the toggle is off) and the end-to-end block below.

Reverted the same day, before release: a draft of this fix had redesigned the fan-out to union every Zone-typed map in the client's tree behind the sample (~55 queries per capped leaf on TBC). The operator: "i didn't need you to change the design." fn.getZoneMaps and the tooltip that described it are gone; the tooltip says what the step does.

Tests/zz_instance_areas_spec.lua (new, 23 examples) pins the generated table's shape, the eleven instances the two TBC reports named silenced on TBC — including the seven Vishiswaz marked with question marks, driven end to end as /who rows through the real registered callback on a TBC client: none is queued, the Nagrand control is, the toggle changes nothing, and a client that cannot name them queues all twelve — the seven vanilla dungeons and the Season of Discovery instances on Classic Era, Zul'Aman silenced on TBC and MoP but not retail, Blackrock Depths everywhere, Deepholm and Westfall nowhere, Icecrown Citadel despite its story phase, the flavour a client reads (Wrath and Cata read MoP's column, the nearest data), fn.getStaticAreas per flavour and its count, and the Quiet Zones tab's PvP group derived from the same table and filtered to this flavour. Tests/zz_who_subdivision_spec.lua (block rewritten, seven examples) drives the real callback: a sampled instance gets no query whatever the toggle says, only the sampled zones are asked about, a sampled zone the map does not name is kept, the custom list is honoured while the toggle is on and searched again when it is off, and a batch standing entirely in quiet zones answers no axis.

Fixed — a grouped welcome went out on the first joiner's clock, so a late joiner could be greeted before they could read it

Field report (Vishiswaz, TBC): "the welcome delay is to make sure that people can see their welcome message … when grouping up multiple joiners together, the timer goes based off the first person invited in the group, not the last, which can result in the last person not seeing their message." Exactly right: PostGuildWelcome set the window's due time once, from the first joiner, and a rider joining in its last second was greeted one second later. A late joiner now pushes the due time out to at least the welcome delay from their join; the window still closes on time when nobody joins late, and the ten-minute life is still counted from the first joiner, so a stream of joiners cannot hold a welcome open forever. welcome_spec pins both.

Fixed — on Classic, a recruit's first reply opened a second History record instead of marking the attempt "replied"

Found by the coverage pass on Modules/WhisperAlert.lua, which had never had its alert path driven offline (17 of 71 lines). The reply was recorded as fn.recruits:onReplied(key) with key the realm-qualified fn:fullPlayerName(sender) ("Zugar-Realm"), while the attempt is counted in fn:invitePlayer as onAttempt(playerName) with the raw /who name ("Zugar"). The roll-up keys every record through fn:normalizePlayerName, which is the identity on every flavour but retail — so on Classic the two spellings were two records: "Zugar: 4 attempts, tried" beside "Zugar-Realm: replied", and the one line the History tab exists for was never written. Retail resolves both spellings to one qualified key, which is why no field report ever named it.

The alert now hands onReplied the raw sender, exactly as the attempt does. Tests/zz_whisper_alert_spec.lua (new, 19 examples) pins it with an attempt followed by a reply and one record at the end; the rest of the file drives the three cues, their three settings, the deferred chat-tab flash (matched case-folded because FCFManager_GetChatTarget upper-cases a whisper target, FloatingChatFrame.lua:2286), the login-burst window and the lockdown gate.

Fixed — a click during an encounter dropped a joiner's welcome whisper for good

Modules/FGI_Welcome.lua's chat-messaging-lockdown check sat inside sendWhisper, which the click pump called after removing the entry from the queue — so the whisper a recruit was owed went nowhere if the recruiter's next click landed inside a Mythic+, and the comment beside it still described the timer era. The pump now asks API.InChatLockdown() once per click and holds the entry, unsent and still queued, for the first click after the encounter, inside its ten-minute life like any other. welcome_spec pins it: nothing goes out during lockdown, the count stays at one, the next click sends.

Tests — thirteen more modules at 100%, and the branches that were deleted instead

The method that closed functions.lua — read each uncovered branch against the Blizzard trees before driving it — over the logic modules. At 100% now: WhisperAlert, RecruitGoals, AnnounceSync, MemberHistory, RealmResolver, Welcome, GuildEvents, AltGroups, WidgetPool, Baton, SettingsProfile, Presence, intro; Recruiter, DeltaSync and Wingman driven to their last lines (re-measure pending — the coverage tool timed out twice on the box while the harness ran its own gate). Suite 2873/0.

DeltaSync (Tests/zz_deltasync_bridge_spec.lua, new): the host callbacks driven at the bridge with a recording host — OnSyncAccepted requests the delta with our baseline, a refused send and a version broadcast reach the debug log, an untrusted query is dropped, a merged anti-spam entry leaves the live scan queue and frees the P2P session slot; /fgids and /fgids sync; the MINOR-15 floor refusing an older DeltaSync in chat; the single-revision and stored-canon arms for a DeltaSync without MakeHashEntry / ComputeHashV2; the recruits item stamped by statusAt. IsTrustedSender is one line now: LibGuildRoster's IsInGuild answers a boolean on every path (its own pre-build arm scans the live roster), so the hand-rolled roster scan and the "can't verify, allow" fallback behind ~= nil could not run — and the second would have opened the gate.

Wingman (Tests/zz_wingman_session_spec.lua, new, plus the prompt and step-menu specs): the configured step order and its fallback, the announce step (posts, falls through when nothing posts, skipped in lockdown), the protected-state pause, auto-stop, the scope line's name caps, the lock tooltip, the overlay's own click (pump first, and nothing over FGI's own windows); a start inside combat that arms and finishes on PLAYER_REGEN_ENABLED, keeping its duty provenance; cancelling while armed and the toggle debounce; the session restore and its retries, and the first duty evaluation waiting out the warmup — all through the load-time timers the prompt spec already recorded; the step menu's tick writing the setting and repainting, and its outside-click close; the auto-stop under a policy that bars both roles.

Deleted as unreachable on every flavour, not covered, each read in F:\Blizzard API Docs per flavour tree: the UIFrameFlash / FCF_StartAlertFlash / CHAT_FRAMES feature-detects in WhisperAlert (Blizzard_SharedXMLBase is AllowLoad: Both, Blizzard_ChatFrameBase is LoadFirst: 1); five no-C_Timer fallback arms across WhisperAlert and FGI_Presence (every UITimerDocumentation.lua declares it); Presence's "client does not expose C_ChatInfo.SendAddonMessage" refusal; WhisperAlert's inner canaccessvalue(sender) retest and pcall(fn.fullPlayerName), and RealmResolver's inline lockdown return — all three sit behind API.RegisterChatEvents' wrapper, which has already asked about every argument and returned during lockdown before the body can run; MemberHistory's addon.API feature-detect; SettingsProfile's locale() pcall (FGI:GetLocale is in the first file of every TOC); Recruiter's listForRole "unknown role" arm (every caller passes isRole first); AltGroups' two identical return false arms. Deleted as code with no production entry point: RealmResolver.NoteFullName and .Count, Recruiter:AssignedMap (superseded by AssignedWire at S48).

WhisperAlert reads addon.DB live rather than a snapshot taken at PLAYER_LOGIN: /fgi resetDB replaces the table, and the snapshot would have kept gating the alert's three settings on the discarded one.

A suite leak, fixed: Tests/zz_functions_spec.lua's conversation-tab block set CHAT_FRAMES, ChatFrame7 and ChatFrame8 to nil in its teardown, written when the harness modelled none of them. The harness owns all three now, so every later spec file ran with no chat-frame list and two dead frames — invisible until the first file walked the array, which failed in the full suite and passed alone. The block now drives the harness's real FCF_OpenTemporaryWindow / FCF_Close and stands in nothing.

The harness pin moves to 2c7279f. UIFrameFlash, FCF_StartAlertFlash, GetNumGroupMembers, IsInRaid and UnitFullName were raised (inbox thread 02b2da6bd297) and delivered the same evening; the stand-ins are gone, and the whisper-alert spec asserts the overlay's visibility and alpha on the clock and the chat tab's alerting flag rather than the call. One specs-only entry point came with it: fn.forgetConversationTabSuppressionHook, because the addon hooks FCF_OpenTemporaryWindow once per session behind a guard and the harness reinstalls that global per spec file, which drops the wrapper while the guard stays set. Nothing in the addon calls it.

Fixed — closing the Settings panel could take the compact tray with it, because the visibility fallback read the wrong SettingsPanel

GUI/SettingsPanel.lua's ShouldBlockEscClose — the check both ESC proxies ask before hiding their window — has a flag for the panel being open and a fallback that reads the panel's own visibility "in case the hook didn't work". The fallback was spelled (Settings and SettingsPanel) or InterfaceOptionsFrame, and inside that file SettingsPanel is our module table (line 595), which has no IsShown, so the read fell through; and InterfaceOptionsFrame is declared in none of the four Blizzard trees, so the second half never resolved either. The fallback answered false on every flavour since it was written. It reads _G.SettingsPanel now — Blizzard_SettingsPanel.xml declares name="SettingsPanel" in classic_era, classic, anniversary and live alike. Not run in a client: whether the flag path already covered every close on Classic, so the fallback never decided anything, is exactly what this could not tell. Found by reading the function to drive the compact tray's ESC proxy.

Tests — the compact tray at 100%, its handlers driven for the first time

Modules/compactFrame.lua measured 590/764 on 2026-09-13: three geometry specs had pinned its layout to the pixel and never pressed a button. Tests/zz_compact_frame_spec.lua (new, 48 examples) drives every handler the way the client would — GetScript and call — the eleven tooltips (including the gate that silences all of them, Wingman taking over the >> and invite tooltips, the help icon's 500 px width hint, the broadcast horn's Ready / countdown / activity rows), the three drag handles ending in one TOPLEFT save, the corner resize saving its width and refusing the button row's floor, expand / close / gear (with the next-frame re-show) / Wingman (right-click menu, warmup hold, toggle) / both horns, the warmup countdown overlay, the picker's own drag, resize and wheel and its name-tie order, the queue rows filled class-coloured and sized, the wheel clamped to the list, the four row icons including blacklist's fast-vs-confirm split, the ESC proxy's two refusals, and the login restore of a saved position and width, a junk save, and the reopen opt-in. 753/753 after.

The login restore is cf.restoreSavedLayout() now, called by the event handler, so a spec can drive it with a chosen save: the event frame is an anonymous file-local that unregisters itself on first delivery, and frames.fireEvent reaches every registered frame in the suite.

Deleted as unreachable on every flavour: the three elseif SetMinResize fallbacks (tray bounds, picker build, picker floor) — SetResizeBounds is in every flavour's SimpleFrameAPIDocumentation.lua and SetMinResize in none; makeRowIcon's Interface\Icons\ TexCoord crop — all four row icons are Buttons/ and RaidFrame/ art. GUI/UI.lua:71 and GUI/LegacyMainWindow.lua:318 carry the same SetMinResize fallback and are left for the GUI pass.

One harness gap, raised and delivered the same hour: _G.SettingsPanel was built shown where Blizzard_SettingsPanel.xml says hidden="true", so the fixed fallback blocked every ESC-close example offline (inbox thread 861ddf9e). The harness pin moves to 82eb99e (contains 557390a, the panel hidden after every reset); the before_each that hid it by hand is gone. Whole suite 2922/0 at the new pin.

[v2.13.1] (2026-09-13) — FGI threw errors all through a Mythic +10, four retail races could never be scanned for, and the Guilded column called every kept candidate guilded

New — a template your character applies at every login, so an alt keeps a different setup from your main

The other half of the Discord request behind v2.13.0's templates: "allowing different characters to have different profiles." The operator, on how it should work, verbatim: "we SHOULD save the templates to SV, that's the only way the persist. we can change it whenever we want, and when we /reload it over writes it ... per user templates, that auto load on init."

Settings > Templates gains one dropdown at the top of "Your templates": Apply at login on this character. Pick a template and it is applied now — behind the same warning the Apply button shows, naming what it replaces — and again at every login on that character, overwriting whatever the settings had drifted to since. Pick None to stop; the settings stay as they are. Each character has its own choice, which is the point: a main that scans 55-60 with the officer whispers and a bank alt that only welcomes. /fgi template auto <n>, /fgi template auto off and a bare /fgi template auto are the slash equivalents, beside the other template verbs, and list marks the one this character follows.

I had described this for a day as needing every setting moved into AceDB's profile scope — "an irreversible saved-variables migration once shipped" — and offered to drop the feature on that basis. That was the wrong shape, and the operator said so: a template is already in SavedVariables, applying it is already the Apply the button runs, and re-running that at login is one pointer in DB.char, nothing moved and nothing migrated. Modules/FGI_SettingsProfile.lua's header said the two halves of the request "ship separately" because one of them was a migration; it now says why that was wrong.

  • The pointer is by name and author — the identity Store de-duplicates on — not by position, so forgetting an unrelated template does not move it, and an author's corrected version of the same template is picked up on the next login without re-pointing.
  • A missing template is said, not silently cleared. If the one you follow has been forgotten, login prints that it is missing and leaves the choice in place; the dropdown shows it as "(no longer in your list)" rather than lying with None. Re-adding it under the same name resumes it.
  • It runs in OnInitialize the moment the database binds, before anything reads a setting. My first placement was after fn:initDB(), and the self-audit moved it: the locale override bakes uiLanguageOverride into the locale table, icon:Register hands LibDBIcon the minimap table (which Apply replaces wholesale), and updateEscFrames acts on keepOpen — all before that point, so a template changing any of them would have taken one extra login to show. The minimap one was worse than that, as peer review put it: LibDBIcon would have kept writing the button's position into an orphaned table, so every drag would silently not save and the button would be "back where the template put it" on each login. It never shipped; if a v2.13.1 tester reports "my minimap button keeps moving back", this is where to look. There is an example that reads the source to pin the order against all four readers, since the offline fixture never runs OnInitialize.
  • Two deviations from the design as I had recorded it, reported rather than re-asked. Picking a template in the dropdown applies it now as well as at every login — the recorded design said the loss warning runs once at pick time, and a confirmed "apply this at every login" that then left the settings untouched until tomorrow would be the one outcome nobody expects; if record-only is wanted, it is one call to delete. And /fgi template auto takes an index like apply and forget, not a name, for their stated reason: a name can match two templates.
  • It is not a setting. char.autoTemplate is in NOT_A_SETTING, so a template can never carry one — applying template A must not re-point a recipient at template B.

KNOWN COST, and it is the model the operator chose rather than a defect: nothing in the catalogue is character-scoped — every setting a template carries is account-, realm- or faction-wide — so "per character" means exactly what they said, each login overwrites. None means the character does not apply a template at login; it still shares the account-wide settings with every other character, so it logs in to whatever the last login left. The dropdown's tooltip and the release notes say it in those words (peer review: anything softer re-invents the lie); the remedy for a character that must not inherit is to give it a template of its own.

Also pinned on review: applying a template that carries announcements replaces the profiles wholesale and leaves the per-character cooldown clocks alone — so a template re-applied at every login does not reset every announcement to Ready. One example asserts it rather than believing it.

Twelve examples in Tests/zz_templates_page_spec.lua.

Fixed — the chat limit threw a Lua error out of an announcement, and the message was lost

The report, four times in one BugSack: SendChatMessage(): Chat message limits exceeded, with a stack through FGI_APICompat.lua's SendChatMessageAnnounce.lua chatSendsendToChannelSend ← Wingman's drain. A ~250-character guild advert to a numbered channel, each time.

BugSack captures Lua errors — so the limit is not a chat line to listen for, it is the API throwing synchronously. The design on the todo list had it arriving as a CHAT_MSG_SYSTEM notice to match against a global string; the operator's own screenshot, read properly, says otherwise. Before this, the error unwound through Announce:Send after the item had been popped off the round and before anything stamped or tracked it: the message was gone from the round, Wingman's drain aborted mid-step, and the player got a Lua error about a message that simply had not been sent.

chatSend now pcalls the API. A refusal puts the item back at the head of the round, unstamped and untracked, and the very next click sends it again, with one chat line saying so. That is the whole of it — the limit is not a number anyone publishes (the ChatThrottleLib wiki page treats it as opaque), so nothing here picks one, waits one, or gates on one. The refusal is the signal; the next hardware event is the retry, which is what every other send here waits for anyway.

Once per item. A second refusal of the same item is not the limit any more but a send the client will not make at all, and requeuing it forever would wedge the round on one item. It falls through to the round's failure summary carrying the client's own words, and the round moves on.

Withdrawn earlier the same day and recorded above: a ChatThrottleLib "room to post" gate that answered this same report by refusing nearly every announcement. This is the event-driven shape that should have been built first.

Five examples in Tests/announce_guild_ready_spec.lua, driving a SendChatMessage that throws the reported text. Not run in a client: the refused-then-resent path is asserted offline only.

Fixed — four audit findings that had sat on the board unanswered for up to three weeks

Peer review asked one question of the 66 board threads I had closed by pairing headings with response blocks: did the pairing leave any unpaired? Re-deriving it by reading the board found seven findings with no response anywhere after them. Three were investigation leads for the ElvUI stutter report, never defects with a fix (S15, S16, S18); the other four were real, all still live in the tree, and are fixed here. None of them is verified in a client.

  • S22 (filed 2026-08-20) — a bare name read as blacklisted when a different realm's character was. fn:isInBlackList passed the slash command's full = true to the resolver, and that fallback ends in a prefix match written so /fgi unblacklist ninja could reach an entry filed as Ninja-Testrealm. Every read-time caller inherited it — the History page's status column, the auto-blacklist gates, the scan batch — so on the Classic family, where names are stored bare, Ninja was "Blacklisted" whenever Ninja-AnyRealm was, and the page stated it as fact about a different person. The wrapper now asks for an exact, case-insensitive match and never a prefix; the lenient form stays on the slash command, the one place a human types.
  • S43 (filed 2026-08-28) — click Alice's invite button, Bob gets the whisper. The invite loop had two gates for a row the user pointed at. The held gate refused that row and said why; the stale gate removed it and carried on at the same index, now holding whoever had shifted into the slot, and contacted them without a word. The head-of-queue drain (no index) still moves on, as it should — nobody is pointing at anything there. An indexed press on a stale row now drops the row, names it in chat with the reason, and contacts nobody. Three of the drain's own examples had been driving the indexed path all along, which is why nothing noticed; they drive the drain now.
  • S44 (filed 2026-08-28) — AltGroups:Snapshot promised a copy and handed out LibGuildRoster's internal arrays. The docstring said a snapshot "cannot go stale, cannot disagree with the library, and cannot outlive a group the library dropped"; all three were false of everything inside the outer map, because GetAltGroup returns the library's own sorted array and says mutating it corrupts the reverse index for every adopter. Copied on the way out now — a handful of names per owner on a path that already walks every owner. The promise is true, and two examples hold it there.
  • S13 (filed 2026-08-17, deferred as "release frozen" — three releases ago). The migration named a legacy message row "Message N" in English while the Messages tab invented the same name eight times through the locale, so a row arriving from an older peer read differently depending on which door wrapped it. One helper, fn.defaultMessageName, both doors, localised; a source-reading example found the eighth site the first sweep missed.
  • S40 restated (docstring only): MayDisplace's comment said "when WE stamped it" where the code and the spec mean any FGI client. Reworded.
  • S9 (self-raised 2026-08-16, accepted, deletion left "for scope"). API.GetGuildRosterInfo was if gv.isRetail then return GetGuildRosterInfo(index) else return GetGuildRosterInfo(index) end — a flavour branch whose arms do not differ is a standing claim that the flavours differ. One line now; the two examples pinning "the flavour makes no difference" stay. Found by re-importing docs/AUDIT.md into the inbox with writ's fixed importer (2026-09-11) and reading the 40 threads it opened against the board and the tree: 39 were settled on the board or in this release and are closed with the line that settles each; this one was still in the tree.

Fixed — the addon touched protected values for the whole of an encounter

The report, from a player: "Just got out of a Mythic +10 Kings Rest and Fast Guild Invite was throwing errors for having touched 'secret' or 'hidden' variables like guid. I can't find a way to shut this thing off inside an instance or raid or delve."

Modules/FGI_RealmResolver.lua reads the sender's GUID — argument twelve — off ELEVEN CHAT_MSG_* events, and had no lockdown guard at all. Every one of those eleven carries SecretInChatMessagingLockdown = true; checked one at a time against Blizzard_APIDocumentationGenerated/ChatInfoDocumentation.lua rather than assumed. Inside an encounter, a Mythic+/challenge run or a rated match the client marks most of a chat payload secret, and the payload tables are explicit about which fields escape: they mark languageName, channelBaseName, specialFlags and lineID as NeverSecret and pointedly do not mark text, playerName or guid.

The touch is NoteGUID's own first line — type(guid) ~= "string" or guid == "" — which reads the value in order to type it and compare it, before anything defensive can run. Its pcall around GetPlayerInfoByGUID never got the chance, and would not have helped: pcall swallows the throw but does not strip the execution taint acquired by touching the value, which is what turns one mistake into a cascade in Blizzard's own chat code on every later message. Party chat and boss yells fire continuously in a dungeon, so this ran on nearly every line.

Four more handlers had the same hole, found by reading sideways rather than by the report. Modules/FGI_Welcome.lua's guild-chat handler had no protection of any kind — it compares author, hands it to Ambiguate and pattern-matches msg. Modules/FGI_Presence.lua and the auto-kick watcher in functions.lua both typed the message before their own bail-outs could spare them. Modules/Announce.lua's delivery-confirmation guard was inverted: it checked channelBaseName, which the docs mark NeverSecret, while text and sender, which are not, flowed past it.

Two others — Modules/WhisperAlert.lua and Scan.lua's AFK/DND handler — consulted canaccessvalue after using the value. Both cite Blizzard_ChatFrameBase/Shared/ChatFrameFilters.lua:37, which checks access first; the guard is now where the cited reference puts it.

Why it shipped, which matters more than the fix. The offline harness installs no GetPlayerInfoByGUID, so NoteGUID bailed at its own feature-detect and the entire GUID path was unreachable in tests. No coverage number ever said so. Tests/zz_chat_lockdown_spec.lua now drives these handlers through the real events with lockdown on and off — including Scan.lua's guard, which shipped in v2.7.1 and had never been asserted by anything.

KNOWN COST, deliberate: no realm or race is learned from chat for the duration of an encounter, and the peer-welcome and presence handlers stand down there too. Nobody recruits mid-Mythic+, the resolver has four other sources, and a missed free lookup costs a later /who at worst.

NOT VERIFIED OFFLINE, stated rather than buried: the harness models the lockdown state but has no secret value and no canaccessvalue, so nothing can prove that touching one throws, and the two ordering corrections above cannot be tested at all. Raised as a harness contract.

Changed — the chat-lockdown guard is installed by the registration itself, not remembered at each handler

Self-audit S69, settled by peer review. Nine frames across seven files register CHAT_MSG_* events, and five of them had no lockdown guard when the Mythic+ report arrived — even though Scan.lua had carried one since v2.7.1. Nine registrations, one correct, across four minor versions: a measured propagation rate of zero for knowledge-based protection. A shared predicate would still be knowledge-based — the tenth handler forgets a call as easily as it forgets a condition.

API.RegisterChatEvents(frame, events, handler) registers the events, installs the handler, and gates only CHAT_MSG_* on the taint-free lockdown state, so a frame mixing roster or login events keeps receiving those. All nine sites converted — and a tenth (functions.lua:1366, the guild-join detector, already guarded since v2.13.0) turned up only because converting forced an enumeration.

Tests/zz_chat_registration_spec.lua reads the shipped file list from the TOC and refuses any bare RegisterEvent("CHAT_MSG_…"). Control-tested: one planted bare registration reddened exactly one example, naming the file and line. Three spec fixtures that hand-built FGI.API now load the real compat layer — a fallback in the modules would have reintroduced the bare path the sweep forbids.

The inline guards at each site remain, now redundant, carrying the reasoning; the comments say the helper is the load-bearing one. Also from the same audit: the label-collision sweep now counts through SP.ResolveArgName rather than a copy of it, and a template carrying nothing says so explicitly.

Fixed — a pasted template could put an unbounded structure into your saved variables

Peer review round 90, finding A. FGI_TemplateWire.lua caps a template at 64 KB — but that bounds the compressed string, and what reaches SavedVariables is the decompressed structure, which SP:Store deep-copies into global.settingsTemplates. Deflate ratios on repetitive input run to three orders of magnitude, so nothing bounded what actually landed.

This addon has already lost saved variables once to an unbounded table — the 40 MB FGI_DEBUG runaway that silently failed to load, which is the incident Store's own row-ceiling comment cites. The shelf had a ceiling on rows; the rows had none.

SP:Decode now bounds the decompressed string before deserializing, returning a new "toobig" reason. Checked there rather than in the wire because there are two doors and the wire is the safer one: the paste box takes text from anywhere and never touches the wire cap. The reviewer could not resolve whether the wire door is guildmates-only; it is not reliably — fn.isCommTrusted (functions.lua:1289-1293) fails open, returning true when the DeltaSync bridge is absent.

Fixed — applying a template checked which settings it carried, never what was in them

Finding B. The key gate is strong and structural — Apply iterates the catalogue and indexes the payload, never the reverse, so no uncatalogued key is reachable at all. The value was ungated, and global.lowLimit = {}, realm.filtersList = 5 and global.minimap = "x" are all catalogued keys. The damage lands far away and much later: the Filters tab iterating a number, LibDBIcon handed a string where it expects its table — Lua errors with nothing pointing back at the template.

SP.TypeMatchesDefault derives the expected type from the shipped default rather than adding a fourth hand-maintained table, and a refused value is not counted into the "settings changed" total. It fails open for lazily-created settings, which have no default to compare against — refusing everything untypeable would turn a hostile-input guard into a data-loss bug for ordinary users.

Finding C in the same pass: nothing asserted SP.HAND_BUILT — the collections the loss warning covers — agreed with the catalogue. The dangerous direction is a curated collection added to one and not the other, because a missing warning is indistinguishable from nothing to warn about. Two sweeps now check both directions.

The depth cap in sameValue stays, and its justification was wrong. It claimed a hostile hand-edited template could carry a cycle. It cannot: AceSerializer has no back-reference syntax, so deserializing can only build a tree. The cap is defence-in-depth against a future input path — but a guard defended by a reason that cannot happen invites the next reader to delete it, and that reader would be right about the reason and wrong about the code.

Fixed — a template's row and its apply message counted different things

Peer review round 90. The library row counted payload.included, which SP:Export records for every selected entry even when the value was absent — deliberately, so a support conversation can tell "the author left this out" from "the author never set it". SP:Apply reports what actually landed. An author who ticks everything and has never touched the lazily-created settings produced a row reading "81 settings" and then "61 settings changed", seconds apart, and the obvious reading is that twenty failed. The row counts payload.scopes now, which is exactly what will land; included keeps the job it was built for. The announce entry drops out of the number for free — it was previously counted and named by its own clause.

Two things the same review checked and found correct, recorded so nobody "fixes" them: clearing keys during a pairs traversal is legal in Lua 5.1 (only assigning new fields is undefined), and apply using the captured payload where forget re-looks-up is deliberate — the confirmation computed what that payload would cost, so re-resolving could apply something the dialog never described. That reasoning is now a comment where the asymmetry is visible.

Fixed — four retail races could never be scanned for, and one of them was reported

"pandaren race on retail I think is not showing" — Spacedoc. It was not, and nor were three others.

Retail's race table has three buckets — Horde, Alliance and Neutral — and the flatten was L.raceByFaction[playerFaction], which picks one. So for any player who has chosen a side, which is every retail player, the entire Neutral bucket was discarded: Pandaren, Dracthyr, EarthenDwarf and Harronir.

Not a cosmetic gap. The scan's race fan-out iterates L.race, so a race missing from it is never asked for — no /who issued, no recruit of that race findable by a race-subdivided scan. Everything else about those four was already correct: all four have full, generated RaceClassCombo rows.

So v2.12.2's Earthen/Harronir fix never worked. That release added both races and said in its notes that they could now be found. It also introduced the Neutral bucket to hold them — and this line, three hundred lines later, threw it away. The claim shipped; the behaviour did not.

Neutral means "both sides", not "neither". GUI/Tabs/RaceClassMatrix.lua:179 already maps it to "both" so those races survive either faction filter; only the flatten disagreed. MoP avoids the whole thing by listing Pandaren under both factions rather than in a third bucket.

Why nothing caught it: the flatten was inline at file scope, run once at load off UnitFactionGroup("player") and the flavour flags, so no spec could choose either. It is FGI.RacesForFaction now, and Tests/zz_race_faction_flatten_spec.lua drives it with tables it controls — control-tested by removing the fix and watching it name Pandaren on both factions. The flatten also aliased a live faction subtable while its own comment said callers mutate the result; it builds fresh now.

Withdrawn the same day — a ChatThrottleLib "room to post" gate on announcements, which silenced every one of them

The report it answered was real: 4x SendChatMessage(): Chat message limits exceeded from a live client, at the bottom of four Wingman drains. It was never a loopAnnounce:Send posts one item per call; Wingman drains on any click, and the per-(profile, channel) cooldown spaces posts to the same channel while doing nothing about posts to different ones.

For one afternoon chatSend asked ChatThrottleLib:UpdateAvail() whether the byte budget covered the message and declined if not. The operator, within hours of it landing: "whatever you did with the announce stuff, broke the soundboard" — then "announce just doesn't work anymore".

The misreading. self.avail is not the free budget; it is the undistributed budget. Whenever anything is queued in the library — and FGI's own sync traffic keeps it queued for most of a session in any guild with peers — ChatThrottleLib.OnUpdate hands the whole of it to the priority rings and sets it to zero, every 80 ms. Read from outside a frame later it is at most 64 bytes, under any message longer than about 24 characters once the 40-byte overhead is added. The gate refused essentially every post, and passed only on a client with nothing in the library's queue — the offline suite, and nobody's game.

The suite saw it and I explained it away. Loading the real library into the announce spec turned ten examples red at once. The previous entry here called that "correct behaviour against a clock that does not move" and the spec substituted a stub whose budget it set by hand. The real library refusing every send was this field report, a day early. The rule that was broken: when a real dependency makes the suite red, the first hypothesis is that the code is wrong, not that the dependency needs replacing.

The gate, its "throttled" outcome and its chat line are deleted; the send is unconditional, as it was in v2.13.0. Tests/announce_guild_ready_spec.lua now drives the real ChatThrottleLib into the busy state — past the post-login clamp, more addon traffic queued than one burst covers, one despool tick run — asserts that state is the one the old gate keyed on, and then asserts the horn and the soundboard both still post. Control-tested: re-inserting the gate reddens three examples, including the one with the library idle, which is exactly the red the old spec dismissed.

KNOWN COST, the original report stands openas this entry was written; closed later the same day by the "chat limit threw a Lua error" fix above, which is the refusal shape this gate should have been. At the time: a post the server drops for rate is reported by the echo sweep as "did NOT go out" and its cooldown stays stamped. There is no read-only "is there room" question the library can answer from outside its despooler, and it cannot be handed the send (it despools from a ticker; retail's SendChatMessage is protected).

Fixed — the Guilded column drew a gold Y on every candidate kept from before v2.13.0

Peer review of the v2.13.0 self-audit, and it inverted the audit's own finding. The audit said a restored row could never carry a Y and the docs wrongly claimed one meant "restored with an older reading". Both true — and the row that predates the field did draw a gold Y, because the RowList substitutes "" for a nil cell before the column formatter runs, the formatter tested v == nil (dead code), and "" is truthy. Anyone with Keep the invite queue between sessions on who upgraded saw every held row read Y beside its gold !: "all of these people are guilded", about rows with no guild reading at all, on exactly the population the column was built to adjudicate.

The formatter now tests the type (type(v) ~= "boolean" → dash), which is the nil-safety idiom the sibling zone and seen columns already used — I copied their shape in v2.13.0 and not the half that mattered. One new example feeds a row with no guilded key and asserts a dash; it failed against the old formatter and passes now.

Seven surfaces said a Y meant a restored row with an older reading; none can. The only writer of the field can only write false (the guild gate returns for anything but p.Guild == ""), and restoreScanState pushes rows verbatim. A Y can only mean the gate was bypassed — a bug worth reporting — and the header tooltip, both code comments, README, the CurseForge page's v2.13.0 block and the popup now say that. The tooltip's locale key changed; it had no translations to orphan.

A held row still renders N for a reading FGI has suspended. Decided rather than left open: the gold ! carries that caveat for its whole row, and the tooltip now says so, instead of a third cell state.

Fixed — the rank breakdown kept the previous guild's rank names after you changed guild

Self-audit F2, confirmed by peer review. v2.13.0 cached the rank labels and dropped the cache on GUILD_RANKS_UPDATE only. Leave guild A (rank 1 "Grunt"), join guild B (rank 1 "Recruit"): the library rebuilt, so the counts were B's, but the label still read "Grunt" until somebody renamed a rank in B or you reloaded — worse than the defect the cache fixed, because the old code read the label off the member record and would have been right. The same frame now also takes PLAYER_GUILD_UPDATE. Two examples: the registration, and a guild change modelled as a silent rank change followed by the client's own signal, with the cache proven still primed in between so the drop is attributable to the event.

A trap deleted from the path of that fix. FGI_Core.lua carried a commented-out PLAYER_GUILD_UPDATE frame from years ago — the obvious thing to revive — and it would never have fired: it wired SetScript('onEvent', ...) with a lowercase o. Gone, with a note saying why.

Fixed — the chat-lockdown guard now asks about the value as well as the state

Self-audit F3. The guard API.RegisterChatEvents installs on every chat frame gated on the lockdown state, which leaves the transition race open: a payload generated mid-lockdown and delivered a tick after the state clears is still secret. Modules/Scan.lua carried a pcall for exactly that and the other nine frames did not — the same propagation failure the helper exists to end.

The wrapper now also asks canaccessvalue() about every argument of a CHAT_MSG_* event and drops the event on the first refusal. That is Blizzard's own question — the call its chat filter makes per value — it does not touch the value (a pcall probe does, and leaves the taint), and it is a real Classic Era global, read on the classic_era tree rather than assumed. On a client without secret values it answers true for everything, nil included, so nothing changes there.

Four examples, driven against a stand-in canaccessvalue that refuses one named value, because the pinned harness has no secret values yet (the delivery is in the harness working tree, unpushed). The refusal example fires the same decline line as its control and differs only in the refusal — a marker that would not have registered anyway proves nothing — and the every-argument example banks a realm from an ordinary GUID first, then refuses the identical event with the GUID in slot twelve. The stand-in is deleted the day the pin carries the real one.

Changed — a template's announcements arrive exactly as their author had them

v2.13.0 forced every imported announcement profile to enabled = false on arrival, argued two ways (the recipient's first horn press should not broadcast unread words; "arrival does not act" applied to the one field that transmits). Both arguments were mine and peer review's. The operator overruled them, verbatim: "it should come through AS IS. whatever the user decides is what it should be. we aren't driving templates, it's USER driven." The override, its constant and the picker tooltip that advertised it are gone; the spec now pins both directions (an author's ON arrives on, an author's OFF arrives off) so neither can quietly become a rewrite again. Apply was already a deliberate press behind a confirmation that names what it replaces; that press is the recipient's decision.

Also from the same conversation, recorded here because the next session builds it: per-character templates that apply themselves at login. The operator: "per user templates, that auto load on init. we need to do this." The design is on the todo list; it is a character picking a library entry, not an AceDB profile-scope migration.

Fixed — the anti-spam window was measured on two different clocks

Found the moment the harness pin moved to 2406895, which gave the offline suite a GetServerTime pinned to a different clock from time() — and ten examples went red. fn:rememberPlayer stamps each anti-spam entry with fn.getTime(), the server clock, deliberately: alreadySended is synced guild-wide and every peer has to agree on when an entry was made. The expiry sweep measured its cutoff with time(), the local clock. Offline the two had always been the same function, so no spec could see them disagree; in a client they differ by the player's PC clock skew, so a machine hours off the server got an anti-spam window hours wrong, silently, in either direction. The sweep now uses fn.getTime(), and functions.lua captures GetServerTime at load beside time — identical on a client, where the global never changes after load.

Test-side, per the harness's own rule — copy the contract, keep the local clock: Tests/support/addon.lua drives one clock through app.setNow; it now installs GetServerTime against that same clock, so a stamp and its cutoff come from one place. The harness's staged stand-ins are gone: the three nameless refusal strings (every key and value diffed against env/wow.lua first, as the delivery asked) and the canaccessvalue stand-in — the value-gate examples now drive FGI's guarded frames with a real wow.secretValue, and the realm resolver's GUID path runs against the real GetPlayerInfoByGUID for the first time.

Suite: 2491 passed, 0 failed. luacheck and markdownlint clean. Only the announce fix has run in a client (operator: "ok, announcements working again"); nothing else here has.

Changed — "Notes on invite" is gone, because no client it was shown on could run it

Settings > Messages carried a Notes on invite section — write a public and/or officer note on the new member when they join — hidden on retail with the note "Retail restricts these guild-note APIs to the Blizzard UI". The code behind it, fn:setNote, opened with if C_GuildInfo and C_GuildInfo.SetNote then return end, on the reasoning that the note API is protected wherever that spelling exists. Read against F:\Blizzard API Docs rather than assumed: C_GuildInfo.SetNote is declared in all four flavour trees (GuildInfoDocumentation.lua), and the bare GuildRosterSetPublicNote the wrapper fell back to exists in none of them — not even the root GlobalAPI.lua. So the function returned on its second line on every Classic client too, and four controls that could never act sat on screen for every Classic user, with a tooltip explaining what they would do.

The section, the function, the 5-second hook off the join handler, the FGI_TooltipInput widget whose only two users were those two note templates, and the FGI_NOTEMAXLENGTH constant are all removed. The four DB keys (setNote, noteText, setOfficerNote, officerNoteText) leave the template catalogue, so a saved template that still carries them imports with those four ignored — which is what the catalogue gate is for. Its ten locale strings went with the orphaned-key sweep below, along with 181 others. Found by the coverage catch-up below, which is the point of running one: a guard that is true everywhere reads as "defensive" until something counts how often the other branch runs.

Changed — Announce's "chat queue is busy" hold never held anything, and is gone

Announce:Send began with a gate reading ChatThrottleLib.Frame.size > 50 and printing "Announce skipped: chat queue is busy". No version of ChatThrottleLib has that field — Frame is a plain frame and the queue lives in Prio[*].Ring — so the gate answered false on every client and never once skipped a post. Removed rather than repaired: an announcement is one hardware-event send that does not go through the throttle's queue, so the queue's depth was never the right question, and this file already records what gating a send on the throttle's budget cost (v2.13.1's UpdateAvail afternoon). announceMsgThrottled is deleted from all 30 locale files. Grouper carries the same read; it has been told.

Fixed — "Reset settings (keep anti-spam + blacklist)" did nothing, and so did /fgi resetsettings

fn:resetSettings opened with if not (addon.DB and FastGuildInvite and FastGuildInvite.db) then return end. FastGuildInvite there is a bare global, and nothing defines one: FGI_Core.lua's FastGuildInvite is a file-local of the same name, AceAddon-3.0 publishes no global for an addon it creates, and no other file spells it. So the guard was false on every client, and the Settings button — behind a confirmation ending "This cannot be undone" — and the slash command both returned silently, having reset nothing and said nothing. It reads addon.lib.db now, the object OnInitialize actually sets. Found by the first spec ever to call it, which could not get past the same line; four examples in Tests/zz_reset_settings_spec.lua now pin the default restore, all seven kept record tables by name, and the in-memory mirrors cleared.

Fixed — invite type 4's whisper-on-decline was sent from a timer, so on retail it never went out (audit S83)

(Round 91 filed these four as S66–S69; the reviewer renumbered them S83–S86 the same evening because S66–S70 were already this repo's 2026-09-10 self-audit findings. The new numbers are used here and in the code comments.)

Peer-review round 91 read every C_Timer callback in the addon down to the deepest WoW API it reaches — 57 sites in 24 files — and found one that ends in a restricted call: Modules/Scan.lua's CHAT_MSG_SYSTEM decline branch, which on invite type 4 ("Invite, whisper if declined") queued C_Timer.After(1, …) → fn:sendWhisper → C_ChatInfo.SendChatMessage. That function carries HasRestrictions = true in every flavour tree, and neither a timer nor a system-message handler has a hardware event on the stack; on retail the client raised ADDON_ACTION_BLOCKED, the whisper never went out, and because a blocked call is not a Lua error, msgQueue[name] = nil ran anyway and the debug line said "Sending decline message". The mode's one job silently failed with a log saying it succeeded. This is the fourth instance of the class in this repo — the whisperDelay slider (v2.2.4), the Recheck drip (v2.11.6) and the welcome's two timer sends (v2.13.0) were the first three — and the audit board had listed this exact site beside the welcome's on 2026-08-20; the welcome was fixed and this was not. functions.lua's S20 note recording that whisper-on-decline "started working" after v2.10.2 and that the stated cause could not exist is answered: nothing had changed, it was still a timer.

The fix has the welcome's shape, generalised. fn.pumpClick() in functions.lua is now THE click pump: modules register a drainer (fn.registerClickPump), and every click handler that used to call addon.Welcome:Pump() — nine of them, across FGI_Core, the compact tray, Wingman's overlay and the main window's horn — calls fn.pumpClick() instead, which serves at most ONE owed message per click because the client allows one chat message per hardware event. The welcome registers first, so a joiner owed a greeting comes before a recruit owed a decline whisper; Welcome:Pump and its flush/sendWhisper now report whether anything went out. The decline branch queues { name, queuedAt } and Scan.lua's drainer sends the oldest one still owed on the next click, drops one whose msgQueue entry has since been cleared (an "already in a guild" for the same name) as the timer's own check did, and drops one that waited over ten minutes with a chat line (declineWhisperExpired, enUS only — the other locales fall through). A registry rather than a second set of call sites because the compact tray had just demonstrated what happens to a fix that lands on a subset (S84 below). Tests/zz_protected_decline_whisper_spec.lua drives the decline through the real CHAT_MSG_SYSTEM event under the harness's hardware-event guard: nothing is sent from the event, the drain with no click is BLOCKED, the drain inside a click sends, one per click, the cleared-entry drop, and the expiry notice. zz_protected_welcome_spec.lua gains an example that the welcome is reachable through fn.pumpClick, since its registration is guarded for the fixture that loads the module alone.

Round 92, the same evening, found a second defect under the first. invitePlayer writes msgQueue[playerName] with the queue row's own name — on retail always Name-Realm — while the server's decline line for a same-realm player carries the bare Name, and the decline branch looked the whisper up under the server's spelling. So on retail the owed entry was never found, the whisper never queued (timer or click), and the debug line said "not in msgQueue" — the likeliest reason the mode "sometimes worked" and nobody could say why (the S20 note). The branch now resolves the spelling through the pending entry, which carries the row's name, and falls back to the raw name only for an entry that has already gone. The spec had hidden this by seeding msgQueue under both spellings; it seeds exactly what invitePlayer writes now, and a retail-shaped example declines a Name-Realm row with a bare-name line. Also from round 92: "one chat message per hardware event" is this repo citing itself, and is unmeasured — the cap in fn.pumpClick is kept because it costs one click, and Welcome:Pump now obeys it within its own queue too (one send per click, the guild line before a whisper) so the two queues have one rule. The one-line client test, when somebody is at a client: two decline whispers owed, one click, /console scriptErrors 1.

Fixed — the compact tray's >> still overwrote the countdown and still carried the safety timer (audit S84)

S58's fix — the fn.startScanCooldown(DB.global.scanInterval) overwrite and the safety C_Timer.After(libWho:GetInterval() + 5) both removed from the scan press — landed on the Scan tab and the legacy window and not on Modules/compactFrame.lua:596-610. So a tray press inside the /who rate floor showed the floor's remainder for a frame and then the scan interval, and the timer — a timer deciding when the button unlocks, which the standing directive forbids — fired at 7-13 s now that libWho.interval is the rate floor, against a give-up deadline of up to 30, clearing a countdown that was still true and leaving the next press refused as "still in flight". Both are gone from the tray, along with the cooldownGen tag and the file's libWho handle that only the timer read. Tests/zz_compact_scan_press_spec.lua presses the real tray button: a refused press leaves the floor's remainder on the button, and the press schedules nothing.

Changed — one scan press behind all four >> entry points (audit S84's cross-cutting note)

The compact tray, the Scan tab, the legacy window and the F6 keybind button each had their own OnClick doing the same things, which is how S58's fix reached two of them and not the third. They all call fn.pressScan(view) now: refuse while the view's countdown runs, refuse in chat without invite permission, fn.pumpClick(), fn:nextSearch(), then the view's own repaint — and a view hands in only its countdown flag and its after. Two behaviour changes fall out of unifying, both stated: the Scan tab's and legacy window's buttons now drain one owed message on the press (they never did; it is a real click), and the Scan tab's permission refusal prints in the client locale like the other two rather than the UI override. The tray's /fgi debug breadcrumbs ("BLOCKED by cooldown" / "Scan dispatch") now fire for every press, not just the tray's. Tests/zz_scan_press_shared_spec.lua pins the press's four branches directly, then substitutes fn.pressScan with a recorder and presses each real button — the legacy window builds offline, which had never been tried — asserting each routes through it with a locked that reads its own flag. LMW.scanButton is exposed for that.

Changed — code with no production entry point, three sites, deleted (audit S85)

fn.suppressConversationTabFor had no caller — fn.hideWhisper, which its comment named, stopped calling it in v2.4.0 — and its body was unreachable on every flavour regardless: if gv.isRetail then return end followed by if not gv.isRetail then return end, so its three C_Timer sweeps had inflated every timer sweep's denominator by three. API.SetPublicNote / API.SetOfficerNote lost their only caller with "Notes on invite" earlier in this release; the wrappers and the four spec examples that pinned a seam nothing crossed are gone. Announce:StartCooldownTicker had been a no-op since v2.10.9 "so its callers stay safe", with all three callers in this repo, one of them a load-time C_Timer.After(3, …) whose callback did nothing; the stub, the timer and FGI_AnnounceSync's refreshCooldownUI are deleted together. The history of the whisper-tab sweep is kept as a comment above closeDedicatedWhisperFramesForTarget, which stays live through the FCF_OpenTemporaryWindow hook.

Changed — the per-frame refresh throttle exists once, in UI.CoalescePerFrame (audit S86)

The Guild Roster tab and the Eligibility tab each carried a copy of scheduleRefresh, the coalescer between a burst of per-member LibGuildRoster callbacks and refresh(); Eligibility's said "COPIED FROM THE SIBLING TAB". They had diverged: Eligibility kept a if not C_Timer.After then refresh() branch that GuildRoster had removed as one no client can take (UITimerDocumentation.lua is in every tree). One function in GUI/UI.lua now, both tabs call it, and the two tabs' existing refresh specs (zz_roster_refresh_spec, zz_eligibility_refresh_spec) pin the same behaviour through it.

Also from round 91: the SetPropagateMouseClicks family carries HasRestrictions = true as well as IsProtectedFunction, and for that family the flag means the COMBAT restriction, not a hardware-event one — the harness's protected_names.lua labels the whole generated list "needs a hardware event", which would call Wingman's load-time timer and restoreSession false hits. Modules/Wingman.lua's overlay comment now says so, with the tree line; the InCombatLockdown guard in applyPropagation is the restriction that applies. Two stale comments corrected: FGI_Welcome.lua's "shared by the timer and click paths" (there is no timer path) and FGI_MemberHistory.lua's "mirrors the 2.5s welcome-message delay" (the welcome no longer delays on a timer). Suite: 2619 passed, 0 failed. Nothing in this section has run in a client.

Changed — 191 locale strings nothing read any more are gone from all 31 locale files

The locale files had been written to for six years and deleted from almost never, so every removed control, retired sync engine, replaced tooltip and reworded slider left its strings behind — and every one of those strings was still being carried in thirty languages, and still being handed to translators as work. This is the enumerate-and-classify pass the "Notes on invite" removal above promised, done the only way it can be done honestly: Locale/enUS.lua was executed to get the runtime key set (1,429 top-level strings), every production file in the TOCs plus Locale/summary.lua was lexed — every string literal in every quoting, every X.field — and a key counted as referenced if any literal or field spelled it. Then every site that indexes a locale table with something other than a literal was read by hand: the counter strip's abbr/label/desc tables, L["batonRole_" .. role] (the three baton role headings, which a grep for batonRole_ would have called dead), the invite-mode and statistics-period tables, CL.helpVcheck and the other seventeen slash-help lines read through fn.chatLoc() by field, the tab catalogue's loc[def.loc], and the wrappers that all take literals. Two keys that survived only because an unrelated table had a field of the same name (last.day, opts.filter) were classified as the orphans they are.

What went, by family: the pre-v2.5 sync engine's dialogue ("Sync with %s.", "Player %s wants to sync %s. Allow?", "Sync type error", "Syncing..."); the legacy window's labels and its \n-wrapped filter help; the counter legend's nine hand-coloured lines and the joined block, which addon.CounterTooltip builds from scanDesc* now; the two scan-interval slider descriptions that argued for the 2-second Classic floor the operator has since measured away; the old Wingman prompt's four sentence-length captions; the date() format-code help ("AM" or "PM", the character name, weekday) for the note templates; the announce* camelCase keys of the pre-profile announce panel; RAID_DIFFICULTY_NAME_*, which were Blizzard globals copied in as if they were ours; and "Notes on invite" with its nine companions. 191 in enUS, 172 in each of the thirty translations (the rest were enUS-only additions that never got translated), 5,351 statements in all.

The removal was structural, not textual: each file was tokenized, only a statement of the exact shape L[<string>] = <string> whose decoded key was on the list was cut, and each file was executed again afterwards — the after-table had to equal the before-table minus the list, value for value, or the file was not written. A backslash-CRLF inside a quoted key decodes to one newline in Lua's lexer and to two characters in a naive one; the eight multi-line keys in the CRLF-ended locale files matched only after the decoder learned that — and the verification step refused to write those files until it did, which is the kind of thing a regex pass would have left behind silently. Suite: 2613 passed, 0 failed.

Fixed — the new reset-settings spec took ninety-five later examples down with it

Tests/zz_reset_settings_spec.lua captured print inside each example and restored it on the way out — and its first example errored between the two: db:ResetDB() fires AceDB's OnDatabaseReset, which FGI_Core.lua:869 answers with addon.API.ReloadUI(), and in the whole suite that callback is on the shared database while the harness defines neither C_UI.Reload nor ReloadUI. The file passed alone, where nothing had registered the callback. With print left captured, every slash command spec after it printed into a dead table and asserted on nothing — 98 failures, three of them real. Its second example then replaced DB.factionrealm.history with a table holding only invites, which the keep-list faithfully carried through the reset and left live for every /who spec after it (Modules/history.lua:35: table.insert(nil)). print and ReloadUI are now restored in after_each, the reload is a spec-local no-op until the harness carries the API, and the example writes its entries into the default tables and takes them out again.

Tests — the coverage catch-up begins, and finds three things

The 2026-08-16 order put coverage last, and it had never been run: the desk's coverage call was being killed at ten minutes by the Claude Code hook default, not by writ. ~/.claude/settings.json's desk-ingest hook now carries "timeout": 3600, and the gate runs. Starting numbers, four targets: functions.lua 80.18%, Modules/Announce.lua 81.72%, GUI/Tabs/GuildRoster.lua 88.02%, FGI_APICompat.lua 98.98% — 82.15% overall. After this pass: FGI_APICompat.lua and GUI/Tabs/GuildRoster.lua at 100%, Announce.lua at 99.3%, functions.lua unchanged in percentage but with the accept path, the auto-kick watcher and the dead note code accounted for.

  • What was found, beyond the two removals above. FGI_APICompat.lua ended with a debug line guarded on addon.functions.debug, which does not exist until functions.lua loads four files later — dead on every flavour, deleted. GuildRoster.lua guarded C_Timer.After twice for a client without it (UITimerDocumentation.lua is in every tree), kept an else for a percentage row with no members (a row only exists for a counted member), and had a two-line tie-break that is the one-line a.nameSort < b.nameSort; all simplified. Each was a branch no client could take, and each counted against the number.

  • The LibWho harvest spec was a coin toss, and the coverage run is what showed it. Its reload of LibWho.lua built a whoFrame that is a file-local no closure captures, so after the chunk returns only the env's weak registry holds it; the plain suite passed every time and the instrumented run — whose line hook allocates on every line and collects far more often — failed "one of the two queries never reported at all" every time. app.load() already pins FGI's own copy for exactly this reason (finding S57); the spec's reload now runs under frames.retainDuring too. Not a race in LibWho; a reachability hole in the fixture.

  • New specs. Tests/zz_roster_sections_spec.lua renders the Guild Roster tab over a real cast for the first time — offline members counted, class colours, the # header sorting descending then ascending with the arrow following, the empty-roster and library-missing lines, both baton tooltips, the Step aside / back in click, and the baton menu opening under its title. It stages two client facts the harness did not model, in the example bodies, and raised both with the harness: RAID_CLASS_COLORS, and the Text FontString InterfaceOptionsCheckButtonTemplate carries (CheckButtonTemplates.xml:54, all four trees). Delivered the same evening in harness 7e929c2; pin moved to bc75950 (commit 85a8660), both stand-ins deleted. Two corrections from the delivery, read from the client's own ClassColors.lua: the Classic Era table has twelve keys, not ten (DEATHKNIGHT, MONK and DEMONHUNTER are declared), so the unknown-class fallback is driven by removing a key rather than naming one of those; and SHAMAN is Paladin-pink on Era. The colour example derives its expected hex from the harness's entry rather than a literal. Tests/announce_channels_spec.lua covers public channels end to end — slot resolution, the echo that confirms a post and the three lines that do not, activity counting by base name, the designate gate's three refusals, the chat limit retried once and then filed with the client's words, a guild announcement's cooldown broadcast, and the profile editor's Create/Duplicate/Delete including the rotation-membership sweep. Tests/zz_record_accept_spec.lua drives a guild join from the system message to the books: every join counted, attribution from each of the three pieces of evidence alone, the retention window, the thirty-second dedup, and the decline-whisper cleanup. Tests/zzzz_blacklist_autokick_spec.lua drives the setting rather than the sweep: the login sweep deferred until the library's roster is built (the v2.12.1 fix, asserted for the first time), the fallback rows, and the joiner watcher with its lockdown gate and one-frame guard.

  • Also new: Tests/zz_stale_pending_spec.lua — the state-driven sweep of invites that never resolved, which had never run offline: a Type-1 entry past the cutoff is filed Unresolved (a stat, never a History row) and forgotten, a Type-3 whisper-only entry is cleared silently because no outcome arriving is its happy path, the caller's own cutoff is honoured, and fn:clearPending's Unresolved branch counts on both the session counter and the lifetime bucket.

  • Tests/zz_settings_widgets_spec.lua is rewritten around its guard. The seven examples that drove FGI_TooltipInput went with the widget; the orphan guard that caught it is generalised to every local Type, Version = "FGI_..." registration, and its twin — every dialogControl names a registered widget, which the file had described as "already green" without writing it — is written.

  • Later the same day: Modules/Scan.lua and Modules/Announce.lua to 100%. Scan.lua's remaining 17 lines were four decline-debug sites each carrying an else print(line) arm that could not run (functions.lua precedes this file in every TOC), the canaccessvalue gate on the AFK/DND handler that API.RegisterChatEvents had already made unreachable, the veteran-account refusal and the sent-invite English fallback nobody drove, the empty "invite sent" receipt branch, and the PLAYER_LOGOUT drain. The unreachable arms are deleted; the rest are driven — the logout drain through the real event, the retail pcall arm by making the classifier raise. Announce.lua's one line was a guild/officer guard inside resolveChannelIndex that all three callers had already applied.

Suite: 2635 passed, 0 failed. Nothing in this section has run in a client.

Fixed — /fgi debugtabremove reset the player's entire chat layout, and /fgi debugtab could not find a free slot once any tab had ever been closed

The debug chat tab (functions.lua, "dedicated debug ChatFrame routing") sat at 0% because the FCF_* globals it calls are not in the harness. Reading Blizzard_ChatFrameBase/Classic/FloatingChatFrame.lua to model them found that all three of the cluster's assumptions about Blizzard's chat SV were wrong:

  • Removing the tab wiped the whole chat layout. fn.removeDebugTab called FCF_ResetChatWindows() — the client's Reset Chat Windows (:1525), which closes every tab but General and Combat Log, strips their message groups and channels, and resets their font size, colour and alpha. It also renamed the slot "Combat Log" first, with the loop index in FCF_SetWindowName's doNotSave position, so a second Combat Log tab was left behind. Now FCF_Close(f) on that one frame — the call the tab's own right-click Close Window makes.
  • "No available chat-frame slot" with eight free. The slot hunt looked for a saved name of "", and Blizzard never clears a name on close (FCF_Close, :1432, undocks and hides). Once the player had ever created and closed any chat tab, every slot looked taken. The window now comes from FCF_OpenNewWindow(name, true) — Blizzard's own New Window, present in every flavour — whose free-slot test is not shown and not docked and not builtin (:433).
  • A closed tab was re-found after /reload, so every debug line went into a hidden frame. Same fact the other way round: both finders matched by name alone. A live tab is now one that is shown or docked — both, because the dock hides every docked frame but the selected one and the frame's OnHide saves shown = false (FCFDock_UpdateTabs :1875, FloatingChatFrame.xml:589).
  • The re-found branch also force-docked a tab the player had deliberately undocked; it no longer moves it. SetTimeVisible went, since fading is off. The adopt-and-hook-OnShow block was written three times and is one adoptDebugFrame; the OnShow hook now checks the frame is still the debug frame rather than that one exists, so a slot the player later reuses stays inert.

Staged Tests/support/chatframes.lua — the SV setters, the ScrollingMessageFrame methods and eight FCF_* functions transcribed from the Blizzard source with line citations — and raised it with the harness as a contract (inbox 01ef0f1874db). Tests/zz_debug_tab_spec.lua (20 examples) drives create, route, repaint and remove through it; three examples are the defects above and fail against the old code. .luarc.json loses the four FCF_* names nothing calls any more.

Delivered by the harness the same evening and pushed on 2026-09-13 as 1037beb; pin moved to 1211a3a, the stand-in deleted. The spec's helpers now read the window record through GetChatWindowInfo and the selected tab through FCFDock_GetSelectedWindow, and each example starts from FCF_ResetChatWindows() — the client's own starting state, General and Combat Log docked at 1 and 2 — instead of wiping the record table by hand. Three corrections from the delivery, none of which touch the addon: isDocked is the number 1 on the frame and the saved docked is the dock position, not a boolean (FloatingChatFrame_Update:253 feeds it back in as an index — the finder's shown or docked was already truthiness); CHAT_NAME_TEMPLATE is "Chat %d"; and AddMessageGroup consults ChatTypeGroup with exact case, so the strip-groups example now asserts its "GUILD" was stored before asserting it was stripped. The same pin brings the harness's string.format("%d") overflow check (raises over 2^31, as the client does) — the whole suite ran green against it, so no %d in the addon takes a value that large.

Suite: 2676 passed, 0 failed. Not verified in a client — in particular, FCF_Close from a slash command has not been checked against the retail taint history in the v2.5.4 entry; the call it replaces ran FCF_Close on all ten frames.

Changed — the race/class tables are data for every branch, written in class keys, and asserted from one suite

functions.lua built only the running client's RaceClassCombo inside an if/elseif on the version flags, so the retail, Mists and TBC literals were dead on a Classic Era client and unreachable offline — a row lost in a regeneration (retail once shipped without three whole races) could not be caught by a spec. They are now one RACE_CLASS_KEYS_BY_BRANCH table, all built at load, with fn.raceClassBranch(gv) picking the branch and fn.raceClassComboFor(branch) materializing it. Wrath/Cata is built from TBC's table plus Death Knight rather than listed — and as a copy, where the old code inserted into the TBC table in place.

The entries are class KEYS, not localized names, and the first cut got that wrong. L.class is per-flavour (Locale/summary.lua:82-93 adds Death Knight, Monk, Demon Hunter and Evoker only where the client has them), so a retail table written in CLASS.Evoker names is full of nils on Era; the spec's own failures found it. The key enum errors on an unknown key rather than yielding nil, so a typo in a generated row fails at load instead of becoming a hole. Tests/zz_raceclass_branches_spec.lua (11 examples) pins the flag-to-branch pick, the counts per branch (26 / 13 / 10 / 8), the two rows the old hand-written Mists table had lost, Wrath = TBC + Death Knight with TBC untouched, and that this client's table resolves every key.

Suite: 2686 passed, 0 failed. Not verified in a client.

Tests — functions.lua reaches 100%, and eleven arms that no client could take are gone

From 85.9% at the start of the evening to 3038/3038. What closed the gap was mostly reading, not specs: each remaining branch was traced to the Blizzard source or the loader before being driven, and most turned out to be unreachable on every flavour. Deleted rather than covered:

  • fn.getTime's return time() — every flavour tree's CommunitiesTicketManagerDialog.lua calls GetServerTime(), and the file captures it as a local at load, so the spec that "drove" the fallback by nilling the global never reached it and passed vacuously. Both gone.
  • fn:messageSplit's character-counting splitter "for an older LLO without SplitToBytes" — LibLocaleOverride is a ## Dependencies: entry and has carried the method since v0.2.0.
  • The SetCVar / GetCVar / C_Timer.After existence guards in the whisperMode block, the elseif ChatFrame_RemoveAllMessageGroups deprecation arms, the retail pcall(fn.fullPlayerName) arms in hideWhisper / hideAwayResponse (Blizzard's filter registry never calls a filter with a secret value — ChatFrameFilters.lua:115-119), safeLower's retail arm (its only caller is installed on the Classic family alone), the inline lockdown gate on the auto-kick handler (the RegisterChatEvents wrapper installs it), locationSplit's empty-results guard (nextSubdivisionAxis is the one decision point), seven (loc and loc[k]) or "English" fallbacks on keys in enUS, and the debugDB file-local snapshot that fn.debug tried before the live read it fell back to.

Driven, in nine spec files: the whisperMode save/restore end to end on the harness clock (zz_whisper_popout_spec.lua, 11), the scan queue's three sources and the bucket wrap (zz_scan_queue_population_spec.lua, 8), every scan refusal and receipt line with the mute off, the race/class compatibility skips in both filter schemas, the female-form lookups on both paths, fn.refreshQueueRun's five answers, the login auto-sync timers, and the per-row restore guard with a keyer that raises. Two order-dependent leaks found by running files in a non-alphabetical batch — zz_who_subdivision_spec left a subdivision tier off, the new queue spec left subdivideLvl off — both now restored in after_each.

The rest of the addon, measured for the first time over its first twenty files: 72.84%, per-file numbers on the todo list. Modules/FGI_ScanGroups.lua went from 68% to 100% the same session (Tests/zz_scan_groups_data_spec.lua, 15 examples: the group and member helpers, the two sweeps the custom-scan editor runs on delete and rename, and the bucket announcement).

Suite: 2760 passed, 0 failed. Not verified in a client.

[v2.13.0] (2026-08-27) — The guilded-players-on-the-scan-list bug is found and closed, policy lists learn about alts, auto-blacklist guild leavers comes back from the dead on Classic, and Clear moves next to Reset

New — settings templates: hand your whole setup to another recruiter

Operator's request, and it decides the shape of everything below: "this isn't a back up of 'data'. this is the backup of a UI settings format that user A with a ton of EXP and a curated setup can give to a new user B to get them up and running QUICKLY." And on who chooses what is in one: "YOU dont' chose what goes into a template, the USER does. you should expose ALL the settings, and then let the template create decide."

Four pieces, and they are deliberately separable. Modules/FGI_SettingsProfile.lua is the engine — a catalogue of every user setting, an Export that takes the author's selection, a library that received templates land in, and the one function that writes settings. Modules/FGI_TemplateWire.lua is the transport. /fgi template is a slash surface. GUI/TemplatesPage.lua is Settings → Templates, which is where a recruiter actually uses it.

A CATALOGUE, NOT AN ALLOW-LIST. The first draft had me deciding which settings counted as "transferable expertise" and quietly dropping the rest — a policy about somebody else's setup, written by the wrong person. Every user setting is offered; the author ticks. What stays out is only what is not a setting, which is a different test from "not worth sharing": records of what happened (blacklist, history, who has been asked), runtime state (which window was open), and guild-replicated policy. Each exclusion carries its reason as a value rather than a comment, so Tests/zz_settings_profile_spec.lua can assert every key is classified.

The catalogue was built by enumerating the Settings panel's real DB reads, not the defaults block — the defaults-block version missed fifteen settings, including subdivideOrder (an entire shipped feature) and autoWelcome, whose absence would have shared the welcome whisper settings while silently dropping the welcome itself. A sweep now walks the real defaults and fails the suite on any key in neither table, so the next setting added cannot go missing here.

Nothing the guild sync carries goes in a template. The operator: "we don't need to add anything to the template that is synced. our sync already takes care of it." This is not merely redundancy — a template is a snapshot, every DeltaSync item is a live arbitrated record, so a template copy either loses the arbitration and did nothing or wins it with stale data. gmPolicy is the worst case: one guild's recruitment rules pushed into another's. All eight sync items are excluded, and the spec cross-references the live ITEM_KEYS so a ninth added later breaks the suite until somebody confirms where it lives.

Arrival cannot change a setting. The operator: "it should just add a template to the recipients list, that they can then enable or not as they see fit. it SHOULD NOT overwrite what they have." That is a stronger safety property than the offer/accept handshake it replaced: with a library there is nothing to negotiate, because the dangerous act does not happen. Store puts a template on a shelf and writes no setting, ever; Apply is called only by a deliberate local press. The worst a hostile sender achieves is a row the recipient deletes. The author is taken from the transport, never from the payload — a payload-supplied author is a string the sender chose, so it could name anybody.

Not through DeltaSync, and that is the load-bearing decision. DeltaSync is for guild-wide convergent state: advertise a hash, arbitrate, agree. A template is point-to-point, one-shot, and the recipient edits it immediately — there is nothing to converge on, and advertising a hash for "my template" would be meaningless traffic. Modules/FGI_Baton.lua is the precedent and its header records the same reasoning: own prefix, AceComm for chunking, AceCommQueue for retry and a real delivery verdict. A ceiling of 64 KB is enforced on both sides, because the sender's cap is a courtesy a hostile client would simply not apply — this addon has already lived through a 40 MB saved-variables file that silently failed to load.

Settings → Templates. A picker with one tick box per setting, grouped the way the Settings panel groups them; a name; Save. Below it, every template you have saved or been sent, each with Apply, Forget, a Send box (with a dropdown of guildmates FGI has heard from, plus a typed name for anyone else), and the template as copyable text. At the bottom, a paste box for a template somebody sent you outside the game. Apply is the only destructive verb on the page and the only one behind a confirm — receiving, saving and sending change nothing, and there is no undo for applying.

The labels are READ OUT OF THE SETTINGS PANEL, not copied from it, and this was the actual work in the page. The catalogue holds a scope and a DB key, and the one thing a picker may not do is show a player global.autoBlacklistLeavers. Every one of those words already exists in the panel's own AceConfig tree, keyed by the same DB key and already translated — so GUI/SettingsPanel.lua now publishes its tree as addon.settingsOptions and SP:Label walks it. Keeping a second table of eighty labels would mean the picker and the checkbox say different things the first time somebody rewords one, silently. The walk is sorted and first-wins, so a key on two pages resolves the same way every session. SP.LABELS covers only what the walk cannot answer: settings edited on a main-window tab rather than in the panel, and the three keys the catalogue carries in two scopes while the panel names one (addonMSG, messageList, curMessage).

Applying names what it will destroy, and that warning went in wrong first. Raised on the audit board (round 90) before this page existed: applying replaces hand-built collections whole — filters, custom scans, whispers — and nothing warned about it. SP:WouldReplace is now shared by the page's confirm and by /fgi template apply, which had no confirmation at all while the page had one and is now gated behind an explicit apply <n> yes. The first version warned whenever the recipient's collection was non-empty, and four specs went red immediately — FGI ships default whisper templates, so "they already have one" is true on a client that has never been touched, and an ordinary save-then-apply round trip raised a loss warning about settings identical to its own. That is precisely the dialog people learn to click through, so it now warns only when theirs is non-empty and differs, by deep comparison with a depth cap of 12 rather than a seen table — failing toward warning, because a cycle in a hand-edited payload would otherwise hang the client inside a confirmation dialog. Replace-not-merge itself is unchanged and deliberate; the defect was that the cost was silent.

The library cap was on the transport and belonged on the shelf — a defect this feature introduced and I found by writing a review request rather than by testing. MAX_LIBRARY was checked in FGI_TemplateWire's receive handler only, so it bounded templates that arrived. When the page added a Save button and a paste-in box, both called Store directly and neither was bounded: the one path a griefer can use was capped, and the two a player uses were not. It matters because this addon has already lost saved variables once to an unbounded table — the FGI_DEBUG runaway, a 40 MB file that silently failed to load. The cap now lives in SP:Store, so every future entry point gets it for free, and the wire reads through rather than keeping its own copy. The ordering is load-bearing and control-tested: the replace branch is checked before the cap, because replacing does not grow the shelf — with the check first, a full library becomes read-only to its own owner.

A row that appears while you are looking at it. The library redraws from a callback on the library itself, not a poll or a rebuild-on-open — the case that matters is a template arriving over the wire while the recipient has the page open, and a silent page reads as the sender having failed to send it. Forget finds its row by name and author, never by the index it was drawn at, because something can land in between and deleting the wrong template is unrecoverable.

Covered in Tests/zz_templates_page_spec.lua (22 examples) alongside the engine and wire specs. The label sweep is a completeness check rather than a behaviour one: it fails on any catalogue entry that resolves to its own key, which is what falling through both the override table and the panel walk looks like. Two examples drive the real AceConfigDialog and read the text off the rendered checkboxes, because an args table can be perfectly well-formed and still draw a page of raw DB keys. Control-tested: breaking SP:Label turns exactly the 61 derived labels red in all three label examples, and removing the library callback turns every library-row example red — so neither half is being carried by the other.

Still not verified in a client, and the wire has never carried a real payload. A template with filters plus custom scans is multi-KB against AceComm's ~250-byte chunking and a hard client throttle; the 64 KB cap is a wall, not a measurement.

New — right-clicking Wingman offers the baton as well as the steps

Operator's request: "Right click wingman also handle batons?"

Right-clicking the Wingman toggle already opened the step menu (announce / invite / scan ticks). It now carries the baton operations underneath them — Take, Request, Assign to, Back to the list order, per role — so duty can be moved from the compact tray without opening the Guild Roster tab.

The entries are the same function, not a copy. showBatonMenu was a closure inside GuildRosterTab.Render, and that mattered: the entries did not exist until that tab had been rendered, so a right-click from the compact tray — the most likely place to want them — would have found nothing, and no spec would have caught it because a spec renders the tab first. It is now GuildRosterTab.AddBatonEntries(level, menuList), which adds buttons rather than owning a frame, so either menu hosts it. The tab's own Baton... button is now a thin host over the same call. This follows the precedent already in Modules/Wingman.lua: ArmMenuCloseWatcher was exposed a version ago for exactly this reason, because two menus each with their own outside-click watcher both call CloseDropDownMenus and shut whichever menu is open.

Flat at level 1, not behind a submenu. The baton's own Assign to already opens a submenu, so nesting the whole block would put candidate names at level 3 to save one line.

A defect caught while writing it, worth recording because the wrong version looks right. The first cut gated the new section on BatonOptions(role) ~= nil. That is always true whenever a Recruiter exists — the function returns a table for every role, and it is the four booleans inside that say whether anything can be done. So the menu would have drawn a "Recruiting duty" heading and three role headings with nothing under them, for exactly the member most likely to see it: one who holds no duty and is named in no policy list. The gate is now GuildRosterTab.HasBatonActions(), and AddBatonEntries skips a role whose four booleans are all false — which also removes the same empty headings from the Guild Roster tab's own menu, where they were already being drawn.

Covered in Tests/zz_roster_duty_line_spec.lua: the gate answers no when nothing applies and yes as soon as one operation does, it does not raise before the Recruiter exists, and — the one that matters — the gate and the drawer are asserted to agree, by driving the real AddBatonEntries with the dropdown API captured and counting buttons. They are two functions reading the same four booleans; that example is what stops one being edited without the other and putting the empty headings back.

And covered where the feature actually is, in Tests/zz_wingman_step_menu_spec.lua — which the first round of tests for this did not do. Those covered the extracted helper in isolation, so deleting the entire baton block from Wingman:ShowStepMenu left the suite green. Four examples now drive the real registered initialiser and read the real buttons the harness records: the duty section appears when an operation applies, the three step ticks survive alongside it, no section is drawn when nothing applies, and level 2 reaches the baton's assign candidates rather than redrawing the steps into the submenu.

Control-tested, with the result recorded in the file rather than claimed. Backing the feature out turns two of the four red — the two that are the feature. The other two stay green and that is correct: one guards against the baton displacing the steps, and the other passes vacuously with the section gone, its real property being pinned by the button-counting example instead. That is written into the spec header so nobody reads four passing examples as four proofs.

Fixed — Q4: with no welcomer list configured, every officer welcomed the new member

The welcomer list exists so that a new joiner gets one welcome rather than one per officer running FGI. But amDesignateFor answers true for a nil list, so leaving that one list blank turned the protection off entirely — the exact duplication it was added to prevent, visible to the new member as N identical whispers.

The remedy is the operator's and it is better than the three that were put to them (coordinate over announceSync, auto-elect from the online FGI-running set, or close won't-fix): "why wouldn't we just use one of the priority lists?" An unset welcomer list now inherits the announcer list. welcomerList() is ownWelcomerList() or priorityList(), so a configured welcomer list always wins — this is a fallback, never an override — and every consumer already routed through that one function, so the election, CanWelcome, the baton's MayHold/CandidatesFor, the duty readout and Settings all agree without being told separately.

Why inheriting beats coordinating. It is a pure function of replicated policy plus the live roster, so two clients cannot reach different answers. Coordination needed a join-time message exchange and a tie-break; auto-electing from "who is running FGI" leans on presence stamped by comm traffic, which is not symmetric between clients — it could produce two welcomes or none, failing as the very bug it fixes. Inheriting adds no message, no state and no clock.

It leaves the case that was already right alone. priorityList() is nil when the announcer list is unset or the policy is inactive, so a guild running no policy still falls open — and with a single FGI install that is exactly one welcome and correct. That is also why falling closed was never the answer: it would trade a duplicate needing several FGI users for a silence needing none.

The announcer list and deliberately not the inviter list. The inviter list is separate precisely because random invites get the throwaway alt server-wide /ignore'd — it is the list of characters you do not want speaking for the guild. A welcome is a whisper from someone representing it.

Two defects came out of building it, both found by the suite. A single handover began printing two duty notices — "You are now the designated announcer." followed by "…designated welcomer." — because the inherited welcomer is the same election; both lines were true, which is what made it a readout defect rather than a wrong answer, and the welcomer is no longer reported separately while inheriting. And the control run caught a duplicated predicate in the fix itself: the readout's "is it inheriting?" test re-derived the condition from the DB instead of from welcomerList, so with the inheritance removed it stayed true and would have gone on announcing an inheritance that was not happening — with all six new examples still passing.

Control-tested: removing the inheritance turns three of the six new examples red. The other three guard cases the fix must not change (a configured list still wins, no policy still falls open, an inactive policy is not inherited from), so its absence is not their failure mode — recorded in the spec header rather than left for six passes to be read as six proofs.

Because HasWelcomerPolicy() now answers true whenever the announcer list is set, the inheritance is stated rather than silent: the Guild Roster duty line carries "(welcomer follows the announcer list)" as a trailing note, the same shape as the existing "(you stepped aside)".

Fixed — nobody had ever clicked a baton menu entry, and the duty line was repainted twice when they did

Operator's question, which is the whole finding: "have you build an end to end test for the baton framework? we've done a lot of changes, and we need a full end to end test. this should include the wingman right click options as well." The answer was no, and the section above is why it looked like yes.

Two thorough halves with nothing joining them. Tests/baton_roundtrip_spec.lua drives the protocol end to end — real TakeBaton/AssignBaton/ReleaseBaton produce a real payload and the real OnCommReceived on a second client applies it — but it never opens a menu. Tests/zz_wingman_step_menu_spec.lua drives the menu, but with a Recruiter double whose baton methods are function() end, and it asserts button text. So no spec had ever invoked a baton info.func: every click handler in AddBatonEntries could have had its body deleted with the suite still green, and the shipped failure would be the entry drawing, the click landing, the menu closing, and nothing happening anywhere.

Tests/zz_baton_e2e_spec.lua (new, 11 examples) closes it, and simulates only the radio. The real menu is opened, the real buttons the harness records are clicked, the real Recruiter produces the payload, SendCommMessage is captured, and that exact string is handed to the real OnCommReceived on a second client whose election then has to move. All four operations: take, assign (with the release driven through the menu after it, so the two stamps are in issue order), request — including that the holder is actually told, which nothing asserted before, so an inert Q branch was green — and pass, typed as /fgi stepaside through the real Recruiter rather than the dispatcher double.

Then the defect it found. Every one of those handlers ended by repainting the Guild Roster tab's duty line. But AssignBaton, ReleaseBaton, StepAside and StepBack all call onBatonChange() before they return, and that already calls GuildRosterTab:RefreshDuty() — the same line. It was painted twice on every baton click. Found by measurement, not by reading: the new example asserted one repaint and got two. Three redundant calls deleted.

And a comment that defended one of them was false. The step-aside button's read "Not left to the baton callback: StepAside/StepBack are guild-synced, and the round trip is what a user reads as the button not having worked." There is no round trip to wait for. Worth recording that the first fix kept that call and justified it as deliberately different — on the strength of that comment rather than on reading StepAside, which took one look and reversed it. The replacement quotes the old wording and says why it was wrong, and the deletion is pinned by its own example rather than by the argument.

Control-tested, with the count in the spec header. Backing all four click handlers out — entries still drawn — turns seven of the eleven red. The four that stay green are named individually with the reason, including one that asserts an absence and therefore passes vacuously under the control; it is marked as not being evidence that Request works.

Fixed — the Eligibility tab's strip: a label clipped off the panel, and a search box in a spelling nothing else uses

Both reported from one screenshot, and both in GUI/Tabs/Eligibility.lua.

The "Show all guild members" label ran off the right edge of the panel. InterfaceOptionsCheckButtonTemplate anchors its .Text to the box's right, and this control sits at the strip's right edge — so the label grew out of the window and was clipped mid-word. Moving the box left would only have traded one problem for another, because the label's width depends on the locale and on the override font. The label is now anchored to the box's left, so the pair grows into the strip at any width in any language, and the text is shortened to Show all ("it doesn't need to be that long") with the full sentence kept as the tooltip title.

The strip builds its own FontString rather than re-anchoring the template's. Taking over a field the template itself anchors, and then overriding it, depends on template internals — and the harness does not model .Text at all, so the whole control was untestable offline. Owning the FontString costs one line and removes both problems; the template's own label is emptied so nothing draws twice.

The tooltip is on the words as well as the box, and the words are clickable. The label is now the larger target and the thing the eye lands on, so a tooltip answering only over the 26px box read as no tooltip at all. A FontString cannot take mouse input, so an invisible button is anchored to the label's own corners. Clicking it toggles the box, which is what a label beside a checkbox means everywhere else in this UI.

The toggle is explicit rather than CheckButton:Click(). The label's handler set the value directly instead of relying on Click() flipping the checked state for us — true in the client, and something this code had no reason to depend on. One function now owns the local, the box and the repaint, so they cannot disagree about which of the two controls was used.

The search box is SearchBoxTemplate, like every other list tab. It was an InputBoxTemplate with a hand-written Find FontString glued to its left — one concept in a second spelling, and the spelling nothing else in the addon uses. docs/AUDIT.md had already named exactly this shape as a finding on the Blacklist tab. The template supplies the magnifier, the greyed Search placeholder and the automatic X clear button, so the label had nothing left to say and went with it — which also removes the cramped gap the report opened with. Handlers are HookScript, not SetScript, because the template owns the placeholder and the X's visibility through its own; Esc wipes the filter rather than merely dropping focus, matching History and AntiSpam.

New spec, Tests/zz_eligibility_strip_spec.lua. The other three Eligibility specs skip Render by design — "it is anchor arithmetic" — which was a reasonable line right up until anchor arithmetic was the defect. A screenshot is a bad place to find out that a label does not fit. It asserts the label's right edge against the strip's own right edge rather than a magic number, that the label is left of the box (so a longer translation cannot silently reintroduce the bug), the short label, a mouse-catching frame over the words, the click-to-toggle, the absence of a Find label, exactly one edit box, and Esc clearing the filter.

Fixed — a server refusal never stopped anything, so those players were re-invited forever

The whole branch was unreachable. When the server refuses a guild invite outright — a Free Trial account, a lapsed veteran account, or someone of the opposite faction — the message carries no player name, so the handler is written to attribute it to the one invite in flight. It never got the chance: a guard above it returned early on exactly the missing name that outcome is defined to have. So the refused player was never remembered, never cleared from the in-flight list, and every later scan offered them again.

Found by writing the coverage the tab had never had: those lines were uncovered because nothing could reach them, which is the one reading of a coverage gap worth more than the number.

Fixed — the fix above left a nameless outcome loose in branches that all assume a name

The way that unreachable branch was reached again was an exemption on the guard — if not name and type ~= "guild_reject" then return end. It works, and it is the wrong shape: it lets a nil name past into a chain whose other outcomes all index addon.msgQueue[name], and a nil table key is a hard error in Lua rather than a silent no-op. Three language-server reports said so at those exact lines; they were right, and only the single-in-flight invariant kept it from firing.

The refusal is now settled ahead of the guard, in handleGuildReject() beside handleNotFound() — and exposed as addon.Scan.HandleGuildReject for the same reason that one is: the caller is a load-time frame no zz_-sorted spec can drive. The guard goes back to a plain if not name then return end, so every branch past it provably has a name. Behaviour is unchanged: same next(addon.pendingInvites) attribution, same anti-spam promotion, same msgQueue clear.

Fixed — a second system-message handler could taint the interface on Retail

Modules/Scan.lua has carried a chat-lockdown guard since v2.7.1: during an encounter, a Mythic+ run or a rated match, the text of a system message is a protected value, and reading it both raises and marks this addon as the cause — the taint cascade that has cost four separate releases.

There is a second handler that listens to the same event — the one that notices somebody joining your guild — and it never got that guard. It read the message directly. Same event, same window, same consequence; it had simply been missed when the first one was fixed. It now checks the same state, and the Classic family is unaffected either way because it has no protected values.

Fixed — a login error that silently caused the "it invited me twice" reports

Two reports that looked unrelated turned out to be one bug.

The error, from a retail client: functions.lua:1077: attempt to concatenate a nil value, in normalizePlayerName, called from restoreScanState during addon start-up. The retail branch appends your realm to a name, guarded by if GetNormalizedRealmName then — a check that the API exists, which it always does. At login it returns nil, and name .. "-" .. nil raises. An existence check reads like a safety check and is not one: the API being present says nothing about whether it can answer yet. It now tests the return.

Why that also invited people twice. The restore puts each saved candidate back into the queue and registers them in the session's already-queued set — the thing the scanner asks "do we already have this person?" — in the same loop. The error aborted that loop, so rows landed in the queue with their registrations never written. The scanner could not see them, so when a later /who found the same player — typically after they had changed zone, which is what puts them in a different query — it treated them as new and queued a second row. Both were then invited, and because the first invite had not resolved yet, the anti-spam list was still empty and caught nothing. Reported as "someone zoned and is on the list twice, that's where the already invited me comes from".

The loop now registers each row independently, so a future error costs one row rather than the whole set, and says so in the debug log instead of failing silently.

New — a Guilded column on the Scan tab

Requested: "i need you to add another column to the scan tab, guilded and the data should be Y/N. i don't need to know which guild, just if they are."

Every queued candidate now shows whether they were in a guild the last time a /who saw them. A scan already discards anyone found in a guild, so a freshly queued candidate always reads N — which is what makes a Y worth seeing: it means the row reached the queue some other way, and that is exactly the class of thing behind the standing "FGI whispers people already in a guild" reports. A dash means the row predates the column. The reading was always on the row; it was simply never shown, so every report became an argument about whether it was happening instead of a look at the list.


Older releases have been moved to CHANGELOG_ARCHIVE.md — v2.12.2 and earlier in full, plus the thirty-eight oldest sections of v2.13.0 itself. Ten went on 2026-09-09, twelve more on 2026-09-10, eight on 2026-09-11, four on 2026-09-13 and four on 2026-09-14, as each later entry put this file back over the working ceiling. Expect to move more: every entry added pushes another one over.

This mod has no additional files