Guild Info — Main / Alt Assignments
Guild Info is a guild-owned, directed main→alt relationship system. It is independent of Loot Helper profile identity links. Raid Supplies (#427) reads these relationships for donation display attribution and three-witness independence; Guild Info does not own Raid Supplies accounting. Issue contracts: #402, #427.
Transport dependency: Guild Gossip (#378). Guild Info does not require a Loot Helper session, raid, or Guild Supplies activation.
Change contract
| Concern | Owner |
|---|---|
| Durable guild record / membership+group state / derived graph | SF.GuildInfo (State) |
Shared officer rank (SF.GuildOfficer) |
Authority via demand-driven GG.GetGuildIdentity / GetGuildMember / GetGuildRanks |
| Local assign / unassign / change-main | Operations |
| Roster snapshot, suggestions, membership status | Roster |
| Authenticated Gossip provider | Gossip (sf.guildinfo.relationships) |
| Settings UI | UI/Settings/Pages/MainAltAssignments.lua |
Guarantees
- Strict hierarchy: a character is unassigned, a main of one group, or an alt of exactly one main.
- No cycles, nesting, or dual ownership.
- One authorized officer edit is enough (no three-witness quorum).
- Departed characters retain relationships; incomplete roster data is unknown, not departure.
- Concurrent edits converge by deterministic version tuples
(n, w)(logical revision + writer nonce), not wall-clock timestamps alone. - Storage scales with retained character/group identities, not lifetime edit count.
Non-goals
- Import/migrate from Loot Helper or legacy Guild Supplies
identityLinks. - Own Raid Supplies donation ledger rows or rewrite historical donor identity (#427 projects attribution read-only).
- Drive Loot Helper profile permissions from Guild Info.
- A Guild Info-owned editor rank. The guild officer threshold lives in
SF.GuildOfficerand is not reset by relationship cleanup. - Multi-level family trees.
- Append-only operation logs, cutovers, or compaction checkpoints.
Read-only consumers (#427)
| API | Use |
|---|---|
GetRelationshipReadiness |
ready / generation_syncing / unavailable |
ResolveContributorIdentity |
Current main (or self when unassigned) for a GUID |
CompareWitnessIdentities |
same / different / unknown for witness quorum |
ResolveCharacterGuidByName |
Unique name→GUID only; never guesses ambiguous names |
Saved schema (schemaVersion = 3)
SpectrumFederationDB.guildInfo = {
schemaVersion = 3,
localWriter = { nonce = "<24 hex>", nextSeq = 1 },
guilds = {
["club:<actual club id>"] = {
schemaVersion = 3,
guildKey = "club:<id>",
clubId = "<id>",
syncGen = { n = <int>, w = "<writer nonce>" }, -- guild-scoped sync generation
baseline = {
complete = true|false,
memberCount = <int>,
groupCount = <int>,
contentFp = "<fingerprint>",
sealRev = { n = <int>, w = "<writer nonce>" }, -- same-gen tip order
},
members = {
-- [guid] = {
-- guid, groupId = "<id>" | false (unassigned tombstone),
-- ver = { n = <int>, w = "<writer nonce>" },
-- at = <server time>, nameRealm?, classFile?
-- }
},
groups = {
-- [groupId] = {
-- groupId, mainGuid?, dissolved?, forwardTo?,
-- ver = { n, w }, at = <server time>
-- }
},
characters = { -- display cache bounded to membership GUIDs
-- [guid] = { guid, nameRealm, classFile }
},
graph = { -- derived from members+groups; not independently authoritative
mains = { [mainGuid] = { mainGuid, groupId, altGuids = { ... } } },
byGuid = { [guid] = { role = "main"|"alt", mainGuid, groupId } },
},
revision = <members + groups count>,
openConflicts = {},
},
},
}
Migration: Schema 1 (op-log prototype) hard-resets to an empty schema-3 root. Schema 2 (pre-epoch state) migrates in place: relationships are preserved and a bootstrap syncGen / sealed baseline is installed. Newer/unknown schemas (schemaVersion > 3) fail closed without mutating db.guildInfo. Guild Supplies, Loot Helper, and unrelated SavedVariables are never touched.
Durable keys are only club:<id>. Transport name: scopes are never persisted.
Versioning and merge
- Each membership and group record carries
ver = { n, w }. - Higher
nwins; equalnties break on lexicographic writer noncew. - Wall-clock
atis metadata/display only and never grants authority. - Independent characters merge independently. Conflicting assignments to the same GUID resolve to one winner; unrelated edits survive.
- Change Main updates only the group’s
mainGuid(and group version). Memberships keep the same stablegroupId, so the logical promotion is atomic and does not rewrite every alt. - Unassign writes
groupId = false(tombstone) for the alt. Stale lower-version assignments cannot resurrect the link. - Unassigning the last alt does not dissolve the group: the solo-main shell stays live and reusable for offline synchronization (see Shared-group lifecycle). Unreferenced dissolved groups without forward dependencies may be reclaimed; membership tombstones are retained until the identity-capacity ceiling or officer history cleanup.
Capacity and cleanup
| Cap | Value | Behavior at limit |
|---|---|---|
MAX_MEMBERS |
256 | New GUID memberships fail closed (members_overflow) |
MAX_GROUPS |
128 | Dissolved unreferenced groups are reclaimed before create; solo-main shells are retained/reused; forward aliases and inbound destinations are retained; live empty shells are never evicted; overflow fails closed if live+retained aliases fill the cap |
MAX_ALTS_PER_MAIN |
32 | Assign rejected (group_full) |
| Gossip record | 2048 bytes CBOR | Oversize exports skipped / remote rejected |
Group lifecycle: Assign → Unassign keeps a reusable solo-main shell (hidden from the mains-only Settings list) so offline peers can still assign to the same group id. Dissolved forwardTo aliases appear when a solo main is demoted into an existing other group (or when concurrent shells are retired), not from ordinary last-alt Unassign. Obsolete shells, aliases, and unassign tombstones are reclaimed through authorized officer history cleanup (sync generation), not automatic dissolve-on-empty. When a new group would exceed MAX_GROUPS, unreferenced dissolved slots without forward dependencies may be reclaimed automatically; live empty shells and forward aliases are never evicted this way — overflow fails closed. Physical Gossip indexes drop retired gi-g:* IDs on durable-change invalidation.
Shared-group lifecycle (emptiness is not global): Unassigning the last locally visible alt does not dissolve the group or tombstone the main. A reusable solo-main shell stays live (hidden from the mains-only Settings list) so an offline peer can still assign another alt to the same group id. Legitimate independent edits to different characters therefore survive Gossip in either delivery order.
Solo-main demotion: When that shell’s main becomes an alt of someone who has no group yet, the shell is repurposed (same group id, new mainGuid) so ordinary same-identity churn does not accumulate group ids or aliases. When the main instead joins an existing other group, the vacated shell is retired as a dissolved forwardTo alias (and inbound aliases are flattened onto the new destination) so memberships that still cite the old id remain visible under the new main. Rejecting demotion while waiting for “sync complete” is not used — Gossip cannot prove that, and indefinite alias retention would freeze the character.
Forward-alias retention: A dissolved group with forwardTo is an active dependency, not reclaimable garbage. ReclaimUnreferencedDissolved never deletes a forward alias or a group that still has inbound aliases, even when no currently received membership points at those IDs — absence of membership is incomplete Gossip, not proof the remote source is finished. Destinations with inbound forwards also refuse ordinary empty-group dissolution. Chains are followed with a bounded, cycle-safe walk up to MAX_GROUPS (+1 hop to inspect a live sink or detect a full-capacity cycle). Reciprocal/cyclic aliases break deterministically onto the lexicographically minimum group id in the cycle so assigned members stay visible without inventing synthetic membership versions. Officer history cleanup fails closed (cleanup_forward_cycle) when a forward cycle is reachable from any assigned membership or when assigned members would still be hidden; wholly unreferenced cycle history may be discarded under the new sync generation so obsolete conflict residue cannot permanently exhaust all 128 group slots. At MAX_GROUPS, creates fail closed with groups_overflow / Settings feedback rather than silently dropping forwarding metadata. Tradeoff: indefinitely disconnected peers plus enough concurrent unrelated live+alias churn can exhaust capacity until an editor removes unused live groups or runs cleanup; unbounded alias history is intentionally not kept.
Concurrent first assign: If two officers independently create groups for the same ungrouped main, the derived graph absorbs loser-group members into the canonical group (main’s durable membership group when present; otherwise highest group version). Automatic reconciliation does not rewrite durable membership/group versions or dissolve loser shells. Officer mutations against the displayed logical group stay consistent: Unassign keeps a solo-main physical group while any sibling live claimant exists (including membership-empty shells — absence is incomplete sync, not emptiness); Change Main consolidates currently known absorbed members into the canonical group with genuine local versions, then retires sibling claimants as dissolved forwardTo aliases so late memberships remain resolvable. At equal membership revision n, a live-group assignment beats an unassign tombstone. Unrelated established groups with different mains are never merged.
Membership tombstones: Unassigned membership rows are not deleted automatically during normal Assign/Unassign. They remain until the 256-identity cap so same-generation stale peers cannot resurrect removed links. Hitting the cap surfaces truthful Settings feedback (members_overflow).
Officer history cleanup / sync generation (Option B)
An officer who meets the shared officer rank may run Clean up retained Guild Info history in Settings. This is not Delete All Assignments, and it does not change the officer rank.
- Retains: currently known valid main/alt relationships (including departed characters’ last-known display identity). Cleanup rebuilds memberships onto canonical derived group ids so absorbed/forwarded alts are not dropped when their physical
groupIddiffers from the displayed home. - Discards: empty solo-main shells, unassign tombstones, forwarding aliases (including wholly unreferenced forward cycles), memberships whose group record is missing (orphans), and other obsolete groups that are not part of a visible relationship. Forward cycles reachable from assigned memberships, and hidden assigned memberships that still reference a present but unresolved group, continue to fail closed until resolved.
- Generation: commits a new guild-scoped
syncGenand sealedbaseline(memberCount/groupCount/contentFp/sealRev). Older-generation member/group wires are rejected even with higher individualver.n. Competing cleanups at the samentie-break on lexicographic writer noncew. Remote generations must use canonical writer ids (bootstrapor 24-hex) and counters that remain safely incrementable; invalid or exhausted generations fail closed before wipe. - Seal freshness: after ordinary Assign/Unassign/Change Main (local or accepted remote) on a completed baseline, seal metadata and
sealRevare refreshed to match durable rows so fresh peers can finish sync without the writer reloading. - Incomplete sync: a follower that adopts a newer epoch wipes prior relationships and stays
baseline.complete = falseuntil the sealed counts/fingerprint match. Same-generation epoch tips followsealRevorder: a higher tip parks already-accepted rows in bounded_sameGenHold(chronological admit order plus per-sender streams), restarts catch-up, then seals only on an exact tip count/fingerprint match — from the durable tip buffer, from a global trailing tip-sized window, or from one of a bounded number of per-sender tip windows (O(hold) per trial, ≤1+senders fingerprints; no combinatorial subset search). Tip-window order entries keep each admit's record body so an advertised snapshot can be reconstructed even when a later higher-version row for the same GUID/groupId won the merge table; after exact tip verification, winning held rows are re-merged. Concurrent extras of any count can then relay without source retransmission. Count supersets alone never complete a baseline. Peers that never receive any author's contiguous tip-sized export remain fail-closed (incomplete) rather than guessing. Relationship APIs reportgeneration_syncingrather than an authoritative empty finished state. While incomplete, Gossip export advertises only the epoch meta record (never a partial completed baseline). When a duplicate/stale admit completes that baseline, the Gossip provider still marks the export dirty so the physical index advertises sealed relationships without requiring another accepted mutation. Hold buffers are trimmed toMAX_MEMBERS+MAX_GROUPS+8entries per stream, at most 8 senders, and cleared on generation change. - Generation bounds:
syncGen/sealRevwriters must bebootstrapor 24-hex; counters must stay strictly belowMAX_SYNC_GEN_Nso ordinary cleanup can still advance. Invalid remote tuples are rejected before wipe. A currently authorized officer remains trusted to publish any valid in-bound generation or seal revision, including the terminal advancable valueMAX_SYNC_GEN_N - 1, after which ordinary cleanup/sealRevbumps reportgeneration_exhausted. Mitigating that residual without rejecting legitimate long-offline catch-up requires a stronger trust/consensus boundary and is explicitly deferred. - Tradeoff: the confirming officer’s known view becomes the new guild-wide baseline. Offline edits that never entered that baseline are intentionally invalidated. Cleanup works at capacity; if every slot is a genuine active relationship, Settings rejects with
cleanup_no_capacityrather than a successful no-op.
Gossip provider
Provider id: sf.guildinfo.relationships (PROVIDER_SCHEMA = 3).
| Wire kind | Record id | Body |
|---|---|---|
guildinfo-epoch |
gi-e:current |
{ gen, memberCount, groupCount, contentFp, sealRev? } |
guildinfo-member |
gi-m:<guid> |
versioned membership + gen |
guildinfo-group |
gi-g:<groupId> |
versioned group + gen |
Schema-2 (epochless) wires are rejected. Remote admission requires a transport-authenticated sender who meets the shared officer rank. Payload versions/authors/epochs never grant editing authority by themselves. Ordinary members may receive/view state but cannot originate or launder mutations. Changing the officer rank does not reset relationships, and relationship cleanup does not replace the officer rank.
Public read API
| API | Contract |
|---|---|
GI:GetRelationshipGroups([guildKey]) |
Sorted-ready group list; empty when unconfigured; hides mains-only rows |
GI:GetCharacterRole(guid[, guildKey]) |
"main" / "alt" / nil |
GI:GetCapabilities() |
canRead / canEdit (UI hints; recheck at commit) |
GI:GetAssignmentDisplayRows(roster) |
Settings list rows with membership status |
GI.CountRetainedState([record]) |
Bound diagnostics (members/groups/tombstones) |
Future Loot Helper / Guild Supplies integration may read these APIs. They must not become the authority for those systems in this ticket.
Settings
Guild Settings in Core, between General and Gameplay → Main / Alt Assignments page. The category id remains guildInfo:
- Class-colored roster autocomplete (offline-inclusive), snapshot refreshed on roster/guild events while visible.
- Grouped list with remove controls for editors; Change Main for the selected group.
- Feedback via section banners only (no normal game Chat).