Mike 1 день назад
Родитель
Сommit
644df6f5d5

+ 15 - 13
server/webapi/aim_handler.go

@@ -312,12 +312,19 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 		return &BuddyListData{Groups: groups}, nil
 	}
 
-	// Wire the alias loader so OSCAR-driven im/presence events can repeat the
-	// buddy's friendly name. The client discards the alias it holds each time it
-	// merges a user map, so an event that omits it renames the buddy. The session
-	// caches what this returns until a feedbag change invalidates it.
-	session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
-		return LookupBuddyAliases(ctx, h.FeedbagService, session.OSCARSession)
+	// Wire the feedbag loader. The session caches what it returns until a feedbag
+	// change invalidates it, and derives every read-only view of the roster from it.
+	session.FeedbagLoader = func(ctx context.Context) ([]wire.FeedbagItem, error) {
+		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
+		snac, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
+		if err != nil {
+			return nil, err
+		}
+		reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+		if !ok {
+			return nil, fmt.Errorf("unexpected feedbag reply type")
+		}
+		return reply.Items, nil
 	}
 
 	// Wire the buddy-icon URL formatter so presence broadcasts (BuddyArrived) can
@@ -328,16 +335,11 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 
 	// Wire permit/deny refresher so FeedbagUpdateItem SNACs trigger a permitDeny event.
 	session.PermitDenyRefresher = func(ctx context.Context) (any, error) {
-		frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-		fb, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
+		items, err := session.Feedbag(ctx)
 		if err != nil {
 			return nil, err
 		}
-		reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-		if !ok {
-			return nil, fmt.Errorf("unexpected feedbag reply type")
-		}
-		return permitDenyData(reply.Items), nil
+		return permitDenyData(items), nil
 	}
 
 	// Only IM-class rate limit updates should surface to the client alert.

+ 7 - 51
server/webapi/buddy_list_manager.go

