GuildRoster-v0.8.2
What's new
Changelog
All notable changes to LibGuildRoster-1.0 are documented here.
0.8.2 -- sister guild ranks, and same-realm sister members are heard
The LibStub MINOR moves 20 -> 21. Additive: nothing an addon calls changed
shape, and one method was added (GetRosterRankHash).
The user, 2026-09-16, on VersionCheck's window showing a blank Rank for
Testthese-Azuresong beside ranked home members: "can you capture this data so
it's included in the sister roster sync?" -- and "we'll make that part of
v0.8.2". The blank was not a capture gap in the home roster: VersionCheck reads
rank through GetMember, which is home-only, and the sister wire had
deliberately carried no rank since 0.7.0.
Added
- Rank on the sister wire. Every served member now carries
rn(rankName) andri(rankIndex) besiden,c,l,pn, omitted when absent. Built inwireMember, read and TYPED inwireToFeed(a string name, a number index, anything else dropped -- it is concatenated into a hash). - Sister members carry
rankNameandrankIndex, the home record's own spellings, so a consumer reads a sister member the way it readsGetMember.SetSisterRostertakes them; the older free-formrankis still stored as-is and fillsrankNamewhen it is a string orrankIndexwhen it is a number.PersistSisterRostersaves both, so a relog keeps them. lib:GetRosterRankHash(key)-- the third "has anything changed" hash, overcharKey \1 index \1 namefor members with a rank, sorted. It is carried everywhere the note hash is:rhon the relay, the guild ask's entries, a guild offer, aq = 2request and a delta;rsbesidehs/nson aq = 1request (kept on a request held for a/who). Absent means "cannot judge", as for notes, so a 0.8.1 peer is never re-pulled over ranks it cannot send.- Why a separate hash rather than rank folded into the note hash: a 0.8.1 client hashes notes alone, so folding rank in would make every 0.8.1 / 0.8.2 pair disagree on every round, and the stamp ordering would then pass the rankless copy back and forth.
Changed
detailsMatch(self, key, noteHash, rankHash)replaces the notes half of the "same copy" comparison, which was written out at six sites. Five call it now (answerGuildAsk,OnSisterRosterComm,serveRoster,takeGuildOffer,PullFromGuildOffers);BroadcastSisterRosters' suppression compares two stored hashes rather than a peer's against ours, and gained its rank half inline.- The relay suppression records
ranksbesidenotes, so a rank-only change is re-relayed rather than swallowed.
Fixed
- A roster served by a SAME-REALM sister member was always dropped as
"we did not ask them" (TOGBankClassic's peer review GR-SAMEREALM-001, found by
its four-client fleet on real AceComm). AceComm hands a receiver
Ambiguate(sender, "none")-- bare for a player on our realm -- while every record the sync compares it with (pendingPull[key].peer,askedPeers, a roster charKey) isName-Realm, and the comparison usedCanonName, which never supplies a realm. My defect: the pull gate (PR-2, earlier the same day) made it fatal, because until then nothing checked who answered. Fixed at the boundary --OnSisterPullComm,OnSisterRosterCommandOnSisterConfigCommnormalize the sender once, on AceComm's contract that a bare addon-channel sender is on our realm -- and every sender comparison (askedFor,consumeAskedPeer,mutual,OnPeerVersion,ResolvePendingVerify,ResolveNameVerify,OnPeerUnreachable) goes through onesenderKey, so a name that arrives from/whoor VersionCheck in either spelling matches too. Also stored bare before this:rosterMeta.via, a refusal'speer, a held asker'ssender. - Every same-realm member ranked "never reported" to VersionCheck and was
never pulled from: VersionCheck keys a peer by its arrival spelling (bare for
our realm) and
PeerLibraryVersionlooked it up asName-Realm. It now tries the bare form for a name on our realm, and only for our realm. - Why no spec saw either:
Tests/env_e2e.luadelivered every message with the sender asName-Realm, the one spelling a real client never gives a same-realm sender. It now delivers it bare (arrivalName); with only the comparison fixes in place, 20 e2e scenarios went red under that change, which is how the stored-bare fields above were found. The harness's ownAmbiguatereturns the full name for"none"whatever the realm, filed with WoWAPITesting. NOT verified in game. - A delta is verified on notes and ranks, not only membership.
r = 2now carriesnhandrh, andtakeServedRosterchecks both over the result before storing it; a mismatch asks for the full roster, as a membership mismatch always did. The base a delta is computed from is snapshotted when the MEMBERSHIP hash changes, so it need not hold the notes the taker holds: a taker whose copy differed only in notes (or now ranks) from that snapshot was sent an empty delta, verified it on membership, stored the same copy and asked again every round. Found by readingserveRosterwhile adding the rank hash; the rank case is the one the new e2e scenario drives (a copy saved before ranks: empty delta, refused, one full pull). The note case takes the same code path and was not separately reproduced.
Tests
hash_spec:GetRosterRankHash-- shape, order, independence from membership and notes, movement on a promotion, a renamed rank and a rank on another member, home rows and fed rosters agreeing, and the free-formrank.sistersync_spec: a relay whose ranks differ is a miss; our relay and request carryrh; a rank-only change is re-relayed; the served wire carriesrn/riand neverrank.e2e_spec: rank crosses on a pull; a returning player's rankless saved copy gets ranks from a guildmate (empty delta refused, then a full pull, nothing sent to the sister guild) and persists them; a promotion crosses as a verified delta with the membership hash unchanged; then "no change". The guild-first scenarios' saved copies now carry the harness's rank, because a copy saved by this version does.sistersync_spec: a same-realm peer VersionCheck recorded bare is found forName-OurRealmand bare, and never for another realm.- Harness pin
34107ac->acac151. Adoptsf5d2383,Ambiguate's context table (our contractf578e2bf):env_e2e.lua'sarrivalNamenow calls the harness'sAmbiguate(name, "none")instead of returning the bare name by hand. The move turned three timer-driven specs red, and the cause is harness-side and MEASURED: a 300 s ticker due at10639.000000000ran withGetTime()=10638.999999996, so the library's own(GetTime() - last) >= SISTER_PULL_INTERVALpacing read the round as too soon. Filed as contractadcdd67f; the harness fixed it the same hour in98c7d86(a timer runs atmax(clock, at)and the clock never moves back), and the local stand-in written for the gap was deleted on adopting it. The three specs are green on the harness's own timers with no margin added. installShowOfflineEchois retired -- the local copy of the show-offline setter'sGUILD_ROSTER_UPDATEedge that the harness shipped as contract 7 long ago. It had stayed because the harness's edge reaches onlyenv.frameswidget-layer frames and nothing here loaded that layer. The three feedback-loop specs inshowoffline_spec.luanow load the library on the widget layer, deliver events withframes.fireEvent, count writes by wrapping the real setter, and the third asserts the harness's setter really reaches the library's frame, so the other two cannot pass on a mechanism that never fired.- 722 specs green; coverage 100% on the library,
env_guild.luaandenv_e2e.lua.
0.8.1 -- the guild is asked first, and a roster with no stamp is dropped
Fixes to what 0.8.0 shipped. The LibStub MINOR still moves 19 -> 20: every
release that changes behaviour moves it once, or an older embedded copy that
loads first keeps running (0.5.1 did the same). Nothing an addon calls changed
shape; four methods were added (AskGuildForSisterRosters,
PullFromGuildOffers, PullFromGuildmates, FinishGuildStep). A 0.8.0 client
ignores both new messages and still answers the guild step's whisper. A 0.7.0
client's relays are no longer taken by this one at all. Verified in game on
2026-09-16: a 290-member roster pulled from a 0.8.0 member of the sister guild
replaced the unstamped 27, and a 0.7.0 guildmate's relay was dropped.
The user, 2026-09-16: "we should ask our guildmates first if they have a newer sister guild bank list. the idea is to sync IN guild first using deltasync and the guild channel for a broadcast, then do the same handshakes we do with the sister guild with the guild members".
Added
- The guild ask. At login (
StartSisterSync) the library now broadcasts, on GUILD, what it holds of every listed sister guild -- one message,{ q = 1, w = { [lower(guildKey)] = { h, nh, t } } }on the roster prefix, an empty entry for a guild it holds nothing of.lib:AskGuildForSisterRosters()is public. A guildmate holding that guild answers per guild:- a different copy, not older than the asker's -> an OFFER over WHISPER
on the pull prefix,
{ f = 1, k, h, nh, t, o }. The asker keeps every offer (sisterSync.guildOffers) and pulls from the best with the ordinaryq = 2handshake -- newest stamp first, then the most compatible version (versionRank), then the name -- so a guildmate holding the asker's state answers with a DeltaSync delta.lib:PullFromGuildOffers(key); - the same copy, known current more recently than the asker knows it -> the same shape as a CONFIRMATION: the asker restarts its cross-guild clock and fetches nothing. Sent only when the responder's stamp is under an interval old and the asker's is not, so after a relog the few guildmates who actually pulled recently answer, not the whole guild;
- an older copy than the asker's -> the guildmate pulls from the asker;
- otherwise silence.
- a different copy, not older than the asker's -> an OFFER over WHISPER
on the pull prefix,
- The guild step, by HANDSHAKE -- what makes it work with 0.8.0 guildmates.
The broadcast alone shipped first and the user's client went straight to the
sister guild ("it went right to asking the sister guild first"): every
guildmate online ran 0.8.0, which ignores the broadcast, so nobody answered
and nothing held the click. That was my design gap -- the user had asked for
"the same handshakes we do with the sister guild with the guild members".
lib:PullFromGuildmates(key)now whispers the best online guildmate VersionCheck has heard runs the library (our version first) with theq = 2request 0.8.0 already answers -- at login, and fromOnPeerVersionas VersionCheck's login round hears guildmates. While that request is out the click holds its/whoor confederation ask; the guild step ends (lib:FinishGuildStep(key)) when the copy lands, when the guildmate says "unchanged", when their copy is older than ours (refused, ours kept), or at a click with no guildmate to ask. A current copy drops the owed discovery; otherwise the sister guild is next. A gone guildmate moves to the next one. A second-hand "unchanged" now carriestso a 0.8.1 guildmate's answer can make our copy current; a 0.8.0 answer has none and sends us on. copyStamp: when a copy was last known CURRENT. The later ofmeta.ts(when it was taken) and the newmeta.confirmed, set when a provider answers "no change" or a guildmate confirms. It is what the ask, the offers, the relaytand a second-hand serve'stnow carry. Without it a copy confirmed unchanged all afternoon still read as taken at lunchtime. Written only to a record the library built (one naming aprovider); a consumer's meta stays opaque.
Changed
- Then the sister guild. A second-hand copy now restarts the cross-guild
pull clock as of when that copy was last current (
restartPullClock), not as of now, and never moves it backwards. A guildmate's fresh copy defers the cross-guild pull a whole interval, as before; a stale one defers nothing, and if the copy's sightings name a sister member online the pull goes out the moment the copy lands. - The discovery owed at login waits for the guild. The
/whoor the confederation ask is still owed at once (whoSync.afterGuildmarks it), but a click sends nothing for a guild whose guild step is out (waitingOnGuild), andFinishGuildStepdrops the owed discovery once the guild makes our copy current. Nothing waits on a clock: every step is a message arriving. A discovery owed by a round or Sync now is never held. OnPeerUnreachabledrops a gone guildmate's offers and re-asks a guildmate pull of the next offer before falling back to the sister guild.
Fixed
- A pulled sister roster went back to an old short copy. The user: "I
FULLY synced the sister roster yesterday with 280 members. logging on today
it's 'back' to 27." Their diagnostics read "relayed by Blessyou-Atiesh",
which only the 0.7.0 relay path writes. A 0.7.0 guildmate relays the whole
roster on GUILD with no stamp; with nothing to order, the relay check let it
replace our stamped copy, and the
{ via }record it left had no stamp, so it was saved. A second route did the same: a 0.8.0 guildmate serving a copy with no provenance named itself provider and stamped it now. My rule, and my spec sent the 0.7.0 shape with atno client ever sent. The rule now, the user's: "if they don't send the hash with the embedded timestamp WE DROP it and don't process it". A relay, a served roster (first- or second-hand) or an offer with notis dropped whatever we hold -- not applied, not pulled, not a sighting; a second-hand roster naming no provider, or a guildmate as provider, is dropped too. The applying branch for a relay carryingmis gone. This build never relays or offers a copy with no stamp, and asked for one it answers{ r = 3, k, s = 2 }("nothing I can vouch for") so the asker's guild step ends at once. - Found by this release's own self-audit, before it shipped. "Unchanged"
now moves state that travels (
meta.confirmed, carried ast), and the first cut honoured it from ANY sender -- one whisper could declare a stale copy current for a whole guild. It now counts only from the member we asked, and a second-hand one (orr = 3) only from a guildmate. The relay handler never checked the sender was a guildmate, so a stranger's same-hash whisper could keep restarting our pull clock; relays are now taken from guildmates only. And after a stale guildmate copy landed, the guild step was closed AFTER the sister-guild pull had stamped the clock, so it logged "the sister guild is not asked" straight after asking it; the order is swapped. And the answer gates matched only the CURRENT pending peer, so once a round re-asked someone else a late answer from the member asked first was dropped; earlier peers now ride on the pending record (prior,askedFor). The "is this copy older?" test, written out at four sites with four nil rules, is one function (olderThan), and the twice-written confirmation is one (confirmCopy). - Seen in game on both of the user's characters, 2026-09-16. One client
sent the confederation ask three times in two minutes and never a
/who:DiscoverPeerstook the VersionCheck path for ANY held copy, and nothing fell back. That copy was the unstamped 27-member one, which could place almost nobody who answered -- andOnPeerVersiondropped an unplaceable answerer without a word. The other client asked a guildmate VersionCheck had heard, got no answer (a 0.7.0 client cannot answerq = 2), and held every click for minutes. Now: the second path is only for a STAMPED copy and only until the confederation has been asked once this session (whoSync.gwSent); an answerer in no copy we hold is looked up by name (QueueWhoName, at mostWHO_ASK_LIMITa session); the guild step asks only a guildmate whose version can answer (0.8.0+,r19+ embedded, or theGuildRoster-v0.8.2dev placeholder); an unacknowledged guildmate request holds ONE click and the next goes on; and/grdiagnostics list who VersionCheck has heard running the library. - An unanswered GUILDMATE pull was retried as a first-hand request. In
RequestSisterRostersthe retry droppedrelayed, so the round sentq = 1to the guildmate, who served their own HOME roster, which the asker then discarded -- the copy never arrived and the round kept doing it. I wrote that in 0.8.0. A guildmate pull is now retried as a guildmate pull once (pending.retried), and after that the round goes to the sister guild without ever sending the guildmate a first-hand request. - Five defects Peer Review found in the fixes above (2026-09-16), all mine.
- "Click again to go on without it" did not go on. The release branch of
waitingOnGuildleft the silent guildmate'spendingPullin place, which blockedPullFromFreshestandPullFromCandidatesand let the round re-ask the same guildmate, so the sister guild was reached minutes later. The record is cleared beforeFinishGuildStep, and a spec now asserts the pull actually goes out. - A guildmate's late answer cleared the pull the round had moved on to. A
guildmate stays in
pending.prioronce the round re-asks the sister guild, soaskedForalone let theirs = 2"nothing to vouch for" or "unchanged" retire a first-hand pull. Second-hand answers now count only against a guildmate request still out (askedGuildmate: asked, in guild, andrelayed). - The server's "not online" asked the sister guild after saying it would
not.
OnPeerUnreachablecalledPullFromFreshesteven whenFinishGuildStephad just ruled the guild's copy current. - "Asking the server where they are" was logged past the lookup cap, where no lookup is made. The line is written only when the name is queued.
- "Unchanged" by name had no placement check. A peer asked by name could
vouch for a different listed guild than the one we place them in; the
roster path already refused that, the
r = 0path now does too. - An ack from anyone marked the pending pull acked (found on the re-check).
A silent guildmate's late
s = 2ack marked a first-hand pull to the sister guild acked, with their member count, and held its retry a round.takeAcknow takes the same gate as the answers:askedGuildmatefors = 2, and for a first-hand ack a non-relayed pending that names the sender.
- "Click again to go on without it" did not go on. The release branch of
Known costs
- A slow guildmate's real roster is dropped once the player clicks past it. Second-hand answers count only while a guildmate request is still out, so a copy that arrives after "click again to go on without it" is thrown away, even a good one. The sister guild pull already on its way replaces it.
- Clients already on 0.8.0 keep taking 0.7.0 relays until they update: released code cannot be changed. A copy saved with no stamp (as the user's 27 is) is not relayed or served by 0.8.1 and is replaced by the next real pull.
- Offers are whispers. A returning player holding nothing of a sister guild draws one small offer from every online guildmate that holds a copy. That is the shape the user already chose for the confederation ask ("a single broadcast over greenwall would have all of them whisper you"); same-copy guildmates stay silent unless they are the recent confirmers.
- A parked VersionCheck request may still go out. When the guild makes a
copy current before the click, the library drops its owed confederation ask,
but the request
VC:RequestAboutalready parked stays in VersionCheck, and VersionCheck's own chat hook sends it at the player's next keystroke. The answers pull nothing (the clock is current). This library does not clear VersionCheck's parked payload.
Testing
Tests/env_e2e.lua: a client can log in with SavedVariables (spec.db), the login's own sends are kept, and the wire settles before a click answers an owed/who-- until now the/whowent first in every pass, which is not the order a client sees and would have hidden the guild ask entirely.- Sixteen
e2e_specscenarios under "the guild is asked before the sister guild": the user's 280 -> 27 case (a 0.7.0 relay and a 0.8.0 guildmate's unprovenanced copy, both refused, and this build answeringr = 3); a newer guildmate copy as a second-hand delta with no sister-guild traffic; a stale one followed at once by the sister guild; an older guildmate pulling from the returning player; a confirmation, and silence when nobody is recent; the guildmate retry (never first-hand); offer ranking, not-found moving on, a stranger's offer ignored, overtaken offers dropped; the whole flow (guild, sister guild, back to the guild, deltas at every hop); a newer offer fetched after the first lands; a note-only change; a guildmate logging off between offer and request; a click beating the guild's answer; and four with guildmates modelled as the released 0.8.0 -- the user's case (whispered first, click held), "unchanged" with and without a stamp, an older copy refused, and version-ranked guildmates with the gone ones skipped. sistersync_spec: the held/whoand the held confederation ask; one click held on an unacknowledged guildmate request, then released; the version gate for guildmates (v0.7.0,r18, garbage refused;v0.8.0,r20,GuildRoster-v0.8.2asked); an unplaceable VersionCheck answerer looked up by name, capped; the diagnostics' VersionCheck line; the second path refused for an unstamped copy and after one confederation ask; "unchanged" honoured only from the member asked, and a late answer from the member asked first; a stranger's relay ignored; a served roster with no stamp dropped; the recheck path that serves an asker a landed copy places (reached before only through the old pump order); the relay-timer spec now tells the login ask from the relays.- The relay and serve specs that fed unstamped copies now stamp them, and the one that asserted a 0.7.0 relay is applied now asserts it is dropped. Proven red first: with the no-stamp drops disabled, both drop specs and both 280 -> 27 scenarios fail.
env_e2e.directWire(c)is the one install of the direct wire, for a scenario that wraps a client's sends and has to put them back.- 714 specs pass; line coverage 100% on the library,
env_guild.luaandenv_e2e.lua. The e2e harness still shares ONE VersionCheck between its clients, so VersionCheck's knowledge is modelled per client in those scenarios rather than exchanged over the wire.
0.8.0 -- a short login build is finished, and the mailbox knows sister guilds
The LibStub MINOR moves 18 -> 19. Additive on the API side. One defect fixed in the login build that every consumer benefits from, one feature moved in from TOGTools on the user's direction, and -- under "Changed" -- the sister sync redesigned on the wire: mutual, handshake-driven, deltas after the bulk, a hash-only relay. Two things a MINOR 18 client will notice, both named there: a 19 provider refuses its request (it carries no guild, so it cannot be checked against the list), and a 19 relay carries no roster for it.
Fixed
A sister client could hold a fraction of a sister guild's roster for the whole session. Seen 2026-09-15: the
/grwindow said "27 members" for a guild whose own members reported 289. The wire was not at fault -- it was working perfectly and carrying the wrong roster.serveRostersendsself.roster, so what a sister receives is exactly what the PROVIDER's login build captured, and that build had stopped at 27.The defect was in the stabilization guard, and this file has admitted it since MINOR 14 (the comment above the join branch: "a STABLE_THRESHOLD = 2 guard still misfires when two of those partial events happen to have the same count -- it 'stabilizes' on the partial snapshot"). While the roster was rebuilt on every event that misfire cost one event's worth of wrong answers. 0.5.0's build-once made it permanent: the partial snapshot was served to every consumer and every sister guild until
/reload, with a membership hash over the partial set, so no sister client had anything to disagree with.I own this: build-once rested on the premise that the stabilized snapshot was complete, and the guard never proved that. Two changes, both in the login build, neither a rebuild-on-event:
- A short scan is not a stable one.
runScannow compares the rows it actually read (newCount) against the client's own total. Fewer rows than the total is a partial stream -- rows the server has not delivered yet read as nil past some index -- and it re-requests the roster from the SAME budget the zero-total retry uses (retryCount/MAX_RETRIES, now reset only by a complete scan). Once the budget is spent the build proceeds on what it has, so a client that never fills its rows in cannot holdOnRosterReadyforever; the second change catches that case. - A build that got through short is FINISHED on the next event that proves
it. After
initialized,OnGuildRosterUpdatestill resolves pending joiners and returns -- but firstLoginBuildIsShortasks one question, costing oneGetNumGuildMembers()and one count of the held roster: does the server's total EXCEED what we hold? Chat maintains membership in place, so held and total agree for the rest of a healthy session. The one legitimate gap -- a departure chat announced that the server's row still lists -- is exactly whatrecentlyLeftrecords, so the check waits it out rather than resurrecting the leaver. A total BELOW what is held is never a deficit (a joiner inserted from chat before the row arrived;ResolvePendingDetailowns it). On a proven deficit the rows are re-read exactly as the login stream reads them, the stabilization block is skipped (soOnRosterReadyfires once per guild, ever, and the sister sync is not restarted),OnRosterUpdatedfires, and the membership hash moves -- which is what tells every sister client to re-pull on its next round, and every one of them then receives the full roster. Capped atlib.MAX_LOGIN_REPAIRS(3) per guild, reset with the other per-guild counters on a guild change, so a client that reports a total it never backs with rows cannot reinstate the per-event rebuild.
Against the standing directive "you should only build the roster once on login and then it's UPDATED based on the events recorded during the play session. IT SHOULD NEVER BE REBUILT" -- this is the login build finishing, not a rebuild: it runs only when the build provably did not complete, and a session whose login build was complete never reads a row after
OnRosterReady. Thebuildonce_specassertions that pin that -- zero row reads, zero callbacks on an ignored event -- are unchanged and still green. Recorded as a KNOWN COST on the directive rather than a reopening.What is NOT known, said plainly: which client behaviour produced the 27-row snapshot on the provider's machine. The Classic Era measurement of 2026-08-16 (997 of 997 rows, box unticked) rules out the Show Offline filter on this client; the wikis say only that the data is "not correct" before the first
GuildRoster()response. Both forms a partial can take -- a total that moves with the stream, and a total known up front with rows arriving later -- are covered by the two changes together, anddocs/HEALTHCHECK.md's command 1 now says what a short build looks like (membersbelow the guild panel's total withready=true). Ten new specs inTests/buildonce_spec.luadrive both forms, the leaver race, the cap, the reset, the throttled-event retry rule and the served-roster consequence.Open, not fixed, recorded for the next session: two home guildmates holding DIFFERENT copies of a sister roster overwrite each other on the GUILD relay (
OnSisterRosterCommadopts any relay whose hash differs from its own, with no ordering), so until the provider is fixed the guild could flip between 27 and 289 with each relay. With every provider serving the full roster the copies converge, which is why this ships without an arbitration rule; one may still be wanted.- A short scan is not a stable one.
Added
Sister-guild names in the Send Mail "To:" autocomplete. A port of TOGTools'
Modules/Mailbox/Mailbox.lua(requested by a guild member on 2026-09-13: Blizzard'sAUTOCOMPLETE_LIST.MAILonly knows the player's own guild), moved here on the user's direction of 2026-09-15: "it shouldn't live in an addon, it belongs in this library." The hook is the same one: the library wrapsSendMailNameEditBox.autoCompleteSource-- the plain function Blizzard's own OnLoad stores there -- with a closure that calls the original and appends sister-roster matches up to the caller'smax, which is a contract, not a hint (one over the shown count reads as "continued..."). No secure function is touched. Installed atPLAYER_LOGINand re-checked on everyMAIL_SHOW, because a mail addon that assigns the field in its own OnLoad after us would otherwise silently remove the wrapper for the session; a re-wrap chains onto the newcomer rather than replacing it. The wrapper is kept on the lib table so a LibStub upgrade recognises the previous copy's closure and does not wrap it twice -- specced with a real upgrade (minor lowered, file loaded again, same table). Every appended entry carriesEnum.AutoCompletePriority.Other(or the legacy global); when the client has neither the wrapper contributes nothing, because an invented priority raises insideAutoComplete_UpdateResultson the first keystroke.The API, all additive:
lib:IsMailAutocompleteEnabled()/lib:SetMailAutocomplete(bool)(a PLAYER preference, so it lives at the root ofLibGuildRosterDBrather than in the per-home-guild record, default on, answers while guildless; firesOnMailAutocompleteChanged(enabled)on a change and not on a repeat),lib:GetSisterRecipients()(every sister-roster "Name-Realm", sorted, deduplicated across rosters, never the home guild's -- Blizzard already lists those; read live per keystroke rather than cached, because hundreds of names is nowhere near a cost worth a cache that can go stale) and the purelib:MergeRecipients(results, text, max, candidates, priority)(case-insensitive prefix on the name half unless the typed text carries a dash, in which case the whole "Name-Realm" is matched; a name Blizzard already returned is not added twice)./guildroster mail [on|off]is the text control and the/grwindow gains a checkbox, which repaints on the new callback so the two cannot disagree.TOGTools keeps working with its copy in place. Both wrappers merge the same rosters and
MergeRecipientsdedupes on the name, so the list is clean either way; its copy is now redundant, not conflicting, and its removal is TOGTools' own work -- told through the inbox with the calls to use.Tests/mailauto_spec.luacarries the matching rule, the reader, the setting and the hook (24 specs, most ported from TOGTools'mailbox_spec.lua).The PUBLIC note rides on the sister roster (TOGBankClassic's LIBREQ-GR-002, 2026-09-14: its banker identity is the
gbankpublic note, and a cross-guild bank cannot be built on a roster that does not carry it).serveRosterand the GUILD relay both sendpn = publicNoteper member, omitted when empty;takeServedRoster,OnSisterRosterCommand the login re-feed keep it asmember.note;PersistSisterRostersaves it. The wire shape is now built in ONE place (wireMember) and read in ONE place (wireToFeed) for both the pull and the relay, which is the cross-cutting finding the fleet review keeps raising, closed before it opened. The officer note is still never sent -- it is rank-gated, the public note is not, and the "a roster is public through the guild panel" rule covers exactly the public half. The membership hash stays over the sorted charKey set, so a note edit never churns it. A consumer readsmember.noteand treats nil as "no note", which is what an old provider or an old library degrades to.A Diagnostics section in the
/grwindow, and/guildroster diagas its text backup. The user, on seeing TOGPM's settings page beside the 27-member window: "i'm thinking we should have more info in the /gr, like TOGPM does on it's settings page." One builder,lib:BuildDiagnosticLines(), feeds both, so the window and the chat print cannot disagree. It answers the questions a bad sync raises in the order they get asked: Library (the loaded MINOR and TOC version, whether the home roster is ready and how many it holds, how many short login builds were finished this session, the chat pattern count -- red with the reason when a message cannot be parsed); Sister sync (store, wire, AceComm registration, the list's size and stamp, the mailbox autocomplete state and how many names it holds); Known rosters (home with online count; each sister with member count, presence count and its PROVENANCE -- pulled from whom at what time, relayed by whom, or fed by a consumer -- plus an outstanding pull or the age of the last automatic one, an unlisted roster flagged red, and a listed guild nothing has arrived for shown as not pulled yet); Persisted sister rosters (member count and fed time per guild, a damaged record reading as 0 rather than raising). The list now sits in a fixed band (SISTER_WINDOW_LIST_HEIGHT, it scrolls on its own) so the diagnostics can take the remaining height in a scroll frame beneath it, the way TOGPM puts its diagnostics last; the window grew to 560x620 to fit. The window also repaints onOnRosterReadynow, since the Library section changes at that moment.
Changed -- the sync on the wire, redesigned on the user's directives
All in the same release and the same MINOR (19). The trigger was the user's
own two-character test on 2026-09-15: "sync still isn't happening ... even
when i push the sync now button". Nothing in the suite could have seen it --
no spec had ever put two clients in two guilds and let them talk -- so the
first thing built was the harness that could (Tests/env_e2e.lua, below), and
the redesign was driven on it. Every directive is quoted where it applies.
The sync is MUTUAL. The user: "we also need to ensure the sync wont work unless BOTH guilds have each other set up as sister guilds. we don't want one guild 'stealing' info from another one without permission." The receive side always had half of this (a roster for a guild we do not list is never stored). The provider side had none:
serveRosteranswered anyone, on the reasoning that a roster is public through the guild panel. Now the pull request carries the asker's home guild (g), and a provider serves only a guild on ITS OWN list; anything else -- an unlisted guild, or a request with nogat all (a MINOR 19 client of the first cut, which cannot say) -- gets a refusal,{ x = 1, k = ourHomeKey, y = reason }, and nothing else. The claim is checked where it can be: a sender who appears in a roster we hold must be claiming that roster's guild, and once we hold the claimed guild's roster the sender must be in it. Only while we hold nothing of the claimed guild does the claim stand, because nothing on the client can check it then -- and the receive-side gate drops whatever a liar's guild turns out to be. A provider that is not ready stays silent rather than refusing: the list is per home guild, and a refusal sent before the new guild's list is read would be wrong, not merely early.On the asking side a refusal IS the answer: the pending record is retired, the status bar says "they have not listed us (X said so, Ns ago)" -- so the user knows whose officer has to act -- and the automatic round keeps asking the member who refused (we hold no roster of theirs, so there is nobody else) until the server says they are gone; their officer listing us is an event this client cannot see, and a refusal is a few bytes. Any roster or no-change answer for that guild clears it. The unknown-requester pull (
sawOnline) is narrowed the same way: a stranger claiming a guild we do not list is never whispered, and one claiming a listed guild is pulled for THAT guild, so the pending record is filed where its answer will be looked for. Recorded as a standing directive./who <guild>is how a sister member is FOUND, sent from the player's next click. The user, on a status bar reading "nobody seen online" beside a Who List showing six of them: "you're relying on someone saying something in chat? no, do the /who <guildname> it's immediate." That reverses the 2026-09-13 ruling against/who(given against a passive listener on the player's own queries) and closes a deadlock I built: presence is not persisted, so after a relog neither client had anyone to ask and neither pulled, until somebody typed a name.Tests/e2e_spec.lua's "THE RELOG CASE" is that exact screenshot, and it passes now.C_FriendList.SendWhoneeds a hardware event -- from a timer or an event handler it raisesADDON_ACTION_BLOCKEDand the query never leaves the client (proven on a Classic client, in FastGuildInvite's own changelog; the docs'HasRestrictionsflag is that). So a/whois owed, and sent from the next click: the window's Sync now, the/guildroster syncEnter, or -- the way FGI's Wingman does it, on the user's direction ("you can tie the /who event button push to a lot of hardware events, like the wingman overlay does, you can capture a mouse click. you only need one to kick it off") -- the player's next click anywhere, caught by a full-screen frame withSetPropagateMouseClicks(true)so the click still lands where it was aimed. The overlay exists only while a/whois owed: it comes up when the roster is ready and a listed guild has nobody seen (and whenever a round finds the same), sends ONE query per click, and goes away the moment the queue is empty or a sighting from anywhere settles the guild ("once you have a successful who you can turn the overlay off"). Propagation is a protected call in combat, and a blocked setter is not a Lua error -- a shown overlay without pass-through would eat every click in the game -- so it is applied out of combat, verified withCanPropagateMouseClicks, and the overlay is never shown until that says yes;PLAYER_REGEN_ENABLEDretries. One/whoper guild perWHO_INTERVAL(the pull interval).The query is the plain guild name, exactly what the user types. The first cut sent the
g-"Guild Name"tag form (WHO_TAG_GUILD), and in the user's client it produced nothing the library could see while/who the other godslisted all six; Blizzard's own Classic UI buildsz-"..."andn-...filters but neverg-, so the plain form is the one with evidence. It matches names and zones too, and the answer is filtered by each row's guild, so that costs nothing. Sent withSetWhoToUi(true)so the answer arrives asWHO_LIST_UPDATEinstead of yellow chat text, and with Blizzard's ownWHO_LIST_UPDATEregistration onFriendsFrameborrowed for the duration --WhoList_Updateends inShowUIPanel(FriendsFrame)and would pop the Social pane open mid-play -- and handed back one frame after the answer is consumed (FGI's LibWho is the reference for both). Not sent while the player's own Who pane is open. Every row of the answer is a member the server says is online right now -- online, not "runs the library" -- so the rows become the guild's candidates, and who is actually asked is decided with VersionCheck (next bullet). The player's own/whoresults are read the same way.lib:QueueWho(key),lib:SendQueuedWho()(call it from a click),lib:OnWhoListUpdate()andlib:PullFromCandidates(key)are public; the status bar says "click anywhere to ask the server who is online" while one is owed and "asking the server who is online" while the answer is out.VersionCheck-1.0 decides WHO is asked: someone who can answer, the most compatible first. The user: "it might be asking someone that CAN'T respond. it needs to be someone with the GR library installed, and if possible the same version ... maximum compatibility first", and "we should also add version check into GR first, so it has it". Two halves:
The library registers itself with VersionCheck at
PLAYER_LOGIN(VC:RegisterCheck("GuildRoster", rawTocVersion), the README's one correct wiring, sentinel and all so a dev build reads as one), looked up by LibStub at call time -- VersionCheck's TOC depends on this library, so it can never be declared here, exactly like DeltaSync. From then on every guildmate's VersionCheck harvests our version from the login batch, the/vcwindow shows LibGuildRoster alongside everything else, andlib:GetVersionString()is what it reports.A sister member is asked what they run before they are asked for a roster -- with ONE broadcast. VersionCheck parks every request for GreenWall, the confederation channel, and every confederated member's VersionCheck whispers back; so when the
/whorows are all unheard-of, the library asks once (lib:AskWhoRunsTheLibrary). The user: "i've asked this 100 times, a single broadcast over greenwall would have all of them whisper you."The broadcast is a TRIGGER naming only this library, not VersionCheck's inventory. Measured on the user's box: GreenWall cuts a segment at 255 bytes after base64 and its own envelope (~222 left for the payload); VersionCheck's full batch with the 17 addons registered there is 592 bytes, and the 17 addon names alone are 272 -- so no encoding of everything fits, and a truncated request is answered by nobody (that was the "asked the confederation ... 1m49s ago" the status bar sat on). A request naming only
GuildRosteris 120 bytes, and VersionCheck's receiver answers the intersection of what was asked with what it has, so that is all a pull needs. The user: "then why don't you do a shorter message, or an encoded one? it's addon to addon ... the broadcast doesn't need to be comprehensive, you just need to elicit whisper responses." So the library prefersVC:RequestAbout("GuildRoster")-- asked of VersionCheck (73cb26699ca7) and delivered at its MINOR 14 the same evening, alongside its own login batch becoming a trigger; feature-detected here, and a spec drives the REAL one to show the wire names GuildRoster and nothing else -- and falls back to the fullVC:RequestCheck()on an older VersionCheck, with a status line saying the full batch may not fit.And the request leaves from the CLICK, never from a chat keystroke. VersionCheck parks its GreenWall request and releases it from its
ChatEdit_ParseTexthook -- the hardware scope GreenWall's channel needs -- which meant the bar sat at "type in chat to ask the confederation" until the player happened to type. The user: "i don't want anything waiting for someone to say something, that's a shitty design" and, of the four steps --/whofor who is online, VersionCheck over GreenWall for their versions, pull from someone on your version first, else from anyone with the library -- "no 2 uses the OVERLAY". So the ask is owed (whoSync.gwOwed), the click-catcher comes up, and the next click -- or Sync now, or the slash command -- releases it (lib:SendConfederationAsk):VC:FlushGreenWall()when VersionCheck carries it (asked, ec8f7fed0bf6), else the same three lines its hook runs, done here with VersionCheck's own bytes under VersionCheck's own name. The row reads "4 online, click anywhere to ask the confederation who runs the library" until then. The replies land in our VersionCheck and theOnPeerVersioncallback is the event that pulls -- from the most compatible member heard from: our exact version first, then any released version, then a dev build, never one who has not reported (lib:PeerLibraryVersion(name), andfreshestPeerranks the round's pulls the same way from now on). The first answer is not waited past -- waiting would be a timer -- but a same-version member who answers later is preferred from the next round on. A client WITHOUT GreenWall asks one member at a time throughVC:RequestFrom(name)(lib:AskPeerVersion, up toWHO_ASK_LIMIT= 10 per answer) -- asked of VersionCheck (754c66fdc937) and shipped in its working tree the same day; a client whose VersionCheck predates it cannot ask and its status line says so. The first cut built VersionCheck's request inside this library, prefix and control keys copied; the user ruled that out ("maybe you should have VC do that work?") and the copy is gone. Without VersionCheck installed at all the first row is asked outright, as before this bullet. GreenWall itself needs nothing: it validates only the sending addon's id, and the broadcast is VersionCheck's, under VersionCheck's name (confirmed by the GreenWall workspace against itsAPI.lua:47).
Two things the same evening's testing found, both mine: the round re-asking an unanswered pull forgot the request when it had nobody to re-ask (it now asks the same member again -- the server would have said if they were gone); and a pull addressed to a member the server then reports offline waited a whole round before trying anyone else --
OnPeerUnreachablenow re-issues it to the next freshest member in the same event, and the first sighting of a guild in a session pulls at once rather than waiting for the ticker (lib:PullFromFreshest(key)). And the status bar said "nobody seen online" beside a Who List showing four people: when the server has listed members and none is library-confirmed yet, the row now reads "4 online, waiting to hear who runs the library" -- or "click anywhere to ask the confederation" while the GreenWall ask is owed to the click (GetSyncStatusText).Three more from the two-character test the same night, all mine: a sister member's own VersionCheck request is a sighting. VersionCheck fires
OnPeerVersionfor a request it harvested (via = "REQ") -- the other guild's login trigger or theirRequestAboutover GreenWall -- and the library ignored it unless the sender was a/whocandidate, so one side sat at "nobody seen online" while the other had just asked it. A member of a held roster heard that way is marked online and pulled from, paced like the round. The/whoanswer's reason was unreadable: the bar shows one truncated event and the next relay overwrote it; the last eight events are kept (SYNC_EVENT_RING) and printed in full under the ⓘ diagnostics as Recent sync events, and an answer with nobody in a listed guild now names the guilds it did see. A/whothat lists nobody clears that guild's candidates, attributed by a FIFO of what each outstanding query asked (whoSync.asked), so the bar cannot keep saying "N online" about people who logged off.And the window itself: the sync status has its own block under the list (heading Sync, one line per guild plus the last event) instead of the bottom status bar, which cut it off after a few words -- the user: "we need to move the sync stuff to another area, not enough space in the status bar". The bar carries the version only; the block re-lays the window out only when its line count changes. The
/whosend refuses while the Social window is open and used to do so silently; it now says "close the Social window, then click anywhere" and logs why, and a queued/whowith no click-catcher up (combat) says "click Sync now". Every answer to our/whorecords whether Blizzard's handler was listening and whether the pane is up, and a pane that is open after the answer but was closed at the send is closed again (RestoreFriendsFrame) -- the user saw the Social window open by itself on an earlier revision.The provider's half of the mutual rule now takes the SERVER's word, not a copy's.
mutual()refused an asker absent from the copy we held of the guild they claimed -- and both of the user's characters were refused by each other that night ("refused Testthese-Azuresong: Horde-The Other Gods is not on our list"), because each held the other's stale 27-of-289 partial copy. Absence from a copy that can be partial proves nothing. The user: "the who proved they were in the guild didn't it?" So: an asker the server listed in the claimed guild (our/whorows,whoSync.candidates), or a copy we hold does, is served; one in a DIFFERENT roster we hold (a home guildmate, a third guild's member) is refused; one nobody places is held (sisterSync.pendingVerify) while a/whofor that guild is owed -- forced past the pacing, released from the click -- and the answer decides: listed, served as if the request had just arrived; not listed, refused with the reason (ResolvePendingVerify). A copy that lands while an asker waits decides them too (RecheckPendingVerify) -- found by the two-client harness, where the copy landed a moment after the request and the owed/whowas then pruned as unnecessary.env_e2enow answers an owed/whoinsidepump()from the clients it knows, as the click would.The bank model for the sync's traffic. The user, with 100 online in mind: "if someone broadcasts the same hash version as you, then restart your timer. and if someone broadcasts a different hash, then you should whisper them." The different-hash half was already the relay's pull-on-miss; the same-hash half is new: a guildmate's relay carrying the hash we hold restarts OUR cross-guild pull clock (
lastPull), and a copy taken second-hand does too. Before this every client pulled from the sister guild on its own clock -- a hundred online, a hundred "unchanged" handshakes per interval -- where one member's pull, relayed, serves everyone. The interval stays at 5 minutes: it is now one hash relay and one two-message handshake per guild per interval however many are on.And the reason The Old Gods side never found anyone, read straight off that ring: "4 row(s), none in a listed guild -- guilds seen: The Other Gods-Azuresong". On connected realms the server spells a foreign guild
Guild-Realm, exactly as it spells the player, and the list is typed as the bare name; the rows were matched literally and none matched. A row's guild is now tried bare (one trailing-Realmstripped) when the spelled form is not on the list.VersionCheck-1.0 is a HARD dependency now, in all six TOCs. It used to depend on this library, which is why it could only ever be looked up by LibStub at call time; it dropped that dependency on 2026-09-16 (its own message on the thread), so the cycle is gone and the declaration is the honest one -- the sync's step 2 does not work without it.
.pkgmetacarriesversioncheck-1-0underrequired-dependenciesso CurseForge installs it, andtoc_spec.luapins both, plus reads VersionCheck's own TOC to assert it still does not depend on us. The release order this needed is already satisfied: VersionCheck v1.5.0, the release that drops the dependency, shipped on 2026-09-16 -- before this one -- so no player can end up with both halves of the cycle. A manual installer still wants VersionCheck v1.5.0 or later; CurseForge handles it. The call-time lookup stays as it is -- an embedded copy in a consumer that ships no VersionCheck still degrades to "cannot ask".A roster nobody asked for is DROPPED. From the peer review of this work (2026-09-16), and mine: anyone who knew the addon prefix and a listed guild's name could whisper
{ r = 1, k = <listed>, m = {...} }cold -- no request behind it -- and it was stored, persisted, relayed to the whole home guild on GUILD, and pulled from us by every guildmate on the hash miss. One whisper poisoned a guild-wide copy that survived relog. A first-hand answer is now taken only from the peer a pending pull for that guild names, or -- for a pull BY NAME, which cannot say what guild to expect (the window's box,/guildroster pull) -- from that one peer, for one answer (sisterSync.askedPeers), and then only for a guild we can place THEM in -- a permission keyed on the peer alone let a by-name answer serve any guild on the list, which the peer review of this session's own audit caught: "a human types/guildroster pull Bob; Bob answers withk= any other guild on the list". A peer we cannot place anywhere is bound by the rule that always applied -- they must appear in the roster they serve. A second-hand answer is still a home guildmate's, as the relay always was.And the permission cannot be MINTED by a stranger -- the same review found that next, and it rebuilt the whole attack in two messages instead of one: a request carrying no
gat all made the claimed guild nil, which skipped the "a stranger from a guild we do not list is not worth a whisper" filter entirely (naming no guild passed a filter that only ran when you named one), and the pull it triggered was filed under no key -- so the sender's next message, a roster for any listed guild with themselves in it, walked straight through. A pull made on a GUESS now carries the guild it is about (opts.expect) and binds the one answer it buys. Deliberately not apendingPull: the guess is ours, not theirs, and a stranger must not be able to occupy a record the automatic round judges.Writing the two-client scenario for it found the hole the gate would otherwise have left open: an unknown sender's message used to earn a pull-back whatever its shape, so a forger's own roster filed the very record the gate then matched it against. Only a REQUEST earns a pull-back now; every other shape is a presence stamp and nothing more.
A refusal says WHY, and a
/whocut at the server's cap refuses nobody. Both from the same review. The refusal carries a reason (y:unlisted/notmember/offline) and the asker's status bar renders it -- "they have not listed us" was being shown for a refusal that really meant "the server did not list you online in your own guild", which points the user at the wrong officer. And a/whoanswers at most 50 rows: absence from a capped answer proves nothing -- the same partial-copy mistake, against the server this time -- so both returns ofGetNumWhoResultsare read, and when the answer was cut the one waiting asker is verified BY NAME instead (QueueWhoName/ResolveNameVerify), which is one row and never capped. The player's own/whoplaces waiting askers too, and its rows now MERGE into a guild's candidates rather than replacing them: only the answer to our own guild-wide query means "these are the people online".The SECOND PATH: a guild whose roster we already hold skips the
/who. The user: "couldn't we have a second path? if we already have a full roster, we could go right to the version check step. the beaute of VC is that it doesn't have the /who cap of 50 people."lib:DiscoverPeers(key)is the fork: a roster held, with GreenWall and VersionCheck present, owes the confederation ask straight away (step 2, from the click) and every online member running the library whispers back, uncapped; nothing held, or nothing to ask through, owes the/whoas before. A member of the held copy who answers is placed and pulled from on the spot.A guild dropped from the list is forgotten completely. Also from the review: the pull in flight, the pacing, the refusal, the server's rows, any asker waiting on a
/whofor it and that verification's by-name query all stayed behind. A waiting asker was then decided by the next recheck -- refused as "we place you elsewhere", which was never the reason -- and the owed/whokept the click-catcher up for a guild nobody lists any more.A PUBLIC NOTE change is a change now. The user, 2026-09-16: "when you sync the roster, are you syncing the public notes? we'll need that for the togbank sister guild work." The notes themselves have ridden the wire since earlier in this release (
pn, LIBREQ-GR-002) -- but the membership hash excludes them deliberately, so nothing ever asked for them: agbanknote added today reached the other guild only when somebody happened to join or leave.lib:GetRosterNoteHash(key)is the second half of the question -- hashed overcharKey\1notefor members WITH a note, so it moves when a note is added, edited, cleared or moved to somebody else, and not otherwise. It rides beside the membership hash everywhere the membership hash goes:nsin a pull request,nhon the GUILD relay and on the relayed-copy request. "Unchanged" (r = 0) now means both matched; a peer that sends neither hash (a MINOR 19 first cut) is still answered on membership alone, because absent is "cannot judge", never "differs". The membership hash itself is untouched, so the delta base every client compares against does not churn.KNOWN COST, PUT TO THE USER AND SETTLED: the PROVIDER's own copy of its guild's notes is read during the login build and never again --
GUILD_ROSTER_UPDATEis ignored afterinitialized, which is the build-once directive working exactly as written. So a note edited mid-session does not reach the wire at all until that player next logs in, and no hash can fix that. Everything above makes a note that EXISTS at login propagate promptly and keeps propagating on every edit thereafter that a relog or a repair scan picks up. Re-reading rows on a note change would be a rebuild, and build-once forbids it. A named consumer will feel this, verified in its source rather than assumed: TOGBankClassic readsmember.notein three places (Modules/Guild.lua:658,:1231,:3409), all feature-detected and citing LIBREQ-GR-002 -- so an officer marking a bank altgbankmid-session is exactly the case that waits for a relog. Decided 2026-09-16: "i'm ok leaving it till a reload. the cost is too high to rebuild the roster, it is a major performance hit while playing." That is the reason build-once exists, said plainly -- frame time during play, not tidiness -- so the wait is the intended behaviour rather than an outstanding defect.The pull is a HANDSHAKE, and there is no timeout anywhere in it. The user: "why are you fucking around with timers? TIMERS are fragile ... they can't account for latency, congestion or other things ... you have a comm channel, a dedicated whisper one, just use handshakes." MINOR 18 cleared a peer's presence stamp 30 s after an unanswered pull (
SISTER_PULL_TIMEOUT), and a 44 KB roster on BULK takes longer than that on a busy link, so the stopwatch fired on every big roster and the loop stalled. Gone. The provider now sends an ack{ a = 1, k, h, n }on NORMAL priority before the roster goes out on BULK, so "no answer yet" becomes "receiving N members" the moment the peer hears us. A pending pull is judged by its handshake in the round that would act on it: never acked, it is asked again (the request was lost or the peer has no library); acked but the bulk has not landed, it is left alone for one round, then asked again of the same peer. The one thing that retires a stamp early is the SERVER: the "No player named 'X' is currently playing" system message (ERR_CHAT_PLAYER_NOT_FOUND_S, now the eighth chat pattern, parsed in all eleven locales,lib:OnPeerUnreachablepublic for a consumer that whispers sister members itself).SISTER_ROSTER_RELAY_AFTER_PULL(a 10 s timer before relaying) is retired with it -- the relay is a hash now and goes out at once. The one timer that remains is the 1 s ticker on the/grwindow's status bar, which is display, not state, and retires itself when the window closes.A paced round leaves a pending pull alone. Found by the status-text spec after the handshake landed, and mine: the round judged an unanswered record (dropped it) BEFORE checking the pacing, so the 45 s first run inside the five-minute window of a manual pull silently forgot the request and the status bar read "next pull in 3m57s" for a pull nobody would re-ask. Pacing is checked first; a paced round reports
pendingand touches nothing.DELTAS after the initial bulk, through DeltaSync's engine, looked up at call time. The user: "this is a lot of data we'll be moving, and once the initial bulk is sent, shouldn't we be using deltasync? ... it won't matter, because none of what you use deltasync for happens at init". DeltaSync's TOC depends on this library, so it can never be a dependency of ours -- but it loads after us and nothing here runs at load, so
LibStub("DeltaSync-1.0", true)at serve/apply time finds it when it is installed. Every membership this client passes through -- its own guild's and every sister roster -- is snapshotted in wire shape under its hash, in a ring of the lastSTATE_RING(8) states. A request carries the hash the asker holds (hs); when that is a state the provider passed through and the engine is present, the answer is{ r = 2, b = theirHash, d = ComputeArrayDelta(...) }-- a few hundred bytes instead of tens of kilobytes -- unless the delta is not much smaller than the roster (DELTA_WORTH_IT, half). The asker applies it to its own copy and verifies: the result must hash to what the provider says it holds, or the whole roster is asked for; a delta it cannot apply (no engine here, or its own hash moved since it asked) falls back the same way. A wrong roster never lands. Without DeltaSync on either side every pull is full, exactly as in 0.7.0.A relay that is OLDER than the copy you hold is not adopted. Filed as open when the hash-only relay landed and fixed before release: a differing hash made us pull, with no ordering at all, so a guildmate still holding last hour's copy pulled our fresh one down to theirs -- and we then relayed the stale copy onward under our own name. The 27-of-289 short login build is exactly the copy that would spread that way, which is what makes this worse than a curiosity. The relay now carries
t, the ORIGINAL provider's stamp on the copy (rosterMeta.ts, from the roster it served), so both sides compare the same clock rather than their own; a strictly older relay is kept out with a line saying how far behind it was. Convergence still happens, and on the newer copy: the suppression recorded THEIR hash, which does not match ours, so our next round relays ours and they pull from us. Neither side stamped -- a consumer-fed roster, or a MINOR 19 first-cut relay -- means no ordering is knowable, and the previous behaviour stands.The GUILD relay is HASH-ONLY, with pull-on-miss. Until now every relay carried the whole sister roster -- ~44 KB on GUILD every five minutes for a 985-member sister, to members who already held it -- and that was the largest thing on the wire by far. It is now
{ k, h, o }(guild, hash, and the names seen online recently), about a hundred bytes. A guildmate whose copy differs asks the relayer for it over the same whisper handshake (q = 2; they just spoke on GUILD, so the whisper is not blind), gets a delta when the relayer has the state they hold, and the answer lands as second-hand (s = 2, trusted because the sender is in our guild, with the ORIGINAL provider kept on the record and the guildmate as the hand it came by). A MINOR 18 relay still carryingmis applied directly, so mixed builds keep working; a MINOR 18 receiver of a MINOR 19 relay sees nomand returns, so it gets nothing by relay from a 19 client -- it can still pull. Building the second-hand delta scenario found one more defect of mine: a second-hand serve stampedpwith OUR name, so the taker would have filed a home guildmate as the sister roster's provider. It carries the held record's provider through now.Sync now pulls NOW, and says why when it cannot. The button and
/guildroster syncran the same paced round the ticker runs, so pressed inside five minutes of an automatic pull they did nothing and said nothing.RequestSisterRosters(force)ignores the pacing; the second return names every listed guild NOT pulled and why (no-peer,pending,paced), and the command prints it: "0 pull(s) issued -- The Other Gods: nobody seen online recently". Forcing never whispers blind. A pull that has been acked is left for its bulk rather than asked twice; one nothing answered is asked again on the press.One more roster request after ready, on the event's own flag. A client that stabilized on a partial TOTAL (rows and total agreeing, both short) sat on it until the guild panel was opened, because nothing asked the server again. The first post-ready
GUILD_ROSTER_UPDATEwhose payload (canRequestRosterUpdate) istruesends oneRequestGuildRoster()-- the same gate Blizzard's FriendsFrame uses for its own re-request -- so it goes out exactly when the server says it may, never on a timer. A complete build answers it with the same total, which the next event ignores as always;buildonce_specpins that it is one request, ever, and never a row read.A live sync line in the
/grstatus bar. The user: "can you add a syncing status into the status bar so i can see if it may be doing stuff and i just can't tell like you did in togbank?"lib:GetSyncStatusText()says, per listed guild, what the sync is doing right now --downand why,asked X, no answer yet (12s),receiving 289 members from X (1m3s),they have not listed us,nobody seen online,not pulled yet,pull due,next pull in 3m57s-- and then the last thing the sync did and how long ago, from a one-line activity log every send and receive stamps (syncEvent). The window ticks it every second while shown (see the timer note above); "idle" and "stalled" now read differently.
Testing
Tests/env_e2e.lua is new: two or more clients in one Lua state, each its
own library instance with its own identity and SavedVariables, the shared
globals swapped per client, and a direct wire that hands every whisper to the
named client and every GUILD send to the sender's guildmates -- with the
server's own "not online" system message modelled for a whisper nobody
receives. It answers an owed /who inside pump() from the clients it knows
(with the server's 50-row cap modelled, and a by-name query answered with the
one row), so a scenario never has to stage the server's word by hand. The user
asked "do you have comprehensive tests in the harness to test the end to end
sync?" and the answer was no. Tests/e2e_spec.lua now
carries nineteen scenarios: THE RELOG CASE (both hold a roster, nobody seen, one
click's /who starts the sync on both sides), the one-pull bootstrap with the other side pulling
back on its own, the automatic rounds, a membership change crossing, THE
USER'S CASE (a short provider serving the rest once it finishes, and Sync now
fetching it at once inside the pacing), Sync now reporting nobody seen, the
server's offline answer stopping the asks and one pull by name restarting
them, the relay spreading a pull to the rest of the guild without a roster on
GUILD, a delta answer verified and a fallback when it does not, a second-hand
delta from a guildmate, the mutual refusal both ways, a forged claim from a
third guild, the ack holding a slow bulk as "receiving", the second-round
re-ask of a lost bulk, the cold whisper that must not poison a guild, the
/who cut at the 50-row cap verifying its one asker by name, a public note
crossing on the next round with no membership change behind it, and the
two-message form of the cold whisper -- a request naming no guild, then a
roster for one it never asked about.
The harness's own model of the click-catcher landed while this was being
written (pins 1166097 then 34107ac), so Tests/env_guild.lua's local
SetPropagateMouseClicks / CanPropagateMouseClicks stand-in is deleted
and the combat-refusal guard is now exercised against the maintained model.
Their first cut put the family on env.frames only -- which this suite does not
load -- and deleting on that word would have left the overlay specs with no
mechanism at all rather than a different one; reported, and fixed within the
hour onto both frame layers. The rule that came back with it is worth carrying:
on the harness's hollow frame layer no field is ever nil, because the
catch-all answers every unknown key with a fresh function, so self._x can
never mean "unset".
688 specs pass, 0 fail (was 579), six of them against the REAL
VersionCheck-1.0 loaded from the sibling folder. Coverage 100% on
LibGuildRoster-1.0.lua (2520 lines), Tests/env_guild.lua and Tests/env_e2e.lua (now a coverage
target: it is the layer the end-to-end specs steer through).
Tests/healthcheck_spec.lua's MINOR assertion went red on the bump, as
designed, and the six prose sites it guards were updated. Verified in a live
Classic Era client, 2026-09-15: MINOR 19 loaded clean (HEALTHCHECK command 1
on a 985-member guild), the mailbox autocomplete confirmed working by the
user, the /who goes out from a click (the status bar's "last: asked the
server who is online in The Other Gods"), and the VersionCheck registration
confirmed: /vc shows a GuildRoster tab with the user's own row at
GuildRoster-v0.8.2.
AND THE SYNC ITSELF IS CONFIRMED WORKING IN A CLIENT, 2026-09-16 -- the
user's two-character test, with the /gr Sync block reading "The Old Gods:
next pull in 4m39s | last: Galdof-OldBlanchy answered: Horde-The Old Gods
unchanged, 9s ago". That one line is the whole redesigned handshake closing:
a peer was found without anyone typing a name, the pull went out over WHISPER,
the provider served it -- so the mutual rule passed on their side, which
means their client placed us in the guild we claimed -- the few-byte r = 0
"unchanged" answer came back and was taken, the status bar rendered it, and the
pacing armed for the next round. It is also a cross-realm peer
(-OldBlanchy), which exercises the name handling that the connected-realm
guild-spelling fix was written for.
What that line does NOT prove, said plainly rather than folded into the
good news: the confederation ask over GreenWall from a mouse-down (still the
one mechanism never observed anywhere in this fleet), the by-name /who that
a 50-row-capped answer falls back to, the public-note hash crossing, and the
"we did not ask them" gate -- which is a refusal, and so is invisible when
everything is behaving. Those remain specced-only.
0.7.0 -- the sister-guild sync moves into the library
The LibStub MINOR moves 17 -> 18. Additive on the API side -- nothing existing changed shape -- with the behaviour changes a consumer can notice listed under Changed. The standalone addon gains a second hard dependency (LibAceGUIWidgets) and its first SavedVariables table; an embedding consumer should read the two notes at the end of Added.
Why. TOGProfessionMaster carried the whole cross-guild feature itself: the
officer-edited list of allied guilds, the gossip that spreads it through the
home guild, the DeltaSync RosterSync host that pulls a sister guild's roster,
the persisted copy and the login re-feed. TOGTools and TOGBankClassic wanted the
same rosters, and three addons each running that would be three lists that can
disagree and three feeders doing wipe-and-replace into one store. The user's
direction (TOGTools' docs/DEPENDENCY_CONTRACTS.md section 3, 2026-09-13):
"i need that and i need it to not step on each other" / "probably best to do
it in the library", and on the 2026-09-13 review of the draft: "what it
should have been asking you to do was a sister guild functionality of having
sister guild roster info like what we did with TOGPM. we need to be able to
serve that to multipole addons". So it is a port of TOGPM's working code
(Scanner.lua:200-281, TOGProfessionMaster.lua:2052-2400), owned once, read
by every consumer.
Added
The sister-guild list, in the library.
lib:SetSisterGuildNames(names)(officer-only, decided bylib:IsOfficer(); takes an array or newline-separated text; trims, de-duplicates case-insensitively, stamps server time, tears down any guild dropped from the list, and gossips the result),lib:GetSisterGuildNames()(a sorted copy -- the one read TOGTools asked for, so a consumer can tell "nothing configured" from "configured, roster not pulled yet"),lib:GetSisterGuildsTs(),lib:GetSisterGuildKeys()andlib:IsSisterGuildKey(key). The last is case-insensitive on purpose: the list is typed by an officer and every key on the wire is spelled the way the provider's client spells its own guild, and one capital letter must not make a whole guild "unlisted". It is the single gate every accept, persist, relay and pull path calls.Config gossip on GUILD (prefix
LibGRxcfg): on change, ~20 s after the roster is ready, and every ~12 minutes; last-writer-wins by the origin stamp, adopted without re-stamping and without re-broadcasting; a member holding nothing (stamp 0) never broadcasts, so an empty list cannot win the race. Own echo dropped. Fires the newOnSisterConfigChanged(names, ts, source)callback,sourcebeing"local"or the sender.Roster relay on GUILD (prefix
LibGRxrst): every held listed roster, ~35 s after ready and every 5 minutes, with a recently-seen-by-hash suppression (270 s) so about one holder relays per interval and the duty rotates. A relay carries who the relayer saw online recently, so one proven sighting reaches every member's presence overlay. Received relays are gated by the list, skipped when the membership hash already matches, and never re-broadcast.Persistence and the login re-feed.
## SavedVariables: LibGuildRosterDBon all six TOCs. Keyed by HOME guild, because the list is a statement about which guilds this guild federates with. A fed roster for a listed guild is snapshotted whoever fed it (RosterSync, the relay, or a consumer still running its own re-feed); an unlisted guild is never persisted; a persisted roster whose guild left the list is forgotten -- under whatever spelling the wire gave it, because the teardown sweeps the held and persisted keys as well as the list's own (found on the release re-read: a copy whose key differed only in case from the dropped entry survived until the next login's re-feed forgot it). Re-fed at roster-ready, beforeOnRosterReadyfires, so a consumer reacting to ready already sees them.lib:RefeedSisterRosters(),lib:PersistSisterRoster(key)andlib:GetSisterDb()are public.RemoveSisterRosternow also drops the persisted copy.The pull, as the library's own two messages over WHISPER (prefix
LibGRxpull): a request carrying every sister hash we hold, and an answer that is either "no change" or the provider's home roster (name, class, level -- rank and notes deliberately excluded) with the provider's own name and a server-time stamp.lib:PullSisterRoster(peer)is the manual bootstrap (what/togpm pullrosterbecomes);lib:RequestSisterRosters()is one automatic round, run every 5 minutes: each listed guild is pulled from its freshest known-online member, never from a member with no presence stamp. A served roster for an unlisted guild is not stored; one whose provider is not in it is dropped (the cheap trust check DeltaSync made); a listed one is stored, persisted, its provider stamped online, relayed to the home guild ten seconds later, and announced with the newOnSisterRosterUpdated(guildKey, source)callback ("pull"or"relay"). An unanswered pull (30 s) clears that peer's stamp so the next round asks somebody else. Serving is accept-all -- a roster is public to anyone who joins the guild -- but every message is learned from: a sister member asking us is provably online, and an unknown requester is pulled from once while a listed roster is still missing, because they are online right now and that is the one thing a blind whisper cannot know.lib:IsSisterSyncAvailable()is true once the roster is ready in a guild. Only a stabilized roster is served. After a guild change the home key resolves the momentGetGuildInfoanswers, while the login stream is still filling the roster in; a partial roster served in that window is a partial hash that every sister client would compare against and re-pull forever. Found on the release re-read, fixed and specced: a request that arrives mid-stream gets silence, which costs the asker one retry.Not DeltaSync, and this is a change from the first draft of the release. The pull was first put on a DeltaSync RosterSync host; the user asked, on seeing the TOC, "since we're doing the sync through GR, do we need deltasync and acecommqueue or is that overkill?" It was: DeltaSync added a second dependency chain (DeltaSync -> AceCommQueue), seven addon-message prefixes for a host that used two, and -- the real cost -- a provider that had to have DeltaSync installed before it could answer. Anyone running this library can serve now, and nothing beyond Ace3 is needed for the sync.
The window --
/gr. The user, on seeing the slash commands: "i feel like we should have a little UI like what we did with version check ... then /gr can just open the UI, most folks wont need to get to it, leave the commands as backup." A LibAceGUIWidgetsClearFramein VersionCheck's shape: an "Add a sister guild" box (disabled, with a note, for anyone who is not an officer or the guild master -- the library refuses regardless), a "Pull a roster from" box, aRowListof the configured guilds (Guild / Members / Online / State, sorted by guild, a remove icon officers see), a right-click menu offering Pull from each member seen online recently -- so the bootstrap is a click once anyone has been sighted -- and Remove, and a Sync now button on the bottom bar with the widget library's cooldown. Built on first open; repaints onOnSisterConfigChanged,OnSisterRosterUpdatedandOnRosterHashChanged; sends nothing by itself./guildrosterwith no argument opens it too. LibAceGUIWidgets is now a TOC dependency of the standalone addon (all six TOCs, and.pkgmeta'srequired-dependencies, whichtoc_spec.luanow checks); an embedded copy without it prints one line and the slash commands still work.lib:GetSisterStatus()is the model both the window and/guildroster sistersdraw from;lib:BuildSisterWindowRows(),lib:SisterRowMenuItems()andlib:ToggleSisterWindow()are public for consumers and the specs./guildroster(aliases/libgr,/gr) -- the text backup to the window. Added after the sync was seen working in game, on the user's words: "we need a way to configure the 'sister' guild somehow into guild roster. it needs to only be configurable by an officer or GM."sisterslists the configured guilds with their state (not pulled yet / N members, M seen online) and whether the pull path is up;sisters add <Guild>,sisters remove <Guild>(case-insensitive) andsisters clearedit the list and are refused with a plain reason for anyone without the officer-note permission -- the gate isSetSisterGuildNames' own,lib:IsOfficer(), which the guild master always passes;pull <Name-Realm>is the manual bootstrap;syncruns the gossip, the relay and one pull round now. The body is public aslib:HandleSlash(msg)so a consumer can route its own command to it. Neither spelling is taken by the client or any addon in this install. Seven examples inTests/sistersync_spec.lua; suite 555 -> 562.Two notes for a consumer that EMBEDS the library rather than depending on the standalone addon. (1) The library reads and writes the global
LibGuildRosterDBand nothing else; only a TOC that declares it as## SavedVariables:makes it survive a logout, and the six standalone TOCs do. An embedded copy in an addon whose TOC does not declare it holds the list and the rosters in memory for the session -- the sync still runs, the gossip restores the list from a guildmate at the next login, and the first roster waits for the first relay or pull -- which is the pre-MINOR-18 behaviour, not an error. (2) The window needs LibAceGUIWidgets; without itToggleSisterWindowreturns nil,/grprints the slash usage instead, and everything else works. Both are the same recommendation: an addon that wants the sync should take the standalone addon as a dependency (## Dependencies: GuildRoster, CurseForge sluglibguildroster) instead of embedding, because the standalone TOCs declare both.
Changed
PRESENCE_TTLis 900 s, was 120. The old value was sized for a consumer's/whopoll that no consumer ever ran. Presence now comes from proven sightings on the wire, which are sparser, and the window has to outlast the pull interval or the provider of one pull is stale before the next. A consumer readingGetOnlineMembersScopedon a sister roster sees a longer tail after a logoff than before.- The library owns persistence, so a consumer's own re-feed is now a second
feeder into the same store. Harmless --
SetSisterRosteris wipe-and-replace and the library snapshots whatever lands -- but the migration named in TOGTools' contract (TOGPM dropping its copy and importing itsprofile.sisterGuildsonce) is what removes the duplicate. docs/HEALTHCHECK.md'sminorrow reads 18.
Not built, and not to be built
- No
/whoof any kind. The contract as written asked for "/who-driven peer discovery" and "MarkOnline fed from the /who poll". Neither exists in TOGPM (its comment says discovery "comes in a later step") and neither can exist as described:C_FriendList.SendWhorequires a hardware event on every client --HasRestrictions = truein the Classic EraFriendListDocumentation.lua, the 8.2.5 patch note, and FastGuildInvite's measured "one query per click" -- so no timer can call it. A draft of this release carried a passiveWHO_LIST_UPDATElistener that only read answers the player's own/whohad produced; the user stopped it: "what are you trying to do with /who, you shouldn't". Removed entirely; the library has noC_FriendListreference. Bootstrap needs one proven sighting per sister guild, from either side: a member of theirs pulling from us, or a consumer handingPullSisterRostera name. - No blind whispers. A whisper to an offline player prints "No player named 'X' is currently playing." in the player's chat, and FastGuildInvite's presence module records the fleet rule: nothing drives those automatically. Hence "freshest known-online member, never an unstamped one" above.
- No DeltaSync involvement at all -- see the pull above. (A draft carried
## OptionalDeps: DeltaSyncfor an hour; DeltaSync's own TOC hard-depends on GuildRoster, so that line was a dependency cycle before it was anything else.)
Tests
Tests/sistersync_spec.lua, 60 examples, all through the REAL AceComm-3.0 (chunking, spool, dispatch) and the REAL AceSerializer-3.0. One library instance plays both ends: what it sent is replayed into AceComm's own frame under a guildmate's name (guild.replaySent), so a receiver sees the bytes a sender produced, control bytes and all -- one example round-trips a 60-member roster through a 12-chunk multipart send, and the pull examples serve and take rosters the same way.Tests/zz_window_spec.lua, 14 examples, builds the real window on the real AceGUI and the installed LibAceGUIWidgets (the harness's widget layer, opted into last, in the same order VersionCheck's suite learned the hard way). With the four examples for health-check command 7 (below), suite 501 -> 579; coverage 1473/1473 on the library and 140/140 onTests/env_guild.lua.zz_window_spec.luamust run last, and thezz_prefix is whatrun.luasorts on: the slash spec's bare/guildrosteropens the window, and if the widget layer was installed by an earlier file and then reset, that open dies inside LibAceGUIWidgets (grip:CreateTextureon a hollow frame). Measured while preparing this release by passing the files tocoverage.luain the wrong order: 578/579, 99.86%. Pass them alphabetically.docs/HEALTHCHECK.mdgains command 7, which separates the three ways the sync fails silently -- the list has not arrived, a listed guild was never pulled, the pull path is down -- andTests/healthcheck_spec.luaruns it like the other six.Tests/env_guild.lualoads AceComm-3.0 and AceSerializer-3.0 for every spec, so each login build takes the branch a live client takes, and gained three things the wire needed: a ChatThrottleLib reset per test (CTL is one global for the suite and queues what it has no bandwidth for -- a broadcast from five tests earlier was adopted by a later test through that queue, and a send made at "35 s" was still queued at "336 s" behind a previous file's backlog); a sweep of AceComm's registry for stale library instances, found by theownertag on the library's comm object, because specs load the library by three routes and no single handle knows them all; andwithoutLib/loadDeltaSyncfor the absent-library and real-DeltaSync branches.- Every timestamp in the sync uses
GetTime, including the relay suppression TOGPM stamped with server time. It is a local "how long ago", the frame clock cannot jump, and it is the clockMarkOnlinealready used. Found because the harness moves server time by a wholeadvanceTimeup front, so a ticker firing "at 35 s" inside a 270 s advance read 270 s of server time and relayed early -- a modelling artefact, but the frame clock is right anyway.
Older versions (0.6.1 and earlier) are in CHANGELOG_ARCHIVE.md.
This mod has no additional files

