promotional bannermobile promotional banner

LibDeltaSync

An communication addon library used on top of the Ace3 framework.
Back to Files

DeltaSync-v4.6.2

File nameDeltaSync-DeltaSync-v4.6.2.zip
Uploader
PmptastyPmptasty
Uploaded
Oct 18, 2026
Downloads
1.6K
Size
184.2 KB
Flavors
Titan Reforged ClassicRetailMoP ClassicClassic TBCClassicForever
File ID
9235912
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 5.5.4
  • 4.4.2
  • 3.80.0
  • 3.4.5
  • 2.5.6
  • 1.60.1
  • 1.15.9

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.items with a non-table made the OFFER handler raise (self-audit of v4.6.1). P2P:OnHashListReceived and P2P:OnOffer guarded only against nil, then ran pairs(items); they now return on anything that is not a table. Spec: Tests/comms_spec.lua. Location: P2PSession.lua.

Improvements

  • docs/COMMUNICATION.md and CLAUDE.md now state the v4.6.1 onOfferReceived filter contract, which only the README and the code comments carried. The onOfferReceived header in DeltaSync.lua also says that replacing data.items with 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.lua instead of pinning a number. It asserts the loaded library reports the revision declared at the NewLibrary call 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

  • onOfferReceived may remove entries from data.items, and P2P honours the removal (Ultimate Guild Roster, inbox f3ec1986, option a). _HandleOffer already handed the hook and OnHashListReceived / OnOffer the same data table, 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, which isValidPeer cannot. That was never written down, so a refactor that copied data before the hook would have silently undone it. It is now documented at the onOfferReceived config entry, at _HandleOffer, and in the README, and pinned by a Tests/comms_spec.lua example 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, but BroadcastItemHashes sent the hash-list on GUILD only, so a sister guild never offered or asked. With greenwall = true (or the TOC name, or { addon, priority, maxMessages }) on NewHost / Initialize, and GreenWall's API at version 2+, the hash-list also goes to the co-guilds through GreenWallAPI.SendMessage, and a handler registered with AddMessageHandler feeds a co-guild's hash-list into the same receive path a GUILD one takes, with the sender canonical Name-Realm and 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, then key, hash, hashV2 per entry, no updatedAt), halves a list refused as too long, drops and logs an entry that cannot fit alone, and spends at most maxMessages (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 after Initialize is 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 isValidPeer accepts 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 is lib:_HandleOffer(sender, data), shared by the addon-message path and the bridge. New debug tag COMMS.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 hashV2 are strings or numbers (hashV2 may be nil); a repeated key is counted once; any other shape is dropped before OnHashListReceived and a consumer's hasContent. The README also notes that onOfferReceived now 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 with GreenWallAPI.RemoveMessageHandler and the id freed. A default isValidPeer built by an InitP2P that ran before the re-init kept the old bridge setting (a sister member stayed rejected after greenwall was turned on); P2P:Init now records cb.isValidPeerIsDefault and the re-init rebuilds that default. A consumer's own isValidPeer is never replaced. Location: DeltaSync.lua, P2PSession.lua.
  • Tests: Tests/greenwall_spec.lua (24 examples), with every bridge send run through GreenWall's real Relay.lua, Constants.lua and Base64BCA.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), and Tests/coverage_exemptions_spec.lua asserts it and that the code-plus-anchor pair occurs once in the file. The uniqueness check caught the DeltaSync.lua:2856 row at once: its if not frame then / return repeats 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-request and numbers-reply were 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 within REPLY_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's n; a version more than a day ahead of the server clock (FUTURE_SLACK) is refused, and WillAdopt applies the same clock rule, so a far-future advert is neither requested nor parked. InitNumbers takes an optional isValidPeer; without it the gates are the numbered P2P's canServePeer / 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 0001 in the same server second held equal numbersVersion, 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 carries d, a digest of the sender's table (ComputeHashV2 over number-by-key, host.numbers:Digest()). Equal v with a different d is 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. OnBroadcast parks only for a sender that passes isValidPeer (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", SendAddonMessage docs) and guild-mode stamps nothing (Peer Review, cd90b7c6 A1). SendMessage now declines a player-targeted send on any distribution but WHISPER and CHANNEL, unless it is GUILD under guild-mode, for every channel, reporting not-attempted with reason unstamped-directed through onComplete / onSendComplete. The defaults (directed channels on WHISPER), guild-mode (stamped) and real broadcasts (no target) are unchanged. A search of the Initialize / NewHost calls in the Classic Era AddOns tree found that only PersonalShopper sets channels, 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 as not-attempted / empty-target; a broadcast passes nil. 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). Broadcast began 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() stayed false and onSelfConsulted never 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). MAX becomes 10^width - 1 and 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 from onDataReceived instead of OnItemCompleted, 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 with all_refused. A call for a key with no active session, or from a sender that is not its peer, returns false and changes nothing. Location: DeltaSyncP2PNumbered.lua.
  • canServePeer(name), a serve-only gate (Ultimate Guild Roster, inbox f220f026). Consulted by HandleSyncRequest, the serve-queue re-check, the offer-back and HandleVersionQuery; defaults to isValidPeer, which keeps gating the fetch side. Named canServePeer rather than the requested canServe, because canServe(key) is already the per-key hook. Location: DeltaSyncP2PNumbered.lua.
  • Targeted numbered broadcasts (inbox 8320a730): BroadcastNumbered(priority, extra, targets) and the broadcastTargets() 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, so SendMessage declines every one and it returns false (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 and HandleSyncRequest, exposed as host.p2p:HoldsNewer) and, when canonImproves is not set, the fetch side's Improves (called with the offered canon as mine and ours as theirs). canonImproves still overrides the fetch side. Defaults to the time-only CanonIsNewer. Location: DeltaSyncP2PNumbered.lua.

Documentation

  • README: the numbers trust gate, the digest, width, Mint, RefuseReply, targeted broadcasts, the three new InitP2P keys, a v4.5.0 behaviour-change entry and a MINOR >= 25 row. RefuseReply is documented as taking the same key as OnItemCompleted. 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 as updatedAt and hash/hashV2, and warns that the VERSION channel's tonumber does not survive a 20-digit canon. The example now builds string.format("%010d%010d", time(), contentHash). The Numbered P2P section and DeltaSyncNumbers.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 the SendMessage guard.
  • guildmode_spec gains '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 asserts target-offline, since without it the new guard declines first. Both checked red with the guard disabled, and the IsSelfConsulted asserts red with BeginConsult moved 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, canonIsNewer on both sides, digest plumbing, the outsider park gate, and d carried from the wire through OnComm_HANDSHAKE into the version-query and version-reply handlers -- checked red by dropping data.d from DeltaSync.lua). numbers_spec gains the trust-gate specs (non-peer request and reply, unsolicited reply, n = 10000 with an empty table leaving Mint working, 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.lua and Tests/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's DS.MINOR example 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). SendMessage declined a directed send with target-offline to any sister-roster member missing from LibGuildRoster: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-state GetSisterPresence(guildKey, name) (true / false / nil) and declines only a positive false. 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 needs GetOnlineMembersScoped on 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, GetSisterPresence in the LibGuildRoster dependency row, a MINOR >= 24 feature-detection row, the DS.MINOR example, 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 six DeltaSync.lua line 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 bd91de2 in this release. The working tree's Tests/wowapi is checked out at 0de2e0a, 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) and Tests/smoke_spec.lua. The two header lines moved the README's six DeltaSync.lua coverage-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 raising SISTER_PRESENCE_MINOR on 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 six DeltaSync.lua coverage-exemption rows moved (482, 495, 531, 2028, 2106, 2576).
  • env_delta leaked every example's host, client and libraries, which made the coverage gate time out (Peer Review, inbox 87bfe59e). The proxy caches subProxies and rosterProxies were 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, and P2PSession.lua showed lines as uncovered that the file does cover. fresh() now starts all three proxy caches empty. p2p_spec went 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_spec from 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, with P2PSession.lua at 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, and SendMessage handed that name to SendCommMessage as the WHISPER target -- so a reply to any sender (QUERY/RESPONSE/DELTA, the hash offer, the handshake, RosterSync) went to First Last-Realm. Forever characters are First 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. SendMessage now computes the wire target separately: for a WHISPER it goes through LibGuildRoster:WhisperTarget (LibGuildRoster 1.1.2, MINOR 29), which drops the realm only when the client's own RegionalUniqueNamesEnabled() returns true; without that method it makes the same check inline. ctx.target, and so every onSendComplete / 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), WhisperTarget in the LibGuildRoster dependency row, adoption item 00000, a v4.4.1 behaviour-change line, a MINOR >= 23 feature-detection row, DS.MINOR example, the spec count, and a v4.4.1 Fixed block. CLAUDE.md gains the rule that a WHISPER's wire target goes through WhisperTarget and 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) and Tests/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 six DeltaSync.lua exemption rows were moved twice from the coverage report's own numbers. RegionalUniqueNamesEnabled added to .luacheckrc and .luarc.json.
  • wow-version-replication.ps1 no 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-File removed 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). OnBroadcast and OnOffer called OnAdvertisedVersion -- which only requests a newer table -- and then decoded every number with our KeyOf. 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 got busy(version): the guild-wide "asking each other for versions nobody held" TOGBank saw live. Now a message is read only when NUM: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 per REQUEST_COOLDOWN; the behind-broadcaster path used to send it on every broadcast. ver-query / ver-reply carry v. A reply on another table is not read and counts as "holds nothing" for every query outstanding to that peer, so the query settles and onNewerCleared fires; 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 onNewerOffered fires for every one of those keys, then each is version-queried and cleared. A UI keyed on onNewerOffered -- 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 empty ver-reply and 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. A v ahead of ours on a ver-query or ver-reply requests that peer's table, as a broadcast's does. The lineage is session memory only: after a /reload just 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 in DeltaSyncNumbers.lua is 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 raises onNewerOffered again. 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 in GetOnlineMembersScoped(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 new lib:_InboundSender at 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 by Init, so an instance from an older MINOR that survived an upgrade without a re-Init would 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().peerCount was always 0. It read #self.peerStates, and peerStates is keyed by sender name, so # counts nothing. It now counts keys. Found by a documentation audit; no spec had called GetCommStats. Location: DeltaSync.lua.

  • A not-attempted completion invented a reason. When an older AceCommQueue gave none, host.lastSendNotAttempted.reason was correctly nil but the completion handed to onSendComplete / onComplete read "suppressed" -- a cause nobody had reported, against the documented "we don't guess". It is now nil on both. Location: DeltaSync.lua.

  • The TOC lacked retail 120005 and 120100 (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 after QUEUE_TTL or once our table leaves its lineage) keeps it, and OnNumbersChanged replays it once every entry resolves -- AFTER the adopt's own re-mint, so the offer-back names the listener's own keys: Adopt now notifies the P2P only after Mint, and the consumer's onChanged order (adopt, then mint) is unchanged. Questbook feature-detects ParkBroadcast to 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's AddonList.lua reads Category on 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, a MINOR >= 22 feature-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, the DS.MINOR example, the CHANNEL transport's required channelModule (without it a CHANNEL prefix sends but never receives), the debug sub-keys, lastSendFailure's reason field, the hash protocol's seven tunables with their defaults, the numbered protocol's hasMissingItems hook (without it the library never re-broadcasts), RosterSync's own isValidPeer, "four" keying rules (it said two in one place and three in another), DebugStatus reporting 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.md gains the two rules a future change must not undo: never decode a peer's numbers without Reads(v), and _InboundSender is 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.lua and Tests/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.lua exemption rows updated from the coverage report. New examples also cover GetCommStats().peerCount and the reason-less not-attempted completion. New examples in numbered_p2p_spec (cross-table broadcast, behind offerer, lineage, first-sync replay after mint, same-table partial replay, park drops, versioned query/reply, onNewerCleared on failure) and comms_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 /who poll does.

  • The 15 weak is_nil-on-a-literal-key assertions writ flagged (inbox dc990cf4) are replaced: an empty-table same({}, ...) where the whole table must be empty, or a key derived once and asserted both ways.

  • Harness pin moved to bd91de2 (from 6d14774, via 18e0f0e, whose inherit="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.ps1 replicates 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:NormalizeSender canonicalises 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[].peer was Bob-Testrealm, isValidPeer received Norm(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 ran NormalizeSender first, so it is the one seam: with LibGuildRoster present it is now GuildRoster:NormalizeName(name) or name -- exactly the Norm() helper P2PSession.lua and DeltaSyncP2PNumbered.lua already 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 with GetNormalizedRealmName(), falling back to GetRealmName() with spaces squashed, and leave it bare only while no realm has resolved. The client does not document CHAT_MSG_ADDON's sender format (ChatInfoDocumentation.lua types it only as cstring), 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 through CanonName. 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 >= 21 feature-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 a Changed block for v4.3.0 and the same contract in its LibGuildRoster section. CLAUDE.md gains 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 NormalizeSender examples: 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_spec and 23 in numbered_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 six DeltaSync.lua exemption rows moved twice (the NormalizeSender comment block, then the MINOR 21 header line), were caught by coverage_exemptions_spec both times, and were updated from the coverage report's own numbers. MINOR 20 -> 21 in DeltaSync.lua and Tests/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 receive Name-Realm where they received Name.

[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:_RegisterPrefix treated only accepted == false as a refusal, but C_ChatInfo.RegisterAddonMessagePrefix returns a number from Enum.RegisterAddonMessagePrefixResult on every client (ChatConstantsDocumentation.lua:130-140, Classic Era, identical elsewhere: Success = 0, DuplicatePrefix = 1, InvalidPrefix = 2, MaxPrefixes = 3; the return is Nilable = false). 0 is truthy and 3 is not false, so the "a prefix the client refuses is reported by name" feature shipped in v4.0.2 -- advertised in the README, the CurseForge page and CLAUDE.md -- could not fire on any client. It passed its spec because the old offline env returned booleans. Now: MaxPrefixes and InvalidPrefix are refusals, reported by name with the reason; Success is fine; DuplicatePrefix is fine too and must never be reported -- AceComm registers each prefix on the addon's behalf first, so our own registration answers DuplicatePrefix on every login, forever. An explicit false is still honoured for a stub or a pre-enum client; nil stays "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, not Charlie-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.lua no 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's run(), 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.lua is deleted (the harness's env/libs.lua loads the installed LibGuildRoster; env/guild.lua carries membership). Both harness contract threads that asked for this came back "not a gap -- already delivered" (37f9b220, eb10e337): wow.client shipped on 2026-09-13 and DeltaSync's pin already carried it. The one stub kept, deliberately: hosts created without a player still 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, not Bob-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[].peer is Bob-Testrealm; isValidPeer receives Norm(sender)), while the transport callbacks (onDataReceived, onDataRequest, peerCapable, onAdvertised) pass the transport spelling (Bob). Consistent within one client, but a host comparing a callback's sender against 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_spec and updated. .luacheckrc / .luarc.json learn the Enum global, and the five unused positional parameters the language server flagged in DeltaSync.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 >= 20 row 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.toc adds 16001 to its ## Interface: list (11509, 16001, 20506, 30405, 38000, 40402, 50504, 120007). Forever (Battle.net product wow_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 _Camelot TOC 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 no DeltaSync_Camelot.toc. DeltaSync's own hard dependencies already declare it (AceCommQueue-1.0 and VersionCheck-1.0 in-list; GuildRoster via GuildRoster_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 in CLAUDE.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, OnNumbersChanged and 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 and strictKeys, and leaf-type routing; the description was also missing the CHANNEL transport, content-hash revision 2, VersionCheck registration and the offline test suite, still wrote lib: for calls that have been host: 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.md said VersionCheck-1.0's Interface list is identical to DeltaSync's. It is not (VersionCheck lists 120100 for Retail, DeltaSync 120007); what the two share is 38000 and 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) and Tests/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 then git 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. New Tests/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.lua gates 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=100 still runs the strict gate. Registered with writ as the coverage gate suite.
  • 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 queued C_ChatInfo echo 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 carry M.libPath in env/guild.lua so Tests/env_guild.lua can 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.toc now declares ## Dependencies: Ace3, VersionCheck-1.0, AceCommQueue-1.0, GuildRoster and .pkgmeta adds the versioncheck slug (the CurseForge slug, never the folder name VersionCheck-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 no DeltaSync-1.0 state 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. At PLAYER_LOGIN it calls VC:Enable("DeltaSync"); the string form makes VersionCheck read ## Version: itself, raw, so a dev checkout reports DeltaSync-v4.6.2 and keeps its update popup suppressed. Verified in game by the operator: DeltaSync has its own tab in /vc, and the dev checkout's row reads DeltaSync-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:OnBroadcast now also offers every servable numbered key the broadcast did not list, merged into the same hash-offer2 whisper, and only to a sender that passes peerCapable and isValidPeer (its own OnOffer refuses 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 v is below ours, numbers:HandleRequest is 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 the extra fields a host passes BroadcastNumbered (TOGBank's addon version). New optional InitP2P config broadcastExtra() -> table, called at send time -- never a cached copy of an earlier extra, 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:OnOffer now 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), and DeltaSyncNumbers.lua's change notification -- every mint and adopt, after the host's own onChanged -- calls p2p:OnNumbersChanged(reason), which replays the now-resolvable numbers through OnOffer (recorded inside a window, acted on at once after one), keeps the remainder, and drops a park older than QUEUE_TTL. A refused peer parks nothing: parking runs after the gates. Location: DeltaSyncP2PNumbered.lua, DeltaSyncNumbers.lua.
  • onOfferReceived fired 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 by peerCapable before the host had read the addon version 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 out DELIVERY_TIMEOUT. OnComm_OFFER now fires the hook first, for both protocols. Because it now runs first it is pcalled: a raising hook is reported through geterrorhandler() and P2P still acts. TOGBankClassic is the only consumer of the hook in the tree. Behaviour change for any onOfferReceived consumer: 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, or DS.MINOR >= 19. Location: DeltaSyncP2PNumbered.lua, README.md.
  • Test environment: C_ChatInfo.SendAddonMessageLogged is installed. Classic Era has it (ChatInfoDocumentation.lua), and real AceComm's ChatThrottleLib hooks it at load with hooksecurefunc, 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 in Tests/numbered_p2p_spec.lua, 1 in Tests/loadorder_spec.lua. Each numbered-protocol fix was switched off and its specs watched fail. Coverage 100% on DeltaSyncP2PNumbered.lua, DeltaSyncNumbers.lua and DeltaSyncVersionCheck.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: its env/libs.lua entry for DeltaSync-1.0 now declares the VersionCheck-1.0 dependency and leaves DeltaSyncVersionCheck.lua out of the library's files on purpose (the eight library files were already there on the harness side since 90530f3). Every Adoption log entry since the old pin reads "nothing" or "run your suite" for a consumer shaped like DeltaSync; Tests/env_delta.lua installs 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.lua moves 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's BankerNumbers generalised to any string key: every eligible key gets a 4-digit number, for life, minted alphabetically from numbersNext by 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 -- over numbers-request / numbers-reply on HANDSHAKE. Storage is the host's: three fields on the table config.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 from bankernumbers_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 (hlb2 entries, hash-offer2 bare 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 after STATE_WAIT (30s); an accept the requester cannot use is answered sync-cancel so 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 observers onAdvertised(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. A ver-reply echoes 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 without n is read the old way. 63 specs, three-peer scenarios over the loopback network, 100% line coverage. Location: DeltaSyncP2PNumbered.lua, routing in DeltaSync.lua (OnComm_OFFER, OnComm_HANDSHAKE), class selection in P2PSession.lua (InitP2P).
  • Per-send completion (LIBREQ-DS-009, raised by TOGBankClassic). onSendFailed reported refusals only and sendsDelivered was 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 proxy aceAddon to find out. Two additive shapes, same info: an optional trailing onComplete(info) on SendMessage / RequestData / SendData, and a host-wide config.onSendComplete(info). info = { prefix, channelType, distribution, target, bytes, at, verdict, reason, unverified }; verdict is exactly one of delivered / refused / not-attempted. Exactly one completion per send under both callback shapes (a per-send completed flag on the context that rides SendCommMessage's arg 7). Sends DeltaSync declines before the transport report too, as not-attempted with 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 flagged unverified = true. A raising host callback is routed to geterrorhandler() 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 nil in a table, so a top-level field the sender removed was detected by ComputeStructuredDelta and then discarded by delta.changes[field] = nil; a field removed from a surviving array record was never visited, because GetChangedFields walked 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-level removed = { fieldName, ... } beside changes, and a modified array entry carries _removed = { fieldName, ... } (lib.REMOVED_FIELDS_KEY). ApplyStructuredDelta nils the former; ApplyArrayDelta lifts the latter off a copy of the entry (so one delta applied to two targets keeps its deletions both times) and applies it after mergeFunc returns, 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. ValidateDelta checks the list; GetDeltaStats counts it, so a deletions-only delta no longer reports zero changes. Mixed builds -- KNOWN COST: an older receiver ignores removed and its default merge copies _removed onto 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 _removed key 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.
  • keyFunc was documented as function(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 reduced removed / modified entry on apply it is nil, 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.
  • pendingDispatch grew without bound under sustained cap pressure (finding 9, MEDIUM). An item queued past the session cap has no sessionsByItem entry, so every later Dispatch() rebuilt it into altList and DispatchList appended 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 by itemKey on 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.
  • ScheduleCatchUp was the last C_Timer.After in 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 to NewTimer in the same file. Now a real handle with the same truthiness semantics. Location: P2PSession.lua.
  • HandleSyncRequest acquired a send slot with no eligibility gate (finding 11, MEDIUM-HIGH). The one inbound P2P path without isValidPeer was 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-second SEND_TIMEOUT, and every legitimate peer was answered sync-busy. The gate now runs first, and rejects with sync-busy rather than silence so a legitimate peer on a stale roster advances to its next candidate instead of waiting out DISPATCH_TIMEOUT. Location: P2PSession.lua.
  • ReleaseSendSlot leaked a slot when the host spelled the name differently (finding 11a, MEDIUM). Slots are acquired under the transport sender -- bare Foo on a same-realm whisper -- while a host typically holds LibGuildRoster's canonical Foo-Realm; the <= 0 guard turned the mismatch into a silent no-op and the slot leaked until SEND_TIMEOUT, stalling a host's outbound path in three sends, every time. P2P:_ResolveSlotKey now 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's seen set 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, so hashV2 failed the same way (both pinned red by Tests/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.lua header 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 with isValidPeer, the default chosen from what the disclosure is worth. The P2P and RosterSync isValidPeer callbacks 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 an OnComm_* handler on every listening client at once. Reachability, checked rather than assumed: Initialize and SetChannelConfig already refused an omitted name, so the live gap was channel = "" (which not "" let through) and a channelConfig mutated 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 spelled channel = "*" 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 in P2PSession.lua, copied onto both): GetActiveSendTotal, TryAcquireSendSlot, ReleaseSendSlot, _ResolveSlotKey, _DecrementSendSlot, and the default isValidPeer. 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's servingSends and calls its ServeQueue when present. Tests/sendslots_spec.lua drives 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_HANDSHAKE passes data.canon (4th argument) to HandleSyncRequest and data.reason to OnSyncBusy. The hash protocol ignores both; the numbered one reads them. sync-queued, sync-cancel, ver-query, ver-reply, numbers-request and numbers-reply are feature-detected on the P2P instance, so a hash-protocol host ignores traffic it does not speak. Location: DeltaSync.lua.
  • Wrath interface level 38000 is RETAINED -- it is the Whitemane private-server build. A fleet-wide TOC review briefly moved it to 38002 in 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, because 38000 is what Whitemane reports and 38002 would flag the library out of date on the one server the value exists for. The same 38000 in VersionCheck-1.0.toc and Grouper_Wrath.toc was right all along. 38000 must 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.lua to Tests/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.toc files 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.md is tracked as of this release (finding 6). Suite: 667 passed, 0 failed across eight library files; coverage at the eight documented-unreachable lines (two return-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 synchronous SendAddonMessage echo, which arrives before BroadcastItemHashes opens 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 .pkgmeta entry such as Tests compiled to ^Tests$, which matches only a file of that name, so Tests/, docs/ and textures/ were listed as ignored, reported as loaded at startup, and replicated into every other WoW install anyway. That stopped being cosmetic once Tests/wowapi began 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/, .busted and .luacheckrc all replicated. Both fixed and both confirmed by -DryRun (the dotfile half on 2026-09-15: every dotfile, Tests\, docs\ and textures\ path reports [skip]; only the shipped files report WOULD).


[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. SendMessage called aceAddon:SendCommMessage(prefix, message, distribution, target, priority) with no delivery callback. WoW silently discards addon messages under congestion, and AceComm-3.0 forwards only ChatThrottleLib's didSend boolean — so a refused message simply vanished. With no callback supplied, AceCommQueue-1.0 reports the refusal itself through geterrorhandler(): correct, but it attributes the failure to the comm layer rather than to DeltaSync, and neither DebugStatus nor 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, and host.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.sendsNotAttempted and host.lastSendNotAttempted. The nil verdict 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.
    • nil deliberately does NOT feed sendFailures. 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 a nil verdict 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 as sendFailures and fire onSendFailed, 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", not delivered == false — which would miss the first two entirely. That correction came from AceCommQueue's maintainer after reviewing our first cut, where all three nil cases 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.lastSendFailure now also carries reason when 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.
  • DebugStatus reports 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 180s DELIVERY_TIMEOUT was 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 at P2PSession.lua now 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), and nil (never attempted), which gets its own counter rather than being folded into either. The nil path is covered both with and without the optional reason argument, 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 SetRetryPolicy or SetDebug. 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 RegisterDebugCategory example 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 GUILD while 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, or if 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 by smoke_spec.lua.
  • LIBREQ-DS-004 (P2P-024, stale safety release) — fixed; see Bug Fixes below.
  • LIBREQ-DS-005 (silent DefaultKeyFunc address 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 calls C_ChatInfo.RegisterAddonMessagePrefix itself in RegisterCommChannels, 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 in Tests/HARNESS_CONTRACT.md rather 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 by RegisterLeafType, which routes on a payload type field; 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. TryAcquireSendSlot armed an unconditional C_Timer.After whose comment claimed it fired "if ReleaseSendSlot was 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 > 0 guard never caught it and MAX_ACTIVE_SENDS could be quietly exceeded under sustained load. Each safety timer is now a NewTimer bound 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 no options.keyFunc, records carrying none of id/ID/key/name were keyed by tostring(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, and options.strictKeys makes it an error instead. Location: DeltaOperations.lua.
  • LIBREQ-DS-006 — keyFields silently had to cover every field keyFunc reads. ComputeArrayDelta reduces a removed entry to just keyFields, but ApplyArrayDelta then calls keyFunc on that reduced entry. If the reduction dropped a field the key depends on — a keyFunc over (id, suffix) with keyFields = {"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 under strictKeys). Location: DeltaOperations.lua.
  • A cyclic record hung the client. ObjectsEqual recursed 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.After returns nothing, so every :Cancel() in P2PSession.lua was a silent no-op. Four timers were created with C_Timer.After and their result stored for later cancellation, but only C_Timer.NewTimer returns a handle carrying :Cancel(). The stored value was always nil, so each cancel sat harmlessly behind an if timer then guard and did nothing — which reads as correct code right up until you look at what the API actually returns. All four now use C_Timer.NewTimer. Location: P2PSession.lua.
    • The user-visible half of this: extending the offer collect window did not extend it. BeginCollectWindow on 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, so Dispatch() 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.
  • DeltaOperations.lua's load-order guard could never fire. It resolved the library with LibStub:GetLibrary(MAJOR) — the non-silent form, which raises LibStub's own generic "Cannot find a library instance" before the if not lib check 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, so integration_spec.lua drives 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.After returns nothing (only NewTimer yields 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 every OnComm_ handler.
  • Tests/coverage.lua reports 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 return after error(), guards whose callers already guarantee the value, a dead branch shadowed by P2PSession.lua's override of lib:InitP2P) and are enumerated individually in Tests/HARNESS_CONTRACT.md rather than papered over.

New Features

  • Hash revision 2 fixes the ComputeHash type collision, and ships alongside revision 1 so mixed-build guilds keep working — no flag day, nothing to coordinate. Revision 1 renders a number with tostring() and a string as itself, so {v = 1} and {v = "1"} produce identical hashes, as do true and "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:1 vs s:1). Locations: DeltaSync.lua, P2PSession.lua.
    • lib:ComputeHashV2(value) — the corrected hash. lib:ComputeHash is 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 populate getMyHashes, 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.
    • P2PSession compares on the highest revision BOTH ends advertise. A peer on an older build sends only hash, 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 and onVersionReceived(sender, version, hash, hashV2) delivers both. Purely additive — existing 4-argument callers and 3-argument callbacks are unaffected, and hashV2 is simply nil from older peers. peerStates records it too.
    • Verified scope first: the library never feeds ComputeHash onto the wire — only consumers do. Two do today (FGI's Bridge:ItemHash → GetMyHashes, and TOGProfessionMaster's HashManager), and neither looks bitten at its current call sites (FGI tonumber()s setAt before 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.lua drives 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 holds updatedAt identical on purpose, so revision 1 genuinely cannot tell the two states apart and the test can only pass because revision 2 can.

Deprecations

  • config.hashStrategy is 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.lua pins 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 :Debug method, so a host passes its existing module straight in — TOGBankClassic's Output: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 to geterrorhandler() 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 newArray no 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 from pairs over a map) kept different records, hashed differently, and offered each other data forever. ComputeArrayDelta now 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.strictKeys makes it an error. Detection costs one table and no extra keyFunc calls: 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.RegisterAddonMessagePrefix returns 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 in host.prefixRegistrationFailed for DebugStatus. Only an explicit false counts — 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 bare os.time equivalent, used as DeltaSyncRoster's GetServerTime fallback) was missing from read_globals, so a valid call was reported undefined. Real code findings fixed: three redundant local x = nil initializers in lib:Debug, an unused MINOR constant in DeltaOperations.lua (only the one at DeltaSync.lua's NewLibrary call site is meaningful), and three unused loop variables. unused_args is off — WoW callbacks are positional, so naming event/language/flags to 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.lua exactly.
  • Fixed a shared-harness bug that affects every TOG addon. busted_config.lua's exclude-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. Running busted from 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 with config.default.recursive = false (an addon's specs live directly in Tests/; the harness's are in Tests/wowapi/spec/), and written up in Tests/HARNESS_CONTRACT.md as a one-line upstream fix. Location: .busted.
  • DeltaSync.code-workspace was disabling Lua's basic, string, table and math builtins, which do exist in WoW — that is why .luarc.json had to re-declare table, string, pairs and type as "globals" to undo it, and why the test suite's assert/require/loadfile were reported undefined (workspace settings override .luarc.json). Those four are re-enabled; io/os/package/debug/utf8 stay 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

  • Tests added to .pkgmeta ignore so the suite and its submodule never reach players.
  • .pkgmeta ignore 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). Tests added, 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 docs and textures "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 no docs or textures directory. The rewrite is still correct and still worth having (the syntax now matches what the packager documents, and Tests genuinely 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.
  • 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.md documents what the shared harness should absorb (the faithful C_Timer contract above all), written so it can be applied upstream rather than re-derived.

LibStub MINOR

  • Bumped from 15 → 16. P2PSession.lua and DeltaOperations.lua both 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 → 11509 in DeltaSync.toc so 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 .lua file 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.


This mod has no additional files