@@ -68,16 +68,10 @@ type BuddyInfo struct {
 
 // GetBuddyListForUser retrieves and converts the buddy list for a user.
 func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Session) ([]BuddyGroup, error) {
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	items, err := sess.Feedbag(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
 	}
-	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-	if !ok {
-		return nil, fmt.Errorf("failed to retrieve feedbag: unexpected reply type")
-	}
-	items := reply.Items
 
 	type buddy struct {
 		name  string
@@ -234,10 +228,7 @@ func (m *BuddyListManager) getBuddyInfo(ctx context.Context, sess *Session, budd
 
 // RemoveBuddyFromFeedbag removes a buddy from a group (or all groups if allGroups is true) using feedbag delete/update SNACs.
 func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, allGroups bool) (resultCode string, err error) {
-	// Buddy items carry the owner's alias for the buddy, and the feedbag service
-	// relays a session's own writes only to the owner's other instances, so every
-	// method here that rewrites buddy items has to drop the alias cache itself.
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	buddyName = strings.TrimSpace(buddyName)
 	if buddyName == "" {
@@ -296,7 +287,7 @@ func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *Ses
 
 // RemoveGroupFromFeedbag deletes a buddy group and updates the root order (TOC DelGroup).
 func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *Session, requestedGroup string) (resultCode string, err error) {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	req := strings.TrimSpace(requestedGroup)
 	if req == "" {
@@ -355,7 +346,7 @@ func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *Ses
 
 // RenameGroupInFeedbag renames a buddy group, updating the group item in place.
 func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *Session, oldGroup, newGroup string) (resultCode string, err error) {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	oldGroup = strings.TrimSpace(oldGroup)
 	newGroup = strings.TrimSpace(newGroup)
@@ -405,7 +396,7 @@ func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *Sessi
 // MoveBuddyInFeedbag moves a buddy to a different group and/or repositions it
 // within a group's order.
 func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *Session, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	buddyName = strings.TrimSpace(buddyName)
 	fromGroup = strings.TrimSpace(fromGroup)
@@ -486,7 +477,7 @@ func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *Session
 // SetBuddyAttributeInFeedbag sets a buddy's friendly (alias) name across all
 // groups it belongs to. An empty friendly clears the alias.
 func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *Session, buddyName, friendly string) (resultCode string, err error) {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	buddyName = strings.TrimSpace(buddyName)
 	if buddyName == "" {
@@ -527,7 +518,7 @@ func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess
 // SetGroupAttributeInFeedbag sets a group's collapsed state. An empty group
 // targets the unnamed default group.
 func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *Session, groupName string, collapsed bool) (resultCode string, err error) {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	groupName = strings.TrimSpace(groupName)
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
@@ -621,38 +612,3 @@ func storedGroupNameForRequest(items []wire.FeedbagItem, requested string) (stri
 	}
 	return "", false
 }
-
-// FeedbagAliases collects the aliases the feedbag owner has assigned to their
-// buddies, keyed by normalized screen name. Buddies without an alias are absent.
-func FeedbagAliases(items []wire.FeedbagItem) map[string]string {
-	aliases := make(map[string]string)
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
-			continue
-		}
-		alias, ok := item.String(wire.FeedbagAttributesAlias)
-		if !ok || alias == "" {
-			continue
-		}
-		aliases[state.NewIdentScreenName(item.Name).String()] = alias
-	}
-	return aliases
-}
-
-// LookupBuddyAliases returns the aliases the session owner has assigned to their
-// buddies, keyed by normalized screen name.
-//
-// Aliases are private to the viewer and live only in their feedbag, so they cannot
-// be derived from a locate reply the way display names are.
-func LookupBuddyAliases(ctx context.Context, feedbagService FeedbagService, instance *state.SessionInstance) (map[string]string, error) {
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	snac, err := feedbagService.Query(ctx, instance, frame)
-	if err != nil {
-		return nil, err
-	}
-	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-	if !ok {
-		return nil, fmt.Errorf("unexpected feedbag reply type")
-	}
-	return FeedbagAliases(reply.Items), nil
-}

+ 60 - 3
server/webapi/buddy_list_manager_test.go

@@ -308,7 +308,9 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			sess := &Session{
 				ScreenName:   state.DisplayScreenName(owner.String()),
 				OSCARSession: state.NewSession().AddInstance(),
+				logger:       slog.Default(),
 			}
+			sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 			got, err := m.GetBuddyListForUser(ctx, sess)
 
 			if tt.wantErr != "" {
@@ -348,6 +350,7 @@ func TestBuddyListManager_GetBuddyListForUser_DisplayIDFromPresenceView(t *testi
 		OSCARSession: state.NewSession().AddInstance(),
 		logger:       slog.Default(),
 	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 	buddyArrives(sess, onlineBuddy("Mike Kelly"))
 
 	got, err := m.GetBuddyListForUser(ctx, sess)
@@ -385,6 +388,7 @@ func TestBuddyListManager_GetBuddyListForUser_DepartureKeepsDisplayID(t *testing
 		BaseURL:      "http://api.example.com",
 		logger:       slog.Default(),
 	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 	buddyArrives(sess, bartBuddy("Mike Kelly", testIconBART, testStatusBART))
 	buddyDeparts(sess, "Mike Kelly")
 
@@ -435,6 +439,7 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesBuddyIcons(t *testing.T)
 		BaseURL:      "http://api.example.com",
 		logger:       slog.Default(),
 	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 	buddyArrives(sess, bartBuddy("onlineicon", wire.BARTID{
 		Type:     wire.BARTTypesBuddyIcon,
 		BARTInfo: wire.BARTInfo{Hash: []byte{0xab, 0xcd}},
@@ -491,6 +496,7 @@ func TestBuddyListManager_GetBuddyListForUser_PublishesStatusMessages(t *testing
 		BaseURL:      "http://api.example.com",
 		logger:       slog.Default(),
 	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 	buddyArrives(sess, bartBuddy("hasstatus", testStatusBART))
 	buddyArrives(sess, onlineBuddy("nostatus"))
 
@@ -543,6 +549,8 @@ func TestBuddyListManager_GetBuddyListForUser_QueriesOnlyAwayBuddies(t *testing.
 		logger:       slog.Default(),
 	}
 
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
+
 	away := wire.TLVUserInfo{ScreenName: "awaybud"}
 	away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
 
@@ -597,9 +605,7 @@ func TestBuddyListManager_SetBuddyAttributeInFeedbag_InvalidatesAliasCache(t *te
 		ScreenName:   state.DisplayScreenName("listowner"),
 		OSCARSession: state.NewSession().AddInstance(),
 	}
-	sess.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
-		return LookupBuddyAliases(ctx, fs, sess.OSCARSession)
-	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 
 	require.Equal(t, "MICHAELKELLY", sess.Aliases(ctx)["mikekelly"])
 
@@ -705,6 +711,7 @@ func TestBuddyListManager_RemoveBuddyFromFeedbag_ForgetsPresence(t *testing.T) {
 				OSCARSession: state.NewSession().AddInstance(),
 				logger:       slog.Default(),
 			}
+			sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 			buddyArrives(sess, onlineBuddy("Mike Kelly"))
 
 			resultCode, err := m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", tt.group, tt.allGroups)
@@ -744,6 +751,7 @@ func TestBuddyListManager_RemoveGroupFromFeedbag_ForgetsPresence(t *testing.T) {
 		OSCARSession: state.NewSession().AddInstance(),
 		logger:       slog.Default(),
 	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
 	buddyArrives(sess, onlineBuddy("Mike Kelly"))
 
 	resultCode, err := m.RemoveGroupFromFeedbag(ctx, sess, "Buddies")
@@ -753,3 +761,52 @@ func TestBuddyListManager_RemoveGroupFromFeedbag_ForgetsPresence(t *testing.T) {
 	_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
 	assert.False(t, ok)
 }
+
+// A method that rewrites the feedbag must re-read it even when the cache is warm:
+// it computes a pending diff from what it reads.
+func TestBuddyListManager_MutationsReReadTheFeedbag(t *testing.T) {
+	ctx := context.Background()
+
+	fb := []wire.FeedbagItem{
+		{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{1})}}},
+		{ItemID: 1, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: "mikekelly"},
+	}
+
+	var queries int
+	fs := newMockFeedbagService(t)
+	fs.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		RunAndReturn(func(context.Context, *state.SessionInstance, wire.SNACFrame) (wire.SNACMessage, error) {
+			queries++
+			return wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: fb}}, nil
+		})
+	fs.EXPECT().DeleteItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(&wire.SNACMessage{}, nil).Once()
+	fs.EXPECT().UpsertItem(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Return(&wire.SNACMessage{}, nil).Maybe()
+
+	m := NewBuddyListManager(fs, newMockLocateService(t), newTestIconSource(t), slog.Default())
+	sess := &Session{
+		ScreenName:   state.DisplayScreenName("listowner"),
+		OSCARSession: state.NewSession().AddInstance(),
+		logger:       slog.Default(),
+	}
+	sess.FeedbagLoader = feedbagServiceLoader(fs, sess.OSCARSession)
+
+	_, err := m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	require.Equal(t, 1, queries)
+	_, err = m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	require.Equal(t, 1, queries, "a warm cache serves the roster without a read")
+
+	_, err = m.RemoveBuddyFromFeedbag(ctx, sess, "mikekelly", "Buddies", false)
+	require.NoError(t, err)
+	assert.Equal(t, 2, queries, "the mutation re-reads rather than trusting the cache")
+
+	_, err = m.GetBuddyListForUser(ctx, sess)
+	require.NoError(t, err)
+	assert.Equal(t, 3, queries, "the write invalidated the cache")
+}

+ 2 - 4
server/webapi/buddylist_handler.go

@@ -76,9 +76,7 @@ func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, sess
 }
 
 func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *Session, groupName string) string {
-	// A session sees no SNAC for its own feedbag writes, so it drops the alias
-	// cache itself. See WebAPISession.InvalidateAliases.
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
 	snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
@@ -166,7 +164,7 @@ func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, s
 
 // addBuddyToFeedbag adds a buddy to the user's feedbag.
 func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, preAuthorized bool, authorizationMsg string) string {
-	defer sess.InvalidateAliases()
+	defer sess.InvalidateFeedbag()
 
 	// Retrieve current feedbag
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}

+ 71 - 59
server/webapi/buddylist_handler_test.go

@@ -34,11 +34,12 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			},
 			setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
 				session := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
+					AimSID:        aimsid,
+					ScreenName:    state.DisplayScreenName("testuser"),
+					OSCARSession:  state.NewSession().AddInstance(),
+					EventQueue:    NewEventQueue(100),
+					LastAccessed:  time.Now(),
+					FeedbagLoader: emptyFeedbagLoader,
 				}
 
 				items := []wire.FeedbagItem{
@@ -63,11 +64,12 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			},
 			setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
 				session := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
+					AimSID:        aimsid,
+					ScreenName:    state.DisplayScreenName("testuser"),
+					OSCARSession:  state.NewSession().AddInstance(),
+					EventQueue:    NewEventQueue(100),
+					LastAccessed:  time.Now(),
+					FeedbagLoader: emptyFeedbagLoader,
 				}
 
 				// Friends group with existingbuddy already present
@@ -90,10 +92,11 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			},
 			setupMocks: func(sm *mockSessionResolver, fs *mockFeedbagService, blmFs *mockFeedbagService, aimsid string) *Session {
 				return &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
+					AimSID:        aimsid,
+					ScreenName:    state.DisplayScreenName("testuser"),
+					EventQueue:    NewEventQueue(100),
+					LastAccessed:  time.Now(),
+					FeedbagLoader: emptyFeedbagLoader,
 				}
 			},
 			expectedStatusCode: http.StatusBadRequest,
@@ -149,11 +152,12 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 
 	newSession := func(aimsid string) *Session {
 		return &Session{
-			AimSID:       aimsid,
-			ScreenName:   state.DisplayScreenName("testuser"),
-			OSCARSession: state.NewSession().AddInstance(),
-			EventQueue:   NewEventQueue(100),
-			LastAccessed: time.Now(),
+			AimSID:        aimsid,
+			ScreenName:    state.DisplayScreenName("testuser"),
+			OSCARSession:  state.NewSession().AddInstance(),
+			EventQueue:    NewEventQueue(100),
+			LastAccessed:  time.Now(),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -280,11 +284,12 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 			queryParams: map[string][]string{"aimsid": {"sess"}, "buddy": {"someBuddy"}, "group": {"Friends"}},
 			setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
 				sess := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
+					AimSID:        aimsid,
+					ScreenName:    state.DisplayScreenName("testuser"),
+					OSCARSession:  state.NewSession().AddInstance(),
+					EventQueue:    NewEventQueue(100),
+					LastAccessed:  time.Now(),
+					FeedbagLoader: emptyFeedbagLoader,
 				}
 				items := []wire.FeedbagItem{
 					{GroupID: 1, ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
@@ -402,11 +407,12 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 			queryParams: map[string][]string{"aimsid": {"sess"}, "group": {"Friends"}},
 			setup: func(sm *mockSessionResolver, blm *BuddyListManager, fs *mockFeedbagService, aimsid string) *Session {
 				sess := &Session{
-					AimSID:       aimsid,
-					ScreenName:   state.DisplayScreenName("testuser"),
-					OSCARSession: state.NewSession().AddInstance(),
-					EventQueue:   NewEventQueue(100),
-					LastAccessed: time.Now(),
+					AimSID:        aimsid,
+					ScreenName:    state.DisplayScreenName("testuser"),
+					OSCARSession:  state.NewSession().AddInstance(),
+					EventQueue:    NewEventQueue(100),
+					LastAccessed:  time.Now(),
+					FeedbagLoader: emptyFeedbagLoader,
 				}
 				// Root order record + Friends group; DeleteGroup will delete Friends and update root.
 				items := []wire.FeedbagItem{
@@ -587,11 +593,12 @@ func TestBuddyListHandler_RenameGroup(t *testing.T) {
 	}
 	sessWithOSCAR := func(aimsid string) *Session {
 		return &Session{
-			AimSID:       aimsid,
-			ScreenName:   state.DisplayScreenName("testuser"),
-			OSCARSession: state.NewSession().AddInstance(),
-			EventQueue:   NewEventQueue(100),
-			LastAccessed: time.Now(),
+			AimSID:        aimsid,
+			ScreenName:    state.DisplayScreenName("testuser"),
+			OSCARSession:  state.NewSession().AddInstance(),
+			EventQueue:    NewEventQueue(100),
+			LastAccessed:  time.Now(),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -669,11 +676,12 @@ func TestBuddyListHandler_MoveBuddy(t *testing.T) {
 	}
 	sessWithOSCAR := func(aimsid string) *Session {
 		return &Session{
-			AimSID:       aimsid,
-			ScreenName:   state.DisplayScreenName("testuser"),
-			OSCARSession: state.NewSession().AddInstance(),
-			EventQueue:   NewEventQueue(100),
-			LastAccessed: time.Now(),
+			AimSID:        aimsid,
+			ScreenName:    state.DisplayScreenName("testuser"),
+			OSCARSession:  state.NewSession().AddInstance(),
+			EventQueue:    NewEventQueue(100),
+			LastAccessed:  time.Now(),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -758,11 +766,12 @@ func TestBuddyListHandler_SetBuddyAttribute(t *testing.T) {
 	}
 	sessWithOSCAR := func(aimsid string) *Session {
 		return &Session{
-			AimSID:       aimsid,
-			ScreenName:   state.DisplayScreenName("testuser"),
-			OSCARSession: state.NewSession().AddInstance(),
-			EventQueue:   NewEventQueue(100),
-			LastAccessed: time.Now(),
+			AimSID:        aimsid,
+			ScreenName:    state.DisplayScreenName("testuser"),
+			OSCARSession:  state.NewSession().AddInstance(),
+			EventQueue:    NewEventQueue(100),
+			LastAccessed:  time.Now(),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -840,11 +849,12 @@ func TestBuddyListHandler_SetGroupAttribute(t *testing.T) {
 	}
 	sessWithOSCAR := func(aimsid string) *Session {
 		return &Session{
-			AimSID:       aimsid,
-			ScreenName:   state.DisplayScreenName("testuser"),
-			OSCARSession: state.NewSession().AddInstance(),
-			EventQueue:   NewEventQueue(100),
-			LastAccessed: time.Now(),
+			AimSID:        aimsid,
+			ScreenName:    state.DisplayScreenName("testuser"),
+			OSCARSession:  state.NewSession().AddInstance(),
+			EventQueue:    NewEventQueue(100),
+			LastAccessed:  time.Now(),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -984,11 +994,12 @@ func TestBuddyListHandler_AddBuddy_PreAuthorized(t *testing.T) {
 			oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
 
 			session := &Session{
-				AimSID:       "sid",
-				OSCARSession: oscarSess.AddInstance(),
-				ScreenName:   state.DisplayScreenName(tt.screenName),
-				EventQueue:   NewEventQueue(100),
-				LastAccessed: time.Now(),
+				AimSID:        "sid",
+				OSCARSession:  oscarSess.AddInstance(),
+				ScreenName:    state.DisplayScreenName(tt.screenName),
+				EventQueue:    NewEventQueue(100),
+				LastAccessed:  time.Now(),
+				FeedbagLoader: emptyFeedbagLoader,
 			}
 			sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
 			sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()
@@ -1120,11 +1131,12 @@ func TestBuddyListHandler_AddBuddy_PendingFlagFollowsPairing(t *testing.T) {
 			oscarSess.SetUIN(state.NewIdentScreenName(tt.screenName).UIN())
 
 			session := &Session{
-				AimSID:       "sid",
-				OSCARSession: oscarSess.AddInstance(),
-				ScreenName:   state.DisplayScreenName(tt.screenName),
-				EventQueue:   NewEventQueue(100),
-				LastAccessed: time.Now(),
+				AimSID:        "sid",
+				OSCARSession:  oscarSess.AddInstance(),
+				ScreenName:    state.DisplayScreenName(tt.screenName),
+				EventQueue:    NewEventQueue(100),
+				LastAccessed:  time.Now(),
+				FeedbagLoader: emptyFeedbagLoader,
 			}
 			sm.EXPECT().GetSession(mock.Anything, "sid").Return(session, nil)
 			sm.EXPECT().TouchSession(mock.Anything, "sid").Return(nil).Maybe()

+ 2 - 0
server/webapi/expressions_handler.go

@@ -161,6 +161,8 @@ type UploadData struct {
 
 // Upload handles POST /expressions/upload, which stores a buddy icon.
 func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, session *Session) {
+	defer session.InvalidateFeedbag()
+
 	ctx := r.Context()
 
 	var bartType uint16

+ 49 - 4
server/webapi/helpers_test.go

@@ -1,6 +1,8 @@
 package webapi
 
 import (
+	"context"
+	"fmt"
 	"log/slog"
 	"testing"
 	"time"
@@ -67,13 +69,20 @@ func newTestOSCARInstance(t *testing.T, classes wire.RateLimitClasses) *state.Se
 // one OSCAR session and therefore one set of rate limit states.
 func newTestWebAPISessionOn(aimsid string, instance *state.SessionInstance) *Session {
 	return &Session{
-		AimSID:       aimsid,
-		ScreenName:   "me",
-		OSCARSession: instance,
-		EventQueue:   NewEventQueue(10),
+		AimSID:        aimsid,
+		ScreenName:    "me",
+		OSCARSession:  instance,
+		EventQueue:    NewEventQueue(10),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 }
 
+// emptyFeedbagLoader stands in for the loader startSession wires, for sessions
+// whose test does not care what is on the roster.
+func emptyFeedbagLoader(context.Context) ([]wire.FeedbagItem, error) {
+	return nil, nil
+}
+
 // newTestWebAPISession builds a WebAPI session backed by a real OSCAR session
 // with rate limit state initialized.
 func newTestWebAPISession(t *testing.T, classes wire.RateLimitClasses) *Session {
@@ -128,3 +137,39 @@ func bartBuddy(screenName string, ids ...wire.BARTID) wire.TLVUserInfo {
 	info.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, ids))
 	return info
 }
+
+// aliasFeedbagLoader returns a FeedbagLoader serving one buddy item per alias.
+func aliasFeedbagLoader(aliases map[string]string) func(context.Context) ([]wire.FeedbagItem, error) {
+	return func(context.Context) ([]wire.FeedbagItem, error) {
+		return aliasFeedbagItems(aliases), nil
+	}
+}
+
+// aliasFeedbagItems builds the buddy rows carrying the given aliases.
+func aliasFeedbagItems(aliases map[string]string) []wire.FeedbagItem {
+	items := make([]wire.FeedbagItem, 0, len(aliases))
+	var itemID uint16
+	for name, alias := range aliases {
+		itemID++
+		item := wire.FeedbagItem{ItemID: itemID, ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: name}
+		item.TLVLBlock = wire.TLVLBlock{TLVList: wire.TLVList{wire.NewTLVBE(wire.FeedbagAttributesAlias, alias)}}
+		items = append(items, item)
+	}
+	return items
+}
+
+// feedbagServiceLoader returns a FeedbagLoader backed by a FeedbagService, as
+// startSession wires it.
+func feedbagServiceLoader(fs FeedbagService, instance *state.SessionInstance) func(context.Context) ([]wire.FeedbagItem, error) {
+	return func(ctx context.Context) ([]wire.FeedbagItem, error) {
+		snac, err := fs.Query(ctx, instance, wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery})
+		if err != nil {
+			return nil, err
+		}
+		reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+		if !ok {
+			return nil, fmt.Errorf("unexpected feedbag reply type")
+		}
+		return reply.Items, nil
+	}
+}

+ 5 - 6
server/webapi/im_handler_test.go

@@ -29,6 +29,7 @@ func requireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Req
 func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*SessionManager, string) {
 	mgr := NewSessionManager()
 	session, _ := mgr.CreateSession(state.DisplayScreenName(screenName), []string{"im", "presence", "buddylist", "sentIM", "typing"}, oscarSession, "", slog.Default())
+	session.FeedbagLoader = emptyFeedbagLoader
 	return mgr, session.AimSID
 }
 
@@ -75,6 +76,7 @@ func sendIMForDest(t *testing.T, dest, locateName, alias string) []Event {
 	mgr := NewSessionManager()
 	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), []string{"im", "sentIM", "conversation"}, oscarInstance, "", slog.Default())
 	require.NoError(t, err)
+	session.FeedbagLoader = emptyFeedbagLoader
 
 	handler := &MessagingHandler{
 		ICBMService:    icbmService,
@@ -84,9 +86,7 @@ func sendIMForDest(t *testing.T, dest, locateName, alias string) []Event {
 	}
 
 	// startSession wires this in production; SendIM reads aliases off the session.
-	session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
-		return LookupBuddyAliases(ctx, handler.FeedbagService, session.OSCARSession)
-	}
+	session.FeedbagLoader = feedbagServiceLoader(handler.FeedbagService, session.OSCARSession)
 
 	req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t="+url.QueryEscape(dest)+"&message=hi", nil)
 	require.NoError(t, err)
@@ -582,6 +582,7 @@ func TestMessagingHandler_SendIM_EchoesRealStates(t *testing.T) {
 		session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"),
 			[]string{"im", "sentIM"}, oscarInstance, "", slog.Default())
 		require.NoError(t, err)
+		session.FeedbagLoader = emptyFeedbagLoader
 
 		if recipientPresence != nil {
 			buddyArrives(session, *recipientPresence)
@@ -593,9 +594,7 @@ func TestMessagingHandler_SendIM_EchoesRealStates(t *testing.T) {
 			FeedbagService: stubFeedbagService(t, "mikekelly", ""),
 			Logger:         slog.Default(),
 		}
-		session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
-			return LookupBuddyAliases(ctx, handler.FeedbagService, session.OSCARSession)
-		}
+		session.FeedbagLoader = feedbagServiceLoader(handler.FeedbagService, session.OSCARSession)
 
 		req, err := http.NewRequest("GET", "/im/sendIM?aimsid="+session.AimSID+"&t=mikekelly&message=hi", nil)
 		require.NoError(t, err)

+ 6 - 9
server/webapi/preference_handler.go

@@ -216,6 +216,8 @@ type PermitDenyData struct {
 
 // SetPreferences handles GET /preference/set requests to update user preferences.
 func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request, session *Session) {
+	defer session.InvalidateFeedbag()
+
 	ctx := r.Context()
 
 	// Preferences are stored as OSCAR buddy prefs in the feedbag, which requires
@@ -452,6 +454,8 @@ func boolToPrefInt(b bool) int {
 
 // SetPermitDeny handles GET /preference/setPermitDeny requests to update permit/deny settings.
 func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request, session *Session) {
+	defer session.InvalidateFeedbag()
+
 	ctx := r.Context()
 
 	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
@@ -601,20 +605,13 @@ func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
 func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request, session *Session) {
 	ctx := r.Context()
 
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
+	items, err := session.Feedbag(ctx)
 	if err != nil {
 		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
 		return
 	}
 
-	reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-	if !ok {
-		SendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
-		return
-	}
-
-	pdd := permitDenyData(reply.Items)
+	pdd := permitDenyData(items)
 	h.Logger.DebugContext(ctx, "permit/deny settings retrieved",
 		"screenName", session.ScreenName.String(),
 		"pdMode", pdd.PDMode,

+ 8 - 12
server/webapi/presence_handler.go

@@ -3,7 +3,6 @@ package webapi
 import (
 	"context"
 	"errors"
-	"fmt"
 	"log/slog"
 	"net/http"
 	"slices"
@@ -198,16 +197,10 @@ func (h *PresenceHandler) directoryProfile(ctx context.Context, screenName strin
 
 // getBuddyListGroups retrieves the buddy list organized by groups.
 func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	reply, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
+	items, err := session.Feedbag(ctx)
 	if err != nil {
 		return nil, err
 	}
-	body, ok := reply.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
-	if !ok {
-		return nil, fmt.Errorf("unexpected feedbag reply body type %T", reply.Body)
-	}
-	items := body.Items
 
 	// Organize items into groups, keyed by GroupID. Group rows store their
 	// identity in GroupID (ItemID is 0 for every group), so a GroupID-keyed map
@@ -390,10 +383,13 @@ func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.S
 
 	presence.State, presence.IdleTime = buddyWebState(info.TLVUserInfo, instance.IdentScreenName().UIN() == 0)
 
-	// Publish the icon only now that locate has confirmed the user is online and
-	// has not blocked the caller. Offline and blocking users return above without
-	// an icon, so neither their icon nor its activity-revealing hash leaks to a
-	// caller they are otherwise invisible to.
+	// An offline user publishes nothing; locate still answers for an invisible one.
+	if presence.State == "offline" {
+		return presence
+	}
+
+	// Offline, blocking and invisible users return before this, so neither their
+	// icon nor its activity-revealing hash leaks to a caller they are hidden from.
 	presence.BuddyIcon = h.IconSource.PublishedURL(ctx, baseURL, ident)
 
 	// The locate reply carries the screen name as the user formatted it, which

+ 75 - 6
server/webapi/presence_handler_test.go

@@ -157,12 +157,6 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 				Logger:         slog.Default(),
 			}
 
-			if tt.seedPresence != nil {
-				sess, err := sessionMgr.GetSession(context.Background(), aimsid)
-				require.NoError(t, err)
-				tt.seedPresence(sess)
-			}
-
 			tt.setupMocks(feedbagService, locateService)
 
 			// Presence payloads carry the viewer's alias, so GetPresence reads the
@@ -170,6 +164,14 @@ func TestPresenceHandler_GetPresence(t *testing.T) {
 			feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
 				Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
 
+			// Seeded after the stubs: an arrival resolves aliases off the feedbag.
+			sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+			require.NoError(t, err)
+			sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
+			if tt.seedPresence != nil {
+				tt.seedPresence(sess)
+			}
+
 			reqURL := "/presence/get?aimsid=" + aimsid
 			if tt.queryParams != "" {
 				reqURL += "&" + tt.queryParams
@@ -343,6 +345,7 @@ func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
 	require.NoError(t, err)
+	sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
 	buddyArrives(sess, onlineBuddy("alice"))
 	buddyArrives(sess, onlineBuddy("bob"))
 
@@ -1508,6 +1511,7 @@ func TestPresenceHandler_GetPresence_BuddyListQueriesOnlyAwayBuddies(t *testing.
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
 	require.NoError(t, err)
+	sess.FeedbagLoader = feedbagServiceLoader(feedbagService, oscarInstance)
 
 	away := wire.TLVUserInfo{ScreenName: "awaybud"}
 	away.Append(wire.NewTLVBE(wire.OServiceUserInfoUserFlags, wire.OServiceUserFlagUnavailable))
@@ -1611,3 +1615,68 @@ func TestPresenceHandler_GetPresence_ForgottenTargetFallsBackToLocate(t *testing
 	// The stale "online" snapshot must not survive the eviction.
 	assert.Equal(t, "offline", got.Response.Data.Users[0].State)
 }
+
+// An invisible user reads as offline, and an offline user publishes nothing.
+func TestPresenceHandler_GetPresence_InvisibleUserPublishesNothing(t *testing.T) {
+	invisible := wire.TLVUserInfo{ScreenName: "sneaky"}
+	invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, wire.OServiceUserStatusInvisible))
+	invisible.Append(wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, []wire.BARTID{testStatusBART}))
+
+	feedbagService := newMockFeedbagService(t)
+	feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
+
+	locateService := newMockLocateService(t)
+	locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("sneaky")).
+		Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+			TLVUserInfo: invisible,
+			LocateInfo: wire.TLVRestBlock{TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, "back later"),
+			}},
+		}}, nil).Once()
+
+	iconRetriever := newMockBuddyIconRetriever(t)
+
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
+	sess, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+	sess.BaseURL = "http://api.example.com"
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		FeedbagService: feedbagService,
+		LocateService:  locateService,
+		IconSource:     BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=sneaky", nil)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+
+	var got struct {
+		Response struct {
+			Data struct {
+				Users []struct {
+					State     string `json:"state"`
+					BuddyIcon string `json:"buddyIcon"`
+					StatusMsg string `json:"statusMsg"`
+					AwayMsg   string `json:"awayMsg"`
+				} `json:"users"`
+			} `json:"data"`
+		} `json:"response"`
+	}
+	require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
+	require.Len(t, got.Response.Data.Users, 1)
+
+	user := got.Response.Data.Users[0]
+	assert.Equal(t, "offline", user.State)
+	assert.Empty(t, user.BuddyIcon)
+	assert.Empty(t, user.StatusMsg)
+	assert.Empty(t, user.AwayMsg)
+
+	iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, mock.Anything)
+}

+ 102 - 50
server/webapi/session.go

@@ -64,28 +64,29 @@ var webAPICaps = [][16]byte{wire.CapICQCh2Extended}
 
 // Session represents an active Web AIM API session.
 type Session struct {
-	AimSID              string                                 // Unique session ID for web client
-	ScreenName          state.DisplayScreenName                // User identity
-	OSCARSession        *state.SessionInstance                 // Bridge to existing OSCAR session
-	BaseURL             string                                 // Web API base URL advertised to the web client, used to build absolute asset URLs
-	Events              []string                               // Subscribed event types
-	EventQueue          *EventQueue                            // Per-session event queue
-	ClientName          string                                 // Client application name
-	ClientVersion       string                                 // Client application version
-	CreatedAt           time.Time                              // SessionInstance creation time
-	LastAccessed        time.Time                              // Last activity time
-	ExpiresAt           time.Time                              // SessionInstance expiration time
-	FetchTimeout        int                                    // Long-polling timeout in milliseconds
-	TimeToNextFetch     int                                    // Suggested delay before next fetch
-	RemoteAddr          string                                 // Client IP address
-	BuddyListRefresher  func(ctx context.Context) (any, error) // Called on feedbag changes to push buddylist event
-	PermitDenyRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push permitDeny event
-	BuddyAliasLoader    func(ctx context.Context) (map[string]string, error)
+	AimSID              string                                                // Unique session ID for web client
+	ScreenName          state.DisplayScreenName                               // User identity
+	OSCARSession        *state.SessionInstance                                // Bridge to existing OSCAR session
+	BaseURL             string                                                // Web API base URL advertised to the web client, used to build absolute asset URLs
+	Events              []string                                              // Subscribed event types
+	EventQueue          *EventQueue                                           // Per-session event queue
+	ClientName          string                                                // Client application name
+	ClientVersion       string                                                // Client application version
+	CreatedAt           time.Time                                             // SessionInstance creation time
+	LastAccessed        time.Time                                             // Last activity time
+	ExpiresAt           time.Time                                             // SessionInstance expiration time
+	FetchTimeout        int                                                   // Long-polling timeout in milliseconds
+	TimeToNextFetch     int                                                   // Suggested delay before next fetch
+	RemoteAddr          string                                                // Client IP address
+	BuddyListRefresher  func(ctx context.Context) (any, error)                // Called on feedbag changes to push buddylist event
+	PermitDenyRefresher func(ctx context.Context) (any, error)                // Called on feedbag changes to push permitDeny event
+	FeedbagLoader       func(ctx context.Context) ([]wire.FeedbagItem, error) // Reads the owner's feedbag; every read-only view of the roster derives from it
 	// BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
 	// hash carried in a presence SNAC. Returns "" when no URL can be published.
 	BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
-	aliases      map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
-	aliasMu      sync.Mutex
+	feedbag      []wire.FeedbagItem // cached FeedbagLoader result, nil when unloaded or invalidated
+	aliases      map[string]string  // aliases derived from feedbag, nil when unloaded or invalidated
+	feedbagMu    sync.Mutex
 	// presence is the session's view of its buddies' last-known presence, keyed by
 	// normalized aimId and fed by the BuddyArrived/BuddyDeparted SNACs the listener
 	// receives.
@@ -119,45 +120,75 @@ func (s *Session) IsExpired() bool {
 }
 
 // Aliases returns this session owner's private buddy aliases, keyed by normalized
-// screen name. Aliases live in the owner's feedbag, so the map is loaded once and
-// cached until a feedbag change invalidates it: a signon that brings a large buddy
-// list online costs one feedbag query instead of one per buddy.
-//
-// The map is owned by the session and must not be mutated by callers.
-//
-// aliasMu is deliberately held across the load rather than released while the
-// feedbag is queried. Another instance of the owner can rename a buddy mid-query,
-// and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
-// the lock, that query's pre-rename result could be stored *after* the
-// invalidation and serve the old alias until the next feedbag change. Holding the
-// lock makes the invalidation wait for the load and then win.
+// screen name, derived from the cached feedbag and memoized alongside it. Buddies
+// without an alias are absent. The map is owned by the session and must not be
+// mutated by callers.
 func (s *Session) Aliases(ctx context.Context) map[string]string {
-	s.aliasMu.Lock()
-	defer s.aliasMu.Unlock()
+	s.feedbagMu.Lock()
+	defer s.feedbagMu.Unlock()
 
-	// The loader is wired after the session is created, so an event arriving in
-	// that window has no way to resolve aliases.
-	if s.BuddyAliasLoader == nil {
-		return nil
-	}
 	if s.aliases == nil {
-		aliases, err := s.BuddyAliasLoader(ctx)
+		items, err := s.feedbagLocked(ctx)
 		if err != nil {
-			s.logger.Error("failed to load buddy aliases", "err", err.Error())
 			return nil
 		}
+		aliases := make(map[string]string)
+		for _, item := range items {
+			if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
+				continue
+			}
+			alias, ok := item.String(wire.FeedbagAttributesAlias)
+			if !ok || alias == "" {
+				continue
+			}
+			aliases[state.NewIdentScreenName(item.Name).String()] = alias
+		}
 		s.aliases = aliases
 	}
 	return s.aliases
 }
 
-// InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
+// Feedbag returns the owner's feedbag rows, reading them through FeedbagLoader on
+// the first call after a change and serving the cached copy afterwards. The slice
+// is owned by the session and must not be mutated by callers.
+//
+// Code that rewrites the feedbag must re-read it rather than use this: it computes
+// item ids and a pending diff from what it reads, and a stale snapshot would
+// overwrite another instance's change.
+func (s *Session) Feedbag(ctx context.Context) ([]wire.FeedbagItem, error) {
+	s.feedbagMu.Lock()
+	defer s.feedbagMu.Unlock()
+	return s.feedbagLocked(ctx)
+}
+
+// feedbagLocked loads and caches the feedbag. Callers hold feedbagMu.
+//
+// The lock is deliberately held across the load. Another instance can change the
+// list mid-read, and its feedbag SNAC invalidates this cache; a load outside the
+// lock could store its pre-change result after that invalidation.
+func (s *Session) feedbagLocked(ctx context.Context) ([]wire.FeedbagItem, error) {
+	if s.feedbag == nil {
+		items, err := s.FeedbagLoader(ctx)
+		if err != nil {
+			s.logger.Error("failed to load feedbag", "err", err.Error())
+			return nil, err
+		}
+		if items == nil {
+			// An empty feedbag must still count as loaded.
+			items = []wire.FeedbagItem{}
+		}
+		s.feedbag = items
+	}
+	return s.feedbag, nil
+}
+
+// InvalidateFeedbag drops the cached feedbag and the aliases derived from it.
 // Callers that change the owner's feedbag must call this: the feedbag service
-// relays FeedbagUpdateItem only to the owner's *other* instances, so a session
-// never sees a SNAC for its own writes.
-func (s *Session) InvalidateAliases() {
-	s.aliasMu.Lock()
-	defer s.aliasMu.Unlock()
+// relays its item SNACs only to the owner's *other* instances.
+func (s *Session) InvalidateFeedbag() {
+	s.feedbagMu.Lock()
+	defer s.feedbagMu.Unlock()
+	s.feedbag = nil
 	s.aliases = nil
 }
 
@@ -231,6 +262,26 @@ func (s *Session) forgetBuddyPresence(buddy state.IdentScreenName) {
 	delete(s.presence, buddy.String())
 }
 
+// forgetUnlistedBuddies drops the cached presence of the named buddies that are no
+// longer on the roster. One still listed in another group is still watched.
+func (s *Session) forgetUnlistedBuddies(names []string) {
+	if len(names) == 0 {
+		return
+	}
+	items, err := s.Feedbag(s.ctx)
+	if err != nil {
+		// Without the roster a partial removal cannot be told from a full one, and
+		// a wrongly dropped entry reads offline until the buddy changes presence.
+		return
+	}
+	for _, name := range names {
+		if stillListsBuddy(items, name) {
+			continue
+		}
+		s.forgetBuddyPresence(state.NewIdentScreenName(name))
+	}
+}
+
 // isAIMViewer reports whether the session owner is an AIM account.
 func (s *Session) isAIMViewer() bool {
 	return s.ScreenName.IdentScreenName().UIN() == 0
@@ -832,9 +883,6 @@ const feedbagResultAuthRequired = uint16(0x000E)
 // refreshBuddyList re-reads the roster and pushes it to the client. Runs on the SNAC
 // listener goroutine, so it uses the session context rather than a request context.
 func (s *Session) refreshBuddyList() {
-	// A buddy item carries its alias, so any feedbag write can change the map.
-	s.InvalidateAliases()
-
 	if s.BuddyListRefresher == nil {
 		return
 	}
@@ -847,6 +895,8 @@ func (s *Session) refreshBuddyList() {
 }
 
 func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
+	s.InvalidateFeedbag()
+
 	switch msg.Frame.SubGroup {
 	case wire.FeedbagStatus:
 		// Insert/update/delete below reach only a user's *other* instances, so this
@@ -877,11 +927,13 @@ func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
 		}
 
 		if isDelete {
+			var removed []string
 			for _, item := range items {
 				if item.ClassID == wire.FeedbagClassIdBuddy && item.Name != "" {
-					s.forgetBuddyPresence(state.NewIdentScreenName(item.Name))
+					removed = append(removed, item.Name)
 				}
 			}
+			s.forgetUnlistedBuddies(removed)
 		}
 
 		s.refreshBuddyList()

+ 201 - 82
server/webapi/session_test.go

@@ -86,6 +86,7 @@ func TestSession_handleRateLimitUpdate(t *testing.T) {
 			IMRateClassID: imClass,
 			EventQueue:    NewEventQueue(10),
 			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -151,6 +152,7 @@ func TestSessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *testing.T)
 
 	sess, err := mgr.CreateSession(state.DisplayScreenName("advbot"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 
 	// Healthy session resolves.
 	got, err := mgr.GetSession(context.Background(), sess.AimSID)
@@ -191,8 +193,10 @@ func TestSessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 
 	s1, err := mgr.CreateSession(state.DisplayScreenName("alice"), []string{"presence"}, inst1, "", slog.Default())
 	assert.NoError(t, err)
+	s1.FeedbagLoader = emptyFeedbagLoader
 	s2, err := mgr.CreateSession(state.DisplayScreenName("bob"), []string{"presence"}, inst2, "", slog.Default())
 	assert.NoError(t, err)
+	s2.FeedbagLoader = emptyFeedbagLoader
 
 	assert.NoError(t, mgr.Shutdown(context.Background()))
 
@@ -224,8 +228,10 @@ func TestSessionManager_ReapExpired(t *testing.T) {
 
 	expired, err := mgr.CreateSession("alice", []string{"presence"}, expiredInst, "", slog.Default())
 	assert.NoError(t, err)
+	expired.FeedbagLoader = emptyFeedbagLoader
 	live, err := mgr.CreateSession("bob", []string{"presence"}, liveInst, "", slog.Default())
 	assert.NoError(t, err)
+	live.FeedbagLoader = emptyFeedbagLoader
 
 	// Force alice's session into the past; bob keeps its default future expiry.
 	expired.ExpiresAt = time.Now().Add(-time.Minute)
@@ -345,10 +351,11 @@ func TestSessionManager_RunAfterShutdown(t *testing.T) {
 // carry a user map, and both would otherwise rename an aliased buddy.
 func TestSession_UINBuddyReportsICQOnArrivalAndDeparture(t *testing.T) {
 	sess := &Session{
-		ScreenName: state.DisplayScreenName("me"),
-		Events:     []string{"presence"},
-		EventQueue: NewEventQueue(10),
-		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		ScreenName:    state.DisplayScreenName("me"),
+		Events:        []string{"presence"},
+		EventQueue:    NewEventQueue(10),
+		logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 
 	sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
@@ -367,13 +374,11 @@ func TestSession_UINBuddyReportsICQOnArrivalAndDeparture(t *testing.T) {
 func TestSession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
 	newSession := func() *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     []string{"im", "conversation", "presence"},
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
-			BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
-				return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
-			},
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        []string{"im", "conversation", "presence"},
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: aliasFeedbagLoader(map[string]string{"mikekelly": "MICHAELKELLY"}),
 		}
 	}
 
@@ -444,9 +449,9 @@ func TestSession_CachesBuddyAliases(t *testing.T) {
 		Events:     []string{"presence"},
 		EventQueue: NewEventQueue(10),
 		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
-		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
+		FeedbagLoader: func(context.Context) ([]wire.FeedbagItem, error) {
 			loads++
-			return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
+			return aliasFeedbagItems(map[string]string{"mikekelly": "MICHAELKELLY"}), nil
 		},
 	}
 
@@ -473,8 +478,8 @@ func TestSession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
 		Events:     []string{"presence"},
 		EventQueue: NewEventQueue(10),
 		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
-		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
-			return map[string]string{"mikekelly": alias}, nil
+		FeedbagLoader: func(context.Context) ([]wire.FeedbagItem, error) {
+			return aliasFeedbagItems(map[string]string{"mikekelly": alias}), nil
 		},
 	}
 
@@ -547,6 +552,7 @@ func TestSession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 				PermitDenyRefresher: func(_ context.Context) (any, error) {
 					return map[string]any{"pdMode": "denySome"}, nil
 				},
+				FeedbagLoader: emptyFeedbagLoader,
 			}
 
 			sess.handleFeedbagMessage(wire.SNACMessage{
@@ -571,12 +577,12 @@ func TestSession_FeedbagSNACRefreshesPermitDeny(t *testing.T) {
 
 // A session sees no SNAC for feedbag writes it makes itself, so the handlers that
 // perform those writes invalidate the cache directly.
-func TestSession_InvalidateAliases(t *testing.T) {
+func TestSession_InvalidateFeedbag(t *testing.T) {
 	alias := "MICHAELKELLY"
 	sess := &Session{
 		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
-		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
-			return map[string]string{"mikekelly": alias}, nil
+		FeedbagLoader: func(context.Context) ([]wire.FeedbagItem, error) {
+			return aliasFeedbagItems(map[string]string{"mikekelly": alias}), nil
 		},
 	}
 
@@ -585,22 +591,22 @@ func TestSession_InvalidateAliases(t *testing.T) {
 	alias = "MIKE"
 	assert.Equal(t, "MICHAELKELLY", sess.Aliases(context.Background())["mikekelly"], "cached until invalidated")
 
-	sess.InvalidateAliases()
+	sess.InvalidateFeedbag()
 	assert.Equal(t, "MIKE", sess.Aliases(context.Background())["mikekelly"])
 }
 
 // A failed load must not be cached as an empty map: aliases would stay missing for
 // the life of the session.
-func TestSession_AliasLoadErrorIsNotCached(t *testing.T) {
+func TestSession_FeedbagLoadErrorIsNotCached(t *testing.T) {
 	var loads int
 	sess := &Session{
 		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
-		BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
+		FeedbagLoader: func(context.Context) ([]wire.FeedbagItem, error) {
 			loads++
 			if loads == 1 {
 				return nil, io.EOF
 			}
-			return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
+			return aliasFeedbagItems(map[string]string{"mikekelly": "MICHAELKELLY"}), nil
 		},
 	}
 
@@ -610,10 +616,11 @@ func TestSession_AliasLoadErrorIsNotCached(t *testing.T) {
 
 func TestSession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
 	sess := &Session{
-		ScreenName: state.DisplayScreenName("me"),
-		Events:     []string{"im", "conversation"},
-		EventQueue: NewEventQueue(10),
-		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		ScreenName:    state.DisplayScreenName("me"),
+		Events:        []string{"im", "conversation"},
+		EventQueue:    NewEventQueue(10),
+		logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 
 	frags, err := wire.ICBMFragmentList("hello")
@@ -651,8 +658,9 @@ func TestSession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
 
 func TestSession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
 	sess := &Session{
-		Events:     []string{"typing"},
-		EventQueue: NewEventQueue(10),
+		Events:        []string{"typing"},
+		EventQueue:    NewEventQueue(10),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 
 	sess.handleTypingNotification(wire.SNACMessage{
@@ -671,8 +679,9 @@ func TestSession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
 
 func TestSession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T) {
 	sess := &Session{
-		Events:     []string{"presence"},
-		EventQueue: NewEventQueue(10),
+		Events:        []string{"presence"},
+		EventQueue:    NewEventQueue(10),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 
 	sess.handleBuddyArrived(wire.SNACMessage{
@@ -732,6 +741,7 @@ func TestSession_PublishesBuddyIconOnPresence(t *testing.T) {
 				}
 				return "icon:" + hex.EncodeToString(hash)
 			},
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -835,6 +845,7 @@ func TestSession_PushesMyInfoOnUserInfoUpdate(t *testing.T) {
 				}
 				return "icon:" + hex.EncodeToString(hash)
 			},
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1022,6 +1033,7 @@ func TestSessionManager_ShutdownBoundedByContext(t *testing.T) {
 	inst := state.NewSession().AddInstance()
 	sess, err := mgr.CreateSession("alice", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 
 	// Stand in for a listener wedged somewhere that never observes cancellation.
 	release := make(chan struct{})
@@ -1050,6 +1062,7 @@ func TestSession_CloseCancelsSessionContext(t *testing.T) {
 	inst := state.NewSession().AddInstance()
 	sess, err := mgr.CreateSession("alice", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 
 	assert.NoError(t, sess.ctx.Err(), "session context should be live before Close")
 
@@ -1067,10 +1080,11 @@ func TestSession_OfflineIM(t *testing.T) {
 
 	newSession := func(events ...string) *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     events,
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        events,
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1155,6 +1169,7 @@ func TestSession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
 
 	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 	sess.StartListeningToOSCARSession()
 
 	// Park a fetcher the way fetchEvents does, with nothing pending.
@@ -1192,6 +1207,7 @@ func TestSession_SelfCloseEmitsNoSessionEndedEvent(t *testing.T) {
 
 	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 	sess.StartListeningToOSCARSession()
 
 	require.NoError(t, mgr.RemoveSession(context.Background(), sess.AimSID))
@@ -1292,6 +1308,7 @@ func TestSession_FeedbagStatusRefreshesBuddyList(t *testing.T) {
 					refreshed++
 					return &BuddyListData{Groups: []BuddyGroup{}}, nil
 				},
+				FeedbagLoader: emptyFeedbagLoader,
 			}
 
 			body := tt.body
@@ -1378,8 +1395,9 @@ func TestSession_HandleClientError(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			sess := &Session{
-				Events:     tt.events,
-				EventQueue: NewEventQueue(10),
+				Events:        tt.events,
+				EventQueue:    NewEventQueue(10),
+				FeedbagLoader: emptyFeedbagLoader,
 			}
 			if tt.record {
 				sess.RecordSentIM(cookie, msgID)
@@ -1442,11 +1460,12 @@ func TestSession_RecordSentIMEvictsOldestCookie(t *testing.T) {
 func TestSession_PublishesMoodOnPresence(t *testing.T) {
 	newSession := func() *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			BaseURL:    "http://host",
-			Events:     []string{"presence"},
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+			ScreenName:    state.DisplayScreenName("me"),
+			BaseURL:       "http://host",
+			Events:        []string{"presence"},
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1593,12 +1612,14 @@ func TestSessionManager_CreateSession_SeedsCapabilities(t *testing.T) {
 
 	sess, err := mgr.CreateSession("testuser", nil, state.NewSession().AddInstance(), "", logger)
 	require.NoError(t, err)
+	sess.FeedbagLoader = emptyFeedbagLoader
 	assert.Equal(t, webAPICaps, sess.Caps())
 
 	sess.SetMood(wire.CapXStatusBeer)
 
 	other, err := mgr.CreateSession("otheruser", nil, state.NewSession().AddInstance(), "", logger)
 	require.NoError(t, err)
+	other.FeedbagLoader = emptyFeedbagLoader
 	assert.Equal(t, webAPICaps, other.Caps(), "one session's mood must not reach the next session's seed")
 }
 
@@ -1607,10 +1628,11 @@ func TestSessionManager_CreateSession_SeedsCapabilities(t *testing.T) {
 func TestSession_IncomingIMReportsSenderPresenceFromTheView(t *testing.T) {
 	newSession := func() *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     []string{"im", "presence"},
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        []string{"im", "presence"},
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1811,10 +1833,11 @@ func TestBuddyPresenceFrom(t *testing.T) {
 func TestSession_PresenceView(t *testing.T) {
 	newSession := func(events ...string) *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     events,
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        events,
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1895,10 +1918,11 @@ func TestSession_PresenceView(t *testing.T) {
 // same record the roster reads.
 func TestSession_PresenceEventCarriesIdleAndOnlineTime(t *testing.T) {
 	sess := &Session{
-		ScreenName: state.DisplayScreenName("me"),
-		Events:     []string{"presence"},
-		EventQueue: NewEventQueue(10),
-		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		ScreenName:    state.DisplayScreenName("me"),
+		Events:        []string{"presence"},
+		EventQueue:    NewEventQueue(10),
+		logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 
 	buddyArrives(sess, userInfoWith("mikekelly", idleTLV(9),
@@ -1917,10 +1941,11 @@ func TestSession_PresenceEventCarriesIdleAndOnlineTime(t *testing.T) {
 func TestSession_ForgetBuddyPresence(t *testing.T) {
 	newSession := func() *Session {
 		return &Session{
-			ScreenName: state.DisplayScreenName("me"),
-			Events:     []string{"presence", "im"},
-			EventQueue: NewEventQueue(10),
-			logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        []string{"presence", "im"},
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
 		}
 	}
 
@@ -1978,41 +2003,94 @@ func TestSession_ForgetBuddyPresence(t *testing.T) {
 	})
 }
 
-// A feedbag delete relayed from another instance names the buddies that left the
-// roster, so the session forgets them without a query of its own.
-func TestSession_RelayedFeedbagDeleteForgetsPresence(t *testing.T) {
-	sess := &Session{
-		ScreenName: state.DisplayScreenName("me"),
-		Events:     []string{"presence", "buddylist"},
-		EventQueue: NewEventQueue(10),
-		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+// A relayed delete names the items that went away, not whether the buddy left the
+// roster, so only a buddy absent from the refreshed roster is forgotten.
+func TestSession_RelayedFeedbagDeleteForgetsUnlistedBuddies(t *testing.T) {
+	newSession := func(roster ...string) *Session {
+		sess := &Session{
+			ScreenName:    state.DisplayScreenName("me"),
+			Events:        []string{"presence", "buddylist"},
+			EventQueue:    NewEventQueue(10),
+			logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+			FeedbagLoader: emptyFeedbagLoader,
+		}
+		sess.ctx = context.Background()
+		sess.FeedbagLoader = func(context.Context) ([]wire.FeedbagItem, error) {
+			items := make([]wire.FeedbagItem, 0, len(roster))
+			for i, name := range roster {
+				items = append(items, wire.FeedbagItem{
+					ItemID: uint16(i + 1), ClassID: wire.FeedbagClassIdBuddy, GroupID: 100, Name: name,
+				})
+			}
+			return items, nil
+		}
+		return sess
 	}
-	buddyArrives(sess, onlineBuddy("Mike Kelly"))
-	buddyArrives(sess, onlineBuddy("keeper"))
 
-	sess.handleFeedbagMessage(wire.SNACMessage{
-		Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem},
-		Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: []wire.FeedbagItem{
-			{ClassID: wire.FeedbagClassIdBuddy, Name: "mikekelly"},
-			// A group row names a group, not a buddy, and must not evict anything.
-			{ClassID: wire.FeedbagClassIdGroup, Name: "keeper"},
-		}},
+	relayDelete := func(sess *Session, items ...wire.FeedbagItem) {
+		sess.handleFeedbagMessage(wire.SNACMessage{
+			Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem},
+			Body:  wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: items},
+		})
+	}
+
+	buddyItem := wire.FeedbagItem{ClassID: wire.FeedbagClassIdBuddy, Name: "mikekelly"}
+
+	t.Run("a buddy gone from the roster is forgotten", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+		relayDelete(sess, buddyItem)
+
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		assert.False(t, ok)
 	})
 
-	_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
-	assert.False(t, ok, "deleted buddy should be forgotten")
+	// The move case: a delete for the old group's item, then an insert.
+	t.Run("a buddy still on the roster keeps their entry", func(t *testing.T) {
+		sess := newSession("mikekelly")
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+		relayDelete(sess, buddyItem)
 
-	_, ok = sess.BuddyPresence(state.NewIdentScreenName("keeper"))
-	assert.True(t, ok, "a group row must not evict a like-named buddy")
+		got, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		require.True(t, ok)
+		assert.Equal(t, "online", got.State)
+	})
+
+	t.Run("a group row does not evict a like-named buddy", func(t *testing.T) {
+		sess := newSession()
+		buddyArrives(sess, onlineBuddy("keeper"))
+
+		relayDelete(sess, wire.FeedbagItem{ClassID: wire.FeedbagClassIdGroup, Name: "keeper"})
+
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("keeper"))
+		assert.True(t, ok)
+	})
+
+	// Without a roster the two cases are indistinguishable, so nothing is dropped.
+	t.Run("a roster lookup failure keeps the entry", func(t *testing.T) {
+		sess := newSession()
+		sess.FeedbagLoader = func(context.Context) ([]wire.FeedbagItem, error) {
+			return nil, io.ErrUnexpectedEOF
+		}
+		buddyArrives(sess, onlineBuddy("Mike Kelly"))
+
+		relayDelete(sess, buddyItem)
+
+		_, ok := sess.BuddyPresence(state.NewIdentScreenName("mikekelly"))
+		assert.True(t, ok)
+	})
 }
 
 // An insert or update leaves the roster membership intact, so it must not evict.
 func TestSession_RelayedFeedbagUpdateKeepsPresence(t *testing.T) {
 	sess := &Session{
-		ScreenName: state.DisplayScreenName("me"),
-		Events:     []string{"presence", "buddylist"},
-		EventQueue: NewEventQueue(10),
-		logger:     slog.New(slog.NewTextHandler(io.Discard, nil)),
+		ScreenName:    state.DisplayScreenName("me"),
+		Events:        []string{"presence", "buddylist"},
+		EventQueue:    NewEventQueue(10),
+		logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+		FeedbagLoader: emptyFeedbagLoader,
 	}
 	buddyArrives(sess, onlineBuddy("Mike Kelly"))
 
@@ -2027,3 +2105,44 @@ func TestSession_RelayedFeedbagUpdateKeepsPresence(t *testing.T) {
 	require.True(t, ok)
 	assert.Equal(t, "online", got.State)
 }
+
+// The roster, its aliases and the membership check a relayed delete makes are three
+// views of the same rows, so one feedbag change must cost one read.
+func TestSession_FeedbagCacheServesEveryReadFromOneLoad(t *testing.T) {
+	var loads int
+	sess := &Session{
+		ScreenName:    state.DisplayScreenName("me"),
+		Events:        []string{"presence", "buddylist"},
+		EventQueue:    NewEventQueue(10),
+		logger:        slog.New(slog.NewTextHandler(io.Discard, nil)),
+		FeedbagLoader: emptyFeedbagLoader,
+	}
+	sess.ctx = context.Background()
+	sess.FeedbagLoader = func(context.Context) ([]wire.FeedbagItem, error) {
+		loads++
+		return aliasFeedbagItems(map[string]string{"keeper": "KEEPER"}), nil
+	}
+
+	buddyArrives(sess, onlineBuddy("keeper"))
+	buddyArrives(sess, onlineBuddy("gone"))
+	require.Equal(t, 1, loads, "arrivals resolve aliases off one load")
+
+	sess.handleFeedbagMessage(wire.SNACMessage{
+		Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem},
+		Body: wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: []wire.FeedbagItem{
+			{ClassID: wire.FeedbagClassIdBuddy, Name: "gone"},
+			{ClassID: wire.FeedbagClassIdBuddy, Name: "keeper"},
+		}},
+	})
+	assert.Equal(t, 2, loads, "one reload serves the whole handler")
+
+	_, ok := sess.BuddyPresence(state.NewIdentScreenName("gone"))
+	assert.False(t, ok)
+	_, ok = sess.BuddyPresence(state.NewIdentScreenName("keeper"))
+	assert.True(t, ok)
+
+	assert.Equal(t, "KEEPER", sess.Aliases(context.Background())["keeper"])
+	_, err := sess.Feedbag(context.Background())
+	require.NoError(t, err)
+	assert.Equal(t, 2, loads)
+}