فهرست منبع

toc2: consolidate feedbag logic into new internal type

Mike 4 ماه پیش
والد
کامیت
bac296ef33
6فایلهای تغییر یافته به همراه1365 افزوده شده و 402 حذف شده
  1. 118 400
      server/toc/cmd_client.go
  2. 39 2
      server/toc/cmd_client_test.go
  3. 316 0
      server/toc/feedbag_list.go
  4. 749 0
      server/toc/feedbag_list_test.go
  5. 49 0
      wire/snacs.go
  6. 94 0
      wire/snacs_test.go

+ 118 - 400
server/toc/cmd_client.go

@@ -9,7 +9,6 @@ import (
 	"errors"
 	"fmt"
 	"log/slog"
-	"math"
 	"net/url"
 	"sort"
 	"strconv"
@@ -1404,40 +1403,16 @@ func (s OSCARProxy) SetPDMode(ctx context.Context, me *state.SessionInstance, ar
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	var pdinfo *wire.FeedbagItem
-	for i := range fb {
-		if fb[i].ClassID == wire.FeedbagClassIdPdinfo {
-			pdinfo = &fb[i]
-			break
-		}
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
+	fl.SetMode(uint8(mode))
 
-	if pdinfo == nil {
-		pdinfo = &wire.FeedbagItem{
-			ClassID: wire.FeedbagClassIdPdinfo,
-			GroupID: 0,
-			ItemID:  randItemID(s.RandIntn, fb),
-			TLVLBlock: wire.TLVLBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(mode)),
-				},
-			},
-		}
-	} else {
-		if currentMode, hasMode := pdinfo.TLVLBlock.Uint8(wire.FeedbagAttributesPdMode); hasMode {
-			if currentMode == uint8(mode) {
-				return []string{}
-			}
-			pdinfo.Replace(wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(mode)))
-		} else {
-			pdinfo.Append(wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(mode)))
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
 		}
 	}
 
-	if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}, []wire.FeedbagItem{*pdinfo}); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
-	}
-
 	return []string{}
 }
 
@@ -1469,68 +1444,19 @@ func (s OSCARProxy) NewGroup(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	rootGroupExists := false
-	for _, item := range fb {
-		if item.ClassID == wire.FeedbagClassIdGroup {
-			if item.Name == groupName {
-				// Group already exists, return success (idempotent)
-				return []string{}
-			}
-			if item.GroupID == 0 {
-				rootGroupExists = true
-			}
-		}
-	}
-
-	newGroupID := randItemID(s.RandIntn, fb)
+	fl := newFeedbagList(fb, s.RandIntn)
+	fl.AddGroup(groupName)
 
-	var items []wire.FeedbagItem
-	if !rootGroupExists {
-		items = append(items, wire.FeedbagItem{
-			ClassID: wire.FeedbagClassIdGroup,
-			GroupID: 0,
-			Name:    "",
-			TLVLBlock: wire.TLVLBlock{
-				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{newGroupID}),
-				},
-			},
-		})
-	}
-
-	items = append(items, wire.FeedbagItem{
-		ClassID: wire.FeedbagClassIdGroup,
-		GroupID: newGroupID,
-		Name:    groupName,
-	})
-
-	if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}, items); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+		}
 	}
 
 	return []string{}
 }
 
-func randItemID(randInt func(n int) int, items []wire.FeedbagItem) uint16 {
-	num := uint16(randInt(math.MaxUint16))
-	for itemID := num; itemID != num-1; itemID++ {
-		if itemID == 0 {
-			continue
-		}
-		exists := false
-		for _, item := range items {
-			if item.GroupID == itemID || item.ItemID == itemID {
-				exists = true
-				break
-			}
-		}
-		if !exists {
-			return itemID
-		}
-	}
-	return 0
-}
-
 // DelGroup handles the toc2_del_group TOC2 command.
 //
 // From the TOC2 docs by Jeffrey Rosen:
@@ -1558,25 +1484,25 @@ func (s OSCARProxy) DelGroup(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Scan feedbag to find group
-	var groupItem *wire.FeedbagItem
-	for _, item := range fb {
-		if item.ClassID == wire.FeedbagClassIdGroup && item.Name == groupName {
-			groupItem = &item
-			break
-		}
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
+	fl.DeleteGroup(groupName)
 
-	if groupItem == nil {
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+			Items: pending,
+		}
+		if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+		}
+	} else {
 		return s.runtimeErr(ctx, fmt.Errorf("group not found: %s", groupName))
 	}
 
-	deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
-		Items: []wire.FeedbagItem{*groupItem},
-	}
-
-	if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+		}
 	}
 
 	return []string{}
