Ultimate Guild Roster

Ultimate Guild Roster - Guild Roster Management, Shared Across Your Guild
Back to Files

UltimateGuildRoster-v0.2.0

File nameUltimate_Guild_Roster-UltimateGuildRoster-v0.2.0.zip
Uploader
PmptastyPmptasty
Uploaded
Oct 14, 2026
Downloads
9
Size
202.9 KB
Flavors
RetailMoP ClassicClassic TBCClassicForever
File ID
9182896
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 5.5.4
  • 2.5.6
  • 1.60.1
  • 1.15.9

What's new

Changelog

All notable changes to Ultimate Guild Roster. Work in progress sits under [Unreleased]; the version number is decided at release prep.

[0.2.0] - 2026-10-14

The MVP features: roster tracking, the event log and Log tab, alts and mains, the ban list, member details, the GRM import, the Roster, Alts, Bans and Sync tabs, and the export window; plus the macro tool's and stats' logic with no UI yet. Almost none of it has been seen in game; the GRM import window was (its text-overlap fix below came from the operator's in-game report).

Needs LibAceGUIWidgets v0.5.0 (MINOR 43), released 2026-10-14 (tag LibAceGUIWidgets-v0.5.0). On an older LibAceGUIWidgets, one Escape in combat on any UGR question also closes the main window; the settings page shows check boxes instead of toggle tokens; and a language switch rebuilds the ban and note dialogs instead of relabelling them.

Added: clearing the log, and a warning when it grows big (LOG-07, LOG-08; EL-15, EL-16, EL-20, EL-22 to EL-25)

  • New EventLog:Clearable(mode, arg) and EventLog:Clear(mode, arg): "filtered" (the rows the Log tab shows), "range" ({ from, to } server epochs, from inclusive, to exclusive) or "all". Clear needs the general edit threshold (ns.Sync:WhyNotAuthor("general"), refused before anything is touched), keeps the order of what stays, logs nothing about itself, and sends UGR_LOG_CLEARED(count) when any went. An unknown mode, or a range without two numbers, raises a named error (Peer Review F4, thread 38bd2214). Changed from the design: event-log.md's range was log indices; a player picks days, not positions.
  • New EventLog:CheckSize() and EventLog.SIZE_WARNINGS = { 50000, 100000 }, run after every Add, AddChanges and AddImported that added something: one chat line, in the chat language, to the chat window settings.chatFrame chose (Peer Review F3), the first time the guild's log reaches each size. Remembered in the guild's new logWarned table (an AceDB default in SavedVariables.lua); a clear forgets every size the log is now below.
  • The Log tab's three Clear buttons are built (they were greyed "Not built yet."). Clear shown entries and Clear the whole log ask first with the count; Clear a range opens a form dialog (ns.Constants.LOG_RANGE_DIALOG_NAME, "UGRLogClearRange") filled with the oldest and newest entry's days, which refuses an unreadable date, From after To or an empty range under the dates, then asks. Days are read on the player's own clock through W.DatePicker, the clock the When column draws. Each button is greyed with the rank reason, or "Nothing to clear.", re-read on every repaint. A refusal at OK is shown over the tab or under the dates. The tab repaints on UGR_LOG_CLEARED.
  • Found while building it: a named form dialog sits on the client's Escape list beside the main window, so one Escape closed both. The range dialog now joins the window's Escape layer (Log:Layer), as the member panel's editors do; the spec failed on exactly that before the fix (run 20261014-140110.758).
  • ns.UI.Strip's own strip, for a LibAceGUIWidgets without CreateButtonStrip, now takes a function reason and has strip:Refresh(), as the library's does; the Log tab needs both on either library.
  • The export window drops its built log rows and redraws an open log set on UGR_LOG_CLEARED (Peer Review F1): an open log export kept showing cleared entries.
  • docs/design/event-log.md: the API table, a "Clearing (LOG-07)" section, the clear gate written as a courtesy on local data and not a protection (Peer Review F2: every player sets their own thresholds and the log is never synced), the local-days choice (F6), and EL-22 to EL-25.
  • Tests: Tests/event_log_spec.lua EL-15 and EL-16 (red first, run 20261014-135104.824), the chat window and named-range specs (red, run 20261014-140247.028); Tests/log_tab_spec.lua EL-20 rewritten for the built buttons, because the old EL-20 asserted every Clear button greyed "Not built yet.", and EL-22 to EL-25 new (red, run 20261014-135809.932); Tests/ui_strip_spec.lua function reasons on both strips (red, run 20261014-135930.715); Tests/export_window_spec.lua the clear (red, run 20261014-140214.425). Two spec files declare their after_each before the examples: the harness's runner ran each example as it was declared, so a hook written after them applied to none (WoWAPITesting confirmed and fixed it in f5389cb, thread c3157954; UGR's pin has not moved yet).
  • Every clear removes exactly the entries counted in its question (Peer Review A2, thread ace8973e): "Clear the whole log" and a range used to re-pick at OK, so an entry logged while the question was up went too. All three now confirm a snapshot through EventLog:Clear("filtered", rows).
  • A guild change closes an open clear question and the range dialog, and a question that survives it refuses at OK with "The guild changed. Nothing was cleared." (A3): it used to clear the new guild's log.
  • ns.UI.Prompt / ns.UI.ClosePrompt: every tab's question goes through one wrapper that keeps the prompt's token, so the window closing or a guild change can close it. The Log, Bans and Sync tabs use it.
  • In combat, one Escape on any of our questions also closed the main window. The fix is in LibAceGUIWidgets MINOR 43, released as v0.5.0 (ShowDialog joins the window's Escape layer for one show, inbox 3d3634d0); on an older library it still happens. A UGR workaround was built and withdrawn, because it edited UISpecialFrames from inside the client's Escape walk (Peer Review). Tests/log_tab_spec.lua and Tests/bans_tab_spec.lua carry the in-combat acceptance specs; they failed before the library's fix existed. v0.5.0 also covers a question over the Clear a range form: the first Escape closes the question, the second the form, the third the window (LibAceGUIWidgets, thread 3d3634d0; not run against UGR's suite).
  • Spec removed, with its reason: Tests/log_tab_spec.lua's "leaves the window's layer when it closes, so the shared prompt is back on the Escape list". LibAceGUIWidgets MINOR 43 puts the prompt's name back on the Escape list at the next show outside a layer, not when the prompt hides (LibAceGUIWidgets-1.0.lua, finishDialog and ShowDialog), so the spec asserted something the library no longer does. The "another addon's prompt" spec still checks the name is back while that prompt is up.

