DeltaSync-v4.6.2
What's new
DeltaSync Changelog
[v4.6.2] (2026-10-17) - An offer hook that replaces data.items with a non-table no longer breaks the offer handler (MINOR 28)
Adopting addons: nothing to change. Nothing changes on the wire.
Bug Fixes
- A hook that replaced
data.itemswith a non-table made the OFFER handler raise (self-audit of v4.6.1).P2P:OnHashListReceivedandP2P:OnOfferguarded only against nil, then ranpairs(items); they now return on anything that is not a table. Spec:Tests/comms_spec.lua. Location:P2PSession.lua.
Improvements
docs/COMMUNICATION.mdandCLAUDE.mdnow state the v4.6.1onOfferReceivedfilter contract, which only the README and the code comments carried. TheonOfferReceivedheader inDeltaSync.luaalso says that replacingdata.itemswith another table is honoured and a non-table is ignored (Peer Review, thread 39fedbb8); the README coverage-exemption table moved with it.- The smoke spec reads MINOR from
DeltaSync.luainstead of pinning a number. It asserts the loaded library reports the revision declared at theNewLibrarycall site, and that it is at least 28, so a stale copy still fails it and a release's MINOR bump no longer needs a spec edit. Location:Tests/smoke_spec.lua. - v4.0.0's and v3.2.1's sections moved to
CHANGELOG_ARCHIVE.md, to keep the release body under the 120,000-character ceiling. Moved whole and unedited.
[v4.6.1] (2026-10-17) - onOfferReceived may filter data.items, now a contract (MINOR 27)
Adopting addons: nothing changes. Behaviour is the same as v4.6.0; what was incidental is now guaranteed.
Improvements
onOfferReceivedmay remove entries fromdata.items, and P2P honours the removal (Ultimate Guild Roster, inbox f3ec1986, option a)._HandleOfferalready handed the hook andOnHashListReceived/OnOfferthe samedatatable, so a hook that dropped an item stopped it being offered back or recorded as a candidate -- the way a host judges a peer per item, whichisValidPeercannot. That was never written down, so a refactor that copieddatabefore the hook would have silently undone it. It is now documented at theonOfferReceivedconfig entry, at_HandleOffer, and in the README, and pinned by aTests/comms_spec.luaexample covering both the hash-list and the hash-offer path. The README coverage-exemption table moved with the code. Location:DeltaSync.lua,README.md,Tests/comms_spec.lua.
[v4.6.0] (2026-10-17) - Sister guilds hear the hash-list over GreenWall (MINOR 26)
Adopting addons: nothing changes unless you set greenwall. Feature-detect with host.OnGreenWallMessage.
New Features
config.greenwall: the hash-list broadcast reaches sister guilds (Ultimate Guild Roster, inbox 3551240e). The hash P2P's whispers already reached sister-guild members, butBroadcastItemHashessent the hash-list on GUILD only, so a sister guild never offered or asked. Withgreenwall = true(or the TOC name, or{ addon, priority, maxMessages }) onNewHost/Initialize, and GreenWall's API at version 2+, the hash-list also goes to the co-guilds throughGreenWallAPI.SendMessage, and a handler registered withAddMessageHandlerfeeds a co-guild's hash-list into the same receive path a GUILD one takes, with the sender canonicalName-Realmand recorded as a sister sighting. Only the broadcast crosses; offers, handshakes and payloads stay on whispers. GreenWall's relay refuses, rather than cuts, a message over about 60-100 raw bytes, so the bridge sends a compact AceSerializer list (dsh1, thenkey, hash, hashV2per entry, noupdatedAt), halves a list refused as too long, drops and logs an entry that cannot fit alone, and spends at mostmaxMessages(10) per broadcast. The home guild's copy and our own echo are dropped. A wrong addon id disables the bridge instead of tripping GreenWall's assert; a second host on one addon id is refused; GreenWall loading afterInitializeis picked up on the next broadcast. The bridge stands down under guild-mode, on send and receive, because the offer back would go out on GUILD where a sister member cannot hear it. Location:DeltaSync.lua.- A bridged host's default
isValidPeeraccepts sister-roster members (LibGuildRoster:IsInAnyRoster), so the hash-list, offer and handshake from a sister member are not ignored. Unbridged hosts keep the home-guild-only default. Location:P2PSession.lua.
Improvements
OnComm_OFFER's post-decode handling islib:_HandleOffer(sender, data), shared by the addon-message path and the bridge. New debug tagCOMMS.GREENWALL. Location:DeltaSync.lua.- Bridged entries are type-checked (Peer Review G1 on inbox eae5e74f). The bridge lets every co-guild member reach the hash-list path, so an entry is kept only when its key is a string or a number (a consumer may key by item id; NaN is refused) and its hash and
hashV2are strings or numbers (hashV2may be nil); a repeated key is counted once; any other shape is dropped beforeOnHashListReceivedand a consumer'shasContent. The README also notes thatonOfferReceivednow sees bridged hash-lists (data.via = "greenwall") and that the offer back reaches a sister member over the same connected-realm cluster GreenWall's channel spans. Location:DeltaSync.lua,README.md. - Re-initialising a bridged host cleans up after itself (Peer Review B and C on inbox eae5e74f). A re-init that changed the addon id or priority, or dropped
greenwall, left the old GreenWall handler registered and the id held, so the old id kept delivering and no other host could take it; it is now removed withGreenWallAPI.RemoveMessageHandlerand the id freed. A defaultisValidPeerbuilt by anInitP2Pthat ran before the re-init kept the old bridge setting (a sister member stayed rejected aftergreenwallwas turned on);P2P:Initnow recordscb.isValidPeerIsDefaultand the re-init rebuilds that default. A consumer's ownisValidPeeris never replaced. Location:DeltaSync.lua,P2PSession.lua. - Tests:
Tests/greenwall_spec.lua(24 examples), with every bridge send run through GreenWall's realRelay.lua,Constants.luaandBase64BCA.lua, and an end-to-end sync from a sister guild. The README coverage-exemption table moved with the code. Location:Tests/,README.md. - The coverage-exemption table pins each row with an anchor (Peer Review LOW, thread 7d2de548). A row whose code is a generic line (
return,return nil,return 0) could drift onto another identical line and stay green, because only the line itself was checked. The table gains an Anchor column, one of the three nearest code lines above (blank and comment-only lines skipped), andTests/coverage_exemptions_spec.luaasserts it and that the code-plus-anchor pair occurs once in the file. The uniqueness check caught theDeltaSync.lua:2856row at once: itsif not frame then/returnrepeats verbatim at 2876 and 2898, so it now anchors on the line that fetched the frame. Location:Tests/coverage_exemptions_spec.lua,README.md. - v3.2.0's section moved to
CHANGELOG_ARCHIVE.md, to keep the release body under the 120,000-character ceiling. Moved whole and unedited.
[v4.5.0] (2026-10-16) - Numbered P2P: a trusted number table, wider numbers, refusals, serve gate and targeted broadcasts (MINOR 25)
Adopting addons: nothing to change unless you want the new keys. Numbered hosts only; the hash protocol is untouched. Every numbered message gains a trailing d field, which an older peer ignores. Feature-detect with DS.MINOR >= 25, or host.p2p.RefuseReply.
Bug Fixes
- Anyone could read a numbered host's whole key list, or empty its number table and stop numbering for good (Peer Review, inbox c9feae7f, HIGH).
numbers-requestandnumbers-replywere routed to the number table before any peer check, so one whisper of{ v = 999999999, n = 10000, t = {} }was adopted wholesale, written to the consumer's SavedVariables and spread to honest peers on the next broadcast. A request is now answered only for a peer the host would serve, and a reply is adopted only from a peer it would fetch from, and only when this client asked that peer withinREPLY_WINDOW(120 s) or the peer advertised exactly that(v, d). The next number after an adopt is the table's own highest + 1, never the sender'sn; a version more than a day ahead of the server clock (FUTURE_SLACK) is refused, andWillAdoptapplies the same clock rule, so a far-future advert is neither requested nor parked.InitNumberstakes an optionalisValidPeer; without it the gates are the numbered P2P'scanServePeer/isValidPeer, then the library's guild-membership default. Follow-up findings N1-N4 from Peer Review's audit of this fix (thread 3908c6b7) are fixed: an outsider or a far-future advert cannot hold off an honest request, both comm paths spell the sender the same way, and both gates see that spelling. Location:DeltaSyncNumbers.lua,DeltaSync.lua. - Two different number tables under the same version never converged (Ultimate Guild Roster, inbox 27fbdf23). Two clients that each minted
0001in the same server second held equalnumbersVersion, so neither requested the other's table, and each read the other's numbers through its own table. Every numbered message (hlb2,hash-offer2,ver-query,ver-reply,numbers-request) now carriesd, a digest of the sender's table (ComputeHashV2over number-by-key,host.numbers:Digest()). Equalvwith a differentdis read as a different table (Reads(v, d)); the side whose name sorts higher (WillAdopt) requests, adopts through the existing tie-break, and re-mints its own keys under a higher version, which the other side then adopts. A broadcast or offer from such a sender is parked and replayed after the adopt.OnBroadcastparks only for a sender that passesisValidPeer(Peer Review, 3908c6b7). Location:DeltaSyncNumbers.lua,DeltaSyncP2PNumbered.lua,DeltaSync.lua. - A send meant for one player could reach the whole guild (Peer Review, inbox e3b7a154 F4 and f34589fd S1). A directed send (QUERY, RESPONSE, DELTA, OFFER, HANDSHAKE, or any send with a player target) went out on its channel's configured distribution; a host that set that channel to GUILD with guild-mode off sent it unstamped, so every member acted on a message meant for one peer -- the numbered offer-back and the unasked number table among them. The same held for RAID, PARTY, INSTANCE_CHAT and OFFICER, where the client ignores the target ("Only applies for targeted channels",
SendAddonMessagedocs) and guild-mode stamps nothing (Peer Review, cd90b7c6 A1).SendMessagenow declines a player-targeted send on any distribution but WHISPER and CHANNEL, unless it is GUILD under guild-mode, for every channel, reportingnot-attemptedwith reasonunstamped-directedthroughonComplete/onSendComplete. The defaults (directed channels on WHISPER), guild-mode (stamped) and real broadcasts (no target) are unchanged. A search of theInitialize/NewHostcalls in the Classic Era AddOns tree found that only PersonalShopper setschannels, and only VERSION and DATA (to CHANNEL); Dibs sends OFFER on RAID/PARTY with no target, which still goes. Location:DeltaSync.lua. - An empty-string target went out to the whole guild (Peer Review, cd90b7c6 A2).
""is truthy in Lua, so it skipped the CHANNEL default and, on GUILD, was sent unstamped as if it were a broadcast. It is now declined on every distribution asnot-attempted/empty-target; a broadcast passesnil. The guild-mode stamp's own""check is gone, since nothing empty reaches it. Location:DeltaSync.lua. - The numbered offer-back logged "offering" before a send that could be declined (Peer Review, cd90b7c6 A4). It now logs after the send, from its return. Location:
DeltaSyncP2PNumbered.lua. - A numbered broadcast that sent nothing held back
IsSelfConsulted(Peer Review, inbox f34589fd S3).Broadcastbegan the self-consult before sending, so a refused targeted broadcast, or a list that left nobody to send to, opened no collect window and nothing ever marked the cycle answered:IsSelfConsulted()stayedfalseandonSelfConsultednever fired. The consult now begins only with a send that went out. Location:DeltaSyncP2PNumbered.lua.
New Features
- Wider numbers:
InitNumbers({ width = 4..6 })(Ultimate Guild Roster, inbox 8320a730).MAXbecomes10^width - 1and the wire entry width follows. The default stays 4, so TOGBankClassic's and Questbook's wire is unchanged. It is a wire format: every client of one host must pass the same width, because a message whose length does not divide by the receiver's width is dropped whole. Location:DeltaSyncNumbers.lua. host.numbers:Mint()is documented as consumer API, for a host that saves keys between broadcasts (inbox 27fbdf23).host.numbers:RequestTable(peer)asks a peer for its table inside the cooldown. Location:README.md.host.p2p:RefuseReply(key, sender, reason)(Ultimate Guild Roster, inbox be665d97). Called fromonDataReceivedinstead ofOnItemCompleted, it marks that provider refused for the key and dispatches to the next untried capable holder; refused peers stay skipped across retry cycles, and when every holder is refused the session fails withall_refused. A call for a key with no active session, or from a sender that is not its peer, returnsfalseand changes nothing. Location:DeltaSyncP2PNumbered.lua.canServePeer(name), a serve-only gate (Ultimate Guild Roster, inbox f220f026). Consulted byHandleSyncRequest, the serve-queue re-check, the offer-back andHandleVersionQuery; defaults toisValidPeer, which keeps gating the fetch side. NamedcanServePeerrather than the requestedcanServe, becausecanServe(key)is already the per-key hook. Location:DeltaSyncP2PNumbered.lua.- Targeted numbered broadcasts (inbox 8320a730):
BroadcastNumbered(priority, extra, targets)and thebroadcastTargets()callback whisper the broadcast, and the catch-up re-broadcast, to a named list instead of GUILD. Under guild-mode each copy goes on GUILD stamped for its recipient and other members drop it (spec'd). When the OFFER channel is set to GUILD with guild-mode off, each copy would reach the whole guild unstamped, soSendMessagedeclines every one and it returnsfalse(see the bug fix above). Location:DeltaSyncP2PNumbered.lua. canonIsNewer(key, mine, theirs)(inbox 8320a730): one order for both sides -- the provider's "do I hold newer" test (offer-back andHandleSyncRequest, exposed ashost.p2p:HoldsNewer) and, whencanonImprovesis not set, the fetch side'sImproves(called with the offered canon asmineand ours astheirs).canonImprovesstill overrides the fetch side. Defaults to the time-onlyCanonIsNewer. Location:DeltaSyncP2PNumbered.lua.
Documentation
- README: the numbers trust gate, the digest,
width,Mint,RefuseReply, targeted broadcasts, the three newInitP2Pkeys, a v4.5.0 behaviour-change entry and aMINOR >= 25row.RefuseReplyis documented as taking the same key asOnItemCompleted. Location:README.md. - README: the canon is
<DTS><hash>, required. "Canonical hashes" used to offer a second shape, the datestamp folded into the hashed input, and said the hash could not tell which version is newer. Both were wrong for the numbered protocol, which orders canons by their first ten digits. Rule 3 now requires a 10-digit publish time in front of a 10-digit content hash computed over the payload alone, so anyone can tell which payload is newer from the canon itself. Rule 7 orders by that DTS. A new rule 8 covers the hash protocol, where the same two values travel asupdatedAtandhash/hashV2, and warns that the VERSION channel'stonumberdoes not survive a 20-digit canon. The example now buildsstring.format("%010d%010d", time(), contentHash). The Numbered P2P section andDeltaSyncNumbers.lua's header say the same. Location:README.md,DeltaSyncNumbers.lua. - CurseForge description: the two links to the test harness's GitHub page are now plain text; these projects link to Discord only. A v4.5.0 Recent Updates block (New / Changed / Fixed), a v4.5.0 paragraph in the numbered-P2P feature section, the
<DTS><hash>canon in the quick-start comment and the content-hash section, and the spec count (789). Location:docs/Curseforge_Description.html.
Development (not shipped to players)
- Suite: 789 passed, 0 failed; coverage gate green at 3715/3723 (99.79%) on the same eight documented lines (desk runs 20261015-142837.851-2669162438 and 20261015-143034.806-20199953).
Tests/run_coverage.lua's threshold re-derived to 99.78% for the new size, and the README exemption rows moved with theSendMessageguard. guildmode_specgains 'a directed GUILD send without guild-mode': a decline per directed channel with its reason, an explicit GUILD distribution, the stamped send once guild-mode is on, a broadcast and an empty target left alone, and WHISPER untouched. Its old 'does not stamp outbound sends' spec asserted that a directed GUILD send went out unstamped -- the defect itself -- and now checks a broadcast instead.comms_spec's directed-GUILD online-guard spec now turns guild-mode on and assertstarget-offline, since without it the new guard declines first. Both checked red with the guard disabled, and theIsSelfConsultedasserts red withBeginConsultmoved back (desk run 20261015-141048.781-3361406004).- New
Tests/numbered_p2p_ugr_spec.lua(26 specs: refusal, serve gate, targeted broadcast including guild-mode stamping and the unstamped-GUILD refusal for both the broadcast and the offer-back,canonIsNeweron both sides, digest plumbing, the outsider park gate, anddcarried from the wire throughOnComm_HANDSHAKEinto the version-query and version-reply handlers -- checked red by droppingdata.dfromDeltaSync.lua).numbers_specgains the trust-gate specs (non-peer request and reply, unsolicited reply,n = 10000with an empty table leavingMintworking, outsider and far-future adverts), a wider-width spec, and a broadcast -> request -> reply run through the real comm handlers; the two-client same-second convergence spec now goes through the digest path. The outsider and far-future specs were each checked red by disabling only the gate they pin. - v3.1.0's and v3.0.0's sections moved to
CHANGELOG_ARCHIVE.md, to keep the release body under the 120,000-character ceiling. Checked line by line against HEAD with git's moved-line diff: identical except v3.0.0's warning-sign pictograph, written out as "Warning:" because the archive is checked for pictographs (the archive's header says so). - MINOR 24 -> 25 in
DeltaSync.luaandTests/smoke_spec.lua, at the release tag per this repo's rule (an early bump was reverted mid-cycle, inbox f34589fd). No header-history line was added, so the README's coverage-exemption rows do not move. The README'sDS.MINORexample reads 25.
[v4.4.2] (2026-10-11) - The sister-guild guard declines only a known-offline member (MINOR 24)
Adopting addons: nothing to change. With LibGuildRoster MINOR 32 or later, a directed send to a sister-guild member completes not-attempted / target-offline only when LibGuildRoster has positively recorded that member offline; an unknown one is whispered. With an older LibGuildRoster the MINOR 22 behaviour stays. Nothing changes on the wire. Feature-detect with DS.MINOR >= 24.
Bug Fixes
- The sister-guild WHISPER guard declined members whose presence was merely unknown (GreenWall, inbox 8d33ba68).
SendMessagedeclined a directed send withtarget-offlineto any sister-roster member missing fromLibGuildRoster:GetOnlineMembersScoped, but that list leaves out both "known offline" and "nobody said": a member is listed only on a wire sighting or a live-stream "on" within 900 s. So a GreenWall relay bridge that had been quiet for 15 minutes was refused, and every forwarded line to that guild was lost. With LibGuildRoster MINOR 32 or later, the guard asks the new tri-stateGetSisterPresence(guildKey, name)(true/false/nil) and declines only a positivefalse. MINOR 32 is also where a server "No player named X" starts recording X as offline (GuildRoster inbox 90f5fd19). The gate is that MINOR (lib.SISTER_PRESENCE_MINOR), not the method's presence, because the method alone cannot show that the offline record shipped with it; without that record an unknown peer would be whispered on every send, the spam LibGuildRoster inbox 69fba6d7 asked DeltaSync to stop. An older LibGuildRoster keeps the scoped-list behaviour. The guard no longer needsGetOnlineMembersScopedon the tri-state path. Location:DeltaSync.lua.
Documentation
- README and the CurseForge description carry this release: adoption item 000000, a v4.4.2 behaviour-change line,
GetSisterPresencein the LibGuildRoster dependency row, aMINOR >= 24feature-detection row, theDS.MINORexample, the sister-guild sentence in the CurseForge roster section, the spec count, a v4.4.2 Fixed block, and the MINOR 24 sentence in the README's roster feature bullet.CLAUDE.md's sixDeltaSync.lualine links (channel config, wire envelope,GenerateShortName,ComputeChecksum, and the MINOR site twice, which said line 6) pointed at lines the code left long ago and now point at it. Location:README.md,docs/Curseforge_Description.html,CLAUDE.md.
Development (not shipped to players)
- Harness pin stays at
bd91de2in this release. The working tree'sTests/wowapiis checked out at0de2e0a, but the release tooling could not stage a submodule pointer, so that move is not in this tag. Which pin the 723-pass run above used was not recorded. - MINOR 23 -> 24 in
DeltaSync.lua(with its header-history line) andTests/smoke_spec.lua. The two header lines moved the README's sixDeltaSync.luacoverage-exemption rows again, to 484, 497, 533, 2030, 2108 and 2578, each read back from the file; the suite and coverage gate have not been re-run since. comms_spec: the existing sister-guard example now forces the scoped-list fallback by raisingSISTER_PRESENCE_MINORon its host. A new example runs against the real LibGuildRoster: an unknown sister member is whispered, an online one is whispered, one the server called offline is declined, and a later sighting lifts that. The README's sixDeltaSync.luacoverage-exemption rows moved (482, 495, 531, 2028, 2106, 2576).env_deltaleaked every example's host, client and libraries, which made the coverage gate time out (Peer Review, inbox 87bfe59e). The proxy cachessubProxiesandrosterProxieswere weak-keyed tables whose values (proxies) capture their own keys through closures. Lua 5.1 has no ephemerons, so such an entry is never collected.p2p_spec's heap grew by about 820 KB per example, from 10 MB to 81 MB, and GC time per example climbed from about 100 ms to 400-600 ms. Under coverage the file took 426 s, past the runner's 300 s per-file limit, andP2PSession.luashowed lines as uncovered that the file does cover.fresh()now starts all three proxy caches empty.p2p_specwent from 53 s to 11 s plain (heap 6.9 MB at the end), and under coverage from 426 s to 8.8 s;numbered_p2p_specfrom 241 s to 82 s; the whole coverage gate runs in 201 s. All of these times were taken on a busy machine. Coverage gate green at the runner's own limits, 3569/3577 (99.78%) on the same eight documented lines, withP2PSession.luaat 100%; suite 723 passed, 0 failed, at the default 60 s per file.Tests/run_coverage.lua's threshold comment now quotes today's line counts.
[v4.4.1] (2026-09-29) - WoW: Forever whispers addressed without the realm (MINOR 23)
Adopting addons: nothing to change. On WoW: Forever only, a WHISPER now leaves the client addressed First Last instead of First Last-Realm; every other client is addressed exactly as before, and every callback, completion and P2P table still holds canonical Name-Realm. Nothing changes in any payload. Feature-detect with DS.MINOR >= 23.
Bug Fixes
- Every whisper DeltaSync sent on WoW: Forever was refused by the server (FastGuildInvite, inbox dd611b26). Since MINOR 21 every name DeltaSync holds is canonical
Name-Realm, andSendMessagehanded that name toSendCommMessageas the WHISPER target -- so a reply to any sender (QUERY/RESPONSE/DELTA, the hash offer, the handshake, RosterSync) went toFirst Last-Realm. Forever characters areFirst Last, unique across the region, and the server answers a realm-suffixed target with "No player named 'Quebeldorf Flamehammer-ClassicBetaPvE2' is currently playing", which a FastGuildInvite player saw three times while that guildmate was online.SendMessagenow computes the wire target separately: for a WHISPER it goes throughLibGuildRoster:WhisperTarget(LibGuildRoster 1.1.2, MINOR 29), which drops the realm only when the client's ownRegionalUniqueNamesEnabled()returns true; without that method it makes the same check inline.ctx.target, and so everyonSendComplete/onComplete/onSendFailed, keeps the canonical name, so callers see the identity they passed in. GUILD, CHANNEL and guild-mode's recipient stamp are untouched. The operator's MINOR 21 directive -- names are held and sent with realms, never normalised per recipient -- stands: this changes only the address the client is handed, on the one client whose server refuses it. Not verified on a Forever client; there is no Forever install on the development machine. Location:DeltaSync.lua.
Documentation
- README and the CurseForge description carry this release: the Forever addressing in the roster feature and the supported-versions paragraph (which no longer lists realm-less names as unhandled),
WhisperTargetin the LibGuildRoster dependency row, adoption item 00000, a v4.4.1 behaviour-change line, aMINOR >= 23feature-detection row,DS.MINORexample, the spec count, and a v4.4.1 Fixed block.CLAUDE.mdgains the rule that a WHISPER's wire target goes throughWhisperTargetand nothing else changes spelling. Location:README.md,docs/Curseforge_Description.html,CLAUDE.md.
Development (not shipped to players)
- MINOR 22 -> 23 in
DeltaSync.lua(with its header-history line) andTests/smoke_spec.lua. - Suite: 722 passed, 0 failed (718 + 4 examples in
comms_spec: realm dropped on a regional-names client, the same without LibGuildRoster, unchanged when the switch is absent or false, and the completion keeping the canonical name). Coverage gate green at 3562/3570 (99.78%) on the same eight documented lines; the README's sixDeltaSync.luaexemption rows were moved twice from the coverage report's own numbers.RegionalUniqueNamesEnabledadded to.luacheckrcand.luarc.json. wow-version-replication.ps1no longer deletes a live file from every replica install (TOGProfessionMaster, inbox b11bc562). An editor that saves by replacing the file raises Deleted and Created/Changed for one save in either order; when Deleted ran last,Sync-Fileremoved the file from every other flavour while the source still had it. A Deleted whose source still exists is now treated as Changed.
[v4.4.0] (2026-09-27) - Numbers read only through the table that minted them; sister-guild send guard; retail and addon-list TOC (MINOR 22)
Adopting addons: no signature change. Numbered-protocol consumers (TOGBankClassic, Questbook) get onNewerCleared on a failed fetch where they got nothing, and a client on a table ours did not grow from is offered every key its peers serve. ver-query / ver-reply carry the table version v; a peer without it is read the old way. Directed sends to a sister-guild member LibGuildRoster does not see online now complete not-attempted / target-offline. A guild running v4.3.0 and v4.4.0 side by side keeps syncing. Feature-detect with DS.MINOR >= 22, or host.p2p.ParkBroadcast on a numbered instance.
Bug Fixes
The numbered protocol read a peer's item numbers through OUR table whatever table they were minted on (TOGBankClassic, inbox 17a1ee39).
OnBroadcastandOnOffercalledOnAdvertisedVersion-- which only requests a newer table -- and then decoded every number with ourKeyOf. Two clients on different tables map one number to two keys, so one banker's canon was credited to another, the requester asked a provider BY NAME for a version it never held, and gotbusy(version): the guild-wide "asking each other for versions nobody held" TOGBank saw live. Now a message is read only whenNUM:Reads(v): our own version, or one our table EXTENDS (a mint only appends, so after adopting v5 and minting to v6, v5 numbers still mean what they meant; an adopt resets it). A broadcast on a table AHEAD of ours is parked whole until we adopt theirs; a bare offer from one is parked as before. A broadcaster BEHIND us is not read at all: everything we serve is offered, our table going first. An offerer behind us is sent our table and its offer dropped. Every unasked table send goes through one gate,NP:SendTableTo, at most once per peer perREQUEST_COOLDOWN; the behind-broadcaster path used to send it on every broadcast.ver-query/ver-replycarryv. A reply on another table is not read and counts as "holds nothing" for every query outstanding to that peer, so the query settles andonNewerClearedfires; waiting on it left the offer raised with nothing to clear it. A query on another table is answered with no canons.What a consumer will see. A behind client is offered every key its peer serves, not only the newer ones, because what it listed cannot be read. On that client
onNewerOfferedfires for every one of those keys, then each is version-queried and cleared. A UI keyed ononNewerOffered-- TOGBankClassic's Bankers tab -- briefly shows "update offered" on every row after a straggler mint. The offer carries bare numbers with no canon, so the receiver cannot suppress the equal ones before the query. Mixed builds: a pre-MINOR-22 requester asking a MINOR-22 peer that is on another table gets an emptyver-replyand reads it as "holds nothing", which can clear an offer the peer could in fact have served; the next broadcast raises it again. A parked bare offer is now dropped once our table leaves its lineage, instead of being replayed through ours. Avahead of ours on aver-queryorver-replyrequests that peer's table, as a broadcast's does. The lineage is session memory only: after a/reloadjust the current version reads, so a peer on a version our table grew from is treated as behind until the next adopt or mint. The KNOWN COST inDeltaSyncNumbers.luais corrected: the misread lasted the whole divergence, not one broadcast; two different tables under one version remain indistinguishable. Location:DeltaSyncP2PNumbered.lua,DeltaSyncNumbers.lua,DeltaSync.lua.A failed fetch session never fired
onNewerCleared(TOGBankClassic, inbox 68a67be9). It fired on completion and on "nobody is newer", not from_FailSession, so a session that exhausted its holders (busy(version)from all, timeouts) left the consumer's "update offered" up until a reload. It now fires on every failure; a later genuine offer raisesonNewerOfferedagain. Location:DeltaSyncP2PNumbered.lua.Directed sends to a SISTER-guild member were never online-guarded (LibGuildRoster, inbox 69fba6d7). The guard asked
GetMember, which knows only the home guild, so a sister member read "not in any roster" and went through -- a session with a sister peer who had logged off kept whispering into "No player named X". Now a target in a sister roster (IsInAnyRoster, key not the home key) is declined unless it is inGetOnlineMembersScoped(key): LibGuildRoster's baton stream, sightings, and its whisper-failure clear. On an older LibGuildRoster that set is sightings only and sparse, and declining there is the side the spam concern wants. The other half: every inbound sender from a sister roster is now reported to LibGuildRoster as a first-hand sighting (MarkOnline, array form), through the newlib:_InboundSenderat every receive path, so a sister peer that just whispered us is never refused a reply. Location:DeltaSync.lua,DeltaSyncChannel.lua.A live LibStub upgrade could raise mid-session. The new per-instance tables (
parkedBroadcasts,tableSentTo) are created byInit, so an instance from an older MINOR that survived an upgrade without a re-Initwould index a missing field on its first parked broadcast. They are now created on first use (NP:_Own). Peer Review, on this release's self-audit. Location:DeltaSyncP2PNumbered.lua.GetCommStats().peerCountwas always 0. It read#self.peerStates, andpeerStatesis keyed by sender name, so#counts nothing. It now counts keys. Found by a documentation audit; no spec had calledGetCommStats. Location:DeltaSync.lua.A
not-attemptedcompletion invented a reason. When an older AceCommQueue gave none,host.lastSendNotAttempted.reasonwas correctlynilbut the completion handed toonSendComplete/onCompleteread"suppressed"-- a cause nobody had reported, against the documented "we don't guess". It is nownilon both. Location:DeltaSync.lua.The TOC lacked retail
120005and120100(FastGuildInvite, inbox 7bca3e75), so retail flagged DeltaSync -- and FGI, which hard-depends on it -- as out of date. Location:DeltaSync.toc.
New Features
- A broadcast that names numbers we cannot resolve is parked and replayed (Questbook, inbox 12ca628b). A guild's first sync -- nobody holds a table -- used to adopt the broadcaster's table and learn nothing else from the line until someone broadcast again.
NP:ParkBroadcast(one per sender, the latest replaces; dropped afterQUEUE_TTLor once our table leaves its lineage) keeps it, andOnNumbersChangedreplays it once every entry resolves -- AFTER the adopt's own re-mint, so the offer-back names the listener's own keys:Adoptnow notifies the P2P only afterMint, and the consumer'sonChangedorder (adopt, thenmint) is unchanged. Questbook feature-detectsParkBroadcastto retire its stand-in. Location:DeltaSyncP2PNumbered.lua,DeltaSyncNumbers.lua. - DeltaSync is grouped under "Library" in the in-game addon list (
## Category: Library, the singular every other fleet library uses -- VersionCheck-1.0, inbox 41688d57, after the operator saw DeltaSync alone under a plural heading; the client'sAddonList.luareadsCategoryon Classic Era and live). The title is in the fleet brand colour too:## Title: |cffFF8000Lib: DeltaSync|r, matching the other fleet libraries. Location:DeltaSync.toc.
Documentation
- README and the CurseForge description carry this release: the numbered-protocol wire line (
ver-query { v, n }/ver-reply { v, e, n }) and the read-only-through-its-table rule, aMINOR >= 22feature-detection row, the sister-guild guard in the roster feature, a v4.4.0 New/Changed/Fixed block, and the spec count. A full audit of both documents against the code also corrected what had drifted: the TOC interface list, theDS.MINORexample, the CHANNEL transport's requiredchannelModule(without it a CHANNEL prefix sends but never receives), thedebugsub-keys,lastSendFailure'sreasonfield, the hash protocol's seven tunables with their defaults, the numbered protocol'shasMissingItemshook (without it the library never re-broadcasts), RosterSync's ownisValidPeer, "four" keying rules (it said two in one place and three in another),DebugStatusreporting P2P state rather than every module, a MINOR 22 adoption row with v4.4.0 and v4.3.0 behaviour-change entries, and a CurseForge line promising an online-member cache DeltaSync has not shipped since v3.0.0.CLAUDE.mdgains the two rules a future change must not undo: never decode a peer's numbers withoutReads(v), and_InboundSenderis for inbound senders only. Location:README.md,docs/Curseforge_Description.html,CLAUDE.md.
Development (not shipped to players)
MINOR 22 is set at the v4.4.0 tag, per the repo rule, in
DeltaSync.luaandTests/smoke_spec.lua; the working tree stays at 21 until then. The header-history line for 22 is already written.Suite: 718 passed, 0 failed; coverage gate green at 3556/3564 (99.78%) on the same eight documented lines, with the README's six moved
DeltaSync.luaexemption rows updated from the coverage report. New examples also coverGetCommStats().peerCountand the reason-lessnot-attemptedcompletion. New examples innumbered_p2p_spec(cross-table broadcast, behind offerer, lineage, first-sync replay after mint, same-table partial replay, park drops, versioned query/reply,onNewerClearedon failure) andcomms_spec(sister guard both ways, inbound sighting). Three parking examples that expected numbers from one table to be replayed through another now assert the opposite, and the RosterSync round-trip example stamps sister presence the way the consumer's/whopoll does.The 15 weak
is_nil-on-a-literal-key assertions writ flagged (inbox dc990cf4) are replaced: an empty-tablesame({}, ...)where the whole table must be empty, or a key derived once and asserted both ways.Harness pin moved to
bd91de2(from6d14774, via18e0f0e, whoseinherit="prepend"/"append"order the harness later reverted to the client's measured one); the whole suite and coverage gate are green on it. No DeltaSync spec depended on that order.wow-version-replication.ps1replicates into World of Warcraft: Forever (_classic_beta_), verified with-DryRun.
[v4.3.0] (2026-09-20) - Every sender a consumer sees is canonical Name-Realm (MINOR 21)
Adopting addons: behaviour change, no signature change. Every sender DeltaSync hands your callbacks -- onDataReceived, onDataRequest, onVersionReceived, peerCapable, onAdvertised, onNewerOffered, onOfferReceived, and the leaf-type handlers -- is now the canonical Name-Realm LibGuildRoster keys its roster by, where a same-realm peer used to arrive as the client spelled it (Bob). Compare it directly against LibGuildRoster keys and host.p2p peer names; delete any realm stripping or appending of your own. A consumer that already normalised through LibGuildRoster is unaffected (idempotent). Feature-detect with DS.MINOR >= 21. Nothing changes on the wire.
Improvements
lib:NormalizeSendercanonicalises at the comm boundary. The operator's directive, verbatim: "we need to use name normalization and send the names with realms, because we should not check if we need to normliaze it or not based on the recipient." Before this, the P2P layers canonicalised through LibGuildRoster (p2p.offers[].peerwasBob-Testrealm,isValidPeerreceivedNorm(sender)) while the transport callbacks passed the client's spelling through -- consistent within one client, but every consumer comparing a callback's sender to roster names had to know which spelling it held, which is finding 11a's whole family. Every inbound handler already ranNormalizeSenderfirst, so it is the one seam: with LibGuildRoster present it is nowGuildRoster:NormalizeName(name) or name-- exactly theNorm()helperP2PSession.luaandDeltaSyncP2PNumbered.luaalready used, so those become idempotent -- and a name LibGuildRoster rejects (empty,-Realm) comes back as given rather than dressed in a realm. Without LibGuildRoster the inline rules qualify a bare name withGetNormalizedRealmName(), falling back toGetRealmName()with spaces squashed, and leave it bare only while no realm has resolved. The client does not documentCHAT_MSG_ADDON'ssenderformat (ChatInfoDocumentation.luatypes it only ascstring), which is exactly why the boundary accepts both spellings rather than assuming one. Appending the receiver's realm is correct here and only here: the transport sender is a name the local client rendered for this viewer, bare exactly when the player is on the viewer's realm; names inside a payload keep going throughCanonName. The CHANNEL transport and guild-mode's recipient stamp already routed through this function or through LibGuildRoster directly. Location:DeltaSync.lua.
Documentation
- The sender contract is stated wherever a consumer would look for it: README's "How a sync actually happens" (a paragraph under the four-step walkthrough), the Features list (the roster bullet), adoption-checklist item 000 with what to delete, the
MINOR >= 21feature-detection row, and the 4.x-compatibility line, which now names this as a spelling change rather than claiming nothing observable moved. The CurseForge description carries aChangedblock for v4.3.0 and the same contract in its LibGuildRoster section.CLAUDE.mdgains the rule with the operator's words, so no future session re-normalises in a handler or passes the bare spelling through. Location:README.md,docs/Curseforge_Description.html,CLAUDE.md.
Development (not shipped to players)
- Suite: 702 passed, 0 failed (697 + 5
NormalizeSenderexamples: the bare-name qualification, the cross-realm sender keeping its own realm, the rejected-name passthrough, and both no-LibGuildRoster fallbacks). 26 examples that asserted the bare spelling after the v4.2.2 env migration --integration_spec,smoke_spec,guildmode_specand 23 innumbered_p2p_spec, whose peer-name constants are now the item keys themselves -- assert the canonical one, each with the reason. Coverage gate green at 3451/3459 (99.77%); the README's sixDeltaSync.luaexemption rows moved twice (theNormalizeSendercomment block, then the MINOR 21 header line), were caught bycoverage_exemptions_specboth times, and were updated from the coverage report's own numbers. MINOR 20 -> 21 inDeltaSync.luaandTests/smoke_spec.lua. - Recorded as a directive (writ
directives#420) with its known cost, so it is never re-asked: TOGBankClassic, TOGProfessionMaster, PersonalShopper and FastGuildInvite receiveName-Realmwhere they receivedName.
[v4.2.2] (2026-09-20) - Two defects the real wire exposed, and the suite moves onto the harness client model (MINOR 20)
Adopting addons: nothing to change. Both fixes are inside the library; no API, config key or wire format moved. Feature-detect with DS.MINOR >= 20 if you need to know a client carries them.
Bug Fixes
- A refused prefix registration was never reported in game.
lib:_RegisterPrefixtreated onlyaccepted == falseas a refusal, butC_ChatInfo.RegisterAddonMessagePrefixreturns a number fromEnum.RegisterAddonMessagePrefixResulton every client (ChatConstantsDocumentation.lua:130-140, Classic Era, identical elsewhere:Success = 0,DuplicatePrefix = 1,InvalidPrefix = 2,MaxPrefixes = 3; the return isNilable = false).0is truthy and3is notfalse, so the "a prefix the client refuses is reported by name" feature shipped in v4.0.2 -- advertised in the README, the CurseForge page andCLAUDE.md-- could not fire on any client. It passed its spec because the old offline env returned booleans. Now:MaxPrefixesandInvalidPrefixare refusals, reported by name with the reason;Successis fine;DuplicatePrefixis fine too and must never be reported -- AceComm registers each prefix on the addon's behalf first, so our own registration answersDuplicatePrefixon every login, forever. An explicitfalseis still honoured for a stub or a pre-enum client;nilstays "no answer". Specs drive the harness's real model with each enum value. Location:DeltaSync.lua,Tests/prefix_spec.lua. - The numbered protocol never recognised a same-realm AUTHOR. Three sites compared a candidate's peer name against the item key -- a canonical
Name-Realm-- with the raw transport sender, which the client delivers BARE for a same-realm player (Charlie, notCharlie-Testrealm). So on a live guild the "skip the version query when the author already advertised its canon" rule (an operator directive from TOGBank's guild), the author-first query ordering, and "the author answered, the query is settled" could not fire for anyone on the sender's realm; every dispatch paid a version-query round it did not need. The old loopback network realm-qualified every sender, which is a spelling the client never produces, and hid it.IsAuthor(peer, key)now canonicalises through LibGuildRoster before comparing. Location:DeltaSyncP2PNumbered.lua.
Development (not shipped to players)
- The test environment moved onto the harness's client model.
Tests/env_delta.luano longer carries a clock, a timer queue, a fake AceAddon network or a stash/restore of LibGuildRoster's singleton state.env.newHost{ player = "Bob" }now creates a harness client (wow.client) and, inside that client'srun(), loads the player's OWN DeltaSync, LibGuildRoster, AceCommQueue and VersionCheck on the client's own LibStub registry under a real AceAddon with AceComm and AceCommQueue embedded; sends go through the real ChatThrottleLib onto the harness wire, which routes GUILD to the sender's guild and WHISPER to the named client, and each receiver's real AceComm dispatches to its own DeltaSync. The spec-facing API (newHost,as,pump,advance,lastPayload, the roster fixtures) is unchanged: a peer's host is a proxy that runs every method as that player, so the ~750 call sites in the specs did not move.Tests/env_guild.luais deleted (the harness'senv/libs.lualoads the installed LibGuildRoster;env/guild.luacarries membership). Both harness contract threads that asked for this came back "not a gap -- already delivered" (37f9b220, eb10e337):wow.clientshipped on 2026-09-13 and DeltaSync's pin already carried it. The one stub kept, deliberately: hosts created without aplayerstill use the fake AceComm object, because comms_spec's delivery-verdict examples model callback shapes of OLDER AceCommQueue revisions that the installed real library can no longer produce. Each peer's ChatThrottleLib is set to "logged in a while ago, wire idle" (past the start-up throttle, limits far above what the suite sends) so timing assertions are not paid for in 80-byte ticks;env.congest(client)puts the real 800 B/s / 4000-byte limits back for a pacing spec. - What the real wire changed in the specs, and why each is right. A same-realm sender now arrives as
Bob, notBob-Testrealm: assertions on callback senders, whisper targets and slot keys moved to the client's spelling, and hand-fed senders in the numbered specs use the same spelling the wire would. Class-identity assertions compare against the PEER's library (env.libOf(client)) and the real object behind a proxy (env.unwrap). One example ("drops a queue entry older than QUEUE_TTL") passed under the old env only because its clock jumped to +181 before firing a +30 timer; with timers firing at their due time the slot frees at +30 and a 30-second-old queue entry is correctly served, so the example now holds the slot through the requester's query, and a new example pins the STATE_WAIT release itself.env.newHostOn(client, opts)models a second consuming addon in ONE client for the "two addons never hear each other" example. - A consumer-facing asymmetry recorded, not changed: the P2P layers canonicalise senders through LibGuildRoster (
p2p.offers[].peerisBob-Testrealm;isValidPeerreceivesNorm(sender)), while the transport callbacks (onDataReceived,onDataRequest,peerCapable,onAdvertised) pass the transport spelling (Bob). Consistent within one client, but a host comparing a callback'ssenderagainst LibGuildRoster names must normalise. Changing what consumers receive is a behaviour change to a shipped API and is the operator's call; it is on the todo list. - Suite: 697 passed, 0 failed (693 + 3 prefix-enum specs + the STATE_WAIT example). Coverage gate green at 3443/3451 (99.77%) on the same eight documented lines; the README table's three moved rows were caught by
coverage_exemptions_specand updated..luacheckrc/.luarc.jsonlearn theEnumglobal, and the five unused positional parameters the language server flagged inDeltaSync.lua's comm handlers (event,prefix,distribution) are spelled_; luacheck was already clean. - README and the CurseForge description carry the two fixes in their feature sections (the prefix report is stated as working from MINOR 20, the author rule is described for the numbered protocol), a
MINOR >= 20row in the feature-detection table, and the suite description on the client model.
[v4.2.1] (2026-09-20) - World of Warcraft: Forever, and the peer-review boards retired
No library .lua file changed; the LibStub MINOR stays at 19. A TOC line, documentation, and development tooling only.
New Features
- DeltaSync loads on World of Warcraft: Forever.
DeltaSync.tocadds16001to its## Interface:list (11509, 16001, 20506, 30405, 38000, 40402, 50504, 120007). Forever (Battle.net productwow_classic_beta, codename Camelot) is the retail client running Classic Era content, and it refuses to load an addon -- or a dependency -- whose TOC does not carry its build. Raised by FastGuildInvite (v2.14.0 ships a_CamelotTOC and hard-depends on DeltaSync); the operator confirmed a 16001 TOC publishes through the BigWigs packager and CurseForge without error. One TOC covers every flavour, so noDeltaSync_Camelot.toc. DeltaSync's own hard dependencies already declare it (AceCommQueue-1.0 and VersionCheck-1.0 in-list; GuildRoster viaGuildRoster_Camelot.toc). Not verified on a Forever client -- there is no Forever install on the development box. Two Forever properties matter to a sync library and are recorded inCLAUDE.md, not yet handled: characters have no realm (how sender names spell a player there is unpublished), and the beta forgets SavedVariables between sessions, so a host's persisted baseline is empty on every login. Location:DeltaSync.toc,CLAUDE.md.
Documentation
- README's feature-detection table now reaches MINOR 18 and 19 (it stopped at 17): the number table, numbered P2P, per-send completion and propagated deletions at 18;
broadcastExtra,OnNumbersChangedand VersionCheck registration at 19. A new "Supported game versions" section names every flavour the TOC declares. Location:README.md. - The feature sections of README and the CurseForge description were audited against the library's actual public surface (every
lib:method, read from source) and brought level with each other. Both were missing the diagnostics system (DebugStatus, categories and tags,config.logger,config.onDebugMessage, the introspection getters), the delta keying safeguards andstrictKeys, and leaf-type routing; the description was also missing the CHANNEL transport, content-hash revision 2, VersionCheck registration and the offline test suite, still wrotelib:for calls that have beenhost:since v4.0.0, and asserted a Blizzard prefix-count cap the README calls unverified (now: "if the client refuses a registration"). Location:README.md,docs/Curseforge_Description.html. CLAUDE.mdsaid VersionCheck-1.0's Interface list is identical to DeltaSync's. It is not (VersionCheck lists120100for Retail, DeltaSync120007); what the two share is38000and the rule around it. Reworded. Location:CLAUDE.md.
Development (not shipped to players)
- Both peer-review boards are retired and deleted.
docs/AUDIT.md(14 findings, 13 rounds, 2026-08-07 to 2026-08-25) andTests/HARNESS_CONTRACT.md(8 harness requests, 2026-08-04 to 2026-08-28) were imported into the writ inbox -- every thread arrived settled and was closed with its citation -- and thengit rm'd, per the operator's directive that a board left in the tree keeps being read as a live channel. Every line of both stays in git history. Findings and contracts now travel through writ's inbox; nothing is written to a board again. - The coverage-exemption table moved from the contract file into
README.md("Coverage: the eight lines deliberately left uncovered"), rewritten at current line numbers after each of the eight lines was re-read in source. NewTests/coverage_exemptions_spec.lua(9 specs) reads that table and fails, naming the row, if a recorded line no longer holds the recorded code -- verified red on a deliberate drift -- so the table cannot rot silently. Tests/run_coverage.luagates coverage at 99.76% rather than leaving the harness's 100% gate permanently red on the eight documented lines (the remedy the 2026-08-23 review agreed on: set the bar deliberately and visibly, next to the reason). The number is chosen so one more missed line fails at today's size (3432/3440);COVERAGE_MIN=100still runs the strict gate. Registered with writ as thecoverage gatesuite.- The full nine-file coverage invocation is documented; the README's command listed six files.
- A wrong todo caught before it was acted on: the plan to delete
Tests/env_delta.lua's comm section in favour of the harness's queuedC_ChatInfoecho would have broken the suite -- that section is a multi-client router (GUILD and WHISPER between named peers, per-client LibGuildRoster identity swap) and the harness echo models one client and never echoes WHISPER. Recorded in the bank; the design question (should the harness carry a multi-client router?) is with WoWAPITesting on the contracts channel. A second contract asks the harness to carryM.libPathinenv/guild.luasoTests/env_guild.luacan be retired. - Suite: 693 passed, 0 failed (684 + the 9 exemption specs). Coverage unchanged at 3432/3440.
[v4.2.0] (2026-09-16) - VersionCheck, and the numbered protocol's three delivery rules (MINOR 19)
Adopting addons: feature-detect with DS.MINOR >= 19. Additive on the wire: no new message type, no new prefix, still seven. A MINOR 18 and a MINOR 19 peer interoperate. The three numbered-protocol changes were raised by TOGBankClassic on the LIBREQ-DS-008 thread (2026-09-15), each shipped there as a host-side stand-in with a delete-on-delivery condition; this release is that delivery.
New Features
- DeltaSync reports its version through VersionCheck-1.0. The user's ask: "onboard deltasync to VersionCheck. that means a hard dep on ace3 and VersionCheck".
DeltaSync.tocnow declares## Dependencies: Ace3, VersionCheck-1.0, AceCommQueue-1.0, GuildRosterand.pkgmetaadds theversioncheckslug (the CurseForge slug, never the folder nameVersionCheck-1.0-- GuildRoster's upload was rejected for exactly that, errorCode 1018). No load cycle: VersionCheck's TOC does not name DeltaSync. The registration lives in a new ninth TOC file,DeltaSyncVersionCheck.lua, which is not a library file: it touches noDeltaSync-1.0state and a consumer vendoring the library never lists it, so only the installed DeltaSync addon reports a version -- an embedded copy never answers on its behalf. AtPLAYER_LOGINit callsVC:Enable("DeltaSync"); the string form makes VersionCheck read## Version:itself, raw, so a dev checkout reportsDeltaSync-v4.6.2and keeps its update popup suppressed. Verified in game by the operator: DeltaSync has its own tab in/vc, and the dev checkout's row readsDeltaSync-v4.6.2. Location:DeltaSyncVersionCheck.lua,DeltaSync.toc,.pkgmeta. - Numbered P2P: a key a broadcast leaves out is offered back. A broadcast lists what its sender can SERVE, so a key absent from it is one the sender holds nothing for -- wipe recovery, a fresh install -- and the old rule (offer only listed keys we hold strictly newer) could never offer it.
NP:OnBroadcastnow also offers every servable numbered key the broadcast did not list, merged into the samehash-offer2whisper, and only to a sender that passespeerCapableandisValidPeer(its ownOnOfferrefuses anyone else at the door). Location:DeltaSyncP2PNumbered.lua. - Numbered P2P: the number table goes before the offer to a broadcaster behind on it. Whenever an offer goes back and the broadcaster's
vis below ours,numbers:HandleRequestis sent first, unasked -- the reply its own request would draw a moment later. Location:DeltaSyncP2PNumbered.lua. - Numbered P2P:
broadcastExtra()for catch-up broadcasts. The library starts catch-up broadcasts itself, so they went out without theextrafields a host passesBroadcastNumbered(TOGBank's addon version). New optionalInitP2PconfigbroadcastExtra() -> table, called at send time -- never a cached copy of an earlierextra, whose fields can move. Location:DeltaSyncP2PNumbered.lua.
Bug Fixes
- A bare offer naming numbers our table could not resolve was dropped. On a real wire that is the common case for a client behind on the table: the offerer sends its table (five chunks, HANDSHAKE) then the offer (one chunk, OFFER), and ChatThrottleLib round-robins the two prefixes a chunk at a time, so the offer ARRIVES first. Measured by TOGBankClassic on its throttled fleet: table +10..+13 s, offer +11 s -- dropped, the collect window closed empty, and the sync waited for the catch-up broadcast at +111 s.
NP:OnOffernow PARKS the unresolved numbers (p2p.parkedOffers[sender] = { v, numbers, at }, one park per sender, replaced by that sender's next offer, cleared by a fully resolvable one), andDeltaSyncNumbers.lua's change notification -- every mint and adopt, after the host's ownonChanged-- callsp2p:OnNumbersChanged(reason), which replays the now-resolvable numbers throughOnOffer(recorded inside a window, acted on at once after one), keeps the remainder, and drops a park older thanQUEUE_TTL. A refused peer parks nothing: parking runs after the gates. Location:DeltaSyncP2PNumbered.lua,DeltaSyncNumbers.lua. onOfferReceivedfired AFTER P2P had acted on the message, contradicting the library's own documentation ("fires before P2PSession processes the offer"). For the numbered protocol that meant a peer's FIRST broadcast was judged bypeerCapablebefore the host had read theaddonversion that broadcast carries -- so it could raise the host's caches and dispatch to a release unable to serve the data leg, a session that then sits outDELIVERY_TIMEOUT.OnComm_OFFERnow fires the hook first, for both protocols. Because it now runs first it ispcalled: a raising hook is reported throughgeterrorhandler()and P2P still acts. TOGBankClassic is the only consumer of the hook in the tree. Behaviour change for anyonOfferReceivedconsumer: a hook that assumed the P2P instance had already recorded the offer when it ran no longer can. Location:DeltaSync.lua.
Improvements
NP:OnNumbersChanged(reason)is a stable, feature-detectable name. TOGBankClassic adopted all three delivery rules from this release's working tree, deleted its stand-ins for them, and now feature-detects this method as its proxy for all three -- warning its players, once per session, that their DeltaSync is out of date when it is absent (CurseForge's required dependency guarantees DeltaSync is installed, not current). The three rules therefore ship together in this one release, and the method's name is marked in the source as a consumer contract. Other consumers wanting the same check can use it, orDS.MINOR >= 19. Location:DeltaSyncP2PNumbered.lua,README.md.- Test environment:
C_ChatInfo.SendAddonMessageLoggedis installed. Classic Era has it (ChatInfoDocumentation.lua), and real AceComm's ChatThrottleLib hooks it at load withhooksecurefunc, which errors on a nil target -- so any spec loading a real Ace3 comm library died before it started. Found by the VersionCheck spec, which loads the real VersionCheck-1.0. Location:Tests/env_delta.lua. - Suite: 684 passed, 0 failed. New:
Tests/versioncheck_spec.lua(4, against the real VersionCheck), 12 inTests/numbered_p2p_spec.lua, 1 inTests/loadorder_spec.lua. Each numbered-protocol fix was switched off and its specs watched fail. Coverage 100% onDeltaSyncP2PNumbered.lua,DeltaSyncNumbers.luaandDeltaSyncVersionCheck.lua;DeltaSync.lua's six uncovered lines are the documented-unreachable ones, none new. - Adopted the WoWAPITesting harness pin
3bff17d(2026-08-22) ->6d14774(2026-09-16). 6d14774 is the harness's answer to DeltaSync's request: itsenv/libs.luaentry forDeltaSync-1.0now declares the VersionCheck-1.0 dependency and leavesDeltaSyncVersionCheck.luaout of the library's files on purpose (the eight library files were already there on the harness side since90530f3). Every Adoption log entry since the old pin reads "nothing" or "run your suite" for a consumer shaped like DeltaSync;Tests/env_delta.luainstalls its own chat and comm stubs over the harness's, so the chat-window changes do not reach it. Full suite on the new pin: 684 passed, 0 failed. - The LibStub MINOR assertion in
Tests/smoke_spec.luamoves to 19.
[v4.1.0] (2026-09-15) - The numbered P2P protocol, deletions that propagate, per-send completions, the peer-review fixes (MINOR 18)
Adopting addons: feature-detect with DS.MINOR >= 18. Every change is additive; a MINOR 17 peer and a MINOR 18 peer interoperate, with the one mixed-build note under "Deletions" below. Two new optional modules; every existing consumer is unaffected -- the hash protocol in P2PSession.lua is unchanged and a host that never calls InitNumbers / InitP2P({ mode = "numbered" }) loads byte-identical behaviour. Still seven prefixes: every new message type rides OFFER or HANDSHAKE.
New Features
- Item numbers (
DeltaSyncNumbers.lua,host:InitNumbers). TOGBankClassic'sBankerNumbersgeneralised to any string key: every eligible key gets a 4-digit number, for life, minted alphabetically fromnumbersNextby clients the host says may mint (so two minters produce the identical table), synced guild-wide by version -- newer wholesale; equal-and-different only from the lower-sorting sender, with the loser re-minting under a higher version -- overnumbers-request/numbers-replyon HANDSHAKE. Storage is the host's: three fields on the tableconfig.table()returns. Fixed-width wire codec (<4-digit number><20-digit canon>, torn strings decode to nothing, never to a prefix).CanonTime(canon)returns nil, not 0, for a canon with no readable time, so a version-less claim never sorts as "oldest" (TOGBank's request). 36 specs lifted frombankernumbers_spec.lua, including two clients converging over the real wire. Location:DeltaSyncNumbers.lua. - Numbered P2P (
DeltaSyncP2PNumbered.lua,host:InitP2P({ mode = "numbered" }),host:BroadcastNumbered). TOGBank's P2P-035 protocol, generalised, as a second P2P class selected per host. The rules, each an operator directive read off a live guild and each pinned by a run: the wire names items by NUMBER (hlb2entries,hash-offer2bare numbers); a peer offers only when it holds a strictly newer canon; a bare offer is followed by a version query (ver-query/ver-reply, up to 5 offerers, 5s window, the author answering ends it) before anyone is asked for data; dispatch goes to holders of the newest canon only and, if they are all busy, the requester waits in their queue (sync-queued { position }) rather than falling back to an older copy;sync-busy { reason = "version" }means "not this candidate" and never "wait here"; a provider serves when it holds the asked canon or newer; at capacity it queues rather than refuses; an accept never followed by the requester's QUERY releases afterSTATE_WAIT(30s); an accept the requester cannot use is answeredsync-cancelso the slot goes back at once; the only fetch cap is per peer (1). Hearing anyone's broadcast advertise a newer canon dispatches to that sender directly. Host hooks:servableCanon/onDeliver(required),heldCanon,canServe,canonImproves,isOwnKey,isValidPeer,peerCapable(name) -> ok, why(a callback, at TOGBank's request -- the verdict is consumer memory the library cannot have), and the observersonAdvertised(key, canon, peer)(every message naming a canon, own keys included),onNewerOffered/onNewerCleared,onSelfConsulted;IsSelfConsulted()/MarkSelfConsulted()for a shared account on several PCs. Provider data leg:host.p2p:QueryArrived(requester, key)claims the accepted slot for the reply;ServeReply(...)releases it when the reply has drained (DS-009). Every timing constant is config-overridable. Aver-replyechoes the numbers it was asked (n) so a reply about one key cannot settle a separate outstanding query about another key to the same peer -- a defect present in the reference implementation; a reply withoutnis read the old way. 63 specs, three-peer scenarios over the loopback network, 100% line coverage. Location:DeltaSyncP2PNumbered.lua, routing inDeltaSync.lua(OnComm_OFFER,OnComm_HANDSHAKE), class selection inP2PSession.lua(InitP2P). - Per-send completion (LIBREQ-DS-009, raised by TOGBankClassic).
onSendFailedreported refusals only andsendsDeliveredwas a counter with no identity, so a host metering its own sends -- a P2P provider that must give a slot back when the reply has drained, not when it was queued -- had to proxyaceAddonto find out. Two additive shapes, sameinfo: an optional trailingonComplete(info)onSendMessage/RequestData/SendData, and a host-wideconfig.onSendComplete(info).info = { prefix, channelType, distribution, target, bytes, at, verdict, reason, unverified };verdictis exactly one ofdelivered/refused/not-attempted. Exactly one completion per send under both callback shapes (a per-sendcompletedflag on the context that ridesSendCommMessage's arg 7). Sends DeltaSync declines before the transport report too, asnot-attemptedwith a reason naming the guard (target-offline,unknown-channel,no-channel-target,no-send-api) -- a completion that fires for some refusals and not others was the trap the contract named. The CHANNEL transport and the raw-API fallback have no delivery callback, so their verdict is read from the synchronous return and flaggedunverified = true. A raising host callback is routed togeterrorhandler()and does not take the queue down. Location:DeltaSync.lua(CompleteSend,SendMessage,RequestData,SendData).
Bug Fixes
All from the peer-review board (docs/AUDIT.md, rounds 8-12), each confirmed red by a spec before the fix.
- A deleted field never propagated (finding 7, HIGH). Lua cannot store
nilin a table, so a top-level field the sender removed was detected byComputeStructuredDeltaand then discarded bydelta.changes[field] = nil; a field removed from a surviving array record was never visited, becauseGetChangedFieldswalked only the new record. The receiver kept the stale field forever and both sides believed they were in sync -- silent permanent divergence, and the compute log said "0 field(s) changed". The format now has a tombstone at both levels: a structured delta carries a top-levelremoved = { fieldName, ... }besidechanges, and amodifiedarray entry carries_removed = { fieldName, ... }(lib.REMOVED_FIELDS_KEY).ApplyStructuredDeltanils the former;ApplyArrayDeltalifts the latter off a copy of the entry (so one delta applied to two targets keeps its deletions both times) and applies it aftermergeFuncreturns, so a custom merge never sees the library's marker. Both lists are omitted when empty, so a delta without deletions is byte-identical on the wire to MINOR 17's.ValidateDeltachecks the list;GetDeltaStatscounts it, so a deletions-only delta no longer reports zero changes. Mixed builds -- KNOWN COST: an older receiver ignoresremovedand its default merge copies_removedonto the record as an inert field -- it keeps the stale field either way, exactly as before. So a pre-18 client keeps disagreeing with upgraded peers on that record's hash and re-syncing it until it upgrades -- as it already did for any deleted field before this release: the stale field alone produced that divergence, and the inert_removedkey adds nothing to it. If the host's data leg delivers full records, one fetch overwrites both and the cost is a single extra cycle. Gone the moment every client is on MINOR 18+. Specs:Tests/deletion_spec.lua. Location:DeltaOperations.lua. keyFuncwas documented asfunction(obj, index)and called with one argument on three paths (finding 8, LOW). The index is only ever supplied when the record sits in an array being scanned; for a reducedremoved/modifiedentry on apply it isnil, so an index-derived key computed differently on the two sides and removals silently never matched. Positional identity is not stable across clients anyway, so the contract is now stated rather than the index threaded: key from the record's fields only. Location:DeltaOperations.lua,README.md.pendingDispatchgrew without bound under sustained cap pressure (finding 9, MEDIUM). An item queued past the session cap has nosessionsByItementry, so every laterDispatch()rebuilt it intoaltListandDispatchListappended it again -- one more copy per catch-up cycle through a long raid night with one slow provider. The flush-time skip kept correctness; the table grew. One funnel,P2P:_QueuePending, now de-duplicates byitemKeyon both the at-cap and partial-slot paths, and a re-offer replaces the queued entry so the freshest candidate list dispatches. Location:P2PSession.lua.ScheduleCatchUpwas the lastC_Timer.Afterin the module (finding 10, LOW). It held a boolean sentinel instead of a handle, so a scheduled catch-up could not be cancelled by anything, against three documented conversions toNewTimerin the same file. Now a real handle with the same truthiness semantics. Location:P2PSession.lua.HandleSyncRequestacquired a send slot with no eligibility gate (finding 11, MEDIUM-HIGH). The one inbound P2P path withoutisValidPeerwas the only one that commits a resource: three well-formed whispers from one ineligible sender filled the outbound cap (maxActiveSends, default 3) for the 90-secondSEND_TIMEOUT, and every legitimate peer was answeredsync-busy. The gate now runs first, and rejects withsync-busyrather than silence so a legitimate peer on a stale roster advances to its next candidate instead of waiting outDISPATCH_TIMEOUT. Location:P2PSession.lua.ReleaseSendSlotleaked a slot when the host spelled the name differently (finding 11a, MEDIUM). Slots are acquired under the transport sender -- bareFooon a same-realm whisper -- while a host typically holds LibGuildRoster's canonicalFoo-Realm; the<= 0guard turned the mismatch into a silent no-op and the slot leaked untilSEND_TIMEOUT, stalling a host's outbound path in three sends, every time.P2P:_ResolveSlotKeynow matches the exact key, then the canonical form, then the bare name; a release that matches nothing is logged. Location:P2PSession.lua.- Aliased sub-tables hashed as "circular" (finding 12, MEDIUM).
SerializeForHash'sseenset held every table ever visited, so it detected revisits, not cycles:{ x = T, y = T }hashed differently from{ x = T, y = copy(T) }over identical content, and two peers offered each other data across a difference that did not exist. Revision 2's scalar type tags could not reach a table-level property, sohashV2failed the same way (both pinned red byTests/hash_aliasing_spec.lua). The traversal now tracks the ancestor path and clears each table on the way out. Revision 1 is frozen and this respects that: output changes only for inputs whose hash was already unstable; a plain tree's value is pinned by a literal in the spec. Location:DeltaSync.lua. - The trust boundary is written down (finding 13, MEDIUM). Three layers gated inbound traffic three ways with nothing saying where the line fell, which is what made finding 11 look arguable. The
P2PSession.luaheader now states the rule the code follows: the transport forwards and the host gates, except where the library itself commits a resource or discloses what it holds -- there it gates withisValidPeer, the default chosen from what the disclosure is worth. The P2P and RosterSyncisValidPeercallbacks are named as deliberately separate. - A CHANNEL prefix with no channel name listened to every channel (finding 14, MEDIUM-HIGH).
not info.channel or <match>read a missing name as a wildcard -- General, Trade, anyone's custom channel -- so one public message could drive anOnComm_*handler on every listening client at once. Reachability, checked rather than assumed:InitializeandSetChannelConfigalready refused an omitted name, so the live gap waschannel = ""(whichnot ""let through) and achannelConfigmutated after init. Now: the validator requires a non-empty string; the module refuses to register a nameless prefix and prints why; a wildcard must be spelledchannel = "*"so it is a decision somebody made. Location:DeltaSyncChannel.lua,DeltaSync.lua.
Improvements
- Send-slot accounting is ONE implementation shared by both P2P classes (
lib._SendSlotMixin, defined inP2PSession.lua, copied onto both):GetActiveSendTotal,TryAcquireSendSlot,ReleaseSendSlot,_ResolveSlotKey,_DecrementSendSlot, and the defaultisValidPeer. The numbered protocol was first written with a second copy of each; the session's self-audit filed it as the shape that let finding 11a happen once (a fix landing in one copy and not the other). The mixin honours the numbered class'sservingSendsand calls itsServeQueuewhen present.Tests/sendslots_spec.luadrives the same slot behaviours through BOTH classes in one describe, so a divergence goes red whatever shape the code takes next. Location:P2PSession.lua,DeltaSyncP2PNumbered.lua. OnComm_HANDSHAKEpassesdata.canon(4th argument) toHandleSyncRequestanddata.reasontoOnSyncBusy. The hash protocol ignores both; the numbered one reads them.sync-queued,sync-cancel,ver-query,ver-reply,numbers-requestandnumbers-replyare feature-detected on the P2P instance, so a hash-protocol host ignores traffic it does not speak. Location:DeltaSync.lua.- Wrath interface level
38000is RETAINED -- it is the Whitemane private-server build. A fleet-wide TOC review briefly moved it to38002in the working tree (2026-08-23), on the strength of what Blizzard-targeting addons ship; the operator reversed that on 2026-09-10, before release, because38000is what Whitemane reports and38002would flag the library out of date on the one server the value exists for. The same38000inVersionCheck-1.0.tocandGrouper_Wrath.tocwas right all along.38000must not be "corrected" against a survey of Blizzard-targeting addons -- a private server's build number is not derivable from them. Location:DeltaSync.toc.
Documentation
- New README section: "Canonical hashes -- compute once, at save, and never again." The existing hashing docs covered how; this covers when, which matters more. A hash is the identity of a version of a record, not a checksum to recalculate on demand -- so it is generated once, by the client that authored the change, at save time, with the datestamp inside the hashed input; stored on the record; and forwarded by every other path (advertising, querying, answering, receiving). A receiver stores the author's hash verbatim even if it could recompute it. Ordering is a separate question from identity and is decided at apply time from the timestamp inside the record that arrived. The section gives the failure mode for each rule, a worked example, and a self-check: grep for assignments to the hash field, and there should be exactly one. Getting this wrong produces the worst class of bug this library can have -- a sync that reports itself healthy while clients run different data. Location:
README.md. - Coverage invocation repointed from the deleted
Tests/coverage.luatoTests/wowapi/coverage.lua. Location:README.md. - Canonical-hash rules 3 and 6 rewritten to say what they meant (raised by TOGBankClassic while adopting them). Rule 6 forbade sending the datestamp as a separate field while the worked example two paragraphs below did exactly that, and rule 7 needs it at apply time -- a hash is one-way. It now forbids a second version identity that can drift from the hash; a timestamp stamped in the same save travels with it. Rule 3 required the datestamp inside the hashed input, which is circular for a design where the content hash is also the change detector ("only mint a version when the content moved"): every save became a new version, i.e. a banker closing an unchanged bank republished guild-wide. It now states the intent -- the identity must distinguish two publishes of identical content -- and gives both spellings that satisfy it, including the composite
<publish time><content hash>canon. Location:README.md. - The 38000 rule is in
CLAUDE.md, next to the versioning rules, so the next reviewer who counts sixty*_Wrath.tocfiles stops before filing.
Development (not shipped to players)
Peer-review board answered and closed. Findings 7-14 (above) fixed with specs; the board itself is now frozen history under writ and every open thread was moved to the inbox.
docs/AUDIT.mdis tracked as of this release (finding 6). Suite: 667 passed, 0 failed across eight library files; coverage at the eight documented-unreachable lines (tworeturn-after-error()lines were deleted rather than excused). New spec files:deletion_spec,hash_aliasing_spec,numbers_spec,numbered_p2p_spec,sendslots_spec.Adopted the WoWAPITesting harness pin
c46cb89(2026-08-02) ->3bff17d(2026-08-22), 20 days of drift. Full suite re-run on the new pin: 509 passed, 0 failed, 0 pending.Tests/env_delta.lua's comm section is deliberately kept: the new pin carries a synchronousSendAddonMessageecho, which arrives beforeBroadcastItemHashesopens its collect window and would be dropped. The queued-echo mode that fixes this is not yet released upstream.Deleted
Tests/coverage.lua, a stale fork of the harness's tool whose argument contract had diverged: ours took spec files positionally, the harness's takes them after--, so following the documented invocation against the local copy silently measured the wrong thing.wow-version-replication.ps1: folder ignores never matched, and neither did most dotfiles. A bare.pkgmetaentry such asTestscompiled to^Tests$, which matches only a file of that name, soTests/,docs/andtextures/were listed as ignored, reported as loaded at startup, and replicated into every other WoW install anyway. That stopped being cosmetic onceTests/wowapibegan shipping a loadable addon of its own. Separately, the always-skip list enumerated four git dotfiles individually with no general rule, so.vscode/tasks.json(which launches the watcher itself),.claude/,.github/,.bustedand.luacheckrcall replicated. Both fixed and both confirmed by-DryRun(the dotfile half on 2026-09-15: every dotfile,Tests\,docs\andtextures\path reports[skip]; only the shipped files reportWOULD).
[v4.0.3] (2026-08-03) - A refused send is no longer invisible (MINOR 17)
Adopts the integration contract AceCommQueue-1.0's README sets out for "a library sitting between a host addon and this queue" — which is exactly DeltaSync's shape.
Bug Fixes
- DeltaSync never checked whether its sends actually went out.
SendMessagecalledaceAddon:SendCommMessage(prefix, message, distribution, target, priority)with no delivery callback. WoW silently discards addon messages under congestion, and AceComm-3.0 forwards only ChatThrottleLib'sdidSendboolean — so a refused message simply vanished. With no callback supplied, AceCommQueue-1.0 reports the refusal itself throughgeterrorhandler(): correct, but it attributes the failure to the comm layer rather than to DeltaSync, and neitherDebugStatusnor the host had any structured way to see it. For a sync library that is the worst possible blind spot — a refused HANDSHAKE or DATA send stalls a P2P session until the 180-second delivery watchdog reclaims it, with nothing anywhere saying why. Location:DeltaSync.lua. - A partially-refused multipart send was recorded as delivered and failed. Only reachable in the unqueued shape, and worth spelling out because it is exactly the trap the contract warns about: when the host has not embedded AceCommQueue the callback fires once per chunk, so a 5-chunk send whose 3rd chunk is refused still ends with a final chunk reporting
true. Counting that as success recorded one message as both outcomes. A partial multipart stream reassembles into a corrupt payload on the receiver, so the only correct reading is that it did not arrive. Found by tracing the unqueued path while verifying the fix above, not by the first draft of it. Location:DeltaSync.lua.
New Features
- Delivery verdicts are recorded and surfaced.
host.sendsDelivered,host.sendFailures, andhost.lastSendFailure({ prefix, channelType, distribution, target, bytes, at }). A refusal is logged as a plain statement — "[NOT DELIVERED] … the message did NOT arrive" — rather than a soft warning, because that is what it means. Location:DeltaSync.lua. - A send that was never attempted is counted separately, not lost.
host.sendsNotAttemptedandhost.lastSendNotAttempted. Thenilverdict covers three situations — the host's own wrapper suppressed it, the queue rejected a bad argument, or the send raised — and an earlier draft of this release recorded none of them, so such a send simply vanished from the counters. Raised by AceCommQueue-1.0's maintainer while reviewing this work. Location:DeltaSync.lua.nildeliberately does NOT feedsendFailures. A suppression is one of the host's own wrappers working exactly as designed (a raid guard, say); counting it as a failure would show an addon a forever-climbing error count for behaving correctly. Trading a blind spot for a false reading would have been worse than the blind spot.- The optional 5th callback argument (
reason), shipped in AceCommQueue-1.0 v1.0.5, is read nil-guarded and decides which bucket anilverdict lands in:"rejected"(the queue refused the call — bad prefix, nil text, unknown priority) and"error"(the send raised) mean the message did not arrive. They are counted assendFailuresand fireonSendFailed, exactly like a refusal, and are logged as a defect — DeltaSync generates its own prefix and validates distribution and priority at config time, so"rejected"should be impossible."suppressed"is the host's own wrapper working as designed. It stays out of the failure count.- So the rule is
reason ~= "suppressed", notdelivered == false— which would miss the first two entirely. That correction came from AceCommQueue's maintainer after reviewing our first cut, where all threenilcases were filed together as benign. - Against a queue too old to supply a reason the send is still counted as not-attempted, and the log says the reason was not reported rather than inventing one. Both paths are covered by specs.
host.lastSendFailurenow also carriesreasonwhen the queue supplies one.
config.onSendFailed(info)— fires when the client refuses a send, so a host can re-send, degrade, or tell the user instead of assuming delivery. Optional; omitting it changes nothing. Location:DeltaSync.lua.DebugStatusreports all three delivery counters (sends: delivered=N refused=N not-attempted=N), the last refusal, the last not-attempted send with its reason, and whether the host embedded AceCommQueue at all. The last one reads__AceCommQueue_embedded, which AceCommQueue documents as part of its contract rather than an internal. It is worth showing because an unqueued host is precisely the one exposed to the chunk-interleaving corruption the queue exists to prevent — and because it determines how the delivery callback behaves. Location:DeltaSync.lua.
Known Interaction (not fixed in this release)
P2PSession's 180sDELIVERY_TIMEOUTwas sized against pre-v1.0.5 AceCommQueue behaviour. Its comment justified the number with an observed ~70s worst-case queue drain at 3 concurrent sends. AceCommQueue-1.0 v1.0.5 now retries a refused message with a doubling backoff (1+2+4 = ~7s) and holds its queue for the duration — deliberately, so ordering survives and a refusing channel is not hammered. Under sustained refusal that compounds: roughly 16 refused messages on one key adds ~112s to the 70s baseline and reaches the watchdog. Raised by AceCommQueue's maintainer, who marked the 7s[LIB-CLAIM]and the interaction[READ]— they have not run our P2P suite against it, and neither have we under real load. The number has deliberately not been re-tuned: the right value needs a real measurement under the new behaviour, and guessing a larger one would only move the cliff. The comment atP2PSession.luanow records the caveat instead of citing a measurement that no longer holds. The better fix is to stop waiting for the watchdog at all — MINOR 17 gives a delivery verdict per send, so a session whose QUERY or DATA was refused could fail immediately rather than 180s later. That is a behaviour change and is not in this release.
Compatibility
- Additive. No API removed, renamed or reordered; no wire-format change; sending behaviour itself is unchanged. A consumer that does nothing gains the diagnostics for free and needs no code change.
- Both AceCommQueue callback shapes are handled, per its documented contract: one terminal callback carrying the whole-message verdict when the host embedded the queue, one callback per chunk when it did not. A check written for one shape misbehaves under the other, so the suite models both —
Tests/comms_spec.lua"delivery verdicts" (16 specs) proves a refused 5-chunk send is counted exactly once either way, that a partial refusal is never also counted as delivered, and that all three verdict states are handled:true(accepted),false(refused — it did not arrive), andnil(never attempted), which gets its own counter rather than being folded into either. Thenilpath is covered both with and without the optionalreasonargument, so the behaviour is pinned against the AceCommQueue that ships today and the one that supplies it. - Already compliant with the contract's other requirement: DeltaSync never calls
SetRetryPolicyorSetDebug. Those live on the shared LibStub table and are session-global, so a library in the middle changing them would alter behaviour for unrelated addons that never asked. Retry policy belongs to the host.
Documentation
- The README is now written for any consumer, not just the addons that happen to embed it today. Three places still claimed MINOR 16 / v4.0.2; the adoption checklist opened by assuming the reader was already a consumer, with no pointer to Quick start for a new one; and the
RegisterDebugCategoryexample used a bank-specific category ("BANK"/ bag scans / mail parsing) that reads as if the library knows something about inventories. It does not — it moves opaque tables between guild members and calls you back, and the README now says so where a new consumer will actually see it. Location:README.md. - Feature detection is now a table rather than a sentence, covering MINOR 12 → 17 (the previous list stopped at 15, so nothing pointed at
MakeHashEntry,RegisterDebugCategory,strictKeys, or this release's delivery verdicts). It also states the reason detection matters at all: the highest MINOR present in the client serves every consumer, so the revision you shipped against is not necessarily the one you get. Location:README.md. - The "behaviour changes you get whether you adopt anything or not" section now carries v4.0.3 as well as v4.0.2 — delivery accounting populating with no config change, a refused send being logged as a plain statement (existing addons broadcasting on
GUILDwhile guildless will start seeing something that was always happening), and the deliberately un-retuned 180s delivery watchdog flagged for anyone driving heavy P2P traffic. Location:README.md.
LibStub MINOR
- Bumped from 16 → 17. Feature-detect with
DS.MINOR >= 17, orif host.lastSendFailure ~= nil or host.sendsDelivered then.
[v4.0.2] (2026-08-03) - Offline test suite, five silent-failure fixes, hash revision 2 (MINOR 16)
Consumer Requirements (TOGBankClassic docs/LIBRARY_CONTRACTS.md)
TOGBankClassic audited all four TOG libraries before migrating onto them and filed LIBREQ-* tickets. Every DeltaSync-side blocker it named is addressed here; the ticket ids are kept so both repos can cross-reference.
LIBREQ-DS-001(timers) — fixed; see Bug Fixes below. Their acceptance criterion ("a spec asserting a cancelled timer's callback does not run") is met bysmoke_spec.lua.LIBREQ-DS-004(P2P-024, stale safety release) — fixed; see Bug Fixes below.LIBREQ-DS-005(silentDefaultKeyFuncaddress fallback) — fixed; see Bug Fixes below.LIBREQ-DS-006(keyFields⊇keyFunc) — fixed; see Bug Fixes below.LIBREQ-ALL-005(check prefix registration) — implemented in DeltaSync rather than the host. The ticket proposed AceCommQueue or the host on the grounds that AceComm discards the result, but DeltaSync callsC_ChatInfo.RegisterAddonMessagePrefixitself inRegisterCommChannels, so it is the right place. See Improvements.LIBREQ-ALL-002(offline testability) — met: the suite runs on the shared WoWAPITesting harness, and the gaps it needs are written up inTests/HARNESS_CONTRACT.mdrather than stubbed privately. Their item 5 ("a test suite should precede the migration") is likewise met.- §3.10 (cyclic
ObjectsEqual) — fixed; a self-referential record hung the client. LIBREQ-DS-002(prefix/protocol transition) — no library change needed. Their recommended option C (one host, requests riding DATA with a type discriminator) is already supported byRegisterLeafType, which routes on a payloadtypefield; option B works too via a second host. This is TOGBank's sequencing decision, not a library gap.LIBREQ-DS-003(logger ownership) — still open, needs a decision. Their preference — DeltaSync accepting an injected logger rather than owning a chat frame — is the cleaner boundary and is not yet implemented.- §3.10 (duplicate keys within
newArray) — acknowledged, not yet addressed.
Bug Fixes
LIBREQ-DS-004/P2P-024— a completed send's safety timer released a LATER send's slot.TryAcquireSendSlotarmed an unconditionalC_Timer.Afterwhose comment claimed it fired "ifReleaseSendSlotwas never called" — it fired either way. When a send completed normally the real release ran, then the stale timer fired later and decremented a slot belonging to a different, still-in-flight send from the same requester. That is an over-release rather than an underflow, so the> 0guard never caught it andMAX_ACTIVE_SENDScould be quietly exceeded under sustained load. Each safety timer is now aNewTimerbound to the acquisition that scheduled it, retired FIFO by the matching release — so the timers still guarding live sends keep their own deadlines and a genuinely leaked slot is still reclaimed on schedule, not early. The inaccurate comment is corrected too. Location:P2PSession.lua.LIBREQ-DS-005— the default key function fell back to a table ADDRESS in silence. With nooptions.keyFunc, records carrying none ofid/ID/key/namewere keyed bytostring(obj). Addresses are stable neither across sessions nor between clients, so every record keys uniquely, every diff reports everything added and everything removed, and the consumer full-resyncs forever — with no error. Positional records ({id, count, suffix}, exactly what TOGBank's V2 rework uses) hit it immediately. The fallback now reports itself once per host through both the debug log and chat, andoptions.strictKeysmakes it an error instead. Location:DeltaOperations.lua.LIBREQ-DS-006—keyFieldssilently had to cover every fieldkeyFuncreads.ComputeArrayDeltareduces a removed entry to justkeyFields, butApplyArrayDeltathen callskeyFuncon that reduced entry. If the reduction dropped a field the key depends on — akeyFuncover(id, suffix)withkeyFields = {"id"}— the key computed on apply differed from the one computed on compute, the removal matched nothing, and deleted items lingered forever with no error. Compute now compares the reduced entry's key against the full record's and reports a mismatch (or errors understrictKeys). Location:DeltaOperations.lua.- A cyclic record hung the client.
ObjectsEqualrecursed with no cycle detection, so a self-referential table recursed until the stack blew. It now carries a seen-pair guard. Location:DeltaOperations.lua. - Timer cancellation never worked —
C_Timer.Afterreturns nothing, so every:Cancel()inP2PSession.luawas a silent no-op. Four timers were created withC_Timer.Afterand their result stored for later cancellation, but onlyC_Timer.NewTimerreturns a handle carrying:Cancel(). The stored value was alwaysnil, so each cancel sat harmlessly behind anif timer thenguard and did nothing — which reads as correct code right up until you look at what the API actually returns. All four now useC_Timer.NewTimer. Location:P2PSession.lua.- The user-visible half of this: extending the offer collect window did not extend it.
BeginCollectWindowon an already-open window is meant to push the deadline back so late-arriving offers still count. The failed cancel meant the original timer survived and a second was stacked on top, soDispatch()fired at the original deadline anyway — cutting the window short and discarding offers that arrived in the extension — and then fired a second time later with the window already closed. On a busy login, where hash-list broadcasts arrive in bursts and repeatedly re-open the window, this cost real offers and doubled the dispatch work. - The other three (dispatch timeout, delivery watchdog, retry cycle) were harmless in effect — every callback re-checks live session state and no-ops — but each leaked a timer that stayed queued for its full duration (up to 180s) after the session it belonged to had completed.
- The user-visible half of this: extending the offer collect window did not extend it.
DeltaOperations.lua's load-order guard could never fire. It resolved the library withLibStub:GetLibrary(MAJOR)— the non-silent form, which raises LibStub's own generic "Cannot find a library instance" before theif not libcheck is reached. An embedder with a bad TOC order got the vague message instead of this file's actionable one. Now uses the silent form, matching the other four modules. Location:DeltaOperations.lua.
Testing
- Added a full offline unit-test suite: 509 specs, 99.54% line coverage across all six library files, run locally with a Lua 5.1 interpreter in milliseconds — no game client, no LuaRocks, no C modules. Built on the shared WoWAPITesting harness, added as a git submodule at
Tests/wowapi. Location:Tests/. - The suite loads the REAL AceSerializer-3.0 and the REAL LibGuildRoster-1.0 from the sibling AddOns folders rather than stubbing them — the exact code that ships to players, so an integration bug cannot hide behind a lookalike.
- A loopback AceComm network (
Tests/env_delta.lua) lets two or three DeltaSync hosts in one Lua state genuinely talk to each other, sointegration_spec.luadrives complete broadcast → offer → handshake → deliver → apply cycles and asserts on the data that lands in the receiver's model. It also swaps per-character LibGuildRoster state around each peer's turn to run, which is what makes a true cross-guild RosterSync exchange testable. - The environment models the WoW API faithfully rather than conveniently.
C_Timer.Afterreturns nothing (onlyNewTimeryields a handle) and a GUILD addon message is echoed back to its own sender. Both are deliberate: the first is what surfaced the timer defect above — a convenience stub returning a fake handle would have made every broken cancel pass — and the second is what exercises the self-ignore guard opening everyOnComm_handler. Tests/coverage.luareports exact line coverage with no dependencies, taking the executable-line set from Lua 5.1 bytecode debug info rather than guessing from source text. Extended over GuildRoster's copy to accept multiple targets so the whole library is measured in one spec run.- The ten lines left uncovered are unreachable by construction (a
returnaftererror(), guards whose callers already guarantee the value, a dead branch shadowed byP2PSession.lua's override oflib:InitP2P) and are enumerated individually inTests/HARNESS_CONTRACT.mdrather than papered over.
New Features
- Hash revision 2 fixes the
ComputeHashtype collision, and ships alongside revision 1 so mixed-build guilds keep working — no flag day, nothing to coordinate. Revision 1 renders a number withtostring()and a string as itself, so{v = 1}and{v = "1"}produce identical hashes, as dotrueand"true". A consumer storing a value as text therefore looks unchanged to a peer storing it as a number, and the sync is skipped. Revision 2 prefixes each scalar with its type (n:1vss:1). Locations:DeltaSync.lua,P2PSession.lua.lib:ComputeHashV2(value)— the corrected hash.lib:ComputeHashis unchanged and now explicitly frozen: its output is compared between clients, so altering it in place would make upgraded peers disagree with un-upgraded ones forever.lib:MakeHashEntry(value, updatedAt)— builds a P2P hash-list entry carrying both revisions (hash+hashV2). This is the recommended way to populategetMyHashes, and it is what makes the rollout free: consumers never name a revision, so retiring revision 1 later changes this one function and no call sites.P2PSessioncompares on the highest revision BOTH ends advertise. A peer on an older build sends onlyhash, and that absence is the signal to fall back to revision 1 — so the pair still agrees instead of reading a phantom difference and offering each other data forever. Two upgraded peers get the collision-free comparison. A guild can therefore upgrade one player at a time, with the fix switching itself on per pair.- The VERSION channel gained the same optional carriage, since that comparison belongs to the consumer:
BroadcastVersion(version, hash, distribution, priority, hashV2)sends both andonVersionReceived(sender, version, hash, hashV2)delivers both. Purely additive — existing 4-argument callers and 3-argument callbacks are unaffected, andhashV2is simply nil from older peers.peerStatesrecords it too. - Verified scope first: the library never feeds
ComputeHashonto the wire — only consumers do. Two do today (FGI'sBridge:ItemHash→GetMyHashes, and TOGProfessionMaster'sHashManager), and neither looks bitten at its current call sites (FGItonumber()ssetAtbefore hashing; TOGPM hashes numeric values under string keys). The defect is latent, which is exactly why it had to be fixed without forcing churn on either addon. - Covered end to end:
integration_spec.luadrives real hosts on both builds against each other — old↔new syncing in both directions, a mixed pair holding identical content staying completely silent (the resync storm this design exists to prevent), and the type-only collision being caught once both ends are upgraded. That last pair of tests holdsupdatedAtidentical on purpose, so revision 1 genuinely cannot tell the two states apart and the test can only pass because revision 2 can.
Deprecations
config.hashStrategyis deprecated and now inert — commented out, pending removal after ~2026-08-24. It was stored on the host and then read by nothing: no code path in any of the six files ever branched on it, so"deep"and"shallow"behaved identically while the API docs described them as a choice. A knob that documents an effect it does not have is worse than no knob, because a consumer sets it and believes it took. Location:DeltaSync.lua.- Passing it remains harmless — it is simply ignored, exactly as it always effectively was. No consumer needs to change anything.
- Commented rather than deleted so it can come back cheaply if a consumer turns out to be reading
host.hashStrategy. Nothing in this workspace does — checked TOGProfessionMaster, PersonalShopper, FGI and TOGBankClassic. It will be removed outright in a later release. config_spec.luapins the deprecation from both sides: the field must be absent, and passing it must not break initialization. If someone re-adds the assignment without wiring it to real behaviour, the first assertion fails.
Improvements
LIBREQ-DS-003— logging ownership is now a choice, in both directions. A consuming addon can either hand logging to DeltaSync or take it over completely:config.logger— the host takes over. DeltaSync forwards every debug line's raw arguments (category, tag, format, ...) and stops filtering, buffering and claiming a chat tab entirely. Accepts a plain function or any object with a:Debugmethod, so a host passes its existing module straight in — TOGBankClassic'sOutput:Debug(fmt, ...)already has the identical call shape, making the integration one line.host:RegisterDebugCategory(name, tags)— DeltaSync keeps it, and the host adds its own categories to the shared system rather than building a second one. Registered categories are indistinguishable from the built-ins (INIT,COMMS,DELTA,P2P, …): same opt-out filtering, same[CATEGORY.TAG]prefixing, same SavedVariables persistence. Per-host, so two consumers never see each other's.- The recommendation is the second one for most consumers. DeltaSync is shared across roughly twenty addons; if every consumer injects its own logger, twenty addons each build a debug subsystem and the library's diagnosability varies by whoever you are debugging. One shared tab means "screenshot the DeltaSync tab" means the same thing everywhere. Inject a logger you already own for other reasons; don't build one in order to inject it.
config.onDebugMessage(message, category, tag)— a tap, not a replacement. Fires for every message DeltaSync logs, alongside its own tab and buffer, so a host can persist or export the library's diagnostics without DeltaSync owning retention. DeltaSync's buffer is deliberately session-only, in-memory and capped at 1000, and it never writes messages to SavedVariables: retention costs the host's SV file, so the host sets the policy. Receives the structured category and tag as well as the rendered text, never fires for a message the host's own filtering suppressed, and routes a throwing sink togeterrorhandler()rather than swallowing it — a broken sink must not take the library's logging down with it, and must not disappear either.- Location:
DeltaSync.lua.
- Duplicate keys within
newArrayno longer let array order decide the outcome (TOGBankClassic §3.10). Two records sharing a key emitted two entries for one target, and on apply the second overwrote the first — so the surviving record depended on the order the host happened to build its array in. Deterministic for one client, but two clients whose source order differs (an array built frompairsover a map) kept different records, hashed differently, and offered each other data forever.ComputeArrayDeltanow reports it once, keeps the last occurrence (exactly what apply already did, so no behaviour changes on upgrade) and emits one entry instead of two — the applied result is identical and the payload is smaller.options.strictKeysmakes it an error. Detection costs one table and no extrakeyFunccalls: keys are computed once in a pre-pass and reused by the main loop. Location:DeltaOperations.lua. LIBREQ-ALL-005— a prefix the client refuses to register is now reported.C_ChatInfo.RegisterAddonMessagePrefixreturns false once the client's registered-prefix cap is reached, and AceComm discards that result; the symptom is messages that never arrive on that prefix, with no error and no warning. DeltaSync now checks it, reports the dead channel by name through the debug log and chat, and records it inhost.prefixRegistrationFailedforDebugStatus. Only an explicitfalsecounts — some clients return nil, and treating that as failure would cry wolf on every register. Location:DeltaSync.lua.options.strictKeys(new, opt-in) turns both keying hazards above into errors instead of warnings, for consumers that would rather fail loudly at development time. Location:DeltaOperations.lua.
Tooling
- luacheck now runs clean: 0 warnings, 0 errors across 24 files. Installed luacheck 1.2.0 into the local Lua 5.1 tree and actually ran it rather than shipping an unexecuted config. It found a genuine config gap of ours —
time(WoW's bareos.timeequivalent, used asDeltaSyncRoster'sGetServerTimefallback) was missing fromread_globals, so a valid call was reported undefined. Real code findings fixed: three redundantlocal x = nilinitializers inlib:Debug, an unusedMINORconstant inDeltaOperations.lua(only the one atDeltaSync.lua'sNewLibrarycall site is meaningful), and three unused loop variables.unused_argsis off — WoW callbacks are positional, so namingevent/language/flagsto reach a later argument is required, not a defect. Whitespace-only codes are ignored, documented, so real findings aren't buried under ~70 cosmetic ones. Locations:.luacheckrc,DeltaSync.lua,DeltaOperations.lua,P2PSession.lua. - The suite is verified under real busted too, not just the bundled runner: 424 successes / 0 failures / 0 errors, matching
run.luaexactly. - Fixed a shared-harness bug that affects every TOG addon.
busted_config.lua'sexclude-pattern = "wowapi"is a no-op: busted matches that pattern against a file's basename, never its path, so a directory name can never match. Runningbustedfrom any consuming addon's root therefore collected the harness's own self-tests and ran them from the wrong working directory, where their relative fixture paths don't resolve — a spurious error in every consumer's suite. Worked around locally withconfig.default.recursive = false(an addon's specs live directly inTests/; the harness's are inTests/wowapi/spec/), and written up inTests/HARNESS_CONTRACT.mdas a one-line upstream fix. Location:.busted. DeltaSync.code-workspacewas disabling Lua'sbasic,string,tableandmathbuiltins, which do exist in WoW — that is why.luarc.jsonhad to re-declaretable,string,pairsandtypeas "globals" to undo it, and why the test suite'sassert/require/loadfilewere reported undefined (workspace settings override.luarc.json). Those four are re-enabled;io/os/package/debug/utf8stay disabled since they genuinely are absent from the client, with the names the offline suite needs declared as globals instead. Location:DeltaSync.code-workspace,.luarc.json.
Packaging
Testsadded to.pkgmetaignoreso the suite and its submodule never reach players..pkgmetaignore list rewritten to the packager's documented syntax rules — folder entries as bare names rather than with trailing slashes, single-star repo-relative globs rather than recursive**/, and no dotfile entries (the packager prunes those unconditionally, so listing them implied coverage they were not providing).Testsadded, which is the entry that actually mattered: without it the whole offline suite would have shipped to players. Location:.pkgmeta.- Correction: an earlier draft of this entry claimed
docsandtextures"were shipping in the zip" as a result of the old syntax. That was wrong, and it was asserted from reading the packager's rules rather than from inspecting a built artifact. Downloading the v4.0.1 release shows it contained exactly the same 11 files as v4.0.2 — the six library files,DeltaSync.toc,README.md,CHANGELOG.md,LICENSE— with nodocsortexturesdirectory. The rewrite is still correct and still worth having (the syntax now matches what the packager documents, andTestsgenuinely needed excluding), but it fixed a latent correctness problem, not an observed one. The v4.0.2 GitHub release body carries the original wording, since it was generated from this file at tag time.
- Correction: an earlier draft of this entry claimed
- Interface versions refreshed to the current build per flavour —
11509, 20506, 30405, 38000, 40402, 50504, 120007. Drops entries superseded by a newer build of the same flavour (11507,20505,50503,50502,110207,120005); every flavour DeltaSync shipped for is still covered. Location:DeltaSync.toc.
Documentation
- README gains developer-facing sections: a protocol walkthrough ("How a sync actually happens") covering the three things integrators most often get wrong, a Testing section, and a symptom→cause troubleshooting table.
Tests/HARNESS_CONTRACT.mddocuments what the shared harness should absorb (the faithfulC_Timercontract above all), written so it can be applied upstream rather than re-derived.
LibStub MINOR
- Bumped from 15 → 16.
P2PSession.luaandDeltaOperations.luaboth changed; embedders on MINOR 16 get working timer cancellation. No wire-format or API change — a MINOR 16 host and a MINOR 15 peer interoperate exactly as before.
[v4.0.1] (2026-07-28) - Classic Era interface bump to 11509
Packaging
- Classic Era interface level bumped
11508 → 11509inDeltaSync.tocso the library isn't flagged out-of-date on the current Classic Era client. The remaining entries in the## Interface:list (11507, 20505, 30405, 38000, 40402, 50503, 50502, 120007, 110207, 120005) are unchanged, so every other flavor DeltaSync ships for is unaffected. Location:DeltaSync.toc.
LibStub MINOR
- Unchanged at 15. No library
.luafile changed in this release — it is a TOC-only bump, so embedders see no new revision and no LibStub upgrade churn.
Older releases (v4.0.0 and earlier, back to v0.0.1-alpha) are in CHANGELOG_ARCHIVE.md.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include