@@ -1620,9 +1546,10 @@ func (s OSCARProxy) NewBuddies(ctx context.Context, me *state.SessionInstance, a
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
+	fl := newFeedbagList(fb, s.RandIntn)
+
 	var replies []string
 
-	var newGroups []uint16
 	// Process each group in config (deterministic order so tests and root Order are stable)
 	groupNames := make([]string, 0, len(groups))
 	for k := range groups {
@@ -1633,30 +1560,9 @@ func (s OSCARProxy) NewBuddies(ctx context.Context, me *state.SessionInstance, a
 	for _, groupName := range groupNames {
 		buddies := groups[groupName]
 
-		var updates []wire.FeedbagItem
-
-		var groupItem *wire.FeedbagItem
-		var isNewGroup bool
-		// Get the group
-		for _, item := range fb {
-			if item.ClassID == wire.FeedbagClassIdGroup && item.Name == groupName {
-				groupItem = &item
-				break
-			}
-		}
-		if groupItem == nil {
-			isNewGroup = true
-			groupItem = &wire.FeedbagItem{
-				ClassID: wire.FeedbagClassIdGroup,
-				Name:    groupName,
-				GroupID: randItemID(s.RandIntn, fb),
-			}
-		}
+		fl.AddGroup(groupName)
 
-		// Does the item already exist in the group?
-		count := 0
 		for _, buddy := range buddies {
-
 			// Parse buddy entry (may contain alias/note: b:buddy:alias:::::note)
 			parts := strings.Split(buddy, ":")
 			buddyName := parts[0]
@@ -1664,116 +1570,53 @@ func (s OSCARProxy) NewBuddies(ctx context.Context, me *state.SessionInstance, a
 				continue
 			}
 
-			// Normalize screen name
-			normalizedBuddy := state.NewIdentScreenName(buddyName).String()
-
-			buddyFound := false
-			for _, item := range fb {
-				if item.ClassID == wire.FeedbagClassIdBuddy && item.GroupID == groupItem.GroupID {
-					count++
-					if item.Name == normalizedBuddy {
-						buddyFound = true
-						break
-					}
-				}
+			var alias, note string
+			if len(parts) > 1 {
+				alias = parts[1]
 			}
-			if buddyFound {
-				continue
+			if len(parts) > 6 {
+				note = parts[6]
 			}
-
-			// Create buddy item
-			buddyItem := wire.FeedbagItem{
-				ItemID:  randItemID(s.RandIntn, fb),
-				ClassID: wire.FeedbagClassIdBuddy,
-				GroupID: groupItem.GroupID,
-				Name:    normalizedBuddy,
-			}
-
-			// Handle alias if present (parts[1])
-			if len(parts) > 1 && parts[1] != "" {
-				buddyItem.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, parts[1]))
+			inserted, err := fl.AddBuddy(groupName, buddyName, alias, note)
+			if err != nil {
+				return s.runtimeErr(ctx, fmt.Errorf("fl.AddBuddy: %w", err))
 			}
-
-			// Handle note if present (parts[6] after alias and colons)
-			if len(parts) > 6 && parts[6] != "" {
-				buddyItem.Append(wire.NewTLVBE(wire.FeedbagAttributesNote, parts[6]))
+			if inserted {
+				replies = append(replies, fmt.Sprintf("NEW_BUDDY_REPLY2:%s:added", buddyName))
 			}
-
-			updates = append(updates, buddyItem)
-			fb = append(fb, buddyItem) // make sure item wasn't already added so far in loop
-			replies = append(replies, fmt.Sprintf("NEW_BUDDY_REPLY2:%s:added", normalizedBuddy))
-		}
-
-		if len(updates) == 0 {
-			continue
-		}
-
-		// insert the items
-		if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}, updates); err != nil {
-			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
 		}
+	}
 
-		var itemIDs []uint16
-		for _, item := range updates {
-			itemIDs = append(itemIDs, item.ItemID)
-		}
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
 
-		// update group
-		if order, hasOrder := groupItem.Bytes(wire.FeedbagAttributesOrder); hasOrder {
-			var memberIDs []uint16
-			if err := wire.UnmarshalBE(&memberIDs, bytes.NewReader(order)); err != nil {
-				return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+		buddyItems := make(map[uint16][]wire.FeedbagItem)
+		for _, item := range pending {
+			if item.ClassID == wire.FeedbagClassIdBuddy {
+				if _, ok := buddyItems[item.GroupID]; !ok {
+					buddyItems[item.GroupID] = nil
+				}
+				buddyItems[item.GroupID] = append(buddyItems[item.GroupID], item)
 			}
-			groupItem.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, append(memberIDs, itemIDs...)))
-		} else {
-			groupItem.Append(wire.NewTLVBE(wire.FeedbagAttributesOrder, itemIDs))
 		}
 
-		itemsToUpsert := []wire.FeedbagItem{*groupItem}
-		if isNewGroup {
-			newGroups = append(newGroups, groupItem.GroupID)
-		}
-		if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}, itemsToUpsert); err != nil {
-			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
-		}
-		if isNewGroup {
-			fb = append(fb, *groupItem)
+		if len(buddyItems) == 0 {
+			return replies
 		}
-	}
 
-	// When we added new groups, update or create the root so its Order lists them (once at end).
-	if len(newGroups) > 0 {
-		var rootItem *wire.FeedbagItem
-		for _, item := range fb {
-			if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
-				rootCopy := item
-				rootItem = &rootCopy
-				break
-			}
-		}
-		if rootItem != nil {
-			if order, hasOrder := rootItem.Bytes(wire.FeedbagAttributesOrder); hasOrder {
-				var memberIDs []uint16
-				if err := wire.UnmarshalBE(&memberIDs, bytes.NewReader(order)); err != nil {
-					return s.runtimeErr(ctx, fmt.Errorf("wire.UnmarshalBE: %w", err))
+		for _, item := range pending {
+			if item.ClassID == wire.FeedbagClassIdGroup {
+				frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+				if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, []wire.FeedbagItem{item}); err != nil {
+					return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
 				}
-				rootItem.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, append(memberIDs, newGroups...)))
-			} else {
-				rootItem.Append(wire.NewTLVBE(wire.FeedbagAttributesOrder, newGroups))
-			}
-		} else {
-			rootItem = &wire.FeedbagItem{
-				ClassID: wire.FeedbagClassIdGroup,
-				Name:    "",
-				GroupID: 0,
-				ItemID:  0,
-				TLVLBlock: wire.TLVLBlock{
-					TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, newGroups)},
-				},
 			}
 		}
-		if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}, []wire.FeedbagItem{*rootItem}); err != nil {
-			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+
+		for _, buddies := range buddyItems {
+			frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+			if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, buddies); err != nil {
+				return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+			}
 		}
 	}
 
@@ -1800,75 +1643,36 @@ func (s OSCARProxy) RemoveBuddy2(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("missing params: need at least one screenname and group"))
 	}
 
-	// Last parameter is group name, rest are screennames
-	groupName := params[len(params)-1]
-	screenNames := params[:len(params)-1]
-
-	// Normalize all screennames
-	normalizedNames := make([]string, len(screenNames))
-	for i, sn := range screenNames {
-		normalizedNames[i] = state.NewIdentScreenName(sn).String()
-	}
-
 	fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Find group by name and keep a pointer so we can update its order TLV
-	var groupID uint16
-	var groupItem *wire.FeedbagItem
-	for i := range fb {
-		if fb[i].ClassID == wire.FeedbagClassIdGroup && fb[i].Name == groupName {
-			groupID = fb[i].GroupID
-			groupItem = &fb[i]
-			break
-		}
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
 
-	if groupItem == nil {
-		return s.runtimeErr(ctx, fmt.Errorf("group not found: %s", groupName))
-	}
-
-	// Find buddy items to delete
-	var itemsToDelete []wire.FeedbagItem
-	for _, normalizedName := range normalizedNames {
-		for _, item := range fb {
-			if item.ClassID == wire.FeedbagClassIdBuddy &&
-				item.GroupID == groupID &&
-				item.Name == normalizedName {
-				itemsToDelete = append(itemsToDelete, item)
-				break
-			}
+	groupName := params[len(params)-1]
+	screenNames := params[:len(params)-1]
+	for _, buddyName := range screenNames {
+		if err := fl.DeleteBuddy(groupName, buddyName); err != nil {
+			return s.runtimeErr(ctx, err)
 		}
 	}
 
-	if len(itemsToDelete) == 0 {
-		return []string{}
-	}
-
-	deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
-		Items: itemsToDelete,
-	}
-
-	if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
-	}
-
-	// Remove deleted buddy ItemIDs from the group's order TLV
-	if order, hasOrder := groupItem.Uint16SliceBE(wire.FeedbagAttributesOrder); hasOrder {
-		removeIDs := make(map[uint16]bool)
-		for _, item := range itemsToDelete {
-			removeIDs[item.ItemID] = true
+	// ensure to delete buddies before groups to ensure they correctly propagate
+	// to concurrent sessions
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
+		deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+			Items: pending,
 		}
-		newOrder := make([]uint16, 0, len(order))
-		for _, id := range order {
-			if !removeIDs[id] {
-				newOrder = append(newOrder, id)
-			}
+		if _, err := s.FeedbagService.DeleteItem(ctx, me, frame, deleteItem); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
 		}
-		groupItem.Replace(wire.NewTLVBE(wire.FeedbagAttributesOrder, newOrder))
-		if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}, []wire.FeedbagItem{*groupItem}); err != nil {
+	}
+
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
 			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
 		}
 	}
@@ -1898,48 +1702,22 @@ func (s OSCARProxy) AddPermit2(ctx context.Context, me *state.SessionInstance, a
 		return s.runtimeErr(ctx, fmt.Errorf("no screennames provided"))
 	}
 
-	// Normalize screen names
-	normalizedNames := make([]string, len(screenNames))
-	for i, sn := range screenNames {
-		normalizedNames[i] = state.NewIdentScreenName(sn).String()
-	}
-
 	fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Build set of existing permit screennames