Changed: asking for the Bans tab below the ban rank opens the Roster tab (UI-01 to UI-03)

  • MainWindow:Open("bans") below the ban rank opened the last tab used; ui.md UI-02 says the first tab. It now opens the Roster tab, whether the window was closed or already open. Specs in Tests/bans_tab_spec.lua (red first, run 20261014-134635.153).
  • UI-05 (Escape with the member panel's date editor open closes only the editor) had no spec; added to Tests/member_panel_spec.lua, shown able to fail by leaving the editor out of the Escape layer (run 20261014-134729.751), then reverted.
  • Tests/roster_tracking_spec.lua RT-20's comment named settings spec ST-05; the milestone spec is ST-07.

Whole suite 1270 passed, 0 failed, 4 pending (run 20261014-140631.180); GUI/Tabs/Log.lua, GUI/UI.lua, GUI/ExportWindow.lua, GUI/MainWindow.lua, Modules/EventLog.lua, Constants.lua, SavedVariables.lua at 100% (coverage run 20261014-140959.350). Nothing seen in game.

Added: Hardcore deaths (ROSTER-12)

  • New ns.Compat.IsHardcore(): C_GameRules.IsHardcoreActive() where the client has it (documented in the Era, TBC, MoP and Forever trees; the Live tree's GameRulesDocumentation.lua has no such function), false otherwise. C_GameRules added to .luarc.json's globals.
  • New Roster:OnDeath(line) on HARDCORE_DEATHS, registered in Roster:Enable on every client and gated on IsHardcore() at each line. The event's one field is called memberName, but the game's raid-warning frame shows it unchanged as the whole warning line (wow-ui-source-classic_era Blizzard_FrameXMLUtil/Classic/RaidWarning.lua:117, :123-124), and Deathlog's parser builds that line from the localized HARDCORE_CAUSEOFDEATH_* strings. So UGR does not take it as a name: it looks for each current, living member's short name in the line as a whole word (UTF-8 bytes count as letters, so "Zoë" is not cut short). Exactly one match is marked dead, diedAt (realm clock) and diedLevel and logged as died through Roster:Apply; none (a stranger, which the player's "All" death-alert setting delivers) or two (a duel line) record nothing. Known cost: a death whose line names a second member is not recorded.
  • New setting tracking.deathTag (off by default) and its switch in the settings page's Tracking section, hidden off a Hardcore realm. With it on, Roster:TagDeath appends L["death_tag"] ("[Dead]") to the public note through Members:SetGameNote, after a space unless the note is empty, only when the result fits in 31 characters and the note does not already hold it. The note's text is never replaced or cut; without the right to edit the note nothing is written.
  • docs/FEATURES.md and docs/design/roster-tracking.md said the payload was the member's name; corrected, with RT-33 to RT-37 added. docs/design/settings.md lists the new default.
  • Tests: new Tests/roster_hardcore_spec.lua (14 specs) red first (run 20261014-131118.646, every example erroring on the missing Roster.OnDeath). The death lines in it are illustrative: their real English wording is in no local tree. The harness has no C_GameRules, so the file installs its own and removes it after each example. Tests/settings_spec.lua ST-01 now lists deathTag = false, and Tests/settings_panel_spec.lua ST-10 places deathTag in Tracking, both because the setting is new. Whole suite 1216 passed, 0 failed, 4 pending (run 20261014-131652.467); Modules/Roster.lua, APICompat.lua, Modules/Settings.lua, GUI/SettingsPanel.lua, SavedVariables.lua, Locale/enUS.lua at 100% (coverage run 20261014-131935.014). Not seen in game: whether the event fires with the death alert set to Never is not verified.
  • Peer Review A1, thread d27fdb70: the death tag is written only while the player's hardcoreDeathAlertType CVar is 1, Guild (new ns.Compat.DeathAlertsGuildOnly(), bare GetCVar as Era's own Lua calls it; GetCVar added to .luarc.json). At All a line may be a stranger's who shares a member's short name (a connected realm) or is named like a word in it, and the note is guild-visible. The death itself is still recorded at every setting. The switch's tooltip says where the setting is (Options, Interface; Blizzard_SettingsDefinitions_Frame/Interface.lua:128 calls InterfaceOverrides.AdjustDisplaySettings). That the server filters lines to the guild at 1 is not verified. Specs red first (run 20261014-132507.316, then RT-37's tooltip red at run 20261014-132617.229); the file is 16/16 (run 20261014-132654.737). A2 (the short-name stranger) and A3 (a wrong death is permanent, no undo) are written into roster-tracking.md as known costs.
  • Harness pin b37b60e -> ad6dc6b (WoWAPITesting delivered C_GameRules.IsHardcoreActive over the tunable wow.hardcore, inbox f83608c3). Tests/roster_hardcore_spec.lua no longer installs its own _G.C_GameRules or deletes it after each example (that would now remove the harness's table until the next reset); it sets wow.hardcore instead, and gains the no-namespace case.

Added: Hardcore death list in the export (EXPORT-04)

  • New Export:DeathRows(sort) and Export:DeathText(rows, opts) in Modules/Export.lua: every record ROSTER-12 marked dead, current and former (a record moves to the former store whole), a name in both stores once as the current record; ordered by date of death (newest first, ties by name, no time last) or by name. Columns Name, Level (the level at death, else the record's), Class, Date of death.
  • The export window gains a fourth tab, "Hardcore deaths", offered only while ns.Compat.IsHardcore(), with the delimiter, the headers and a "Sort by" dropdown saved as settings.export.hardcoreSort (default "date", new in SavedVariables.lua). A window left on it opens on Members off a Hardcore realm. Locale keys "Hardcore deaths", "Date of death", "Sort by".
  • Tests: EX-18/EX-19 in Tests/export_spec.lua red first (run 20261014-132757.260, the functions missing and hardcoreSort unset), EX-20 in Tests/export_window_spec.lua red first (run 20261014-132849.497). Tests/settings_spec.lua ST-01 lists hardcoreSort = "date" because the setting is new. Whole suite 1229 passed, 0 failed, 4 pending (run 20261014-133545.125); Modules/Export.lua, GUI/ExportWindow.lua, APICompat.lua, Modules/Roster.lua, GUI/SettingsPanel.lua, SavedVariables.lua, Locale/enUS.lua at 100% (coverage run 20261014-133829.249). docs/design/export.md has the decisions and EX-18 to EX-20. Not seen in game.

Changed: days and months start at the realm's midnight, not UTC's

  • Peer Review 5d2eb9c4 P2. ns.Dates.offset is the realm's zone in seconds from UTC; FromEpoch and EpochFromAgo's month and year steps read the realm's calendar, and new Dates.EpochOf(date) is the realm's midnight starting a day. So a join or promotion day, a log day, a leave day and a Stats:Turnover month fall where the realm's clock puts them.
  • New ns.Compat.RealmOffset(): C_DateAndTime.GetServerTimeLocal() - GetServerTime(), rounded to a quarter hour because the local value refreshes only every 60 seconds (warcraft.wiki.gg, API_C_DateAndTime.GetServerTimeLocal); 0 without the function or past 14 hours. Documented in all five flavour trees (DateAndTimeDocumentation.lua). OnEnable sets ns.Dates.offset from it first.
  • The GRM import reads GRM's day and time tables on the realm's clock through Dates.EpochOf (a log entry's time, a former member's leftAt), so an imported entry shows on GRM's day.
  • Days already saved as YYYYMMDD are not rewritten: each is the day recorded when it was written, and rewriting a synced record would give it a new contentHash for every officer to fetch.
  • Known cost: the offset is the realm's zone now, so across a daylight-saving change an epoch within an hour of midnight can land on the neighbouring day, and a change mid-session applies at the next login.
  • Tests: Tests/env_ugr.lua's load takes realmOffset and defaults it to 0 rather than the harness's -5 hours, because every UGR fixture is written as a UTC day; env.peer sets 0 too. Specs in Tests/dates_spec.lua, Tests/apicompat_spec.lua (all five clients) and Tests/grm_import_spec.lua red first (run 20261014-124620.765, 26 failed); the realm-month spec in Tests/stats_spec.lua was written after the code and passed at once. Whole suite 1190 passed, 0 failed, 4 pending (run 20261014-125408.102); Modules/Dates.lua, APICompat.lua, Modules/GRMImport.lua, Core.lua at 100% (coverage run 20261014-125151.229). Not seen in game.
  • Each path that writes a stored day is now pinned on a realm off UTC (Peer Review T1, thread 6eda7d78): Tests/roster_tracking_spec.lua's "stored days on a realm off UTC" runs the comparison's arrival, a join, a rejoin, a leave, a promotion and Members:RankTimeline's leave-day end at UTC-5 and UTC+10, two hours from UTC midnight. They pass against the code; with FromEpoch ignoring the offset all 12 fail and nothing else in that file does (run 20261014-125939.623). Whole suite 1202 passed, 0 failed, 4 pending (run 20261014-130126.722).
  • GRMImport.lua's stampOf comment no longer says an imported entry shows at GRM's hour: the epoch is GRM's day and time on the realm's clock, and the Log draws its time on the player's own (Peer Review T2).

Changed: the export window adds new log entries instead of rebuilding the log

  • New Export:LogRowsAdd(rows, added): the added entries Render draws go in front of the built rows, newest first, the later entry first in a tie, when none is older than the newest row; nil when one is, so the caller builds again. GUI/ExportWindow.lua's UGR_LOG_ADDED handler uses it instead of dropping logRows on every entry (Peer Review B1, thread deeb7863 reply d625d7c7). Specs in Tests/export_spec.lua (EX-17) and Tests/export_window_spec.lua (EX-14) red first (run 20261014-121935.180). Whole suite 1161 passed, 0 failed, 4 pending; Modules/Export.lua, GUI/ExportWindow.lua and Modules/Roster.lua at 100% (run 20261014-122638.119). The time saved was not measured (Tests/perf/log_perf.lua not run).
  • Export:LogText writes no line for an entry Render no longer draws. Rows the window cached while the player could read officer notes outlive a demotion, and each such entry wrote its time with an empty event. Found in this session's self-audit; spec red first (run 20261014-122929.154), whole suite 1162 passed, 0 failed, 4 pending, Modules/Export.lua 176/176 (run 20261014-123132.113). Known cost: until the window is built again, its "Rows x-y of N" still counts the hidden entry.
  • The export window's OnClose drops logRows. It kept them, so after the log set was shown once every later log batch copied the whole cached list for a window that builds it again on Open (Peer Review P1, thread 04dd90b0). Open on another set holds no rows already: a set switch goes through Render, which drops them. Spec red first (run 20261014-123622.556); whole suite 1163 passed, 0 failed, 4 pending (run 20261014-123822.223).
  • Modules/Roster.lua's comment above its GUILD_RANKS_UPDATE handler said it was the only one in the addon; Alts:Enable and Bans:Enable register their own (Peer Review C1, thread 9f39e230). Comment corrected.

Added: the guild info's thresholds block (SY-35 to SY-40; SYNC-08)

  • A block such as UGR{g=4;c=4;b=1} in the guild info text sets the three sharing thresholds (dates and alts, custom notes, bans) for every client in the guild. Sync.ParseGuildInfo reads the first block only; an unknown letter, a value that is not plain digits and a repeated letter are ignored. Sync:GuildLock(kind) keeps a value only when the guild has that rank, so a value naming no rank leaves that threshold to each officer's own setting.
  • Sync:Threshold returns the lock ahead of the saved setting, so CanAuthor, the receive gate and the ban host follow it without other changes. The saved setting is kept and applies again once the block drops it. The text is read on every lookup and parsed only when it changed.
  • Settings:Set and Settings:Check refuse a locked threshold with "Set in the guild info."; the settings page shows the locked rank, greys its dropdown and gives that sentence as the tooltip. The first wording, "Set by the guild leader in the guild info.", was wrong: whoever passes CanEditGuildInfo may write the block (Peer Review N1, thread 4d99f87a; spec strings changed with it).
  • Sync:CheckGuildInfo() runs on GUILD_ROSTER_UPDATE (the event Blizzard's own guild info frame re-reads the text on, Blizzard_Communities/GuildInfo.lua:28-30) and, when a lock changed, runs Settings:Changed(), so a ban lock above the player stops their ban host. Settings:Changed() is new: the one list of reactions to a changed setting (ban host, settings page, shown tab), called by Settings:Set too. Before it, the guild info path skipped MainWindow:RefreshActiveTab, so an open tab stayed painted from the old threshold (Peer Review N2, thread 4d99f87a; spec red first, run 20261014-115124.998).
  • GUILD_RANKS_UPDATE now runs Settings:Changed() too (Modules/Roster.lua). A changed rank list can change a threshold, because a removed rank reads as the lowest, but the event only repainted the settings page here and checked the ban host in Sync.lua. It never repainted the shown tab. Found in this session's self-audit; spec red first (run 20261014-120130.652). Whole suite 1155 passed, 0 failed, 4 pending (run 20261014-120320.394); Sync.lua, Modules/Settings.lua, Modules/Roster.lua, GUI/SettingsPanel.lua and Locale/enUS.lua at 100% (run 20261014-120608.785).
  • Sync.lua no longer registers GUILD_RANKS_UPDATE on the ban lane: Settings:Changed() already runs CheckBanHost for it, and Core.lua's OnEnable always runs Roster:Enable before Sync:Start, so the second handler had no case of its own (Peer Review S1, thread 9f39e230). PLAYER_GUILD_UPDATE stays. The sync_bans_spec rank-list cases still pass through the one path; whole suite 1155 passed, 0 failed, 4 pending (run 20261014-121025.028), Sync.lua and Modules/Roster.lua at 100% (run 20261014-121246.006).
  • sync.md SY-37 now says the lock is as current as the client's copy of the guild info, which Blizzard's frame refreshes with a roster request, and records the known cost that the block carries rank numbers, so adding or removing a rank shifts what a pasted block means (Peer Review, thread 4d99f87a).
  • Harness pin 05cb0c8 -> b37b60e, which adds CanEditGuildInfo (inbox 69f13c3b). The SY-40 spec sets guild.canEditGuildInfo instead of replacing the global; SY-35 reads the harness's global and keeps three replaced-global cases only for the wrapper's own coercion. Whole suite 1154 passed, 0 failed, 4 pending (run 20261014-115939.543).
  • The Sync section has a "Guild info block" button for a player who may edit the guild info: it shows Sync:GuildInfoBlock(), the current thresholds as a block, in W:ShowCopyBox.
  • ns.Compat.GuildInfoText() (C_GuildInfo.GetInfoText, "" when there is none) and ns.Compat.CanEditGuildInfo() (the bare global, present in all five trees). The harness has no CanEditGuildInfo; the specs supply it.
  • New Tests/sync_guild_info_spec.lua, red first (run 20261014-114051.593). Whole suite 1153 passed, 0 failed, 4 pending (run 20261014-114402.419); Sync.lua, APICompat.lua, Modules/Settings.lua, GUI/SettingsPanel.lua and Locale/enUS.lua at 100% (run 20261014-114635.204). Not seen in game, and whether Retail hands the guild info text to addons is not verified.

Added: the Sync tab, Sync now and /ugr syncnow (SY-29 to SY-34; SYNC-06, SYNC-07)

  • GUI/Tabs/Sync.lua replaces the old Sync tab, which listed one row per held record. The tab now lists who in the guild runs the addon: one row per VersionCheck roster row (VC:GetRoster), its four states as "Running", "Not answering", "Offline" and "Unknown" (VersionCheck README.md:205-209), the version tidied (sorted by its numbers, so 0.10.0 follows 0.2.0), and a Records column. The player's own row counts Sync:Servable() (see Peer Review below); a guildmate's counts what their last numbered broadcast advertised. The status line still reads "Records shared: %d". A version heard and a broadcast counted repaint the open tab.
  • Sync:OnOfferReceived is the general host's onOfferReceived (DeltaSync.lua:1804): an hlb2 from a guild member stores #DecodeEntries(data.e) as Sync:Advertised(sender); a guild change forgets the counts. The ban host is not counted, because its entries are ban keys and the tab is for every rank.
  • Sync:SyncNow([chat]) announces on the general host and on a running ban host, at most once per 30 seconds (GetTime), with VersionCheck's RequestCheck return convention: true; false, seconds left; or false, 0, reason (guildless, sync off, not started, broadcast not sent), which starts no clock. The tab's "Sync now" button is W:BindCooldownButton on the cooldown "UGR-SyncNow" with Sync:SyncNowWait as its seconds; a refusal shows in a one-button dialog over the tab. /ugr syncnow prints the outcome in the chat language and starts the same cooldown.
  • GUI/Tabs.lua loses its generic list render: every tab draws itself now, so it had no caller left. It maps each tab key to its module.
  • Spec changes, each with its reason: Tests/window_spec.lua's "the Sync tab lists the synced records" now asserts only the status count (the rows are who runs the addon now); Tests/slash_spec.lua and Tests/toc_spec.lua list syncnow and GUI/Tabs/Sync.lua. Locale keys "Record" and "Saved" removed with the old tab.
  • Specs red first (run 20261014-104155.588); the version-sort spec red with sortValue removed (run 20261014-105229.513). Whole suite 1123 passed, 0 failed, 4 pending; Sync.lua, GUI/Tabs/Sync.lua, GUI/Tabs.lua, Slash.lua and Locale/enUS.lua at 100% (run 20261014-105433.148). Not seen in game.
  • Peer Review (thread 8ae2f42a, reply b91db605), specs red first (run 20261014-110155.582):
    • Q2: the player's own row and the status line counted Sync:Count(), every held record, while a peer's row counts what their broadcast advertised, which is only numbered keys with a servable canon. New Sync:Servable() is #host.p2p:ServableEntries(), DeltaSync's own list (DeltaSyncP2PNumbered.lua:267), so both rows count one thing; 0 before sync starts.
    • N1: blank Version and Records cells were "", which RowList sorts before any text, so the first click on either column filled the top with guildmates who had no data. They are nil now, the version sort value is nil for no version, and both columns set nilsLast.
    • N2: Sync.advertised was keyed by DeltaSync's sender (realm added by LibGuildRoster's NormalizeName) and read by VersionCheck's row name (which may be bare). Both now go through DeltaSync's NormalizeSender.
    • Q6: the version-sort spec read RowList's private _getSortedData; it reads the drawn cells through the public CellFrameFor instead.
    • Q5: a comment at SyncNow's ban-lane announce says why its result is ignored.
    • Q3: the tab's content release calls W:UnbindCooldownButton (LibAceGUIWidgets MINOR 43, asked for on inbox thread 58585085; feature-detected), so a language switch no longer leaves the old Sync now button counting down. Spec red first (run 20261014-111540.794).
    • Spec changes with their reasons: four sync_tab_spec assertions of "" now assert nil (N1); window_spec's status-count spec saves a real record instead of planting one with no number and an unservable canon, which the count now rightly leaves out (Q2).
    • Whole suite 1127 passed, 0 failed, 4 pending (run 20261014-111725.740, against LibAceGUIWidgets' unreleased MINOR 43 working copy); Sync.lua and GUI/Tabs/Sync.lua at 100% (run 20261014-111932.485).
  • Peer Review (thread b1e1c2d4, reply f9dccf8e):
    • F3: Sync:Count() removed. After Q2 it had no production caller; the specs that used it count pairs(Sync:Records()) through a local held(), which is what they meant by "held".
    • F4: one Sync tab refresh read Sync:Servable() twice (the player's row and the status line), and each read walks and sorts every held key. It is read once per refresh and shared, so the two numbers agree by construction. New spec: one refresh calls ServableEntries once. It was written after the fix, so its red run is the old double call put back (run 20261014-112421.117, expected 1, got 2).
    • Whole suite 1128 passed, 0 failed, 4 pending (run 20261014-112844.098); Sync.lua and GUI/Tabs/Sync.lua at 100% (run 20261014-113048.933). Cost of rebuilding on every broadcast heard in a large guild: not measured.
  • Peer Review (thread b295c480, reply 3461efd7), test helpers only:
    • S1: the two held() copies are one env.held(Sync) in Tests/env_ugr.lua.
    • S2: env.with saved through __index and restored by assignment, so an inherited method came back as an own field and a later stub of the class method would not reach that object. It saves with rawget and restores with rawset. New Tests/env_ugr_spec.lua, red first (run 20261014-113438.265); whole suite 1133 passed, 0 failed, 4 pending (run 20261014-113620.675).

Fixed: the export window held the keyboard, missed new log entries, and was slow on a big log (Peer Review, thread deeb7863)

  • A1: the copy fix below focused the box on every fill, so after any click in the window the player could not move or use a key binding, and the first Escape only cleared the box's focus (AceGUI's MultiLineEditBox sets OnEscapePressed to ClearFocus). The window no longer takes the focus; gaining it selects the whole text (OnEditFocusGained, Blizzard's idiom for a box taken whole), and a put-back while focused selects again. The label is now "Click the box, then press Ctrl+C to copy." The old Finding 1 spec asserted focus after every fill; it is replaced, reason as above. Not verified in game: whether the client fires OnTextSet inside SetText or later.
  • A3: the guild-change spec now moves ns.Guild to a second guild with its own member and asserts the text shows that member and not the first guild's.
  • A4: RefreshLocale and the UGR_GUILD_CHANGED handler share one rebuild(firstPage); a language switch keeps the page, a guild change returns to page 1.
  • A5: measured, not argued (Tests/perf/log_perf.lua, new export case): a redraw of the log set over a 50,000-entry log took 265 ms against the 250 ms budget (run 20261014-102856.788). The window now builds Export:LogRows once per build and reuses it for every choice: build 199 ms, a choice 3 ms (run 20261014-102954.372). A new log entry drops the rows and redraws an open log set, which before this never showed an entry logged while it was open.
  • Specs red first (runs 20261014-102753.527 and 20261014-102926.429). Whole suite 1091 passed, 0 failed, 4 pending (run 20261014-103221.535); GUI/ExportWindow.lua 217/217.

Fixed: the export window's copy, a guild change, and the log's order (Peer Review, thread 9fec1de3)

  • Ctrl+C copied nothing: nothing in the box was ever selected, and AceGUI's SetText clears any selection. Every fill of the box (open, a choice, a page, the read-only put-back) now focuses it and selects the whole text (fill in GUI/ExportWindow.lua). Finding 1.
  • Leaving the guild with the window open left it on the old guild, and the next click raised (no settings to read). ExportWindow now listens for UGR_GUILD_CHANGED: it closes guildless and redraws from the new guild otherwise. Finding 2.
  • The log export walked the log by position, so after a login batch it was not newest first and disagreed with the Log tab. Export:LogRows is now built from ns.EventLog:View (every category, no headers), one ordering for both. It ignores the Log tab's category filter on purpose, now written in export.md. Finding 3.
  • Specs red first: the log order, focus and guild-change specs all failed on the old code (desk run 20261014-101612.813). Four EX-17 specs changed their entry times from fixed small numbers to times after now: they relied on the export ordering by position, and the setup logs an alt-link entry at the current time, which a by-time order puts above an entry dated 1000.
  • Finding 4 (an item dragged or a link shift-clicked into the box may stay until the next fill) is an in-game check, written under "Not verified in game" in export.md.

Added: the export window and /ugr export (EX-09 to EX-15, EX-17; EXPORT-01 to EXPORT-03, SLASH-05)

  • GUI/ExportWindow.lua (ns.ExportWindow), in all five TOCs after GUI/ImportDialog.lua: an AceGUI Frame with Members, Former members and Log tabs (LAGW-TabStrip), a delimiter dropdown (;, ,, Tab), an Include headers chip, Mains only and Alts only chips, two level steppers, one chip per column with Select all columns, Previous and Next with a "Rows a-b of n" line, and a read-only box to copy from. Every choice is saved in the guild's settings.export and returns to page 1. The box shows each | doubled, as export.md required. Size and place in the new profile.exportWindow.
  • Export:LogRows and Export:LogText: the log data set, newest first, When and Event, officer-note entries left out for a player who may not read them.
  • /ugr export opens it; guildless it says "You are not in a guild." MainWindow:RefreshLocale relabels an open export window.
  • Specs: new Tests/export_window_spec.lua (EX-09 to EX-15) and EX-17 in export_spec, red first (desk run 20261014-100531.427: the window and log specs failed on the missing module). One spec was corrected after the first green run: it fired a level stepper's callback without the stepper taking the value, which a player's change never does; it now sets the value first, as the chip helper does.
  • List specs extended for the new file, saved default and command: toc_spec, savedvariables_spec, slash_spec. Whole suite 1079 passed, 0 failed, 4 pending (desk run 20261014-101000.906); Export, ExportWindow, Slash, MainWindow, SavedVariables and enUS at 100% (desk coverage run 20261014-100754.686). Not seen in game.

Changed: the settings page's switches are toggle tokens (ST-15)

  • The operator, 2026-10-14: "can you make all the check boxes on the settings UI be toggle tokens?" GUI/SettingsPanel.lua names LAGW-Chip as every toggle's dialogControl and gives it no width, so each token sizes to its label (LibAceGUIWidgets inbox 20d63ff4, chip widget Version 2, MINOR 43).
  • Feature-detected in Build with AceGUI:GetWidgetVersion("LAGW-Chip") >= 2: an older library would raise on AceConfigDialog's first SetLabel, so it keeps the check boxes at their old widths. MINOR 43 is not released yet, so players see check boxes until it is.
  • New ST-15 specs in settings_panel_spec (the options table, the drawn page through the real AceConfigDialog, and the old-library fallback), red first. Not seen in game.

Changed: a language switch relabels the ban and note dialogs in place

  • LibAceGUIWidgets MINOR 43 added the form dialog's frame:SetLabel(i, text) and frame.labels[i] (inbox 1a2fe53f, asked for by UGR). ns.UI.DialogSlot takes a third argument, relabel(frame), and a Relabel() method: with SetLabel it relabels the same frame, so an open dialog keeps what the player typed and no frame or Escape-list name is left behind per switch; without it (an older library) it falls back to the old Retire and rebuild under the next generation's name.
  • GUI/BanDialog.lua builds its rows and labels from one list (rowKeys) so build and relabel cannot disagree; the title follows the dialog's state (Ban player or Edit ban). GUI/MemberPanel.lua relabels both note dialogs' labels, Save button, title and the custom note's "Last edited" body in place.
  • Specs: bans_tab_spec reads labels from frame.labels instead of the library's private _rowHolders; the F4 spec now asserts the same frame and typed text kept (red first). New old-library specs in bans_tab_spec and member_panel_spec remove SetLabel from the frame and assert the rebuild; a public-note relabel spec in member_panel_spec. Not seen in game.
  • An in-place relabel also clears a refusal on screen (frame.err), which the old reopen did and the in-place switch left in the old language (Peer Review, thread 325dae6b). Specs in both files, red first.

Changed: every tab's button strip is LibAceGUIWidgets' (UI-17)

  • LibAceGUIWidgets MINOR 43 added W:CreateButtonStrip, which is UI.Strip moved into the library (inbox 31133c51, asked for by UGR). UI.Strip returns it when the library has it, and builds its own strip as before when it does not. Same buttons, reasons and status line; the library's strip also scales with W:SetScale, and pins the status line's left edge to the last button, so a long status is clipped instead of drawn over the buttons.
  • Because the strip now grows with the scale, UI.List hangs the list from the strip's bottom edge instead of a fixed STRIP_H offset, which would have put the list over the strip above scale 1. A tab with no second row (the Sync placeholder) now starts its list at the strip's left inset, like the others.
  • MainWindow.MinWidth counts the strip's 6 inset as scaled with the library strip and as fixed pixels without it. roster_tab_spec's scale-2 case changed for that reason: at the old floor the Custom note drew 194 px, short of 200 (desk run 20261014-094522.987).
  • Harness pin 07696f5 -> 05cb0c8, which loads LibAceGUIWidgets-ButtonStrip.lua. New Tests/ui_strip_spec.lua (UI-17), red first. Not seen in game.

Changed: note and ban-reason boxes stop at their limit and count with the library's counter

  • The note boxes (31 for a game note, 200 for a custom note) and the ban reason (150) now call SetMaxLetters, so the client stops the typing at the store's limit. None set a limit before; the store refused an over-long text on Save.
  • With LibAceGUIWidgets MINOR 43 the count is W:AttachCharCounter (inbox 31133c51), on the label line, red at the limit, as frame.counter. The note dialogs' hand-drawn count in the row's note is kept for an older library; the ban reason had no count and gets one only from the library.
  • Specs in member_panel_spec (the counter, the limits, the old-library row note) and bans_tab_spec, red first. The harness does not refuse a keystroke at the limit and counts bytes, not characters, so the client's own stop and a non-ASCII count are not verified offline. Not seen in game.

Changed: Settings is a gear by the Close button, not a tab (UI-16)

  • The operator, 2026-10-14: "so settings shouldn't be a tab, it should be the gear wheel icon next to the close button like in FGI. it's in widgets." GUI/MainWindow.lua dresses the window's bottom row with W:DressBottomRow (LibAceGUIWidgets README.md:738-767), FastGuildInvite's gear texture and crop; a click is SettingsPanel:Open(). OnClose calls W:UndressBottomRow before the release, so the pooled frame carries no gear into another addon's window. RefreshLocale re-dresses it to relabel the tooltip.
  • ns.Constants.TAB_ORDER loses "settings", GUI/Tabs.lua its Settings def, Locale/enUS.lua tab_settings and Open settings (new key Settings). A saved profile.activeTab = "settings" is no longer a tab, so the window opens on Roster; no migration needed.
  • Specs changed for the operator's decision, with the reason in each: window_spec (five tabs, a list on every tab; the Settings tab's button and no-status specs removed), load_spec (TAB_ORDER), slash_spec (no settings exception). New UI-16 specs in window_spec, red first. README.md, CLAUDE.md and docs/design/ui.md updated; the CurseForge page waits for release prep.

Fixed: the GRM import window's text ran under its buttons

  • The preview and the report listed their counts and skip lines over the Import and Cancel buttons (operator, in game, 2026-10-14). W:ShowDialog gives every prompt one fixed height (LibAceGUIWidgets-1.0.lua:3849). GUI/ImportDialog.lua now grows the frame after each show to hold the prompt's top inset, the text, a gap and the buttons, using the library's own layout numbers. The library resets the size on its next show, so another addon's prompt is not left tall.
  • New spec in Tests/grm_import_spec.lua, red first. It sets the prompt's height itself, because the harness reports one line's height for any text (Tests/wowapi/env/frames.lua:1681).

Added: the guild's stats (SS-01 to SS-05; Later feature, no UI yet)

  • New Modules/Stats.lua (ns.Stats) in all five TOCs after Export. Pure counting over the current guild; nothing is saved (SS-05, pinned against a deep copy of UGR_DB, mutant red).
  • Stats:Overview() counts members, mains, alts and unlinked characters (the three sum to members) and spreads them by class token, rank index and level bucket. A level at the client's cap is its own bucket, any other level its ten. A missing class, level or rank is counted under "unknown", so every spread also sums to members.
  • Stats:Activity(now) counts members whose last-seen time is within 7, 30 and 90 days. A member the library knows is read through the new Roster.SeenAt, the reading Roster:Live now also uses: the stored lastSeenAt is written only when a comparison runs, so it went stale for anyone who came online since (self-audit f8482e31, Peer Review 5d2eb9c4 P1; spec red first).
  • Modules/Dates.lua's header said the realm's offset could not be known. It can, from C_DateAndTime.GetCurrentCalendarTime; the comment now says UTC is a choice, and UTC months are recorded as a known cost in docs/design/stats.md (Peer Review 5d2eb9c4 P2).
  • Stats:Turnover(months, now) counts joins, leaves, removals and rejoins per UTC calendar month, newest first. Every leave (left, kicked, leftUnknown) counts as a leave and a kick also as a removal. Imported log entries count.
  • Every call reads the current guild, not a key as docs/design/stats.md first sketched: the alt view is built for the current guild only. Decisions recorded in the design doc's "As built".
  • Tests/toc_spec.lua lists Modules/Stats.lua (a new file was added).

Added: the export's rows and text (EX-01 to EX-08; Later feature, no UI yet)

  • New Modules/Export.lua (ns.Export) in all five TOCs after MacroQueue. Export:Rows(set, opts) lists current or former members by name, filtered by level range and mains or alts. Export:Text(rows, columns, opts) joins them with the chosen delimiter, a header line first when asked. Export.Page gives the page's first and last row, clamped at both ends.
  • A delimiter, line break or carriage return inside a value becomes a space, matched as plain text, so a . or % delimiter is never a pattern. The officer note column is offered and written only for a player who may view it.
  • A former member's role and characters come from what was saved when they left, decided by which store holds the record, so a caller cannot pass former rows as current ones and read a live alt group that still names them (self-audit 50c18e31 E1, Peer Review thread d6507c7e). A birthday whose day or month is not a number writes blank instead of stopping the whole export.
  • New per-guild settings.export defaults (delimiter, headers, one switch per column, level range, mains or alts, page size). The column switches are filled from Export.COLUMNS, the one list.
  • Spec changes, each because a new saved area or file was added: Tests/settings_spec.lua ST-01 now expects the export defaults, and Tests/toc_spec.lua lists Modules/Export.lua.
  • Locale keys for the new headers and the birthday pattern "%m-%d". Decisions the design left open are recorded in docs/design/export.md, "As built".

Added: the macro tool's queue (MC-08 to MC-10; Later feature, no UI yet)

  • New Modules/MacroQueue.lua (ns.MacroQueue), in all five TOCs after MacroRules, enabled in OnEnable after the roster. It is session-only and nothing is saved; a guild change empties it, a rename does not.
  • MacroQueue:WhyNot(action, name) refuses what the game would refuse (MC-09). It uses the game's own guild frame's rules (Classic/FriendsFrame.lua:2969-2989 in the classic_era tree). The same CanGuildRemove / CanGuildPromote / CanGuildDemote calls appear in Blizzard_Communities/GuildRoster.lua in all five client trees. The rank needs the permission, and the member must be strictly below the player. A promotion may not reach the player's rank or guild master, and a demotion may not go below the lowest rank. An unknown rank (the player's, the member's, or the rank list) refuses and says which. Add refuses with that reason, or when the same action on the same member is already pending or sent.
  • StartBatch(n) settles the previous batch, re-checks each item (marked skipped with its reason if it is now refused), and records the member's live rank and rank name before anything is sent (MC-10). Check runs on UGR_ROSTER_CHANGES and marks an item done once the roster shows it. Settle, which the next batch calls, marks the rest failed (MC-08). There is no timer (directive b15a1e31). The known cost: a refused action reads as sent until the officer presses again. A failed item that the roster later shows done turns done, so a slow success is not left reading as a failure.
  • ns.Compat.PlayerRankIndex's comment no longer says the 0-base is unverified: classic_era Classic/FriendsFrame.lua:2912-2989 compares it directly with GetGuildRosterInfo's rankIndex.
  • A done demotion with a rule saves MacroRules.DemoteNote with the rank name held before it, as the custom note, keeping the note's share switch. When a retry was sent before the first demotion showed, both land in one check and the note is written once (spec red against a mutant of DemoteNote's already-present check; Peer Review thread 695743e0).
  • New ns.Compat.CanGuildAct(action) over the three bare globals; .luarc.json declares them. Twelve new locale strings for the refusals.
  • Tests/macro_queue_spec.lua, 18 specs, red first (run 20261014-074930.942: the module did not exist). Rank changes are driven by the server's demotion chat line, which is how LibGuildRoster learns them (LibGuildRoster-1.0.lua:2877-2904); a roster rescan alone did not move the library's rank. A mutant that read the live rank name for the note went red (run 20261014-075515.905). The spec first installed CanGuild* itself; WoWAPITesting delivered them (inbox 2c103638), the harness pin moved abdc05e -> 07696f5 and the spec now sets guild.canGuildRemove / canGuildPromote / canGuildDemote instead (whole suite 984/0/4, run 20261014-081341.043; pointer not committed). Tests/toc_spec.lua lists the new file. The failed-to-done spec was red first (run 20261014-075743.050). Whole suite 979 passed, 0 failed, 4 pending (run 20261014-075856.474); MacroQueue.lua, APICompat.lua, Core.lua and enUS.lua at 100% (run 20261014-075504.966).
  • Peer Review, thread 748b97f1 / b5098e5c, each red first:
    • A promotion or demotion could be carried out twice for one rule match. Add refused a copy only while the first was pending, so a second could be queued while the first was sent and the roster still showed the old rank, and the member moved two ranks. Add now also refuses while the same action on the same member is sent; a failed one may still be retried. When Check turns an item done, a pending retry of it is skipped with "%s already had that done." (run 20261014-080611.290 red).
    • A guild rename no longer empties the queue: the handler compares ns.Guild:Data() with the table the queue was filled in, as Modules/Roster.lua does, so a rename keeps it and a real change empties it (run 20261014-080142.570 red).
    • A demotion whose custom note is refused stays done (the demotion happened) and keeps SetCustomNote's reason as item.noteWhy, for the tab to show (same red run).
    • Whole suite 984 passed, 0 failed, 4 pending (run 20261014-080832.590); MacroQueue.lua and enUS.lua at 100% (run 20261014-080945.550).
  • docs/design/macro-tool.md: "Queue requirements" became "The queue (built 2026-10-14)". The Shared rules section no longer says SaveRules uses the general threshold; it uses the ban threshold, MACRO-06 (Peer Review, thread 356f3036).

Added: the macro tool's shared rules (MC-06; Later feature, no UI yet)

  • MacroRules:SaveRules(action, rules) saves a copy of an action's rule list as one rules:<action> sync record through Sync:SaveRecord; the new rules applier stores it as global.guilds[key].macro.rules[action] (new AceDB default macro = { rules = {} }, no migration: nothing was saved there before), read by MacroRules:Rules(action).
  • Rules are gated at the ban rank, not the general rank: Sync.ClassOf("rules") is "ban", so SaveRecord refuses a player below it with "Your rank cannot change this." and Accepts refuses a received rules record from a sender below it (Peer Review P1, thread 83f02ad9). The first cut used the general rank, which defaults to the lowest rank, so any member could have rewritten the rule lists every officer's queue reads. macro-tool.md MACRO-06 and sync.md's record-key table corrected.
  • MacroRules:Rules(action) returns a copy: the stored list is the synced record's own table, and an editor changing it in place would leave the record's contentHash describing different contents (Peer Review P2, same thread).
  • Tests/macro_rules_sync_spec.lua now loads a three-rank guild with the sender as a ranked member, in place of stubbing IsInGuild, because the gate reads the sender's rank. Ban-rank specs red first (run 20261014-073938.997: ClassOf gave "general", the low player saved, Rules handed out the stored table, the low sender was stored). Tests/sync_two_peers_spec.lua carries a rule naming rank 0 across the real serializer and checks the key arrives as the number 0; it passed when written (a regression guard). Whole suite 961 passed, 0 failed, 4 pending (run 20261014-074058.264); Sync.lua and Modules/MacroRules.lua 100% (run 20261014-074211.881).
  • Every rule field's type is checked before a list is saved or taken from a peer (MacroRules.ValidRule, ValidRecord; Peer Review, thread c87f38f4). Sync.KEYS.rules returns nil for an invalid record, so Sync refuses it before storing it; Sync's KEYS comment now says a nil key refuses. Before this, a peer's inactiveDays = "90" would have raised a Lua error in Evaluate.
  • A rule carrying a field this version does not know now matches nobody, as one with an UNBUILT field does, so a later version's condition is never silently dropped (usable() reads the rule's own fields instead of the UNBUILT list).
  • New locale string "That rule cannot be saved.".
  • Tests/macro_rules_sync_spec.lua, red first (run 20261014-073326.550: the functions did not exist). Whole suite 957 passed, 0 failed, 4 pending (run 20261014-073501.524); Modules/MacroRules.lua, Sync.lua and SavedVariables.lua 100% line coverage (run 20261014-073605.652).

Added: the macro tool's rule matching (MC-01 to MC-05; Later feature, no UI yet)

  • Modules/MacroRules.lua (ns.MacroRules), in all five TOCs after Modules/GRMImport.lua. MacroRules.Evaluate(action, subject, rules, now) returns the first rule a member matches and the reason to show; MacroRules.DemoteNote(record, rule, oldRankName) builds the custom note to save after a demotion. Both are pure: the queue that will call them, the rule editor and the secure button are not built, so nothing a player does reaches this code yet.
  • Decided in docs/design/macro-tool.md ("Matching"), where the sketch left it open: a rule with no condition matches nobody; a member or alt never seen is never inactive; "mains" takes a character in no alt group, as the Roster tab's chips do; the demotion note is measured in characters, as the custom note editor cuts it.
  • Peer Review 300c6a98, specs red first (run 20261014-072029.553), then green:
    • A record with no level never matches a rule with minLevel or maxLevel. It was counted as level 0, so a maxLevel remove rule took a member whose level was not known.
    • The safe tag is looked for in the officer note; the public and custom notes only when the rule sets safeTagAllNotes. The first spec accepted all three notes by default, and was changed for this reason: a member who may write their own public note could exempt themselves from removal. A first fix kept the custom note on by default as officer-only; the session's self-audit found sync.customNoteRank defaults to the lowest rank (Sync:Threshold), so any member may write a custom note, and it moved behind the same switch (spec red first, run 20261014-072608.161).
    • DemoteNote takes the old rank name as an argument instead of reading record.rankName, which holds the new rank once the roster refreshes. The existing DemoteNote specs pass the rank now.
    • MacroRules.CONDITIONS, NOT_CONDITIONS and UNBUILT class every rule field; a spec reads the field list from macro-tool.md and fails on a field in no list or two, so a new field forces the decision. A rule setting an UNBUILT field matches nobody rather than ignoring it. macro-tool.md's field line gains name and safeTagAllNotes, and "Queue requirements" (MC-09 permission check, MC-10 old rank name) records what the queue must do when it is built.
    • From the self-audit: a rule setting allAltsInactive without inactiveDays matches nobody; the switch had been dropped silently, running the rule wider than asked (spec red first, run 20261014-072831.424).
    • Whole suite 941 passed, 0 failed, 4 pending (run 20261014-072925.451); Modules/MacroRules.lua 100% line coverage.
  • Tests/macro_rules_spec.lua, red first (run 20261014-071159.254: the module did not exist). The character-count case was not seen red before the code had it, so it was checked by putting the byte count back (red, run 20261014-071457.384). Two locale keys added. Tests/toc_spec.lua's file order gains the new module (the reason for that spec change). Whole suite 934 passed, 0 failed, 4 pending (run 20261014-071449.604); Modules/MacroRules.lua and Locale/enUS.lua 100% line coverage (run 20261014-071553.848).

Fixed: anyone outside the guild could whisper in a shared record (SY-09)

  • Sync:OnDataReceived checked IsMember only on a live push. A catch-up reply (no live flag) or an "unchanged" answer went straight on, and DeltaSync's transport forwards DATA from anyone, leaving the gate to the host (P2PSession.lua:62-66). At the default threshold Sync:Accepts takes anyone without a rank lookup, so a player outside the guild could whisper a reply-shaped message and have the record stored, applied, and served on to every guildmate who caught up from us; an "unchanged" answer from them completed a P2P item. Every received message is now gated on Sync:IsMember first. The ban lane was not affected: its IsValidPeer gates every message before this.
  • SY-09 rewritten for the sender gate decided earlier today (Sync:Accepts); the author-rank Accept it was written against is not built. New spec in Tests/sync_records_spec.lua, red first (run 20261014-065404.925).
  • A record of a kind this version does not know (no Sync.KEYS entry) is refused on receive, live or catch-up, before the rank gate. I dropped this clause with Accept and Peer Review asked for it back (thread 761f70fb): Accepts judges the sender, so an old client storing a kind it cannot judge would relay a later version's stricter kind past its threshold, through any officer still on this code. Spec red first (run 20261014-070206.577). Cost: an older client neither holds nor relays a newer client's kinds.
  • Spec changes, each for the same reason (the gate now covers catch-up, and these senders were not in the test roster): Tests/sync_spec.lua "a received record" and "route the host's data callbacks", Tests/sync_records_spec.lua Sync.APPLIERS, and two Tests/alts_spec.lua receive specs now make the sender a guild member, that one sender only, so a non-member added to those specs later still meets the real gate (thread 761f70fb). With the kind refusal, specs that received records of a made-up kind (test, motd, x, or none) now register that kind in Sync.KEYS first; the sync-off receive spec now sends a member's real join record, so the switch is the only thing refusing it. Whole suite 910 passed, 0 failed, 4 pending (run 20261014-070619.193); Sync.lua 100% line coverage (run 20261014-070724.343).

Fixed: "Only send to ranks at or above mine" did nothing (SY-14)

  • The setting sync.limitSendToMyRank was saved and shown on the settings page, but Sync:IsValidPeer never read it, so the player synced with every guild member whatever the switch said. With it on, a peer is now a member at or above the player's own rank (an equal or lower rank index); an unknown rank, theirs or the player's, refuses, as the ban lane's rank check does. Specs first in Tests/sync_spec.lua ("peers", SY-14), red before the change (run 20261014-062018.053); whole suite 891 passed, 0 failed, 4 pending (run 20261014-062116.101).
  • The live push still reached every rank: every non-ban edit went out on a GUILD broadcast, which never consults IsValidPeer, so the switch only narrowed catch-up (Peer Review, thread f05adc36). With the switch on, Sync:Push now whispers each online member IsValidPeer takes, as the ban lane does, and returns how many; with it off it broadcasts as before. A player at the guild's lowest rank still broadcasts once: every member qualifies, so the whispers would reach the same people one message each (self-audit; spec red first, run 20261014-063502.461; suite 902/0/4 and Sync.lua 100%, run 20261014-063607.521).
  • Receiving is no longer limited. The first cut also gated the live receive on IsValidPeer, so with the switch on the player took nothing live from a lower rank; the setting is about sending. New Sync:IsMember (a guild member while sync is on) is the live receive's gate; IsValidPeer is IsMember plus the rank limit. Specs for both halves in Tests/sync_records_spec.lua, red first (run 20261014-062921.146); whole suite 901 passed, 0 failed, 4 pending (run 20261014-063038.039); Sync.lua 100% line coverage (run 20261014-063150.971).
  • KNOWN COST: DeltaSync consults its one isValidPeer callback both to serve and to fetch (DeltaSyncP2PNumbered.lua:433, :1314), so with the switch on catch-up still stops fetching from a lower rank. A serve-only gate is requested of DeltaSync; until then a lower rank's edits reach the player only by live push.

Sync: UGR_SYNC_APPLIED (SY-12, SY-11)

  • Sync:OnDataReceived now sends UGR_SYNC_APPLIED(key, record) after an incoming record wins and is applied, as sync.md's Messages line has always said; nothing sent it before. A losing record and a local SaveRecord send nothing. Spec in Tests/sync_records_spec.lua, red first (run 20261014-064317.293).
  • SY-11 (a live push touches no P2P session) was already pinned by the live-push spec in the same file; recorded in sync.md.
  • A ban that wins on the ban lane is announced on the same message, with its "ban:" key, and a ban from a sender below the ban rank announces nothing (Peer Review, thread 47f8c0f9). Pinned in Tests/sync_bans_spec.lua; it passed when written, since the ban lane reaches the same receive code. sync.md's Messages line says so.

Sync: the GRM import's save (SY-07, SY-08)

  • sync.md specified a Sync:SaveImported; the import was built on Sync:SaveRecords instead (grm-import.md, "As built"), so SY-07 and SY-08 are pinned on that path. Spec change reason: the function they named does not exist by a settled build decision, not because the code differs.
  • New SY-07 spec in Tests/grm_import_spec.lua: the import stores and numbers its shared records and pushes none live. It passed when written; a mutant passing push = true from Import:Run turned it red (run 20261014-064902.600). The "older" half was already IM-08 and IM-09.
  • New SY-08 spec in Tests/sync_spec.lua: a planted contentHash on a SaveRecords row is replaced by the hash of the record. It passed when written (Sync:Stamp clears the field before hashing).
  • sync.md and grm-import.md no longer name SaveImported as a function to call.

Sync: a removal and a refused record (SY-13, SY-10)

  • SY-13 written in Tests/sync_records_spec.lua: a newer removed = true record replaces the held one and reaches the applier; an older one is ignored; a stale copy of the removed record cannot come back. It passed when written: a removal goes through the same Sync:Receive as any record.
  • SY-10 was already pinned by the existing refusal specs (wrong key, wrong kind, rank gate, non-member live push), each asserting nothing stored and nothing applied; labelled and recorded in sync.md.
  • SY-16 written in Tests/sync_two_peers_spec.lua: a custom note of ~|~?| reaches the other client byte for byte, with the author's setAt and contentHash, by live push and by login catch-up. It passed when written (run 20261014-063803.834). The harness wire does not check message text; the live rule permits | (warcraft.wiki.gg, C_ChatInfo.SendAddonMessage: "All characters (decimal ID 1-255) are permissible except NULL (ASCII 0)"; Peer Review, thread 6df7757b). Not observed in game.

Sync: one place computes a hash (SY-04)

  • New static spec Tests/sync_hash_scan_spec.lua: over every file the TOC loads, tokenized, exactly one assignment to contentHash computes a value, and it is in Sync:Stamp. It catches x.contentHash =, x["contentHash"] =, a table-constructor field and rawset; a bare nil clears and is allowed; anything unsure counts as computing. It passed against the code as it was, so it was shown red once on a mutant (Bans.lua's copy keeping the old hash; run 20261014-062457.580, naming Modules/Bans.lua:95 in copy). Whole suite 897 passed, 0 failed, 4 pending (run 20261014-062553.066).
  • The Lua tokenizer moved out of Tests/locale_strings_spec.lua into Tests/lua_tokens.lua, so both static scans read code through one tokenizer.

Slash commands for each tab (UI-12 to UI-14, SLASH-01, SLASH-02)

  • /ugr config opens the settings exactly as /ugr settings does. /ugr roster, /ugr log, /ugr alts and /ugr sync open the window on that tab and print nothing. Specs first in Tests/slash_spec.lua (UI-12, UI-13), red before the commands existed (run 20261014-054503.840).
  • UI-14: a spec that every registered command's description is a key Locale/enUS.lua defines. It passed before any code change, because every description already was one; it guards the rule.
  • /ugr bans now describes itself as "Open the Bans tab.", matching the other tab words; the old key "Open the ban list." is gone.

Fixed: a date cell with no date raised an error while drawing (Peer Review H1, thread 2bd58719)

  • W.RowList hands a column formatter "" for a nil cell, not nil (RowList:_cellText), so every t and ... guard in our formatters always passed and "" reached date() or Dates.Unpack. Rows that reach it, per Peer Review's reading on thread 2bd58719 (Members.lua:266, GRMImport.lua:296; not traced here): a member whose join date was cleared (Joined), a former member imported from GRM with no leftAt (Left). Also an offline member with no last-online time (Last online, Roster and Alts tabs). The Bans tab's Banned and the Log tab's When had the same formatter shape.
  • One guard, in GUI/UI.lua: UI.Stamp answers "" for anything but a number, and the new UI.Day does the same for a stored day. UI.LastOnline (the Last online cell) and UI.LastSeen (an online member sorts as now) replace the copies the Roster and Alts tabs each had. All five sites use them.
  • The specs had called each formatter with the row's own field (nil), which is why this was not seen. New specs read the cell as DRAWN (rl:CellFrameFor(...)). They failed with bad argument #2 to 'date' before the fix (run 20261014-054733.365). The Bans and Log checks call the formatter with "" and draw no row; each spec says so.

Fixed: a tab was built before its frame was placed, so no tab list drew a row offline

  • MainWindow:ShowTab called ns.Tabs.Render on a new content frame before attaching it to the TabGroup. UI.FilterRow's fit read a width of 0 while building, so the filter row, and the list anchored under it, had no rect, and RowList laid out no rows. frames.whyUnplaced named it: "is anchored to an anonymous Frame, which has SetWidth(0) and no anchors that span that axis" (run 20261014-055434.538). The frame is now placed first, then rendered. In game a later OnSizeChanged probably refitted the row once the frame was attached (not verified in game).
  • My first draft of the drawn-cell specs hand-set the list area's height and said the harness answers 0 for an anchored frame. That was wrong: the harness derives height from anchors, and the 0 came from the order above. Peer Review caught it (thread 2bd58719). The hand-set height is gone; the specs now assert frames.whyUnplaced(list area) is nil.

Main window size (UI-04)

  • profile.window is kept through W:PersistWindow (LibAceGUIWidgets' replacement for a bare SetStatusTable), with a floor of MainWindow.MIN_WIDTH x MIN_HEIGHT. A saved size under the floor is raised on open; a release forgets the window. Spec in Tests/window_spec.lua, red before the change (run 20261014-055400.689). The last tab was already kept in profile.activeTab.
  • The width floor is derived, not picked (Peer Review, thread a532a13f, reply 755d24ff). My first floor, 600, was narrower than the officer Roster view's fixed columns (745 px), and so was the old default width, 760: RowList gives the width-less Custom note column what is left, so it had 0 px. MIN_WIDTH is now ns.Tabs.Roster.FixedWidth() (the widest view's fixed widths and its column count) plus RowList's left pad and per-column gap, 100 px for the Custom note, and the Frame, TabGroup, strip and scrollbar insets, each cited in GUI/MainWindow.lua: 965 today. The window opens at that width by default (PersistWindow's width option), so profile.window's default is now empty and the size is kept in one place; Tests/savedvariables_spec.lua changed with that reason. MIN_HEIGHT 360 is still a choice: room for the strip, the filter row, the header and a few rows, not measured in game.
  • New Tests/roster_tab_spec.lua cases measure the drawn Custom note (RowList:CellWidth) in both officer views at the floor and at the default size, with each header's text width declared as 0 because the harness's text metrics are deliberately not real (so a header wider than its column in game, which RowList widens to fit, is not covered). Red at 600 (run 20261014-060355.533), green after (run 20261014-060346.754). Whole suite 887 passed / 0 failed / 4 pending (run 20261014-060546.059); MainWindow.lua, Tabs/Roster.lua and SavedVariables.lua at 100% (coverage run 20261014-060703.718).
  • The floor follows LibAceGUIWidgets' scale without doubling the chrome (Peer Review, thread a532a13f, reply 3127b1ca). PersistWindow multiplies a number floor by the library scale, but only the list's part (MainWindow.LIST_WIDTH: fixed columns, pad, gaps, Custom note) is scaled; the AceGUI insets, the strip's inset and RowList's scrollbar lane stay at pixel size. The floor is now the function MainWindow.MinWidth(scale), LIST_WIDTH + CHROME / scale, the form the library documents for a floor that is partly fixed art, so at scale s it comes back as LIST_WIDTH * s + CHROME. FixedWidth now takes the per-column gap and returns the widest view's total. A third roster-tab case opens at scale 2 on a 1920x1080 screen and expects exactly LIST_WIDTH * 2 + CHROME; red with the number floor (1930, clamped to the screen's 1920; run 20261014-061430.871), green with the function (run 20261014-061423.411). Whole suite 888 / 0 / 4 (run 20261014-061530.524); MainWindow.lua and Tabs/Roster.lua at 100% (coverage run 20261014-061645.578).
  • Known cost, not fixed: PersistWindow ends with W:ClampWindow, which shrinks a window to fit the screen (LibAceGUIWidgets-1.0.lua:1650-1651). On a screen narrower than the floor (for example scale 2 on a 1024-wide UI) the window is drawn narrower than the floor and the Custom note column gets less than its 100 px, or none. Not seen in game.

Slash: every tab has a word

  • A spec checks that every Constants.TAB_ORDER key except settings has a registered /ugr word that opens that tab, so a new tab cannot be added without one (Peer Review, thread a532a13f). Shown red by removing the sync line once (run 20261014-055525.982). Whole suite 885 passed / 0 failed / 4 pending (run 20261014-055624.142); GUI/MainWindow.lua and Slash.lua 100% (run 20261014-055737.132).

Roster tab: a keystroke drew the old rows first (Peer Review, thread 2bd58719, item 2)

  • The tab called list:SetSort on every paint, and SetSort redraws before SetData hands over the new rows. It is now called only when the sort key or direction differs from the list's. A spec counts the calls during one keystroke; it failed against the old line (run 20261014-054834.275).
  • Whole suite 883 passed / 0 failed / 4 pending (run 20261014-054930.709); GUI/UI.lua, the Roster, Alts, Bans and Log tabs, Slash.lua and Locale/enUS.lua at 100% line coverage (run 20261014-055030.765). Not seen in game.

Roster tab (UI-02, UI-06 to UI-11, docs/design/ui.md)

  • New GUI/Tabs/Roster.lua (ns.Tabs.Roster), in all five TOCs first after GUI/Tabs.lua; the placeholder def in GUI/Tabs.lua is now a one-line render (and its unused none helper is gone). Specs first, in the new Tests/roster_tab_spec.lua, run red (12 of 12, run 20261014-053221.680) before the tab existed.
  • Columns: Name (class colour), Level, Rank (sorts by rank index, draws the rank name), Last online, Main/Alt, Joined (Members:CurrentJoinDate through ns.Dates.Format), Public note, Officer note (only while ns.Compat.CanViewOfficerNote(), rebuilt with rl:SetColumns on every repaint, so a demotion drops it), Custom note. Notes are read through Members:NoteFor, as the member panel reads them.
  • externalSort: the tab orders the rows itself, by the column, then rank index ascending in both directions, then short name. Grouped rows sort inside their group.
  • Search over names (short and full), public note, custom note, and the officer note only while it is viewable, every word in any order (W:SearchMatch, the Log and Alts tabs' rule).
  • Chips (W:AddChips): Mains, Alts, Offline, Group alts under main, Former members. The former list has Left and Reason columns; Reason is the member's newest leave entry in the event log. Every chip is disabled guildless, and the first four while Former is on.
  • char.roster (new SavedVariables default, per character as ui.md lists) holds the chips and the sort, so a rebuilt tab reads them back. Tests/settings_spec.lua ST-02's pinned char defaults and Tests/toc_spec.lua's file order were updated to match.
  • The tab repaints on UGR_ALTS_CHANGED and UGR_LOG_ADDED while showing. A left click opens the member panel (a group heading opens its main); right-click stays free for the macro tool.
  • New locale keys: "Search the roster", "Mains", "Offline", "Group alts under main", "Former members", "Not linked", "Main/Alt", "You are not in a guild.", "Left the guild", "Removed by %s", "Removed".
  • Spec correction while going green: the first example assumed a member with no join date, but the first roster read gives every member one; the fixture now empties Dan's history so the blank case is real.
  • Self-audit fix: a former member with no leave in the log had Reason "", which sorted first where every other blank sorts last; it is nil now. The spec asserting "" was changed for that reason, and the new assertion failed against the old line (run 20261014-054201.111) before the fix went back in.
  • Whole suite 873 passed / 0 failed / 4 pending (run 20261014-054252.271); GUI/Tabs/Roster.lua 100% line coverage. Not seen in game.

Member detail panel (MEMBER-02, MD-20 to MD-28, docs/design/member-details.md)

  • New GUI/MemberPanel.lua (ns.MemberPanel), in all five TOCs after GUI/BanDialog.lua: an AceGUI ClearFrame (LibAceGUIWidgets) docked to the main window with W:DockWindow, its position saved in profile.memberPanel. It draws the name in class colour (RAID_CLASS_COLORS, a new .luarc.json global, present in every flavour's tree), level and rank marked Main or Alt, the live status (or the day a former member left), a ban warning, the join and promotion dates with their history on hover, the public, officer and custom notes, and the alts. The officer note row exists only with permission to view it.
  • A right-click on a date opens its menu: Set (the date editor, LAGW-DatePicker), Confirm, Clear, Mark as unknown, and for the join date Copy to alt group in its three modes. A greyed entry carries the store's own reason: new Members:WhyNotEdit, WhyNotVerify and WhyNotCopy answer what the edit would refuse, and the verifiers and CopyJoinDateToGroup now ask them too, so the menu and the store cannot disagree (Peer Review F1, thread 15653960; specs red first, desk run 20261014-050639.451).
  • Members:WhyNotVerify (so VerifyJoinDate and VerifyPromotionDate) refuses a date already verified, with "This date is already verified.", and re-stamps and saves nothing. Before, a second verify overwrote who verified it first and saved the record again: Sync:Stamp gives every save the realm's current time, so re-verifying a stale date made a newer record that would win over a colleague's correction. The panel's own copy of this rule is gone; it asks the store (Peer Review A1, thread 1b76d611; spec red first, desk run 20261014-051600.427).
  • A click on a note row opens its editor, a W:CreateFormDialog. Members:WhyNotGameNote is asked before the editor opens and again by SetGameNote. The public and officer notes show the text just written, marked "waiting for the game", until the roster comparison records the note (Members:NoteFor, Members:NoteLanded).
  • Escape closes the open editor, then the panel, then the main window (W:EscapeLayer). A language switch relabels the panel and its editors (MainWindow:RefreshLocale calls MemberPanel:RefreshLocale). A left-click on a member's row in the Alts tab opens the panel.
  • Waiting notes (Peer Review N1 and F3, thread ab3bc972): they are keyed by the record's name, cleared by the roster comparison when it records that note, whoever wrote it (so a later officer's note is not hidden), dropped when the member leaves, carried through a rename, and wiped on UGR_GUILD_CHANGED. Four of the five NoteFor specs failed first; the first passed on the old code too.
  • New ns.UI.DialogSlot: a form dialog a language switch can rebuild under a new global name, since the dialog cannot change a row label after build (LibAceGUIWidgets request 1a2fe53f). The ban dialog moved onto it, and the note dialogs use it, so the workaround exists once.
  • The own-note test in WhyNotGameNote compares the record's name, closing the last item of Peer Review thread aa8b112c.
  • Harness pin 6f9ae02 to b0f2cfe: the harness models CanEditPublicNote and C_GuildInfo.SetNote (guild.canEditPublicNote, guild.noteWrites), so Tests/env_ugr.lua's stand-in for them is deleted and members_spec.lua uses the harness's.
  • Harness pin b0f2cfe to abdc05e: the harness ships CloseSpecialWindows and frames.pressEscape() (a26b6c5, request 0984d7cc), so Tests/env_ugr.lua's pressEscape stand-in is deleted and member_panel_spec.lua MD-25 presses Escape through the harness. Whole suite 860 passed / 0 failed / 4 pending on the new pin (desk run 20261014-052653.443).
  • Tests/member_panel_spec.lua setup changed once, with its reason in the file: officer-note view permission is granted before the load, since the roster read is what records the officer note, and the below-the-threshold examples set the threshold they rely on. Asserted behaviour is unchanged.
  • An open date menu closes when the panel redraws; LibAceGUIWidgets closes a menu when its anchor hides, and a spec pins that the panel keeps relying on it (Peer Review F2).
  • Known costs: a note write the server silently refuses stays "waiting for the game" until that note next changes or the session ends; a language switch while a note editor is open, after the player lost permission, closes the editor and loses the typed text. Whole suite 859 passed / 0 failed / 4 pending (desk run 20261014-050815.769); Modules/Members.lua and GUI/MemberPanel.lua at 100% (desk coverage run 20261014-050926.105). Nothing here has been seen in game.

Member details, the store half (MEMBER-03 to MEMBER-07, docs/design/member-details.md)

  • Modules/Members.lua gains the officer edits, each returning true or false plus the message to show: SetJoinDate / VerifyJoinDate / ClearJoinDate / SetJoinUnknown and the four promotion equivalents, CopyJoinDateToGroup ("earliest", "main", "this", one SaveRecords batch pushed live), CurrentJoinDate / CurrentPromotionDate, TimesInGuild and RankTimeline. A correction to a verified date goes in front of it as a new "edited" entry, so the old date stays in the history and is not counted as another join.
  • SetCustomNote: 200 characters, stamped with who and when, shared as cnote:<guid> only while sharing is on; turning sharing off shares a removal. Gated on the custom-note rank; a changed text is logged.
  • SetGameNote writes a public or officer note through the game (ns.Compat.SetNote, 31 characters counted with strlenutf8). It records and logs nothing itself: SetNote confirms nothing, so the roster comparison records the change once the game's roster carries it (Peer Review N1, thread aa8b112c). Every call writes, even text the stored note already holds: the stored note lags a write, so a "same text, skip it" test turned "new" then back to "old" into a skipped write (spec red first, desk run 20261014-043337.136). The officer note needs view as well as edit permission. ns.Compat.CanWriteNotes is true on Era, TBC and Mists only, until note writing is checked in game on Retail and Forever.
  • The join, promo and cnote sync appliers: only a received record is applied, only under its own key, and only to a member held by GUID. A received removal (another officer's Clear) also clears a client's own unverified, auto-detected date; decided in member-details.md.
  • New ns.Compat wrappers: CanEditOfficerNote, CanEditPublicNote, CanWriteNotes, SetNote. CanEditPublicNote is a new .luarc.json global. The harness models both it and C_GuildInfo.SetNote since the pin moved to b0f2cfe (see "Member detail panel" above), and the stand-in this line used to describe is deleted.
  • Tests/members_spec.lua (MD-06 to MD-19), red first. sync_two_peers_spec.lua and sync_records_spec.lua changed: their sample join/promo/cnote records now carry the guid their key names, because those kinds now have key rules that refuse a record filed under another member.
  • Self-audit fixes (thread aa8b112c): a new join entry no longer carries a rank index; the note length check uses the game's strlenutf8, as Bans does, instead of a second hand-written counter. Whole suite 820 passed / 0 failed / 4 pending, Members, APICompat and enUS at 100% (desk coverage run 20261014-043440.185).
  • Members are keyed by GUID for sync, and LibGuildRoster does not supply GUIDs yet (request 2f8b405d): until it does, dates and custom notes stay on the officer's own client and every game note edit is refused. Nothing here has been seen in game, and the member panel that calls it is not built.

Import from Guild Roster Manager (IMPORT-01 to IMPORT-05, docs/design/grm-import.md)

  • New Modules/GRMImport.lua (ns.Import): Find reads the current guild's entries from GRM's saved tables (members, former members, the log, alt groups; a faction-level save too), writing nothing. Plan is pure and returns the counts and skips; Run plans again against what UGR holds at that moment, then writes. Status reads the guild's import marker, global.guilds[key].import.
  • What comes across: members and former members (join and rank history merged and de-duplicated, the GUID, birthday and the leaving details a held record lacks; a custom note only when newer), the newest verified join and promotion dates and shareable custom notes as join:/promo:/cnote: records, alt groups as alt:grm:<group>:<edit time> records, bans and unbans at the ban rank, and the log once per guild. Log types 3, 4, 8, 14 and 19 become UGR entries; any other keeps GRM's saved line as an imported entry.
  • Every shared record keeps GRM's own edit time as setAt (decision 1) and goes through Sync:SaveRecords in one batch with no live push; catch-up carries it to other officers.
  • Anything UGR already holds at the same or a newer time is skipped as older, so a second run brings nothing. Unreadable entries are skipped and counted, never fatal.
  • Only an officer (LibGuildRoster IsOfficer) may import; bans also need the ban rank, and every other shared record needs the officer's rank to author it ("rank too low", Peer Review P2, thread f596b0c0; spec IM-12b red first, desk run 20261014-035629.114).
  • While the guild's ranks are still loading, Import:WhyNot returns Sync's wait message ("The guild's ranks have not loaded yet. Try again in a moment.") for the preview and Run alike. Before, an import run right after login planned against a missing rank threshold and reported every shared record as "rank too low" and every ban as "not permitted", both false (Peer Review A2, thread 7da28d2c; spec IM-18b red first, desk run 20261014-040639.932, green 20261014-040650.124).
  • New GUI/ImportDialog.lua: the first-time offer after the login comparison, once per guild, and the preview from /ugr import (new slash command), which shows the counts and skip reasons and then the report. Members:AddFormer and EventLog:AddImported added for it; imported entries are never said in chat.
  • All five TOCs list the two new files and ## OptionalDeps: Guild_Roster_Manager, so GRM's saved data is loaded first when GRM is enabled.
  • Tests/grm_import_spec.lua (IM-01 to IM-20), its fixture keyed exactly as a real GRM save file (version R1.99422) with invented players. Red first (desk run 20261014-034703.201: ns.Import missing), green 20261014-035148.104. toc_spec.lua now expects the two files and the optional dependency, and slash_spec.lua lists import among the built-in commands.
  • Not verified, none of it seen in game: reading a disabled GRM's data, the clock GRM's times are in (read as UTC), how GRM lays out log types 7, 10, 1, 2, 6, 5, 11, 9 and 24, the banned-stranger rule (rank index 99), and filled altsAtTimeOfLeaving. Re-importing after GRM edits an alt group makes a second group, because the group's id includes its edit time.

General live push (SY-05, SY-11)

  • Sync:Push now sends any record that is not a ban once on the general host's DATA broadcast ({ type, record, live = true }, GUILD by DeltaSync's default), exactly as stored, its hash forwarded. Nothing is sent while guildless, before the host starts, or while sync is off. It returns true or false for these, and still the whisper count for a ban.
  • Sync:SaveRecord pushes every record it saves, after storing, numbering and applying it; before, only bans were pushed.
  • Sync:SaveRecords(batch, push) pushes each stored entry after the appliers when push is true. Alts saves every edit through it with push set; the GRM import will leave it off, as catch-up carries an import. Until this, alt edits went out only at the next Announce, and the live push above had no production caller (Peer Review N2, thread d4f235a3). Spec red first: an alt link made in Alts on one client reaches the other live, sync_two_peers_spec.lua (desk run 20261014-033843.809); green, with a sync_records_spec.lua case for the flag, 20261014-033929.751.
  • A batch naming one key twice pushes only the copy it kept; sending the overwritten one too could leave a receiver on it at an equal setAt when its hash is the greater (self-audit; spec red first, desk run 20261014-034218.754).
  • Tests: env_ugr.peerLogin fires GUILD_ROSTER_UPDATE STABLE_THRESHOLD + 1 times, as the single-client specs do. Once was never enough for LibGuildRoster to go ready, so a two-client login never ran the roster comparison and the member store stayed empty (traced for Peer Review on thread d4f235a3: initialized=false in desk run 20261014-034313.237). A new spec asserts both members are recorded, and the Alts live-push spec no longer adds them by hand.
  • Sync:OnDataReceived refuses a live push from anyone the lane does not take as a peer before storing anything, and completes no P2P item for it.
  • Specs red first (desk run 20261014-032519.265), green 20261014-032603.276: a new block in sync_records_spec.lua, SY-23 in sync_two_peers_spec.lua (an edit reaches the other client live, hash unchanged). sync_bans_spec.lua's "pushes nothing of another kind yet" asserted the old refusal this work removes, so it now asserts the general host carries it and the ban host never does.
  • Sync:SaveRecord sets by to the player (GetNormalizedPlayer) when the record names no author, before the stamp, so the author is inside the hash (sync.md SY-05); a record that names its own, as Alts does, keeps it. Spec red first (desk run 20261014-034709.600). Two older specs computed an expected hash without by, so each now includes it: sync_spec.lua "SaveRecord passes a supplied setAt", and SY-25 in sync_two_peers_spec.lua, whose copies now name one author, as two clients would otherwise stamp two different bys. No player sees this yet: the only SaveRecord caller is Bans, whose records go to the ban lane, and Alts names its own by (Peer Review P3, thread f596b0c0). Ban records saved through SaveRecord now gain by too; it is only hashed.
  • The general rank gate (sync.md SY-05, SY-06): Sync:Accepts(sender, kind) takes a general record, live or by catch-up, only from a sender at or above its kind's threshold (customNoteRank for a custom note, generalRank otherwise, Sync.ClassOf), judged on the sender's rank and never on by, the shape the ban lane uses. Everyone qualifies at the lowest rank; nobody while the ranks load. SaveRecord returns nil and the reason for a general record the player may not author. Before this a live push was taken from any guild member with sync on. Six specs in sync_records_spec.lua, red first (desk run 20261014-035001.192), green 20261014-035119.466. The general gate in SaveRecord has no production caller yet (see by above); the player-visible gate is the receive side.
  • Sync:SaveRecords applies the same rule per entry: a general record the player's rank may not author is skipped, and the rest of the batch is stored. It also sets an absent by, as SaveRecord does. Before this an officer below a raised generalRank could import alt groups that every other client refuses from them, so they lived on the importer's client only (Peer Review P2, thread f596b0c0). Spec red first in sync_records_spec.lua (desk run 20261014-035629.114), green 20261014-035719.365. The SY-07 spec in sync_spec.lua ("SaveRecords stores a whole import") computed its expected hash without by, so the whole suite went red on it (desk run 20261014-040518.176) once SaveRecords set one; it now includes by and asserts it is the player, for the same reason as the two specs above.
  • Known cost, pending on DeltaSync (UGR request be665d97): in a guild that raises a threshold, a member below it can still be picked as a catch-up provider for an officer's record, and the refused reply waits out DeltaSync's 180 s delivery timeout before a new catch-up starts. At the default lowest rank nothing changes. A three-client spec in sync_two_peers_spec.lua measures it (red, desk run 20261014-035905.294) and is pending until DeltaSync ships a refuse-and-advance call.

Bans learn a GUID, and a banned stranger's join is warned about at once (BN-17)

  • Bans:OnBannedPresent and Bans:CheckPresence write a GUID the member record has gained since the ban into a newer version of a ban that has none (ban-list.md, Edge cases), so a later rename still finds it. No log entry; never for a ban that already has a GUID, or when the player may not author.
  • A stranger banned before they ever joined was warned about only at the next login, though BAN-05 is "warn loudly when a banned player is in the guild". Roster tracking now marks such a join banned and calls OnBannedPresent(name, true), which logs bannedPresent and says it in chat at once.
  • Specs red first (desk run 20261014-032632.043), green 20261014-033011.184.

Fixed: a live joiner broke the next login's roster comparison

  • A joiner LibGuildRoster learns from the chat line has no rank, class or GUID until its roster row arrives, an invented level 1 and empty notes (LibGuildRoster-1.0.lua:2759-2786), and the library fills them later with no callback. Roster:Refresh copied those onto the new member's record, so the next login's comparison raised "attempt to compare number with nil" in the rank check (Roster.lua, present) and the whole comparison stopped; with that fixed, it would have logged a false level-up and a note added. Refresh now stores rank, level and notes only once the entry has a rank, and the comparison reads a saved rank of nil as unknown, not a move. Found while writing BN-17; spec red first (desk run 20261014-032916.809).
  • A chat-line joiner promoted or demoted later in the same session raised "attempt to compare nil with number" in Roster:OnRankChanged: their saved rank stays nil all session, and the library fires its rank callback once the row has filled in (Peer Review N1, thread d4f235a3). OnRankChanged now takes the old rank from the library's callback when the saved record has none, and logs the promotion or demotion; when neither side knows it, it stores the new rank and logs nothing. Two specs red first (desk run 20261014-033638.677), green 20261014-033655.461.

Bans tab and the add/edit dialog (BN-13 to BN-16)

  • GUI/Tabs/Bans.lua (ns.Tabs.Bans) and GUI/BanDialog.lua (ns.BanDialog), in all five TOCs after GUI/Tabs/Alts.lua. The tab: a strip (Refresh, Add ban, and "Remove all banned members" greyed as not built, BAN-07), a search over name, full name and reason, and the active bans in Bans:List()'s order with Name, Rank (the member's rank, else "Former member" or "Never a member"), Banned, By, Flags ("In the guild", "Not identified") and Reason. Right-click a row to edit the ban or lift it after a confirmation. UGR_BANS_CHANGED repaints it while it shows.
  • The tab exists only at or above the ban rank: MainWindow builds its tab list from Bans:CanSee(), and Sync:CheckBanHost sends UGR_BAN_ACCESS_CHANGED(canSee) whenever the player's right to author bans changes, sync running or not; the open window rebuilds its tabs and moves off the Bans tab if it was on it (MainWindow:RefreshTabs). /ugr bans opens it, or says "The ban list is for officers.".
  • The dialog is a W:CreateFormDialog: a searchable name dropdown over current and former members with the typed text offered first for a stranger, Realm (no row on WoW Forever), Class (the client's CLASS_SORT_ORDER and LOCALIZED_CLASS_NAMES_MALE, both new .luarc.json globals, defined in every flavour tree) and Reason. Picking a member fills class and realm; a refusal stays under the rows. ns.Constants.BAN_DIALOG_NAME is its global name. UI.List takes an options table for the row click.
  • Realm is locked whenever the chosen name already carries one, and in an edit: Bans:Ban appends a realm only to a bare name and Edit takes none, so a realm typed there was dropped without a word (Peer Review F3, thread 4151079e; two specs red first, desk run 20261014-030950.584). Unlocking empties the field: after picking a member, a bare stranger's name would otherwise have been banned on that member's realm (found in my self-audit; spec red first, desk run 20261014-031741.523).
  • A language switch rebuilds the dialog, open or not, under the next generation's global name: the form dialog has no row-label setter, so its labels were fixed until a /reload (F4; spec red first, same run). BanDialog:RefreshLocale runs from MainWindow:RefreshLocale; LibAceGUIWidgets has been asked for a label setter.
  • A refused Unban shows its reason in a one-button dialog over the tab instead of chat, since the reason is in the window's language (F5; spec red first, same run).
  • ns.Compat.RealmOf, nil-safe, replaces the two copies of realmOf in Modules/Bans.lua and the dialog; the module's copy raised on a non-string (F6; spec red first, same run).
  • The dialog's Realm is a searchable dropdown like Name (BAN-03): the typed text first when it is no suggested realm, then the matching Bans:RealmSuggestions(), the realms in the saved member and former-member names. It was a plain field. The realm specs read it with GetValue now, since a dropdown has no GetText; one new spec covers the suggestions (red first, desk run 20261014-032343.922; green 20261014-032408.193).
  • The Settings tab's status line no longer says the tab is a placeholder. slash_spec, window_spec and toc_spec were updated for the new tab and files.

Sync on and off (SY-15)

  • Sync off now stops the ban host as well as the general one: Sync:Enabled() is read by StartBanHost and CheckBanHost. A ban saved while sync is off is still stamped, numbered and kept (Sync:BanSaveLane builds the host without starting it), and is shared once sync is back on (Peer Review b10eb582, F5).
  • Turning sync back on announces what the player holds at once, instead of at the next login (Settings:Set; spec red first, desk run 20261014-025639.736). SY-15's older case now counts two broadcasts, the automatic one plus its own Announce, for that reason.

Ban list (BN-01 to BN-12)

  • Modules/Bans.lua (ns.Bans), in all five TOCs after Modules/Alts.lua: Ban, Edit, Unban, Get, IsBanned, List, IdFor, CanSee, OnBannedPresent, CheckPresence, Rekey (and OnMemberRenamed, the name Members:Rekey calls), RealmSuggestions and the ban applier. One record per player, saved through Sync:SaveRecord on the ban host only; an unban is a newer version and nothing is deleted. Banning a player already banned edits that ban; a re-ban after an unban is a new ban under the same record.
  • Every edit needs Sync:CanAuthor("ban") and returns true or false plus the message to show, as Alts does (ban-list.md said a reason key). Below the ban rank nothing is shown, saved or warned about, and a table kept on disk from before a demotion is hidden, never deleted.
  • IdFor is DeltaSync's frozen ComputeHashV2 of the lower-cased canonical name, twice with two labels, as 16 hex digits (sync.md said 12 characters of one hash). It is not a secret; see the next section.
  • Reasons are limited to 150 characters, counted with strlenutf8, so a reason in a two-byte script is not cut at 75. Every client has it: Blizzard's own AutoComplete calls it in the classic_era, classic_anniversary, classic (Mists), live and forever trees. strlenutf8 is a new .luarc.json global.
  • Roster:CompareAtLogin calls Bans:CheckPresence once the members are current: one bannedPresent entry per banned current member, not repeated in the session, and none for a player a rejoin already reported.
  • A received NEW BAN ACT another officer authored logs banReceived: the version it replaced was absent, unbanned, or had another bannedAt. My first build logged every received active version, so another officer's edit or rename read as a new shared ban, and a newly promoted officer's catch-up of 150 bans printed 150 chat lines (Peer Review B1, thread 37cb4ede). A live push says its own line; the first catch-up ban of the session says one line pointing at the Bans tab and later ones say nothing. There is no end-of-session hook in DeltaSync's numbered P2P and directive b15a1e31 rules out a timer, so the line cannot carry a count. Sync now hands every applier a third argument, { previous, received, live }, captured before the record is stored; EventLog:Say(category, text) prints one line through Announce's chat-window rule (now one local chatFrame). BN-12's first case sent an edit and expected banReceived; it now sends a new ban, for that reason.
  • banReceived was gated and credited on by, which is whoever saved the version last, not who banned: a ban edited by a second officer was credited to them on catch-up, and the player's own ban caught up after a wiped SavedVariables read as shared by someone else. It now gates on and names bannedBy (Peer Review B7, thread 314c672a); two B7 specs were written red first (desk run 20261014-023519.479). A received version naming no banning officer is stored but logs nothing, where it had logged an entry with no from (found in my self-audit; spec red first, desk run 20261014-023839.897).
  • The ban lane accepts any version a peer at the ban rank sends, and the log credits bannedBy (ban-list.md C2). It first also refused a version whose by the roster held below the ban rank (Peer Review C2, thread c93f2110). That check judged the author's rank NOW: a ban by an officer since demoted, never edited, was refused by every officer promoted later, while one by an officer who had left the guild was taken; and by travels in the record unchecked against the sender, so it stopped no forgery the sender check had not. Removed (Peer Review ruling 82af8aa5, thread b367ea9d): sync_bans_spec.lua's low-author case was inverted and run red first, then the check deleted from BanLane:OnDataReceived; a new case pins the unedited ban by a demoted officer. sync.md records that the general lane's planned Accept must not repeat the author check.
  • CheckPresence and Rekey refused only because the ranks were loading were dropped, so the login warning could be lost for the session and a rename left the ban under the old name (B2, B3). Both now queue and run once when GUILD_RANKS_UPDATE or GUILD_ROSTER_UPDATE lets the player author bans (Bans:Enable, called from OnEnable before the roster, and Bans:RetryWaiting, which checks the ban host first because a held-back rename saves through it).
  • Rekey reads the GUID from the member record under the NEW name: Members:Rekey moves it before it calls Bans, so the GUID path never fired and a ban saved under another spelling was missed (B3).
  • Ban refuses the player's own name (B5); Edit no longer takes a realm, which is part of the name (B6).
  • Six new locale strings: "Enter a name.", "%s is not banned.", "Reasons are limited to %d characters.", "Bans cannot be saved right now. Try again in a moment.", "You cannot ban yourself." and "Other officers' bans have arrived. Open the Bans tab to see them.".
  • Tests/bans_spec.lua, written red first (all 30 cases failed on the missing module, desk run 20261014-021357.368), including a two-client case that carries a reason with ~, | and ? across the real wire. One assertion was corrected after: a name on another realm keeps its realm in a message, because ns.Compat.ShortName drops only the player's own realm. Tests/toc_spec.lua lists the new file. The B1, B2, B3, B5 and B6 cases were written red first (9 failed, desk run 20261014-022631.209).

Sync: the numbers handshake gate (Peer Review U1/U2, thread b10eb582)

  • DeltaSync answers a numbers-request from anyone and adopts a numbers-reply from anyone. On the ban host the first handed the whole key list to any whisperer, and since a ban's id is an unkeyed hash of the name, hashing the roster read the ban list off it; on either host one reply of n = 10000 ended numbering for good. A stopped ban host still answered.
  • STOPGAP until DeltaSync gates it (DeltaSync inbox c9feae7f): build() replaces HandleRequest and HandleReply on each host's own numbers instance, so both answer and adopt only the lane's valid peers, and a stopped ban host nobody.
  • The design docs no longer call ban keys opaque: the id stops duplicate bans and hides nothing, and keeping bans from lower ranks rests on the gate (sync.md gap 4, ban-list.md).
  • The ban lane inherits only Sync's functions, plus APPLIERS and KEYS by name, so a per-host field added to Sync later cannot read through the lane as the ban host's own (Peer Review U3).
  • Four handshake specs and one inheritance spec in Tests/sync_bans_spec.lua, run red against the code with the gate and the old inheritance switched off (desk run 20261014-021258.695), then green.

Sync: the ban host (SY-17 to SY-20)

  • Bans travel on a second DeltaSync host, namespace "UGRBans" (ns.Constants.BAN_SYNC_NAMESPACE), that only a player at or above the ban rank runs (operator decision 4). Below it the host is never built, so no ban prefix is registered and no ban is asked for, received or answered.
  • ns.Sync.banLane is the ban half of sync: Sync's own catch-up methods over the ban dataset (global.guilds[key].bans, "ban:<id>" -> record) and the ban host, overriding only the peer rule (a guild member at or above the ban rank), the dataset, and the running check. Both hosts are built by one function, so no catch-up rule is written twice. Its number table is global.guilds[key].banSync; both tables are new AceDB defaults, so no migration.
  • Sync:StartBanHost, StopBanHost, BanHostRunning and CheckBanHost. DeltaSync cannot take a host down and the client cannot unregister a prefix, so a stopped host stays registered and every hook refuses. CheckBanHost runs at enable, on a guild change, GUILD_RANKS_UPDATE, PLAYER_GUILD_UPDATE, Roster:OnRankChanged and every Settings:Set.
  • Sync:SaveRecord and SaveRecords route a ban record to the ban dataset and number it on the ban host (one mint per host touched in a batch), and store no ban while the ban host is not running. SaveRecord pushes a ban at once: Sync:Push whispers it to each online member the ban lane takes as a peer, never the player and never GUILD. The general live push (SY-05) is still not built, so Push refuses any other kind.
  • Sync:OnDataReceived refuses a ban, by kind or by ban: key, on the general lane and anything else on the ban lane; the ban lane also refuses a sender below the ban rank and an author the roster holds below it. A live push completes no P2P item. Sync.KEYS.ban files a ban under "ban:<id>".
  • Sync:RankOf(name) reads LibGuildRoster's rankIndex.
  • Tests/sync_bans_spec.lua, written red first (all 17 cases failed on the missing API, desk run 20261014-015511.943). Two of its own assertions were corrected before the code passed: DeltaSync's prefixes are lower-cased (README.md:176) and NumberOf returns the four-digit string.
  • Tests/sync_spec.lua's announce counter now counts the general host only: it wrapped every NewHost, and the ban host's own announce read as a second general broadcast.
  • Tests/alts_spec.lua: the refused-feed spec also asserts, on the second shape change, one more stale offer and no RemoveAltGroup, and was run red against a mutation that records a refused feed (Peer Review, thread fa27a466).

The earliest 0.2.0 sections (the Alts tab and store, the Log tab, the event log, roster tracking, the settings page, packaging and the test environment) and the whole of 0.1.0 are in CHANGELOG_ARCHIVE.md.