Skip to content

fix: unified profile - global self profile#1131

Closed
cameronvoell wants to merge 5 commits into
cv/unified-profile-repository-read-write-livefrom
cv/unified-profile-global-self-and-drop
Closed

fix: unified profile - global self profile#1131
cameronvoell wants to merge 5 commits into
cv/unified-profile-repository-read-write-livefrom
cv/unified-profile-global-self-and-drop

Conversation

@cameronvoell

@cameronvoell cameronvoell commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Note

Replace per-conversation DBMemberProfile with a global DBProfile for all profile reads and writes

  • Introduces ProfileInboundApplier as the single entry point for writing inbound profile events (update, snapshot, history) to the canonical DBProfile and DBProfileAvatar tables, replacing scattered direct DBMemberProfile mutations across StreamProcessor, MessagingService, ConversationWriter, and IncomingMessageWriter.
  • Adds DBConversationMember.profile (hasOne to DBProfile) and DBConversationMember.avatarSlot (hasOne to DBProfileAvatar) associations, and removes the old memberProfile and inviterProfile associations that targeted DBMemberProfile.
  • Updates all query sites — ConversationsRepository, MessagesRepository, DBConversationMemberProfileWithRole, DBConversation, and DBMessage — to join the unified profile tables instead of DBMemberProfile.
  • Removes profileMemberMirror and profileShadowComparator from MessagingService; replaces startProfileMirroring/stopProfileMirroring with startProfileServices/stopProfileServices that only run backfill and bind the publish session.
  • AgentVerificationWriter.reverifyUnverifiedAgents now promotes DBProfile rows and marks hasHadVerifiedAgent on every conversation the agent belongs to, not just one.
  • Risk: members without a corresponding DBProfile row will appear with nil name/avatar until backfill completes; historical non-member profiles are no longer included in message composition caches.
📊 Macroscope summarized 3b9bfe6. 20 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Claude finished @cameronvoell's task —— View job


PR Review: Unified Profile - Global Self Profile

Completed comprehensive code review focusing on code quality, potential bugs, performance, security, and test coverage.


🏗️ Architecture Overview

This PR implements a major architectural change, migrating from per-conversation DBMemberProfile to global DBProfile and DBProfileAvatar tables. The key components are:

  1. ProfileInboundApplier: Single write entry point for all inbound profile events
  2. ProfilesRepository: Canonical read source with in-memory cache
  3. ProfileBackfill: One-time migration from legacy data
  4. ProfilePublisher: Durable publish queue for self-profile distribution

Verdict: ✅ Clean architecture with proper separation of concerns


🔴 Critical Issues

1. Self-Profile Handling in NSE (High Severity)

File: ProfileSnapshotBuilder.swift:154

Issue: The snapshot builder reads DBProfile rows (line 182) which excludes the current user's own profile by design (ProfileInboundApplier:63 skips self). When the NSE sends a snapshot, it will omit the sender's own identity.

// Line 182-186
let profiles = try DBProfile.fetchAll(db, inboxIds: memberInboxIds)
return try profiles.compactMap { profile -> MemberProfile? in
    let avatar = try DBProfileAvatar.fetchOne(db, inboxId: profile.inboxId, conversationId: conversationId)
    return profile.snapshotMemberProfile(avatar: avatar)
}

Impact: New members won't see the conversation creator's profile when the creator sends the initial snapshot.

Fix Required: fetchDBProfiles must union DBProfile rows with DBSelfProfile:

private static func fetchDBProfiles(...) async throws -> [MemberProfile] {
    guard let databaseReader else { return [] }
    return try await databaseReader.read { db in
        let selfInboxId = try? DBInbox.currentInboxId(db)
        var profiles = try DBProfile.fetchAll(db, inboxIds: memberInboxIds)
        
        // Include self-profile if in member list
        if let selfInboxId, memberInboxIds.contains(selfInboxId),
           let selfProfile = try DBSelfProfile.fetchOne(db) {
            // Convert DBSelfProfile to DBProfile for snapshot
            let selfAsProfile = DBProfile(/* ... */)
            profiles.append(selfAsProfile)
        }
        
        return try profiles.compactMap { /* ... */ }
    }
}

2. Avatar Clearing Semantics (Medium Severity)

File: ProfileInboundApplier.swift:92-99

Issue: Avatar disposition logic differs between ProfileUpdate (.addressed) and ProfileSnapshot (.fillIfPresent):

  • ProfileUpdate with hasEncryptedImage=false.explicitClear (line 94)
  • ProfileSnapshot with hasEncryptedImage=false.silent (line 97)

Problem: If user clears their avatar and sends a ProfileUpdate, then someone sends a ProfileSnapshot containing the old cached avatar, the snapshot's .fillIfPresent will restore the avatar that was explicitly cleared.

Root Cause: ProfileMerge.mergeAvatar needs to respect explicit clears, but the current implementation doesn't differentiate between "no avatar in snapshot" and "user explicitly cleared avatar".

Fix Options:

  1. Add precedence check in ProfileMerge.mergeAvatar: ProfileUpdate clears beat ProfileSnapshot fills
  2. Track "cleared at" timestamp in DBProfileAvatar to make clears stick
  3. Accept current behavior as-is (snapshots can resurrect avatars - document this)

3. Contact Sync Breakage (High Severity)

File: ContactSyncCoordinator.swift + ContactsWriter.swift

Issue: Both files still write to DBMemberProfile when syncing contacts:

  • ContactSyncCoordinator:173-190 persists contact profiles to memberProfile
  • ContactsWriter:225-235 mirrors contact data into memberProfile

Impact: Contact profile updates won't propagate to the canonical stores. Users will see stale contact names/avatars in conversations.

Fix Required: Replace DBMemberProfile writes with ProfileInboundApplier.apply or direct DBProfile writes (source: .contact).


4. Asset Renewal Collector Reading Deleted Table (High Severity)

File: AssetRenewalURLCollector.swift:42-86

Issue: The collector still reads DBMemberProfile.avatar (line 61) to find renewable avatar URLs. Since DBMemberProfile is now inert (no longer written to), avatar renewal will stop working for profile images.

Fix Required: Switch to DBProfileAvatar query:

// Replace memberProfile join with profileAvatar
static let profileAvatar: HasOneAssociation<DBConversationMember, DBProfileAvatar> = hasOne(
    DBProfileAvatar.self,
    using: ForeignKey([Columns.inboxId], to: [DBProfileAvatar.Columns.inboxId])
)

5. Profile Staleness from Snapshot Precedence (Medium Severity)

File: ProfileMerge.swift (precedence logic)

Issue: ProfileSnapshot has lower precedence than ProfileUpdate (.profileSnapshot vs .profileUpdate). If:

  1. Alice sends ProfileUpdate with name "Alice Smith"
  2. Bob (who cached "Alice Jones") sends ProfileSnapshot
  3. The snapshot's .fillIfPresent won't overwrite Alice's ProfileUpdate