-	existingPermits := make(map[string]bool)
-	for _, item := range fb {
-		if item.ClassID == wire.FeedbagClassIDPermit {
-			existingPermits[item.Name] = true
-		}
-	}
-
-	var updates []wire.FeedbagItem
-	for _, normalizedName := range normalizedNames {
-		// Skip if already in permit list
-		if existingPermits[normalizedName] {
-			continue
-		}
-
-		permitItem := wire.FeedbagItem{
-			ItemID:  randItemID(s.RandIntn, fb),
-			ClassID: wire.FeedbagClassIDPermit,
-			GroupID: 0,
-			Name:    normalizedName,
-		}
-		updates = append(updates, permitItem)
-		fb = append(fb, permitItem)
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
 
-	if len(updates) == 0 {
-		return []string{}
+	for _, sn := range screenNames {
+		fl.PermitUser(sn)
 	}
 
-	if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}, updates); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+		}
 	}
 
 	return []string{}
@@ -1967,38 +1745,24 @@ func (s OSCARProxy) RemovePermit2(ctx context.Context, me *state.SessionInstance
 		return s.runtimeErr(ctx, fmt.Errorf("no screennames provided"))
 	}
 
-	// Normalize screen names
-	normalizedNames := make([]string, len(screenNames))
-	for i, sn := range screenNames {
-		normalizedNames[i] = state.NewIdentScreenName(sn).String()
-	}
-
 	fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Find permit items to delete
-	var itemsToDelete []wire.FeedbagItem
-	for _, normalizedName := range normalizedNames {
-		for _, item := range fb {
-			if item.ClassID == wire.FeedbagClassIDPermit && item.Name == normalizedName {
-				itemsToDelete = append(itemsToDelete, item)
-				break
-			}
-		}
-	}
-
-	if len(itemsToDelete) == 0 {
-		return []string{}
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
 
-	deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
-		Items: itemsToDelete,
+	for _, sn := range screenNames {
+		fl.DeletePermit(sn)
 	}
 
-	if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+			Items: pending,
+		}
+		if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+		}
 	}
 
 	return []string{}
@@ -2026,54 +1790,22 @@ func (s OSCARProxy) AddDeny2(ctx context.Context, me *state.SessionInstance, arg
 		return s.runtimeErr(ctx, fmt.Errorf("no screennames provided"))
 	}
 
-	// Normalize screen names
-	normalizedNames := make([]string, len(screenNames))
-	for i, sn := range screenNames {
-		normalizedNames[i] = state.NewIdentScreenName(sn).String()
-	}
-
 	fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Build set of existing deny screennames
-	existingDenies := make(map[string]bool)
-	myScreenName := me.IdentScreenName().String()
-	for _, item := range fb {
-		if item.ClassID == wire.FeedbagClassIDDeny {
-			existingDenies[item.Name] = true
-		}
-	}
-
-	var updates []wire.FeedbagItem
-	for _, normalizedName := range normalizedNames {
-		// Prevent self-block
-		if normalizedName == myScreenName {
-			continue
-		}
-
-		// Skip if already in deny list
-		if existingDenies[normalizedName] {
-			continue
-		}
-
-		denyItem := wire.FeedbagItem{
-			ItemID:  randItemID(s.RandIntn, fb),
-			ClassID: wire.FeedbagClassIDDeny,
-			GroupID: 0,
-			Name:    normalizedName,
-		}
-		updates = append(updates, denyItem)
-		fb = append(fb, denyItem)
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
 
-	if len(updates) == 0 {
-		return []string{}
+	for _, sn := range screenNames {
+		fl.DenyUser(sn)
 	}
 
-	if _, err := s.FeedbagService.UpsertItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}, updates); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+	if pending := fl.PendingUpdates(); len(pending) > 0 {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
+		if _, err := s.FeedbagService.UpsertItem(ctx, me, frame, pending); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.UpsertItem: %w", err))
+		}
 	}
 
 	return []string{}
@@ -2101,38 +1833,24 @@ func (s OSCARProxy) RemoveDeny2(ctx context.Context, me *state.SessionInstance,
 		return s.runtimeErr(ctx, fmt.Errorf("no screennames provided"))
 	}
 
-	// Normalize screen names
-	normalizedNames := make([]string, len(screenNames))
-	for i, sn := range screenNames {
-		normalizedNames[i] = state.NewIdentScreenName(sn).String()
-	}
-
 	fb, err := s.FeedbagManager.Feedbag(ctx, me.IdentScreenName())
 	if err != nil {
 		return s.runtimeErr(ctx, fmt.Errorf("FeedbagManager.Feedbag: %w", err))
 	}
 
-	// Find deny items to delete
-	var itemsToDelete []wire.FeedbagItem
-	for _, normalizedName := range normalizedNames {
-		for _, item := range fb {
-			if item.ClassID == wire.FeedbagClassIDDeny && item.Name == normalizedName {
-				itemsToDelete = append(itemsToDelete, item)
-				break
-			}
-		}
-	}
-
-	if len(itemsToDelete) == 0 {
-		return []string{}
-	}
+	fl := newFeedbagList(fb, s.RandIntn)
 
-	deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
-		Items: itemsToDelete,
+	for _, sn := range screenNames {
+		fl.DeleteDeny(sn)
 	}
 
-	if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
-		return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+	if pending := fl.PendingDeletes(); len(pending) > 0 {
+		deleteItem := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{
+			Items: pending,
+		}
+		if _, err := s.FeedbagService.DeleteItem(ctx, me, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}, deleteItem); err != nil {
+			return s.runtimeErr(ctx, fmt.Errorf("FeedbagService.DeleteItem: %w", err))
+		}
 	}
 
 	return []string{}

+ 39 - 2
server/toc/cmd_client_test.go

@@ -3230,13 +3230,25 @@ func TestOSCARProxy_RecvClientCmd_NewBuddies(t *testing.T) {
 									Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
 									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{100})}},
 								},
-								{Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
-									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}}},
+								{
+									Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{})}},
+								},
 							},
 							err: nil,
 						},
 					},
 					feedbagServiceUpsertItemParams: feedbagServiceUpsertItemParams{
+						{
+							frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem},
+							items: []wire.FeedbagItem{
+								{
+									Name: "Buddies", GroupID: 100, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1})}},
+								},
+							},
+							msg: nil, err: nil,
+						},
 						{
 							frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem},
 							items: []wire.FeedbagItem{
@@ -3485,6 +3497,10 @@ func TestOSCARProxy_RecvClientCmd_NewGroup(t *testing.T) {
 						{
 							frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem},
 							items: []wire.FeedbagItem{
+								{
+									Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{17724, 1})}},
+								},
 								{ClassID: wire.FeedbagClassIdGroup, GroupID: 1, Name: "Family"},
 							},
 							msg: nil, err: nil,
@@ -3605,6 +3621,10 @@ func TestOSCARProxy_RecvClientCmd_NewGroup(t *testing.T) {
 						{
 							frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem},
 							items: []wire.FeedbagItem{
+								{
+									Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{17724, 1})}},
+								},
 								{ClassID: wire.FeedbagClassIdGroup, GroupID: 1, Name: "Family"},
 							},
 							msg: nil, err: assert.AnError,
@@ -3689,6 +3709,18 @@ func TestOSCARProxy_RecvClientCmd_DelGroup(t *testing.T) {
 							msg: nil, err: nil,
 						},
 					},
+					feedbagServiceUpsertItemParams: feedbagServiceUpsertItemParams{
+						{
+							frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem},
+							items: []wire.FeedbagItem{
+								{
+									Name: "", GroupID: 0, ItemID: 0, ClassID: wire.FeedbagClassIdGroup,
+									TLVLBlock: wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{17724})}},
+								},
+							},
+							msg: nil, err: nil,
+						},
+					},
 				},
 			},
 		},
@@ -3799,6 +3831,11 @@ func TestOSCARProxy_RecvClientCmd_DelGroup(t *testing.T) {
 					DeleteItem(ctx, matchSession(tc.me.IdentScreenName()), params.frame, params.inBody).
 					Return(params.msg, params.err)
 			}
+			for _, params := range tc.mockParams.feedBagParams.feedbagServiceUpsertItemParams {
+				fbSvc.EXPECT().
+					UpsertItem(ctx, matchSession(tc.me.IdentScreenName()), params.frame, params.items).
+					Return(params.msg, params.err)
+			}
 
 			svc := OSCARProxy{
 				Logger:         slog.Default(),

+ 316 - 0
server/toc/feedbag_list.go

@@ -0,0 +1,316 @@
+package toc
+
+import (
+	"fmt"
+	"math"
+	"slices"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// feedbagList provides operations for manipulating a collection of feedbag
+// items. It supports lookups by class/name/group, item insertion with
+// automatic ID generation, and transparent root group management.
+type feedbagList struct {
+	items          []*wire.FeedbagItem
+	randInt        func(int) int
+	pendingUpdates []*wire.FeedbagItem
+	pendingDeletes []*wire.FeedbagItem
+}
+
+// newFeedbagList creates a feedbagList from the given items. The randInt
+// function is used for generating unique item/group IDs; inject a
+// deterministic function in tests to assert exact feedbag item slices.
+func newFeedbagList(items []wire.FeedbagItem, randInt func(int) int) *feedbagList {
+	ptrs := make([]*wire.FeedbagItem, len(items))
+	for i := range items {
+		ptrs[i] = &items[i]
+	}
+	return &feedbagList{
+		items:   ptrs,
+		randInt: randInt,
+	}
+}
+
+// SetMode upserts the permit/deny mode item.
+func (f *feedbagList) SetMode(mode uint8) {
+	f.upsertItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdPdinfo,
+		TLVLBlock: wire.TLVLBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.FeedbagAttributesPdMode, mode),
+			},
+		},
+	})
+}
+
+// AddGroup returns the existing group with the given name or creates a new
+// one with an auto-generated GroupID. When a new group is created, the root
+// group's order TLV is updated to include it. Call PendingUpdates to retrieve
+// new or modified items for persistence. Returns the group item.
+func (f *feedbagList) AddGroup(name string) wire.FeedbagItem {
+	if g := f.groupByName(name); g != nil {
+		return *g
+	}
+
+	var root *wire.FeedbagItem
+	for _, item := range f.items {
+		if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
+			root = item
+			break
+		}
+	}
+	if root == nil {
+		root = &wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, GroupID: 0}
+		f.items = append(f.items, root)
+		f.trackUpdate(root)
+	}
+
+	group := &wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdGroup,
+		Name:    name,
+		GroupID: f.genID(),
+	}
+	f.items = append(f.items, group)
+
+	root.AppendOrderMembers(group.GroupID)
+	f.trackUpdate(root)
+	f.trackUpdate(group)
+
+	return *group
+}
+
+// DeleteGroup marks a group item for deletion by name. If the group exists
+// and is not the root group, the root group's order TLV is updated.
+func (f *feedbagList) DeleteGroup(groupName string) {
+	deleted, found := f.deleteItem(wire.FeedbagItem{
+		Name:    groupName,
+		ClassID: wire.FeedbagClassIdGroup,
+	})
+	if found && deleted.GroupID > 0 {
+		for _, item := range f.items {
+			if item.ClassID == wire.FeedbagClassIdGroup && item.GroupID == 0 {
+				item.RemoveOrderMembers(deleted.GroupID)
+				f.trackUpdate(item)
+			}
+		}
+	}
+}
+
+// AddBuddy upserts a buddy item in the given group (by name), optionally
+// attaching alias and note attributes. Returns true if a new buddy was inserted.
+func (f *feedbagList) AddBuddy(groupName, screenName, alias, note string) (bool, error) {
+	group := f.groupByName(groupName)
+	if group == nil {
+		return false, fmt.Errorf("group %q not found", groupName)
+	}
+	item := wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdBuddy,
+		GroupID: group.GroupID,
+		Name:    screenName,
+	}
+	if alias != "" {
+		item.Append(wire.NewTLVBE(wire.FeedbagAttributesAlias, alias))
+	}
+	if note != "" {
+		item.Append(wire.NewTLVBE(wire.FeedbagAttributesNote, note))
+	}
+	result, inserted := f.upsertItem(item)
+	if inserted {
+		group.AppendOrderMembers(result.ItemID)
+		f.trackUpdate(group)
+	}
+	return inserted, nil
+}
+
+// DeleteBuddy marks a buddy item for deletion in the given group (by name).
+// The parent group's order TLV is updated to remove the buddy.
+func (f *feedbagList) DeleteBuddy(groupName, buddyName string) error {
+	group := f.groupByName(groupName)
+	if group == nil {
+		return fmt.Errorf("group %q not found", groupName)
+	}
+	deleted, found := f.deleteItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIdBuddy,
+		GroupID: group.GroupID,
+		Name:    buddyName,
+	})
+	if found {
+		group.RemoveOrderMembers(deleted.ItemID)
+		f.trackUpdate(group)
+	}
+	return nil
+}
+
+// PermitUser upserts a permit-list entry for the given screen name.
+func (f *feedbagList) PermitUser(screenName string) {
+	f.upsertItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIDPermit,
+		Name:    screenName,
+	})
+}
+
+// DenyUser upserts a deny-list entry for the given screen name.
+func (f *feedbagList) DenyUser(screenName string) {
+	f.upsertItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIDDeny,
+		Name:    screenName,
+	})
+}
+
+// DeletePermit marks a permit-list entry for deletion.
+func (f *feedbagList) DeletePermit(screenName string) {
+	f.deleteItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIDPermit,
+		Name:    screenName,
+	})
+}
+
+// DeleteDeny marks a deny-list entry for deletion.
+func (f *feedbagList) DeleteDeny(screenName string) {
+	f.deleteItem(wire.FeedbagItem{
+		ClassID: wire.FeedbagClassIDDeny,
+		Name:    screenName,
+	})
+}
+
+// PendingUpdates returns items that were explicitly upserted via upsertItem
+// and items that were implicitly created or modified as side effects of other
+// operations (e.g., group order updates from upsertItem, root group updates
+// from AddGroup). The pending list is cleared after each call.
+func (f *feedbagList) PendingUpdates() []wire.FeedbagItem {
+	var result []wire.FeedbagItem
+	for _, p := range f.pendingUpdates {
+		result = append(result, *p)
+	}
+	f.pendingUpdates = nil
+	if len(result) == 0 {
+		return nil
+	}
+	return result
+}
+
+// PendingDeletes returns items marked for deletion via deleteItem.
+// The pending list is cleared after each call.
+func (f *feedbagList) PendingDeletes() []wire.FeedbagItem {
+	var result []wire.FeedbagItem
+	for _, p := range f.pendingDeletes {
+		result = append(result, *p)
+	}
+	f.pendingDeletes = nil
+	return result
+}
+
+// groupByName returns the group item with the given name, or nil if not found.
+func (f *feedbagList) groupByName(name string) *wire.FeedbagItem {
+	for _, item := range f.items {
+		if item.ClassID == wire.FeedbagClassIdGroup && item.Name == name {
+			return item
+		}
+	}
+	return nil
+}
+
+// trackUpdate adds item to the pending-updates list if not already present.
+func (f *feedbagList) trackUpdate(item *wire.FeedbagItem) {
+	if slices.Contains(f.pendingUpdates, item) {
+		return // already tracked
+	}
+	f.pendingUpdates = append(f.pendingUpdates, item)
+}
+
+// itemsMatch reports whether two feedbag items are considered the same for
+// upsert/delete (buddy: ClassID, Name, GroupID; others: ClassID and Name).
+// Stored items are assumed to have normalized names; the input (b) name is
+// normalized for comparison when the class is buddy, permit, or deny.
+func (f *feedbagList) itemsMatch(a, b *wire.FeedbagItem) bool {
+	if a.ClassID != b.ClassID {
+		return false
+	}
+	var nameMatch bool
+	if hasScreenName(a.ClassID) {
+		nameMatch = a.Name == state.NewIdentScreenName(b.Name).String()
+	} else {
+		nameMatch = a.Name == b.Name
+	}
+	if !nameMatch {
+		return false
+	}
+	if a.ClassID == wire.FeedbagClassIdBuddy {
+		return a.GroupID == b.GroupID
+	}
+	return true
+}
+
+// deleteItem removes the first item matching the same criteria as upsertItem:
+// buddy items by ClassID, Name, and GroupID; other items by ClassID and Name.
+// Returns the deleted item and true if found, or a zero item and false otherwise.
+func (f *feedbagList) deleteItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
+	for i, existing := range f.items {
+		if f.itemsMatch(existing, &item) {
+			f.pendingDeletes = append(f.pendingDeletes, existing)
+			f.items = append(f.items[:i], f.items[i+1:]...)
+			return *existing, true
+		}
+	}
+	return wire.FeedbagItem{}, false
+}
+
+// upsertItem updates an existing feedbag item or inserts a new one. Buddy
+// items are matched by GroupID, ClassID, and Name; all other items are matched
+// by ClassID and Name. When matched, the existing item is replaced in place
+// (preserving its ItemID). When no match is found, a new item is inserted with
+// an auto-generated ItemID. Names for buddy, permit, and deny items are
+// normalized before storage. Returns the stored item and true if a new item
+// was inserted, or the existing item and false if it was updated/unchanged.
+func (f *feedbagList) upsertItem(item wire.FeedbagItem) (wire.FeedbagItem, bool) {
+	if hasScreenName(item.ClassID) {
+		item.Name = state.NewIdentScreenName(item.Name).String() // normalize name
+	}
+	for _, existing := range f.items {
+		if f.itemsMatch(existing, &item) {
+			if !existing.IsEqual(item) {
+				item.ItemID = existing.ItemID
+				*existing = item
+				f.trackUpdate(existing)
+			}
+			return *existing, false
+		}
+	}
+
+	item.ItemID = f.genID()
+	f.items = append(f.items, &item)
+	f.pendingUpdates = append(f.pendingUpdates, &item)
+	return item, true
+}
+
+// genID generates a unique ID that does not conflict with any existing ItemID
+// or GroupID in the list.
+func (f *feedbagList) genID() uint16 {
+	num := uint16(f.randInt(math.MaxUint16))
+	for itemID := num; itemID != num-1; itemID++ {
+		if itemID == 0 {
+			continue
+		}
+		exists := false
+		for _, item := range f.items {
+			if item.GroupID == itemID || item.ItemID == itemID {
+				exists = true
+				break
+			}
+		}
+		if !exists {
+			return itemID
+		}
+	}
+	return 0
+}
+
+// hasScreenName reports whether the feedbag class stores a screen name that
+// should be normalized (buddy, permit, deny).
+func hasScreenName(classID uint16) bool {
+	return classID == wire.FeedbagClassIdBuddy ||
+		classID == wire.FeedbagClassIDPermit ||
+		classID == wire.FeedbagClassIDDeny
+}

+ 749 - 0
server/toc/feedbag_list_test.go

@@ -0,0 +1,749 @@
+package toc
+
+import (
+	"math"
+	"testing"
+
+	"github.com/mk6i/open-oscar-server/wire"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFeedbagList_upsertItem(t *testing.T) {
+	t.Run("generates unique ItemID", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 42 })
+
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		assert.True(t, inserted)
+		assert.Equal(t, uint16(42), result.ItemID)
+		assert.Equal(t, "alice", result.Name)
+		assert.Equal(t, wire.FeedbagClassIdBuddy, result.ClassID)
+		assert.Equal(t, uint16(1), result.GroupID)
+	})
+
+	t.Run("subsequent insert avoids collision", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 42 })
+
+		fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "bob",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		assert.True(t, inserted)
+		assert.Equal(t, uint16(43), result.ItemID)
+	})
+
+	t.Run("non-buddy item does not update group order", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIDPermit,
+		})
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, wire.FeedbagClassIDPermit, upserts[0].ClassID)
+	})
+
+	t.Run("updates existing non-buddy item in place", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "alice",
+				ClassID: wire.FeedbagClassIDPermit,
+				ItemID:  7,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, uint16(100)),
+					},
+				},
+			},
+		}, nil)
+
+		_, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIDPermit,
+			TLVLBlock: wire.TLVLBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(0x01, uint16(200)),
+				},
+			},
+		})
+		assert.False(t, inserted)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, uint16(7), upserts[0].ItemID)
+		val, ok := upserts[0].Uint16BE(0x01)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(200), val)
+	})
+
+	t.Run("updates existing buddy item matched by group", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Group1", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "Group2", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 20},
+		}, nil)
+
+		_, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 2,
+			TLVLBlock: wire.TLVLBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(0x01, uint16(999)),
+				},
+			},
+		})
+		assert.False(t, inserted)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, uint16(2), upserts[0].GroupID)
+		assert.Equal(t, "alice", upserts[0].Name)
+		assert.Equal(t, uint16(20), upserts[0].ItemID)
+		val, ok := upserts[0].Uint16BE(0x01)
+		assert.True(t, ok)
+		assert.Equal(t, uint16(999), val)
+	})
+
+	t.Run("skips update when existing item is identical", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "alice",
+				ClassID: wire.FeedbagClassIDPermit,
+				ItemID:  7,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(0x01, uint16(100)),
+					},
+				},
+			},
+		}, nil)
+
+		_, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "alice",
+			ClassID: wire.FeedbagClassIDPermit,
+			ItemID:  7,
+			TLVLBlock: wire.TLVLBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLVBE(0x01, uint16(100)),
+				},
+			},
+		})
+		assert.False(t, inserted)
+
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("normalized screen name: buddy stored with lowercase name", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 10 })
+
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "Alice",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		assert.True(t, inserted)
+		assert.Equal(t, "alice", result.Name)
+	})
+
+	t.Run("normalized screen name: buddy upsert with different case matches existing", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 99},
+		}, nil)
+
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "ALICE",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		assert.False(t, inserted)
+		assert.Equal(t, uint16(99), result.ItemID)
+		assert.Equal(t, "alice", result.Name)
+	})
+
+	t.Run("normalized screen name: permit stored with lowercase name and spaces stripped", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 1 })
+
+		result, _ := fl.upsertItem(wire.FeedbagItem{
+			Name:    " Bob Smith ",
+			ClassID: wire.FeedbagClassIDPermit,
+		})
+		assert.Equal(t, "bobsmith", result.Name)
+	})
+
+	t.Run("normalized screen name: permit upsert with different case matches existing", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 5},
+		}, nil)
+
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "BOB",
+			ClassID: wire.FeedbagClassIDPermit,
+		})
+		assert.False(t, inserted)
+		assert.Equal(t, "bob", result.Name)
+		assert.Equal(t, uint16(5), result.ItemID)
+	})
+
+	t.Run("normalized screen name: deny upsert with different case matches existing", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "charlie", ClassID: wire.FeedbagClassIDDeny, ItemID: 3},
+		}, nil)
+
+		result, inserted := fl.upsertItem(wire.FeedbagItem{
+			Name:    "Charlie",
+			ClassID: wire.FeedbagClassIDDeny,
+		})
+		assert.False(t, inserted)
+		assert.Equal(t, "charlie", result.Name)
+		assert.Equal(t, uint16(3), result.ItemID)
+	})
+}
+
+func TestFeedbagList_deleteItem(t *testing.T) {
+	t.Run("non-buddy item does not update group order", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 5},
+		}, nil)
+
+		fl.deleteItem(wire.FeedbagItem{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 5})
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("removes item from items list", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1},
+			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 2},
+			{Name: "charlie", ClassID: wire.FeedbagClassIDPermit, ItemID: 3},
+		}, nil)
+
+		fl.deleteItem(wire.FeedbagItem{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 2})
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "bob", deletes[0].Name)
+		assert.Equal(t, wire.FeedbagClassIDPermit, deletes[0].ClassID)
+	})
+
+	t.Run("multiple deletes accumulate", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIDDeny, ItemID: 1},
+			{Name: "bob", ClassID: wire.FeedbagClassIDDeny, ItemID: 2},
+		}, nil)
+
+		fl.deleteItem(wire.FeedbagItem{Name: "alice", ClassID: wire.FeedbagClassIDDeny, ItemID: 1})
+		fl.deleteItem(wire.FeedbagItem{Name: "bob", ClassID: wire.FeedbagClassIDDeny, ItemID: 2})
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 2)
+	})
+
+	t.Run("normalized screen name: delete buddy by different case", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 50},
+		}, nil)
+
+		deleted, found := fl.deleteItem(wire.FeedbagItem{
+			Name:    "ALICE",
+			ClassID: wire.FeedbagClassIdBuddy,
+			GroupID: 1,
+		})
+		assert.True(t, found)
+		assert.Equal(t, "alice", deleted.Name)
+		assert.Equal(t, uint16(50), deleted.ItemID)
+	})
+
+	t.Run("normalized screen name: delete permit by different case", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "bob", ClassID: wire.FeedbagClassIDPermit, ItemID: 7},
+		}, nil)
+
+		deleted, found := fl.deleteItem(wire.FeedbagItem{Name: "Bob", ClassID: wire.FeedbagClassIDPermit})
+		assert.True(t, found)
+		assert.Equal(t, "bob", deleted.Name)
+	})
+
+	t.Run("normalized screen name: delete deny by different case", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "charlie", ClassID: wire.FeedbagClassIDDeny, ItemID: 8},
+		}, nil)
+
+		deleted, found := fl.deleteItem(wire.FeedbagItem{Name: "CHARLIE", ClassID: wire.FeedbagClassIDDeny})
+		assert.True(t, found)
+		assert.Equal(t, "charlie", deleted.Name)
+	})
+}
+
+func TestFeedbagList_genID(t *testing.T) {
+	tt := []struct {
+		name    string
+		randInt func(n int) int
+		items   []wire.FeedbagItem
+		want    uint16
+	}{
+		{
+			name:    "empty items list returns random ID",
+			randInt: func(n int) int { return 1000 },
+			items:   []wire.FeedbagItem{},
+			want:    1000,
+		},
+		{
+			name:    "finds next available ID when starting ID conflicts with ItemID",
+			randInt: func(n int) int { return 100 },
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 101, GroupID: 1},
+			},
+			want: 102,
+		},
+		{
+			name:    "finds next available ID when starting ID conflicts with GroupID",
+			randInt: func(n int) int { return 50 },
+			items: []wire.FeedbagItem{
+				{ItemID: 1, GroupID: 50},
+				{ItemID: 2, GroupID: 51},
+			},
+			want: 52,
+		},
+		{
+			name:    "wraps around and skips 0 to find next available ID",
+			randInt: func(n int) int { return math.MaxUint16 - 2 },
+			items: []wire.FeedbagItem{
+				{ItemID: math.MaxUint16 - 2, GroupID: 1},
+				{ItemID: math.MaxUint16 - 1, GroupID: 1},
+				{ItemID: math.MaxUint16, GroupID: 1},
+			},
+			want: 2,
+		},
+		{
+			name:    "skips 0 when starting from 0 and finds next available",
+			randInt: func(n int) int { return 0 },
+			items:   []wire.FeedbagItem{},
+			want:    1,
+		},
+		{
+			name:    "returns 0 when all IDs are taken",
+			randInt: func(n int) int { return 100 },
+			items: func() []wire.FeedbagItem {
+				items := make([]wire.FeedbagItem, 0, math.MaxUint16+1)
+				for i := 0; i <= math.MaxUint16; i++ {
+					items = append(items, wire.FeedbagItem{
+						ItemID:  uint16(i),
+						GroupID: uint16(i),
+					})
+				}
+				return items
+			}(),
+			want: 0,
+		},
+		{
+			name:    "finds ID that conflicts with both ItemID and GroupID",
+			randInt: func(n int) int { return 200 },
+			items: []wire.FeedbagItem{
+				{ItemID: 200, GroupID: 201},
+				{ItemID: 201, GroupID: 200},
+			},
+			want: 202,
+		},
+		{
+			name:    "finds available ID immediately when no conflicts",
+			randInt: func(n int) int { return 500 },
+			items: []wire.FeedbagItem{
+				{ItemID: 100, GroupID: 1},
+				{ItemID: 200, GroupID: 2},
+				{ItemID: 300, GroupID: 3},
+			},
+			want: 500,
+		},
+		{
+			name:    "handles single conflict and finds next",
+			randInt: func(n int) int { return 42 },
+			items: []wire.FeedbagItem{
+				{ItemID: 42, GroupID: 1},
+			},
+			want: 43,
+		},
+		{
+			name:    "finds ID before starting point when wrapping",
+			randInt: func(n int) int { return 5 },
+			items: []wire.FeedbagItem{
+				{ItemID: 5, GroupID: 1},
+				{ItemID: 6, GroupID: 1},
+				{ItemID: 7, GroupID: 1},
+			},
+			want: 8,
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			fl := newFeedbagList(tc.items, tc.randInt)
+			got := fl.genID()
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestFeedbagList_AddGroup(t *testing.T) {
+	t.Run("creates group and root group when none exists", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+
+		group := fl.AddGroup("Buddies")
+
+		assert.Equal(t, uint16(5), group.GroupID)
+		assert.Equal(t, "Buddies", group.Name)
+		assert.Equal(t, wire.FeedbagClassIdGroup, group.ClassID)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+		root := upserts[0]
+		assert.Equal(t, wire.FeedbagClassIdGroup, root.ClassID)
+		assert.Equal(t, uint16(0), root.GroupID)
+		order, ok := root.Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{5}, order)
+	})
+
+	t.Run("updates existing root group order", func(t *testing.T) {
+		existing := []wire.FeedbagItem{
+			{
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 0,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1}),
+					},
+				},
+			},
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}
+		callCount := 0
+		fl := newFeedbagList(existing, func(n int) int {
+			callCount++
+			return callCount + 1
+		})
+
+		group := fl.AddGroup("Coworkers")
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+		order, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{1, group.GroupID}, order)
+	})
+
+	t.Run("multiple AddGroup calls accumulate in root order", func(t *testing.T) {
+		callCount := 0
+		fl := newFeedbagList(nil, func(n int) int {
+			callCount++
+			return callCount * 10
+		})
+
+		g1 := fl.AddGroup("Group1")
+		g2 := fl.AddGroup("Group2")
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 3)
+		order, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{g1.GroupID, g2.GroupID}, order)
+	})
+}
+
+func TestFeedbagList_DeleteGroup(t *testing.T) {
+	t.Run("updates root group order", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 0,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{1, 2, 3}),
+					},
+				},
+			},
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+			{Name: "Coworkers", ClassID: wire.FeedbagClassIdGroup, GroupID: 2},
+			{Name: "Family", ClassID: wire.FeedbagClassIdGroup, GroupID: 3},
+		}, nil)
+
+		fl.DeleteGroup("Coworkers")
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "Coworkers", deletes[0].Name)
+		assert.Equal(t, uint16(2), deletes[0].GroupID)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, uint16(0), upserts[0].GroupID)
+		order, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{1, 3}, order)
+	})
+}
+
+func TestFeedbagList_AddBuddy(t *testing.T) {
+	t.Run("updates parent group order", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 50 })
+
+		inserted, err := fl.AddBuddy("Buddies", "alice", "", "")
+		assert.NoError(t, err)
+		assert.True(t, inserted)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+		assert.Equal(t, uint16(1), upserts[1].GroupID)
+		order, ok := upserts[1].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{upserts[0].ItemID}, order)
+	})
+
+	t.Run("multiple buddies accumulate in parent group order", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "Buddies",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10}),
+					},
+				},
+			},
+		}, func(n int) int { return 50 })
+
+		_, err := fl.AddBuddy("Buddies", "alice", "", "")
+		assert.NoError(t, err)
+		_, err = fl.AddBuddy("Buddies", "bob", "", "")
+		assert.NoError(t, err)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 3)
+		order, ok := upserts[1].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{10, upserts[0].ItemID, upserts[2].ItemID}, order)
+	})
+
+	t.Run("returns error when parent group does not exist", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		_, err := fl.AddBuddy("Nonexistent", "alice", "", "")
+		assert.ErrorContains(t, err, "group \"Nonexistent\" not found")
+	})
+
+	t.Run("normalized screen name: stores buddy with lowercase name", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 1 })
+
+		inserted, err := fl.AddBuddy("Buddies", "Alice", "", "")
+		assert.NoError(t, err)
+		assert.True(t, inserted)
+
+		upserts := fl.PendingUpdates()
+		var buddy *wire.FeedbagItem
+		for i := range upserts {
+			if upserts[i].ClassID == wire.FeedbagClassIdBuddy {
+				buddy = &upserts[i]
+				break
+			}
+		}
+		assert.NotNil(t, buddy)
+		assert.Equal(t, "alice", buddy.Name)
+	})
+
+	t.Run("normalized screen name: second AddBuddy with different case does not insert duplicate", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 1 })
+
+		inserted1, err := fl.AddBuddy("Buddies", "alice", "", "")
+		assert.NoError(t, err)
+		assert.True(t, inserted1)
+
+		inserted2, err := fl.AddBuddy("Buddies", "ALICE", "", "")
+		assert.NoError(t, err)
+		assert.False(t, inserted2)
+	})
+
+	t.Run("normalized screen name: DeleteBuddy finds buddy by different case", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 1 })
+		_, err := fl.AddBuddy("Buddies", "alice", "", "")
+		assert.NoError(t, err)
+		_ = fl.PendingUpdates()
+
+		err = fl.DeleteBuddy("Buddies", "Alice")
+		assert.NoError(t, err)
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "alice", deletes[0].Name)
+	})
+}
+
+func TestFeedbagList_DeleteBuddy(t *testing.T) {
+	t.Run("updates parent group order", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "Buddies",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20, 30}),
+					},
+				},
+			},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+			{Name: "bob", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 20},
+			{Name: "charlie", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 30},
+		}, nil)
+
+		err := fl.DeleteBuddy("Buddies", "bob")
+		assert.NoError(t, err)
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "bob", deletes[0].Name)
+		assert.Equal(t, uint16(20), deletes[0].ItemID)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		order, ok := upserts[0].Uint16SliceBE(wire.FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{10, 30}, order)
+	})
+
+	t.Run("removes only buddy in specified group when same screen name in two groups", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{
+				Name:    "Buddies",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 1,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{10, 20}),
+					},
+				},
+			},
+			{
+				Name:    "Coworkers",
+				ClassID: wire.FeedbagClassIdGroup,
+				GroupID: 2,
+				TLVLBlock: wire.TLVLBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.FeedbagAttributesOrder, []uint16{20}),
+					},
+				},
+			},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 2, ItemID: 20},
+			{Name: "alice", ClassID: wire.FeedbagClassIdBuddy, GroupID: 1, ItemID: 10},
+		}, nil)
+
+		err := fl.DeleteBuddy("Buddies", "alice")
+		assert.NoError(t, err)
+
+		deletes := fl.PendingDeletes()
+		assert.Len(t, deletes, 1)
+		assert.Equal(t, "alice", deletes[0].Name)
+		assert.Equal(t, uint16(1), deletes[0].GroupID, "should delete from Buddies (group 1), not Coworkers (group 2)")
+		assert.Equal(t, uint16(10), deletes[0].ItemID)
+	})
+}
+
+func TestFeedbagList_PendingUpdates(t *testing.T) {
+	t.Run("empty when nothing inserted", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 1 })
+		assert.Nil(t, fl.PendingUpdates())
+	})
+	t.Run("includes inserts and updates", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "Buddies", ClassID: wire.FeedbagClassIdGroup, GroupID: 1},
+		}, func(n int) int { return 50 })
+		_, err := fl.AddBuddy("Buddies", "alice", "", "")
+		assert.NoError(t, err)
+		_, err = fl.AddBuddy("Buddies", "bob", "", "")
+		assert.NoError(t, err)
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 3)
+		assert.Equal(t, wire.FeedbagClassIdBuddy, upserts[0].ClassID)
+		assert.Equal(t, wire.FeedbagClassIdGroup, upserts[1].ClassID)
+		assert.Equal(t, wire.FeedbagClassIdBuddy, upserts[2].ClassID)
+	})
+	t.Run("clears after retrieval", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl.AddGroup("Buddies")
+		assert.Len(t, fl.PendingUpdates(), 2)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+}
+
+func TestFeedbagList_PendingDeletes(t *testing.T) {
+	t.Run("empty when nothing deleted", func(t *testing.T) {
+		fl := newFeedbagList(nil, nil)
+		assert.Nil(t, fl.PendingDeletes())
+	})
+
+	t.Run("clears after retrieval", func(t *testing.T) {
+		fl := newFeedbagList([]wire.FeedbagItem{
+			{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1},
+		}, nil)
+		fl.deleteItem(wire.FeedbagItem{Name: "alice", ClassID: wire.FeedbagClassIDPermit, ItemID: 1})
+		assert.Len(t, fl.PendingDeletes(), 1)
+		assert.Nil(t, fl.PendingDeletes())
+	})
+}
+
+func TestFeedbagList_PendingUpdates_upsertsOnly(t *testing.T) {
+	t.Run("tracks upserted items", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		result, inserted := fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice"})
+		assert.True(t, inserted)
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 1)
+		assert.Equal(t, "alice", upserts[0].Name)
+		assert.Equal(t, uint16(5), result.ItemID)
+	})
+
+	t.Run("clears after retrieval", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 5 })
+		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit})
+		assert.Len(t, fl.PendingUpdates(), 1)
+		assert.Nil(t, fl.PendingUpdates())
+	})
+
+	t.Run("multiple inserts accumulate", func(t *testing.T) {
+		fl := newFeedbagList(nil, func(n int) int { return 10 })
+		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "alice"})
+		fl.upsertItem(wire.FeedbagItem{ClassID: wire.FeedbagClassIDPermit, Name: "bob"})
+
+		upserts := fl.PendingUpdates()
+		assert.Len(t, upserts, 2)
+	})
+}

+ 49 - 0
wire/snacs.go

@@ -2654,6 +2654,55 @@ type FeedbagItem struct {
 	TLVLBlock
 }
 
+// IsEqual reports whether f and other have identical field values, including
+// the same TLVs in the same order with the same data.
+func (f *FeedbagItem) IsEqual(other FeedbagItem) bool {
+	if f.Name != other.Name || f.GroupID != other.GroupID || f.ClassID != other.ClassID {
+		return false
+	}
+	if len(f.TLVLBlock.TLVList) != len(other.TLVLBlock.TLVList) {
+		return false
+	}
+	for i, tlv := range f.TLVLBlock.TLVList {
+		o := other.TLVLBlock.TLVList[i]
+		if tlv.Tag != o.Tag || !bytes.Equal(tlv.Value, o.Value) {
+			return false
+		}
+	}
+	return true
+}
+
+// AppendOrderMembers adds member IDs to the item's order attribute TLV. If
+// the order TLV already exists, the new IDs are appended to it. Otherwise a
+// new order TLV is created.
+func (f *FeedbagItem) AppendOrderMembers(memberIDs ...uint16) {
+	if existing, ok := f.Uint16SliceBE(FeedbagAttributesOrder); ok {
+		f.Replace(NewTLVBE(FeedbagAttributesOrder, append(existing, memberIDs...)))
+	} else {
+		f.Append(NewTLVBE(FeedbagAttributesOrder, memberIDs))
+	}
+}
+
+// RemoveOrderMembers removes the specified member IDs from the item's order
+// attribute TLV. IDs not present in the order are ignored.
+func (f *FeedbagItem) RemoveOrderMembers(memberIDs ...uint16) {
+	existing, ok := f.Uint16SliceBE(FeedbagAttributesOrder)
+	if !ok {
+		return
+	}
+	remove := make(map[uint16]struct{}, len(memberIDs))
+	for _, id := range memberIDs {
+		remove[id] = struct{}{}
+	}
+	filtered := make([]uint16, 0, len(existing))
+	for _, id := range existing {
+		if _, found := remove[id]; !found {
+			filtered = append(filtered, id)
+		}
+	}
+	f.Replace(NewTLVBE(FeedbagAttributesOrder, filtered))
+}
+
 // ICQDCInfo represents ICQ direct connect settings.
 type ICQDCInfo struct {
 	IP                      uint32

+ 94 - 0
wire/snacs_test.go

@@ -291,3 +291,97 @@ func TestCapabilityUUIDs(t *testing.T) {
 		})
 	}
 }
+
+func TestFeedbagItem_AppendOrderMembers(t *testing.T) {
+	t.Run("creates order TLV when none exists", func(t *testing.T) {
+		item := FeedbagItem{ClassID: FeedbagClassIdGroup, GroupID: 1}
+		item.AppendOrderMembers(10, 20)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{10, 20}, order)
+	})
+
+	t.Run("appends to existing order TLV", func(t *testing.T) {
+		item := FeedbagItem{
+			ClassID: FeedbagClassIdGroup,
+			GroupID: 1,
+			TLVLBlock: TLVLBlock{
+				TLVList: TLVList{
+					NewTLVBE(FeedbagAttributesOrder, []uint16{5, 10}),
+				},
+			},
+		}
+		item.AppendOrderMembers(15, 20)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{5, 10, 15, 20}, order)
+	})
+
+	t.Run("single member", func(t *testing.T) {
+		item := FeedbagItem{ClassID: FeedbagClassIdGroup}
+		item.AppendOrderMembers(42)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{42}, order)
+	})
+}
+
+func TestFeedbagItem_RemoveOrderMembers(t *testing.T) {
+	t.Run("removes specified members", func(t *testing.T) {
+		item := FeedbagItem{
+			ClassID: FeedbagClassIdGroup,
+			TLVLBlock: TLVLBlock{
+				TLVList: TLVList{
+					NewTLVBE(FeedbagAttributesOrder, []uint16{5, 10, 15, 20}),
+				},
+			},
+		}
+		item.RemoveOrderMembers(10, 20)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{5, 15}, order)
+	})
+
+	t.Run("no-op when order TLV does not exist", func(t *testing.T) {
+		item := FeedbagItem{ClassID: FeedbagClassIdGroup}
+		item.RemoveOrderMembers(10)
+		_, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.False(t, ok)
+	})
+
+	t.Run("removes all members", func(t *testing.T) {
+		item := FeedbagItem{
+			ClassID: FeedbagClassIdGroup,
+			TLVLBlock: TLVLBlock{
+				TLVList: TLVList{
+					NewTLVBE(FeedbagAttributesOrder, []uint16{5, 10}),
+				},
+			},
+		}
+		item.RemoveOrderMembers(5, 10)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Empty(t, order)
+	})
+
+	t.Run("ignores IDs not in order", func(t *testing.T) {
+		item := FeedbagItem{
+			ClassID: FeedbagClassIdGroup,
+			TLVLBlock: TLVLBlock{
+				TLVList: TLVList{
+					NewTLVBE(FeedbagAttributesOrder, []uint16{5, 10}),
+				},
+			},
+		}
+		item.RemoveOrderMembers(99)
+
+		order, ok := item.Uint16SliceBE(FeedbagAttributesOrder)
+		assert.True(t, ok)
+		assert.Equal(t, []uint16{5, 10}, order)
+	})
+}