However, if the order is reversed:

  1. Bob sends ProfileSnapshot with Alice's old name "Alice Jones"
  2. Alice's ProfileUpdate "Alice Smith" arrives later
  3. ProfileUpdate precedence wins ✅

Verdict: Current behavior is correct - ProfileUpdate should always beat ProfileSnapshot. Not a bug.


⚠️ Additional Issues

6. Missing Canonical Data for Non-Message Members (Low Severity)

File: ConversationWriter.swift:368-381

Issue: The "fill gaps" logic only writes appData profiles for members without message-sourced data:

if existing?.name != nil || existing?.avatar != nil || existing?.memberKind != nil {
    return
}

Problem: If a member joins via appData (e.g., agent invited by conversation creator) but hasn't sent ProfileUpdate, they won't get a DBProfile row because ProfileInboundApplier only fires on message arrival.

Impact: Queries like DBProfile.fetchAll(db, inboxIds: memberInboxIds) will miss these members entirely.

Fix: Seed empty DBProfile + DBProfileAvatar rows for appData-sourced members in the "fill gaps" section.


7. Race Condition in ImageLastRenewed Preservation (Low)

File: ConversationWriter.swift:719-724

Issue: imageLastRenewed preservation reads existing conversation inside the transaction, but asset renewal writes happen in parallel. Theoretical race:

  1. Renewal updates imageLastRenewed to T1
  2. Conversation sync reads existing row (gets T1)
  3. Renewal updates imageLastRenewed to T2 (concurrent transaction)
  4. Conversation sync writes, preserving T1 (overwrites T2)

Verdict: GRDB serializes writes, so this can't actually happen. The renewal and conversation-sync transactions serialize — not a bug.


8. ProfilePublisher Not Started in Failing Tests (Test Infrastructure)

File: Tests that call publishMyProfile without startProfileServices

Issue: Several integration tests fail with "Profile publish session not attached" because they call profilesRepository().publishMyProfile() without first calling messagingService.startProfileServices().

Fix: Test setup should call await messagingService.startProfileServices() before publish operations.


✅ Code Quality Observations

Excellent Practices

  1. Self-profile isolation: ProfileInboundApplier:63 correctly skips self, preventing self-echo pollution
  2. Agent verification preserved: Lines 107-129 use transient DBMemberProfile probe for verification logic
  3. Idempotent migration: ProfileBackfill guards against double-runs
  4. Transaction batching: ConversationWriter.persist batches member + profile writes
  5. Precedence logic: ProfileMerge correctly prioritizes ProfileUpdate > ProfileSnapshot > Contact

Performance Considerations

  1. In-memory cache: ProfilesRepository.warmUp() loads all profiles into memory

    • Concern: Could grow large on accounts with thousands of contacts
    • Mitigation: Cache is append-only and immortal (no eviction policy)
    • Recommendation: Monitor memory usage; consider LRU eviction if needed
  2. ValueObservation overhead: Every profile read creates a reactive publisher

    • Verdict: Acceptable - GRDB's ValueObservation is efficient

🧪 Test Coverage

Strengths:

  • ProfileBackfillTests.swift: Migration idempotence, self-profile separation ✅
  • ProfileInboundApplierTests.swift: Self-echo skip, avatar disposition ✅
  • ProfileMergeTests.swift: Precedence rules ✅
  • ProfilePersistenceTests.swift: End-to-end integration ✅

Gaps:

  • ❌ No tests for ProfileSnapshotBuilder self-profile inclusion
  • ❌ No tests for contact sync → canonical store propagation
  • ❌ No tests for asset renewal with DBProfileAvatar
  • ⚠️ Integration tests fail due to missing startProfileServices() calls

🔒 Security Review

No security issues identified. Profile encryption keys are correctly carried forward from conversation keys.


📊 Summary

Issue Severity Fixed? Impact
NSE self-profile exclusion 🔴 High New members won't see creator's profile
Avatar clear → snapshot restore 🟡 Medium Cleared avatars can resurrect
Contact sync → memberProfile 🔴 High Contact updates won't propagate
Asset renewal reads deleted table 🔴 High Avatar renewal breaks
appData members missing DBProfile 🟡 Low Agents/invited members missing canonical data

✅ Approval Recommendation

Approve after critical fixes. The architecture is sound, but the 4 high-severity issues must be addressed:

  1. Fix ProfileSnapshotBuilder.fetchDBProfiles to include self
  2. Update ContactSyncCoordinator + ContactsWriter to write to canonical stores
  3. Update AssetRenewalURLCollector to read from DBProfileAvatar
  4. Add test coverage for NSE snapshot + contact sync flows

The medium-severity avatar clearing issue can be documented as expected behavior or fixed in a follow-up.

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more


How to use the Graphite Merge Queue

Add the label merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

guard let databaseReader else { return [] }
let rows = try await databaseReader.read { db in
try DBMemberProfile.fetchAll(db, conversationId: conversationId, inboxIds: memberInboxIds)
return try await databaseReader.read { db in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ProfileMessages/ProfileSnapshotBuilder.swift:181

fetchDBProfiles fetches only DBProfile rows, but the local user's canonical profile is stored in DBSelfProfile, not DBProfile. When this installation sends a ProfileSnapshot, its own member entry is missing from dbProfiles unless a recent ProfileUpdate or prior snapshot for itself still falls within the last 500 scanned messages. As a result, new joiners can receive a snapshot that omits the creator's name and avatar, rendering them as an unknown member. Consider including the local user's DBSelfProfile (with its DBProfileAvatar) in the fetched profiles so the sender's own identity is always represented.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/ProfileMessages/ProfileSnapshotBuilder.swift around line 181:

`fetchDBProfiles` fetches only `DBProfile` rows, but the local user's canonical profile is stored in `DBSelfProfile`, not `DBProfile`. When this installation sends a `ProfileSnapshot`, its own member entry is missing from `dbProfiles` unless a recent `ProfileUpdate` or prior snapshot for itself still falls within the last 500 scanned messages. As a result, new joiners can receive a snapshot that omits the creator's name and avatar, rendering them as an unknown member. Consider including the local user's `DBSelfProfile` (with its `DBProfileAvatar`) in the fetched profiles so the sender's own identity is always represented.

memberKind: update.memberKind.dbMemberKind,
metadata: metadata.isEmpty ? nil : metadata,
receivedAt: receivedAt
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Inboxes/MessagingService+PushNotifications.swift:899

Passing selfInboxId: currentInboxId into ProfileInboundApplier.apply causes the applier to return early for every self-authored ProfileUpdate in the NSE, before any canonical DBProfile or DBProfileAvatar rows are written. On a fresh install or secondary device with an empty profile store, replaying your own history no longer seeds your existing name/avatar from the network, so your local self profile stays blank until you edit it manually. The same regression exists in the sibling processProfileSnapshotInNSE path. Consider passing nil for selfInboxId in the NSE (or otherwise ensuring self echoes still seed the canonical profile) so history replay can populate the local self profile.

Also found in 1 other location(s)

ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift:289

Passing currentInboxId into processProfileSnapshot makes ProfileInboundApplier.apply drop the local user's own row from every inbound snapshot. That means a fresh install/device never backfills its own DBProfileAvatar slot from snapshots. Later, when ProfilePublisher.processNameOnlyJob republishes just a name change, it reads no existing avatar for that conversation and sends avatar: nil, which ProfileUpdate semantics treat as an explicit clear. Renaming yourself from a device that joined via snapshot will therefore clear your avatar for other members in that conversation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift around line 899:

Passing `selfInboxId: currentInboxId` into `ProfileInboundApplier.apply` causes the applier to return early for every self-authored `ProfileUpdate` in the NSE, before any canonical `DBProfile` or `DBProfileAvatar` rows are written. On a fresh install or secondary device with an empty profile store, replaying your own history no longer seeds your existing name/avatar from the network, so your local self profile stays blank until you edit it manually. The same regression exists in the sibling `processProfileSnapshotInNSE` path. Consider passing `nil` for `selfInboxId` in the NSE (or otherwise ensuring self echoes still seed the canonical profile) so history replay can populate the local self profile.

Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift:289 -- Passing `currentInboxId` into `processProfileSnapshot` makes `ProfileInboundApplier.apply` drop the local user's own row from every inbound snapshot. That means a fresh install/device never backfills its own `DBProfileAvatar` slot from snapshots. Later, when `ProfilePublisher.processNameOnlyJob` republishes just a name change, it reads no existing avatar for that conversation and sends `avatar: nil`, which `ProfileUpdate` semantics treat as an explicit clear. Renaming yourself from a device that joined via snapshot will therefore clear your avatar for other members in that conversation.


if let snapshot = resolvedSnapshot {
for memberProfile in snapshot.profiles {
if let resolvedSnapshot {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Writers/ConversationWriter.swift:1098

processProfileMessagesFromHistory feeds every ProfileSnapshot profile into ProfileInboundApplier with receivedAt: resolvedSnapshot.sentAt, and ProfileMerge uses that timestamp for .profileSnapshot tie-breaking. A ProfileSnapshot is rebuilt from the sender's cached DB rows and prior fallback snapshots, so it can carry stale names/avatars for other members whose authoritative ProfileUpdate has already aged out of the 500-message window. Because ProfileMerge compares sentAt values, a recently re-published but stale snapshot wins over the older, accurate profile data already in DBProfile/DBProfileAvatar and then blocks correction from earlier snapshots, producing incorrect member names/avatars after history replay. Consider gating snapshot-sourced profile writes so they only fill slots that are still empty (matching the old existing?.name == nil, existing?.avatar == nil guard), or skip the snapshot branch entirely once per-member ProfileUpdate data is already canonical.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift around line 1098:

`processProfileMessagesFromHistory` feeds every `ProfileSnapshot` profile into `ProfileInboundApplier` with `receivedAt: resolvedSnapshot.sentAt`, and `ProfileMerge` uses that timestamp for `.profileSnapshot` tie-breaking. A `ProfileSnapshot` is rebuilt from the sender's cached DB rows and prior fallback snapshots, so it can carry stale names/avatars for other members whose authoritative `ProfileUpdate` has already aged out of the 500-message window. Because `ProfileMerge` compares `sentAt` values, a recently re-published but stale snapshot wins over the older, accurate profile data already in `DBProfile`/`DBProfileAvatar` and then blocks correction from earlier snapshots, producing incorrect member names/avatars after history replay. Consider gating snapshot-sourced profile writes so they only fill slots that are still empty (matching the old `existing?.name == nil, existing?.avatar == nil` guard), or skip the snapshot branch entirely once per-member `ProfileUpdate` data is already canonical.

])
.including(required: DBConversationMember.memberProfile)
.including(optional: DBConversationMember.inviterProfile)
.including(optional: DBConversationMember.profile)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Repositories/ConversationsRepository.swift:247

detailedConversationQuery() now selects from DBConversationMember.profile and DBConversationMember.avatarSlot (optional joins) instead of the legacy memberProfile association. The write paths in ConversationWriter.persist and InviteJoinRequestsManager still only populate DBMemberProfile, so on a fresh or newly synced conversation the profile and avatarSlot joins return nil and member names/avatars silently disappear — conversations render as generic "Somebody" with a blank avatar even though the profile data exists in the database. Update the remaining write paths to also populate DBConversationMember.profile/avatarSlot, or add a migration/fallback so existing DBMemberProfile data is visible through the new associations.

Also found in 7 other location(s)

ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift:50

DBConversationMember.profile now joins only DBProfile by inboxId, but the existing write paths still populate member identity in DBMemberProfile (for example ConversationWriter saves DBMemberProfile for conversation members, and InviteJoinRequestsManager/IncomingMessageWriter also write per-conversation member profiles). DBConversationMemberProfileWithRole.fetch* includes this new association, and hydrateMessagePreview now reads row.profile?.name / row.isAgent; when no DBProfile row exists yet, those values become nil/false and normal conversations render remote senders as "Somebody" and non-agents even though the member's stored per-conversation profile has a name/kind.

ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift:657

isMemberAgent now reads only DBProfile, but agent identity can still be introduced through InviteJoinRequestsManager, which persists the joiner's memberKind into DBMemberProfile/DBContact without creating a canonical DBProfile row. If that newly joined agent sends an HTML attachment before any profile message arrives, this method returns false and the push notification falls back to the generic attachment text instead of the agent-specific "made you a thing" body.

ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift:850

fetchLightweightConversation() switched the creator/member joins to DBConversationMember.profile/avatarSlot, but the app still writes many profile updates only into legacy DBMemberProfile rows (ConversationWriter.persist app-data fill, MyProfileWriter, InviteJoinRequestsManager, etc.). When composeMessages() loads a conversation before an inbound profile event has copied that data into the canonical tables, the lightweight conversation comes back with nil profiles, so message composition loses member names/avatars and falls back to empty/&#34;Somebody&#34; profiles on a normal code path.

ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift:1078

processProfileMessagesFromHistory no longer writes any of the replayed profile data back into DBMemberProfile; it only updates the new DBProfile / DBProfileAvatar tables through ProfileInboundApplier. Several live paths still read DBMemberProfile directly, including ContactSyncCoordinator when it builds ContactProfileSnapshots for a conversation. On a fresh install/pairing or any catch-up that relies on historical ProfileUpdate/ProfileSnapshot messages, those DBMemberProfile rows stay blank, so contact sync imports members without names, avatars, or agent verification even though the history replay just decoded that data.

ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift:25

reverifyUnverifiedAgents now upgrades only the canonical DBProfile row (updated.save(db)), but several live agent features still read verification from legacy DBMemberProfile rows. For example, AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds filters DBMemberProfile for verified agents, so an agent that is reverified here remains invisible there and its pending connection grants never replay.

ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift:392

bootstrapSenderProfile now trusts only DBProfile, but batch catch-up persists regular messages before any profile-history replay populates that table. In BatchCatchUp.persistPreparedEntries, messageWriter.persist(...) runs immediately after conversationWriter.persist(...), while ConversationWriter.runPostPersistSideEffects applies ProfileInboundApplier only later on a detached task. On a cold launch/backlog replay, a real verified agent's CloudConnectionGrantRequest therefore sees no DBProfile, senderVerified becomes false, and persist silently drops the request even though the sender is legitimate.

docs/plans/2026-06-29-profile-table-implementation.md:725

The follow-up plan to backfill-then-drop member_profile in the same PR (docs/plans/2026-06-29-profile-table-implementation.md:725) is unsafe because the current app still has multiple live runtime paths that query DBMemberProfile, not just vestigial self-editing. For example AssetRenewalURLCollector.collectRenewableAssets() enumerates profile avatars from DBMemberProfile, ContactSyncCoordinator.syncConversation reads every member's DBMemberProfile to build contact snapshots, and AgentTimezonePublisher.republishTimezoneForAgentConversations() finds the user's conversations by fetching DBMemberProfile rows. Dropping the table under those callers will turn those paths into SQL "no such table: memberProfile" failures and break avatar renewal / contact sync / timezone republish after migration.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/ConversationsRepository.swift around line 247:

`detailedConversationQuery()` now selects from `DBConversationMember.profile` and `DBConversationMember.avatarSlot` (optional joins) instead of the legacy `memberProfile` association. The write paths in `ConversationWriter.persist` and `InviteJoinRequestsManager` still only populate `DBMemberProfile`, so on a fresh or newly synced conversation the `profile` and `avatarSlot` joins return `nil` and member names/avatars silently disappear — conversations render as generic `"Somebody"` with a blank avatar even though the profile data exists in the database. Update the remaining write paths to also populate `DBConversationMember.profile`/`avatarSlot`, or add a migration/fallback so existing `DBMemberProfile` data is visible through the new associations.

Also found in 7 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMember.swift:50 -- `DBConversationMember.profile` now joins only `DBProfile` by `inboxId`, but the existing write paths still populate member identity in `DBMemberProfile` (for example `ConversationWriter` saves `DBMemberProfile` for conversation members, and `InviteJoinRequestsManager`/`IncomingMessageWriter` also write per-conversation member profiles). `DBConversationMemberProfileWithRole.fetch*` includes this new association, and `hydrateMessagePreview` now reads `row.profile?.name` / `row.isAgent`; when no `DBProfile` row exists yet, those values become `nil`/`false` and normal conversations render remote senders as "Somebody" and non-agents even though the member's stored per-conversation profile has a name/kind.
- ConvosCore/Sources/ConvosCore/Inboxes/MessagingService+PushNotifications.swift:657 -- `isMemberAgent` now reads only `DBProfile`, but agent identity can still be introduced through `InviteJoinRequestsManager`, which persists the joiner's `memberKind` into `DBMemberProfile`/`DBContact` without creating a canonical `DBProfile` row. If that newly joined agent sends an HTML attachment before any profile message arrives, this method returns `false` and the push notification falls back to the generic attachment text instead of the agent-specific "made you a thing" body.
- ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift:850 -- `fetchLightweightConversation()` switched the creator/member joins to `DBConversationMember.profile`/`avatarSlot`, but the app still writes many profile updates only into legacy `DBMemberProfile` rows (`ConversationWriter.persist` app-data fill, `MyProfileWriter`, `InviteJoinRequestsManager`, etc.). When `composeMessages()` loads a conversation before an inbound profile event has copied that data into the canonical tables, the lightweight conversation comes back with `nil` profiles, so message composition loses member names/avatars and falls back to empty/`"Somebody"` profiles on a normal code path.
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift:1078 -- `processProfileMessagesFromHistory` no longer writes any of the replayed profile data back into `DBMemberProfile`; it only updates the new `DBProfile` / `DBProfileAvatar` tables through `ProfileInboundApplier`. Several live paths still read `DBMemberProfile` directly, including `ContactSyncCoordinator` when it builds `ContactProfileSnapshot`s for a conversation. On a fresh install/pairing or any catch-up that relies on historical `ProfileUpdate`/`ProfileSnapshot` messages, those `DBMemberProfile` rows stay blank, so contact sync imports members without names, avatars, or agent verification even though the history replay just decoded that data.
- ConvosCore/Sources/ConvosCore/Crypto/AgentVerificationWriter.swift:25 -- `reverifyUnverifiedAgents` now upgrades only the canonical `DBProfile` row (`updated.save(db)`), but several live agent features still read verification from legacy `DBMemberProfile` rows. For example, `AgentBuilderConnectionGrantReplayer.verifiedAgentInboxIds` filters `DBMemberProfile` for verified agents, so an agent that is reverified here remains invisible there and its pending connection grants never replay.
- ConvosCore/Sources/ConvosCore/Storage/Writers/IncomingMessageWriter.swift:392 -- `bootstrapSenderProfile` now trusts only `DBProfile`, but batch catch-up persists regular messages before any profile-history replay populates that table. In `BatchCatchUp.persistPreparedEntries`, `messageWriter.persist(...)` runs immediately after `conversationWriter.persist(...)`, while `ConversationWriter.runPostPersistSideEffects` applies `ProfileInboundApplier` only later on a detached task. On a cold launch/backlog replay, a real verified agent's `CloudConnectionGrantRequest` therefore sees no `DBProfile`, `senderVerified` becomes `false`, and `persist` silently drops the request even though the sender is legitimate.
- docs/plans/2026-06-29-profile-table-implementation.md:725 -- The follow-up plan to backfill-then-drop `member_profile` in the same PR (`docs/plans/2026-06-29-profile-table-implementation.md:725`) is unsafe because the current app still has multiple live runtime paths that query `DBMemberProfile`, not just vestigial self-editing. For example `AssetRenewalURLCollector.collectRenewableAssets()` enumerates profile avatars from `DBMemberProfile`, `ContactSyncCoordinator.syncConversation` reads every member's `DBMemberProfile` to build contact snapshots, and `AgentTimezonePublisher.republishTimezoneForAgentConversations()` finds the user's conversations by fetching `DBMemberProfile` rows. Dropping the table under those callers will turn those paths into SQL "no such table: memberProfile" failures and break avatar renewal / contact sync / timezone republish after migration.

@cameronvoell cameronvoell changed the title unified profile: global-only self profile + drop member_profile unified profile: global-only self profile Jul 1, 2026
@cameronvoell cameronvoell changed the title unified profile: global-only self profile fix: unified profile: global-only self profile Jul 1, 2026
@cameronvoell cameronvoell changed the title fix: unified profile: global-only self profile fix: unified profile - global-only self profile Jul 1, 2026
try await publisher.publish(avatarBytes: avatarBytes, priorityConversationId: priorityConversationId)
}

/// Seeds a freshly created or joined conversation with the current profile.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repository/ProfilesRepository.swift:259

publishMyProfileMetadata calls publisher.publish(avatarBytes: nil, ...), which enqueues hasAvatar == false jobs that supersede any older pending avatar jobs for the same conversation. If a queued avatar change is still waiting to be delivered (e.g., retrying after an upload/send failure, or pending across a restart), enqueueJob's supersedeOlderThan call deletes that pending avatar job, and processNameOnlyJob re-sends the previously published avatar instead — so the new avatar is silently lost. Consider avoiding superseding pending avatar jobs when issuing a metadata-only publish, or enqueueing metadata updates as a separate job type that doesn't replace avatar jobs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfilesRepository.swift around line 259:

`publishMyProfileMetadata` calls `publisher.publish(avatarBytes: nil, ...)`, which enqueues `hasAvatar == false` jobs that supersede any older pending avatar jobs for the same conversation. If a queued avatar change is still waiting to be delivered (e.g., retrying after an upload/send failure, or pending across a restart), `enqueueJob`'s `supersedeOlderThan` call deletes that pending avatar job, and `processNameOnlyJob` re-sends the previously published avatar instead — so the new avatar is silently lost. Consider avoiding superseding pending avatar jobs when issuing a metadata-only publish, or enqueueing metadata updates as a separate job type that doesn't replace avatar jobs.

try await session?.messagingServiceSync().profilesRepository().publishMyProfile(
displayName: resolvedName,
avatarBytes: imageData,
priorityConversationId: nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Profile/ProfileSettingsViewModel.swift:182

saveAndAwait() calls profilesRepository().publishMyProfile(displayName:resolvedName, avatarBytes:imageData, ...) even when the user deleted their photo and imageData is nil. In ProfilePublisher.publish, nil avatarBytes means "re-send the existing avatar," not "clear the avatar," so removing a photo in Settings leaves the old avatar published to every conversation. The local DBMyProfile is cleared, but other participants keep seeing the stale photo. Consider distinguishing a deliberate avatar removal from a name-only edit — e.g. by passing a sentinel or a dedicated delete API — so the publish path clears per-conversation avatars instead of re-sending them.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Profile/ProfileSettingsViewModel.swift around line 182:

`saveAndAwait()` calls `profilesRepository().publishMyProfile(displayName:resolvedName, avatarBytes:imageData, ...)` even when the user deleted their photo and `imageData` is `nil`. In `ProfilePublisher.publish`, `nil` `avatarBytes` means "re-send the existing avatar," not "clear the avatar," so removing a photo in Settings leaves the old avatar published to every conversation. The local `DBMyProfile` is cleared, but other participants keep seeing the stale photo. Consider distinguishing a deliberate avatar removal from a name-only edit — e.g. by passing a sentinel or a dedicated delete API — so the publish path clears per-conversation avatars instead of re-sending them.

Comment thread ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift
Comment thread ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift
// repository so it fans out to every conversation via
// the durable publisher. No image bytes are available on
// the adoption path, so only the display name is sent.
try await session.messagingService().profilesRepository().publishMyProfile(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Conversations List/ConversationsViewModel.swift:395

In onApplyAdoptedProfile, the adopted avatar is saved to DBMyProfile with only imageAssetIdentifier (no imageData), and then publishMyProfile is called with avatarBytes: nil. Since ProfilePublisher only records an avatar source when avatarBytes is non-nil, the canonical profile store never gets the adopted photo. Any new conversation created on the paired device seeds only the display name and omits the avatar, even though the adopted profile had a photo. Consider passing the actual avatar image bytes into publishMyProfile so the fan-out carries the avatar, or load the adopted photo from imageAssetIdentifier before publishing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Conversations List/ConversationsViewModel.swift around line 395:

In `onApplyAdoptedProfile`, the adopted avatar is saved to `DBMyProfile` with only `imageAssetIdentifier` (no `imageData`), and then `publishMyProfile` is called with `avatarBytes: nil`. Since `ProfilePublisher` only records an avatar source when `avatarBytes` is non-nil, the canonical profile store never gets the adopted photo. Any new conversation created on the paired device seeds only the display name and omits the avatar, even though the adopted profile had a photo. Consider passing the actual avatar image bytes into `publishMyProfile` so the fan-out carries the avatar, or load the adopted photo from `imageAssetIdentifier` before publishing.

@cameronvoell cameronvoell changed the title fix: unified profile - global-only self profile fix: unified profile - global only self profile Jul 1, 2026
@cameronvoell cameronvoell changed the title fix: unified profile - global only self profile fix: unified profile - global self profile Jul 1, 2026
conversationId: conversationId,
name: (global.name?.isEmpty ?? true) ? nil : global.name,
name: (selfRow.name?.isEmpty ?? true) ? nil : selfRow.name,
avatar: nil,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repositories/MyProfileRepository.swift:159

observedProfile always constructs the Profile with avatar: nil, so when ProfilePublisher persists the current user's avatar into DBProfileAvatar, fetch() and myProfilePublisher never surface it — the profile keeps showing no avatar or stale fallback data even after a successful upload. Consider reading the avatar row (e.g. DBProfileAvatarSource.fetchOne(db, inboxId:)) and passing its value into the returned Profile.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/MyProfileRepository.swift around line 159:

`observedProfile` always constructs the `Profile` with `avatar: nil`, so when `ProfilePublisher` persists the current user's avatar into `DBProfileAvatar`, `fetch()` and `myProfilePublisher` never surface it — the profile keeps showing no avatar or stale fallback data even after a successful upload. Consider reading the avatar row (e.g. `DBProfileAvatarSource.fetchOne(db, inboxId:)`) and passing its value into the returned `Profile`.

@cameronvoell cameronvoell force-pushed the cv/unified-profile-global-self-and-drop branch from 0215de0 to e2b0536 Compare July 1, 2026 17:49
@cameronvoell cameronvoell force-pushed the cv/unified-profile-repository-read-write-live branch from 564cbd8 to 4fa3f85 Compare July 1, 2026 17:49
inboxId: senderInboxId,
name: nil,
avatar: nil
event: ProfileInboundApplier.Incoming(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Syncing/StreamProcessor.swift:488

processProfileUpdate and processProfileSnapshot now call only ProfileInboundApplier, which writes the canonical profile/profileAvatar tables but does not save a DBMemberProfile row. ContactSyncCoordinator.sync reads name, avatar, and agent-template fields from DBMemberProfile, so a member learned only through a streamed ProfileUpdate or ProfileSnapshot is added as a contact with nil name, avatar, and template metadata. The trigger is: receive a member's profile message, then hit the first-message contact sync for that conversation. Consider persisting a DBMemberProfile row alongside the canonical profile write, or updating ContactSyncCoordinator to read from the canonical profile table.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift around line 488:

`processProfileUpdate` and `processProfileSnapshot` now call only `ProfileInboundApplier`, which writes the canonical `profile`/`profileAvatar` tables but does not save a `DBMemberProfile` row. `ContactSyncCoordinator.sync` reads name, avatar, and agent-template fields from `DBMemberProfile`, so a member learned only through a streamed `ProfileUpdate` or `ProfileSnapshot` is added as a contact with nil name, avatar, and template metadata. The trigger is: receive a member's profile message, then hit the first-message contact sync for that conversation. Consider persisting a `DBMemberProfile` row alongside the canonical profile write, or updating `ContactSyncCoordinator` to read from the canonical `profile` table.

@@ -787,24 +797,13 @@ struct MemberProfileCache {

init(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repositories/MessagesRepository.swift:798

When a member leaves a conversation, their reactions on existing messages disappear from the transcript. MemberProfileCache now only indexes rows from DBConversationMember (active members). The reaction hydration path looks up each reaction's sender in the cache and drops reactions whose sender is not found, so any reaction authored by a former member is silently discarded rather than rendered with the sender's historical profile.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Repositories/MessagesRepository.swift around line 798:

When a member leaves a conversation, their reactions on existing messages disappear from the transcript. `MemberProfileCache` now only indexes rows from `DBConversationMember` (active members). The reaction hydration path looks up each reaction's sender in the cache and drops reactions whose sender is not found, so any reaction authored by a former member is silently discarded rather than rendered with the sender's historical profile.

ProfileUpdate/Snapshot).

Ordering:
- [ ] `IncomingMessageWriter.bootstrapSenderProfile` reads `DBProfile`, but in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plans/2026-06-29-profile-table-implementation.md:768

On cold-start backlog replay, BatchCatchUp persists messages before ProfileInboundApplier runs, but IncomingMessageWriter.bootstrapSenderProfile now checks only the canonical DBProfile table to decide whether to keep a CloudConnectionGrantRequest. When the backlog is from a verified agent, the canonical profile row has not been written yet at that point, so senderVerified is false and the grant request is silently dropped instead of being stored. The cleanup checklist already calls out landing the canonical write before the trusted-sender gate is checked; consider either reordering so ProfileInboundApplier runs before the trusted-sender gate in BatchCatchUp, or making the gate tolerate a pending profile during cold-start replay.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/plans/2026-06-29-profile-table-implementation.md around line 768:

On cold-start backlog replay, `BatchCatchUp` persists messages before `ProfileInboundApplier` runs, but `IncomingMessageWriter.bootstrapSenderProfile` now checks only the canonical `DBProfile` table to decide whether to keep a `CloudConnectionGrantRequest`. When the backlog is from a verified agent, the canonical profile row has not been written yet at that point, so `senderVerified` is false and the grant request is silently dropped instead of being stored. The cleanup checklist already calls out landing the canonical write before the trusted-sender gate is checked; consider either reordering so `ProfileInboundApplier` runs before the trusted-sender gate in `BatchCatchUp`, or making the gate tolerate a pending profile during cold-start replay.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

) -> any ConversationStateManagerProtocol {

Opening an existing conversation now emits a ProfileUpdate message every time. conversationStateManager(for:initialMemberInboxIds:) passes profileConversationSeeder into ConversationStateManager, and handleStateChange calls scheduleProfileSync on every .ready transition. The seeder invokes ProfilesRepository.publishMyProfileToConversation(_:) unconditionally — unlike the old MyProfileWriter.syncFromGlobalProfile(conversationId:), it does not check whether the conversation already has the current self profile. So merely opening an existing chat produces a redundant ProfileUpdate message and app-data write on every open. Consider guarding the seeder with a profile-already-present check, or skipping the seeder for the existing-conversation path.

Also found in 1 other location(s)

ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:219

scheduleProfileSync now launches profileConversationSeeder directly in a detached task without the old per-conversation ProfileSyncCoordinator guard. ConversationStateMachine.State.ready is documented elsewhere in this PR as able to re-emit, so the same conversation can schedule multiple seeds concurrently. With the production seeder (ProfilesRepository.publishMyProfileToConversation) that enqueues and drains a publish job, two .ready emissions can both publish the self profile, causing duplicate profile-update uploads/messages for the same conversation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Messaging/MessagingService.swift around line 268:

Opening an existing conversation now emits a `ProfileUpdate` message every time. `conversationStateManager(for:initialMemberInboxIds:)` passes `profileConversationSeeder` into `ConversationStateManager`, and `handleStateChange` calls `scheduleProfileSync` on every `.ready` transition. The seeder invokes `ProfilesRepository.publishMyProfileToConversation(_:)` unconditionally — unlike the old `MyProfileWriter.syncFromGlobalProfile(conversationId:)`, it does not check whether the conversation already has the current self profile. So merely opening an existing chat produces a redundant `ProfileUpdate` message and app-data write on every open. Consider guarding the seeder with a profile-already-present check, or skipping the seeder for the existing-conversation path.

Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:219 -- `scheduleProfileSync` now launches `profileConversationSeeder` directly in a detached task without the old per-conversation `ProfileSyncCoordinator` guard. `ConversationStateMachine.State.ready` is documented elsewhere in this PR as able to re-emit, so the same conversation can schedule multiple seeds concurrently. With the production seeder (`ProfilesRepository.publishMyProfileToConversation`) that enqueues and drains a publish job, two `.ready` emissions can both publish the self profile, causing duplicate profile-update uploads/messages for the same conversation.

updateDisplayNameTask?.cancel()
beginUpdate()
let unsafeWriter = myProfileWriter
let repository = messagingService.profilesRepository()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Profile/MyProfileViewModel.swift:117

update(displayName:conversationId:) calls messagingService.profilesRepository().publishMyProfile(...), but the durable publisher only fans out to conversations after startProfileServices() attaches a ProfilePublishSession. If a profile edit happens before that background startup task runs, ProfilePublisher.publish sees session == nil, computes conversationIds as [], and enqueues no jobs — so the name update lands in the local self store but never reaches any existing conversation. Consider guarding callers on publish-session readiness or documenting this race window.

Also found in 1 other location(s)

Convos/Conversation Detail/ConversationViewModel.swift:1283

ConversationViewModel now builds MyProfileViewModel with the shared messagingService instead of the old per-conversation writer. MyProfileViewModel publishes edits through messagingService.profilesRepository(), but SessionManager starts startProfileServices() on a background Task after initialization. If the user edits their profile before that async startup binds the ProfilePublisher session, ProfilesRepository.publishMyProfile(...) sees session == nil, enqueues no conversation jobs, and only updates the local self-profile row. The edit looks saved locally but is never sent to the current conversation or other members.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Profile/MyProfileViewModel.swift around line 117:

`update(displayName:conversationId:)` calls `messagingService.profilesRepository().publishMyProfile(...)`, but the durable publisher only fans out to conversations after `startProfileServices()` attaches a `ProfilePublishSession`. If a profile edit happens before that background startup task runs, `ProfilePublisher.publish` sees `session == nil`, computes `conversationIds` as `[]`, and enqueues no jobs — so the name update lands in the local self store but never reaches any existing conversation. Consider guarding callers on publish-session readiness or documenting this race window.

Also found in 1 other location(s):
- Convos/Conversation Detail/ConversationViewModel.swift:1283 -- `ConversationViewModel` now builds `MyProfileViewModel` with the shared `messagingService` instead of the old per-conversation writer. `MyProfileViewModel` publishes edits through `messagingService.profilesRepository()`, but `SessionManager` starts `startProfileServices()` on a background `Task` after initialization. If the user edits their profile before that async startup binds the `ProfilePublisher` session, `ProfilesRepository.publishMyProfile(...)` sees `session == nil`, enqueues no conversation jobs, and only updates the local self-profile row. The edit looks saved locally but is never sent to the current conversation or other members.

@graphite-app graphite-app Bot force-pushed the cv/unified-profile-repository-read-write-live branch from 4fa3f85 to 24d5edb Compare July 1, 2026 20:08
@graphite-app graphite-app Bot force-pushed the cv/unified-profile-global-self-and-drop branch from e2b0536 to cf2d0ec Compare July 1, 2026 20:08
migrator.registerMigration("createProfileAvatarLatestView", migrate: Self.createProfileAvatarLatestView)
}

/// Creates the `profileAvatarLatest` view: the most recently updated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Storage/SharedDatabaseMigrator.swift:226

The view uses GROUP BY inboxId while selecting bare columns like conversationId, url, salt, and nonce. When an inbox has multiple rows with the same max updatedAt, SQLite returns bare columns from an arbitrary row in the group, so the view exposes a mix of columns from different avatar rows. Because ProfileBackfill writes every legacy avatar for an inbox at the same epoch floor timestamp, backfilled users with avatars in multiple conversations get nondeterministic results and may render the wrong avatar. Consider selecting a single deterministic row per inbox, e.g. with ROW_NUMBER() OVER (PARTITION BY inboxId ORDER BY updatedAt DESC) or an additional tie-breaker on conversationId.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/SharedDatabaseMigrator.swift around line 226:

The view uses `GROUP BY inboxId` while selecting bare columns like `conversationId`, `url`, `salt`, and `nonce`. When an inbox has multiple rows with the same max `updatedAt`, SQLite returns bare columns from an arbitrary row in the group, so the view exposes a mix of columns from different avatar rows. Because `ProfileBackfill` writes every legacy avatar for an inbox at the same epoch `floor` timestamp, backfilled users with avatars in multiple conversations get nondeterministic results and may render the wrong avatar. Consider selecting a single deterministic row per inbox, e.g. with `ROW_NUMBER() OVER (PARTITION BY inboxId ORDER BY updatedAt DESC)` or an additional tie-breaker on `conversationId`.

private func processProfileUpdate(_ message: DecodedMessage, conversationId: String) async {
private func processProfileUpdate(_ message: DecodedMessage, conversationId: String, currentInboxId: String) async {
guard let update = try? ProfileUpdateCodec().decode(content: message.encodedContent) else {
Log.warning("Failed to decode ProfileUpdate from message \(message.id)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Syncing/StreamProcessor.swift:472

processProfileUpdate now routes through ProfileInboundApplier.apply, which writes only DBProfile/DBProfileAvatar and never mirrors the change onto DBContact. The removed ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction call updated the contact row on every inbound profile update. After this change, a contact's displayName/avatar is frozen at the value they had when first added — subsequent ProfileUpdate messages from that person update the canonical profile but leave DBContact stale, so the contact list and contact detail surfaces show the old name/avatar indefinitely.

Consider calling ContactsWriter to mirror the resolved identity onto DBContact after ProfileInboundApplier.apply completes, or document why contacts are intentionally not kept in sync.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Syncing/StreamProcessor.swift around line 472:

`processProfileUpdate` now routes through `ProfileInboundApplier.apply`, which writes only `DBProfile`/`DBProfileAvatar` and never mirrors the change onto `DBContact`. The removed `ContactsWriter.saveMemberProfileAndMirrorToContactInTransaction` call updated the contact row on every inbound profile update. After this change, a contact's `displayName`/avatar is frozen at the value they had when first added — subsequent `ProfileUpdate` messages from that person update the canonical profile but leave `DBContact` stale, so the contact list and contact detail surfaces show the old name/avatar indefinitely.

Consider calling `ContactsWriter` to mirror the resolved identity onto `DBContact` after `ProfileInboundApplier.apply` completes, or document why contacts are intentionally not kept in sync.

inboxId: inboxId,
source: .profileUpdate,
name: entry.update.hasName ? entry.update.name : nil,
avatar: .fillIfPresent(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Writers/ConversationWriter.swift:1088

processProfileMessagesFromHistory passes ProfileUpdate avatar data to ProfileInboundApplier as .fillIfPresent(...) instead of .addressed(...). For a ProfileUpdate, a missing encryptedImage means the sender cleared their avatar, but .fillIfPresent maps that to IncomingAvatar.silent, so the existing DBProfileAvatar row is left untouched. After a cold start or history catch-up, a contact who removed their avatar will still display the stale avatar until a fresh live update arrives. Consider using .addressed(...) for ProfileUpdate events so an absent image clears the stored avatar.

Suggested change
avatar: .fillIfPresent(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil),
avatar: .addressed(entry.update.hasEncryptedImage ? entry.update.encryptedImage : nil),
Also found in 1 other location(s)

ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:98

Replacing MyProfileWriter.syncFromGlobalProfile with profileConversationSeeder also drops the avatar-clear path. The old code explicitly called update(avatar: nil, ...) when DBMyProfile.imageData and its digest were cleared, but ProfilesRepository.publishMyProfileToConversation only enqueues a name-only job. ProfilePublisher.processNameOnlyJob then reuses any existing profileStore.avatar(...) for that conversation, so opening a conversation after the user removed their global photo re-sends the stale per-conversation avatar instead of clearing it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationWriter.swift around line 1088:

`processProfileMessagesFromHistory` passes `ProfileUpdate` avatar data to `ProfileInboundApplier` as `.fillIfPresent(...)` instead of `.addressed(...)`. For a `ProfileUpdate`, a missing `encryptedImage` means the sender cleared their avatar, but `.fillIfPresent` maps that to `IncomingAvatar.silent`, so the existing `DBProfileAvatar` row is left untouched. After a cold start or history catch-up, a contact who removed their avatar will still display the stale avatar until a fresh live update arrives. Consider using `.addressed(...)` for `ProfileUpdate` events so an absent image clears the stored avatar.

Also found in 1 other location(s):
- ConvosCore/Sources/ConvosCore/Storage/Writers/ConversationStateManager.swift:98 -- Replacing `MyProfileWriter.syncFromGlobalProfile` with `profileConversationSeeder` also drops the avatar-clear path. The old code explicitly called `update(avatar: nil, ...)` when `DBMyProfile.imageData` and its digest were cleared, but `ProfilesRepository.publishMyProfileToConversation` only enqueues a name-only job. `ProfilePublisher.processNameOnlyJob` then reuses any existing `profileStore.avatar(...)` for that conversation, so opening a conversation after the user removed their global photo re-sends the stale per-conversation avatar instead of clearing it.

.including(optional: DBConversationMember.inviterProfile)
.including(optional: DBConversationMember.profile)
.including(optional: DBConversationMember.avatarSlot)
.including(optional: DBConversationMember.inviterProfileIdentity)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Database Models/DBConversationMemberProfileWithRole.swift:60

The query joins inviterProfile via DBConversationMember.inviterProfileIdentity, but self identities aren't stored in DBProfile, so when the current user is the inviter the association resolves to nil and hydrateConversationMember() returns invitedBy: nil. Callers like composeAgentTemplateConversations then misclassify conversations the current user added an agent to as addedByOthers. Consider joining the inviter through an association that includes the local user's own identity, or falling back to the current inbox id when the inviter is self.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Storage/Database Models/DBConversationMemberProfileWithRole.swift around line 60:

The query joins `inviterProfile` via `DBConversationMember.inviterProfileIdentity`, but self identities aren't stored in `DBProfile`, so when the current user is the inviter the association resolves to `nil` and `hydrateConversationMember()` returns `invitedBy: nil`. Callers like `composeAgentTemplateConversations` then misclassify conversations the current user added an agent to as `addedByOthers`. Consider joining the inviter through an association that includes the local user's own identity, or falling back to the current inbox id when the inviter is self.

@cameronvoell cameronvoell force-pushed the cv/unified-profile-repository-read-write-live branch from 24d5edb to aa29d71 Compare July 2, 2026 18:47
@cameronvoell cameronvoell force-pushed the cv/unified-profile-global-self-and-drop branch from f51e630 to 0af531c Compare July 2, 2026 18:47
Comment on lines +79 to +81
existing: existingAvatar,
inboxId: inboxId,
conversationId: conversationId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Repository/ProfileInboundApplier.swift:79

avatarEvent returns .explicitClear when an .addressed image ref is non-nil but invalid (image.isValid is false). So a ProfileUpdate that sets hasEncryptedImage = true but carries a malformed URL/salt/nonce overwrites the existing avatar slot and makes the avatar disappear. Malformed image refs should be treated as absent, not as a destructive clear — returning .silent here avoids silently dropping the avatar on corrupt updates.

Suggested change
existing: existingAvatar,
inboxId: inboxId,
conversationId: conversationId,
case let .addressed(image):
guard image?.isValid == true else { return .silent }
return .set(url: image!.url, salt: image!.salt, nonce: image!.nonce, key: existingKey ?? fallbackKey)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @ConvosCore/Sources/ConvosCore/Profiles/Repository/ProfileInboundApplier.swift around lines 79-81:

`avatarEvent` returns `.explicitClear` when an `.addressed` image ref is non-nil but invalid (`image.isValid` is false). So a `ProfileUpdate` that sets `hasEncryptedImage = true` but carries a malformed URL/salt/nonce overwrites the existing avatar slot and makes the avatar disappear. Malformed image refs should be treated as absent, not as a destructive clear — returning `.silent` here avoids silently dropping the avatar on corrupt updates.

// repository so it fans out to every conversation via
// the durable publisher. No image bytes are available on
// the adoption path, so only the display name is sent.
try await session.messagingService().profilesRepository().publishMyProfile(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Conversations List/ConversationsViewModel.swift:395

The pairing-adoption path calls profilesRepository().publishMyProfile(...) with the adopted displayName, but when displayName is nil the method skips updateSelfProfile and only publishes the avatar. ProfilePublisher then reads the existing self-profile row via selfProfileStore.load() and re-sends whatever stale name/metadata is still in that table to every conversation. Because refreshAfterPairingCompleted() / deleteAllInboxes() do not clear the canonical selfProfile table, a device that previously belonged to another account republishes the old account's identity when the adoption share payload carries no display name.

Also found in 1 other location(s)

Convos/Profile/ProfileSettingsViewModel.swift:182

saveAndAwait() now calls profilesRepository().publishMyProfile(...) immediately after writer.save(...). publishMyProfile updates DBSelfProfile from the repository actor's in-memory cachedSelf, and when profile services have not finished warmUp() yet cachedSelf is still nil. In that window, saving a display name writes a new DBSelfProfile row with only the name and metadata == nil, overwriting any existing self metadata that ProfileBackfill had just seeded from DBMyProfile. The next publish then sends the user profile without its metadata until it is re-authored.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @Convos/Conversations List/ConversationsViewModel.swift around line 395:

The pairing-adoption path calls `profilesRepository().publishMyProfile(...)` with the adopted `displayName`, but when `displayName` is `nil` the method skips `updateSelfProfile` and only publishes the avatar. `ProfilePublisher` then reads the existing self-profile row via `selfProfileStore.load()` and re-sends whatever stale name/metadata is still in that table to every conversation. Because `refreshAfterPairingCompleted()` / `deleteAllInboxes()` do not clear the canonical `selfProfile` table, a device that previously belonged to another account republishes the old account's identity when the adoption share payload carries no display name.

Also found in 1 other location(s):
- Convos/Profile/ProfileSettingsViewModel.swift:182 -- `saveAndAwait()` now calls `profilesRepository().publishMyProfile(...)` immediately after `writer.save(...)`. `publishMyProfile` updates `DBSelfProfile` from the repository actor's in-memory `cachedSelf`, and when profile services have not finished `warmUp()` yet `cachedSelf` is still `nil`. In that window, saving a display name writes a new `DBSelfProfile` row with only the name and `metadata == nil`, overwriting any existing self metadata that `ProfileBackfill` had just seeded from `DBMyProfile`. The next publish then sends the user profile without its metadata until it is re-authored.

@cameronvoell

Copy link
Copy Markdown
Contributor Author

folded into #1126

@cameronvoell cameronvoell deleted the cv/unified-profile-global-self-and-drop branch July 2, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant