Skip to content

[Plan] DMs on a single inbox (supersedes #398)#721

Draft
yewreeka wants to merge 1 commit intodevfrom
jarod/dm-single-inbox-plan
Draft

[Plan] DMs on a single inbox (supersedes #398)#721
yewreeka wants to merge 1 commit intodevfrom
jarod/dm-single-inbox-plan

Conversation

@yewreeka
Copy link
Copy Markdown
Contributor

@yewreeka yewreeka commented Apr 21, 2026

Summary

Draft v1 plan for private DMs rebuilt on top of the single-inbox identity model from #713, replacing the multi-inbox-era design in #398.

The architecture shift: every user now has exactly one stable inboxId (ADR 011). That collapses "DMs" from a custom back-channel protocol into two stock XMTP calls — conversations.newConversation(with:) and ConsentState. No new content types, no new codecs, no allow_dms profile-metadata flag, no self-addressed config stream. Just UI + glue.

Stacked on single-inbox-refactor (PR #713) because the entire plan is predicated on that merging. If #713 bounces, this plan goes with it.

What's in the plan

  • Sender flow: long-press member → newConversation(with: inboxId) → DM opens with origin-group profile inherited
  • Receiver flow: inline consent bar (.unknown → .allowed / .denied), no separate request box
  • Explicit scope cut: no allow-list, no per-group DM toggle, no group spinoffs, no fresh-inbox-per-DM (all deferred to v2)
  • Four open-question items (UAQ) to resolve before leaving Draft:
    1. Origin label format
    2. Should sender see a pending-DM affordance?
    3. Auto-.allowed when pair shares N ≥ 2 groups?
    4. Notification behavior for .unknown DMs

Why this is ~150 lines instead of ~500

The previous spec in #398 had to invent the convos.org/convo_request content type, a back-channel DM between group-inboxIds, an allows_dms metadata flag on profile messages, a self-addressed XMTP message stream for the select-members list, and disappearing origin-context tied to message expiration — entirely because every user had many inboxes and there was no direct way to DM someone. Single-inbox dissolves all of that.

Review asks

  1. Scope vote — is the v1 cut right, or should we fold in the allow-list / per-group DM toggle from day one?
  2. Open questions — the four UAQ items want concrete decisions before I hand off to swift-architect.
  3. Close Update DM one-pager with simplified design #398 as superseded once this is approved — happy to do that or leave it to the author.

Test plan (for the plan)

  • Product review — does the v1 experience match the Convos brand promise?
  • Architecture review (swift-architect) — sanity-check that XMTP's newConversation primitive actually does what's claimed here, and that the per-conversation profile inheritance fits the existing DBMemberProfile pipeline.
  • Design review — mockups for the four UAQ items
  • Legal / abuse review — does "one block stops all DMs from that peer" meet the safety bar?

🤖 Generated with Claude Code

Note

Add plan document for DMs on a single inbox

Adds a 1-pager design plan at docs/plans/dm-single-inbox.md covering the single-inbox model for direct messages. The document outlines sender/receiver flows using XMTP consent states, v1 non-goals, mockups, FAQs, and next steps. Supersedes #398.

📊 Macroscope summarized e600dd1. 62 files reviewed, 7 issues evaluated, 3 issues filtered, 0 comments posted

🗂️ Filtered Issues

ConvosCore/Sources/ConvosCore/Config/ConfigManager.swift — 0 comments posted, 2 evaluated, 1 filtered
  • line 147: The gatewayUrl override is computed at lines 115-117 but not passed to ConvosConfiguration for the dev environment at lines 142-149. This means overrides.gatewayURL is silently ignored for dev builds. Compare to the local environment (lines 126-134) which correctly passes both xmtpEndpoint and gatewayUrl. [ Posting failed ]
ConvosCore/Sources/ConvosCore/Inboxes/SessionStateMachine.swift — 0 comments posted, 1 evaluated, 1 filtered
  • line 429: The .stop action is not handled for .authorizing, .registering, or .authenticatingBackend states - it falls through to the default case which only logs a warning. If a caller invokes stop() while the state machine is mid-authorization or mid-registration, the stop request is silently ignored and the operation continues to completion, potentially transitioning all the way to .ready state despite the stop request. This defeats the caller's expectation that stop() will halt the state machine. [ Posting failed ]
ConvosCore/Sources/ConvosCore/Messaging/UnusedConversationCache.swift — 0 comments posted, 2 evaluated, 1 filtered
  • line 163: If task cancellation occurs after try await optimisticConversation.publish() completes but before group = xmtpGroup assignment, the code catches CancellationError and returns without calling leaveGroup(). The MLS group is now live on the XMTP network but orphaned locally with no reference to clean it up. The comment claims "the post-publish branch below owns rollback" but that branch is never reached when CancellationError is caught in the first do block before group is assigned. [ Posting failed ]

@claude
Copy link
Copy Markdown

claude Bot commented Apr 21, 2026

Claude finished @yewreeka's task —— View job


Code Review: DMs on Single Inbox Plan

This is an excellent plan document that demonstrates strong architectural thinking and clear communication. The shift from the multi-inbox approach in #398 to this single-inbox design is a significant simplification enabled by ADR 011.


✅ Strengths

Architectural Soundness

  1. Leverages existing primitives correctly: The plan correctly identifies that conversations.findOrCreateDm(with: inboxId) (line 174 in XMTPClientProvider.swift) already exists and handles DM stitching. No new content types needed.

  2. Smart reuse of profile system: The ProfileUpdate protobuf (lines 25-30 in profile_messages.proto) already has a map<string, MetadataValue> metadata field, perfect for adding allows_dms without schema changes.

  3. Consent model aligns with XMTP: The plan's use of conversation-level and inbox-level consent states matches the existing XMTPClientProvider patterns (lines 142-148 in XMTPClientProvider.swift).

  4. Single-inbox dependency is clear: The plan explicitly states it's stacked on Single-inbox identity refactor #713 and would fail without it — honest scope management.

Clarity and Completeness

  1. Decision function is precise: The receiver decision function (lines 54-75 in the plan) is algorithmic enough to hand directly to an implementer. Step 0 handles DM stitching idempotence elegantly.

  2. Privacy properties are honest: The "Per-conversation profiles are display, not identity" section (lines 99-132) doesn't hide the limitations. The worked scenario (lines 108-109) shows exactly how identity leakage happens.

  3. UAQ items are well-scoped: The 8 unresolved questions (lines 209-218) are appropriately deferred — they're UX details, not architectural blockers.

  4. V1 scope cut is clean: The "explicitly deferred to v2" section draws a hard line. No scope creep.

Code Quality Considerations

  1. Protocol-wrapper pattern preservation: The plan references XMTPClientProvider throughout, maintaining the abstraction pattern from CLAUDE.md line 13-27.

  2. Platform independence respected: No UIImage or iOS-specific types mentioned — follows the ConvosCore cross-platform requirement.


⚠️ Issues and Concerns

Critical Issues

1. Missing consent API in XMTPClientProvider

  • Location: docs/plans/dm-single-inbox.md:44, :95, :236
  • Issue: The plan claims to extend XMTPClientProvider to expose PrivatePreferences.setConsentState with entryType: .inbox_id, but the current protocol (ConvosCore/Sources/ConvosCore/Messaging/XMTPClientProvider.swift:125-149) only has update(consent: Consent, for conversationId: String) — conversation-level only.
  • Impact: Inbox-level block won't work until the protocol is extended.
  • Fix needed: Add to v1 scope:
    func setConsentState(inboxId: String, state: ConsentState) async throws
    func streamConsentStates() -> AsyncThrowingStream<ConsentListEntry, Error>

2. DM stitching consent scope is unverified

  • Location: docs/plans/dm-single-inbox.md:95-96, UAQ :217
  • Issue: The plan correctly identifies this as a UAQ: "Does setConsentState(entries: [.conversation_id(dmId, .denied)]) apply across all stitched MLS groups?" This is critical for the receiver decision function's idempotence claim (step 0).
  • Impact: If consent is per-MLS-group (not per-Dm), a peer's new installation creates a new group → new conversationId → .denied doesn't apply → denied DM resurfaces.
  • Mitigation: The plan says "always pair with inbox-level consent" (line 96), which is correct but requires the missing API from issue [Screen] Onboarding #1.
  • Action before leaving Draft: Test against XMTPiOS or read SDK source to confirm Dm.consentState() aggregates across stitched groups.

3. NSE welcome filtering is underspecified

  • Location: docs/plans/dm-single-inbox.md:96-97, v1 scope :240
  • Issue: The plan says "NSE must recognize 'this welcome is for a Dm I already have stitched'" but doesn't specify how. The current CachedPushNotificationHandler (ADR 011 lines 97-110) has no welcome-type filtering.
  • Impact: Users get spammed with notifications every time a DM peer gets a new device.
  • Fix needed: V1 scope must include:
    • Detect welcome messages in NSE payload
    • Query local DB for existing Dm with peerInboxId
    • Drop notification if Dm already exists with .allowed consent

Security Concerns

4. Silent filtering has no abuse reporting path

  • Location: docs/plans/dm-single-inbox.md UAQ:215
  • Issue: If Bob DMs Alice and is silently denied, Alice never sees it → can't report Bob. The plan defers this to "group-admin moderation" but that assumes the shared group has active admins and that Bob's bad behavior is visible in the group.
  • Scenario: Bob is well-behaved in the group but sends abusive DMs. Alice blocks Bob (inbox-level), but other members don't know to remove him.
  • Recommendation: Add to v1 or v2 scope: a "Recently blocked" list in settings that shows inbox-level blocks with a "Report to group admins" action (sends a lightweight flag to admins of shared groups).

5. Consent re-evaluation on membership changes is missing

  • Location: docs/plans/dm-single-inbox.md UAQ:212, receiver decision :62
  • Issue: The decision function computes shared = groups where both S and me are currently members but doesn't specify when this recomputes. If Alice allows DMs from Book Club members, then Book Club removes Bob, Bob's existing DM stays .allowed (per line 80) but Bob can still send new DMs if Alice doesn't re-run the decision function.
  • Impact: A removed member can continue DMing until Alice restarts the app or a membership-change event triggers re-evaluation.
  • Fix needed: V1 scope must include:
    • On group.removedMembers event, recompute shared for all DMs
    • If shared becomes empty → leave consent as-is (line 82 behavior)
    • If allowedGroups becomes empty → future DMs are .denied (no retroactive change to existing DM)

Performance Concerns

6. Shared-groups computation may be expensive

  • Location: docs/plans/dm-single-inbox.md:62, UAQ:212
  • Issue: The receiver decision function queries groups where both S and me are currently members (local DB) on every incoming DM event. If the user is in 50 groups, that's a join query across DBConversation and DBMember tables.
  • Impact: High-frequency DM senders (or a spam attack) could degrade home-list responsiveness.
  • Recommendation:
    • Cache shared per peerInboxId in a Map<InboxId, Set<ConversationId>> in-memory
    • Invalidate on join/leave events (observe StreamProcessor)
    • UAQ line 212 should specify this caching strategy

Code Quality Issues

7. allows_dms naming is inconsistent with Swift conventions

  • Location: docs/plans/dm-single-inbox.md:41, :138, :155
  • Issue: The plan uses snake_case allows_dms for the protobuf field name, which is correct for proto, but doesn't specify the Swift property name. ConvosCore uses camelCase (see profile_messages.proto lines 26-29 → name, encrypted_image fields become name, encryptedImage in Swift).
  • Fix: Clarify in v1 scope that ProfileUpdate.metadata["allows_dms"] is the wire format, and Swift accessor is memberProfile.allowsDms (computed property).

8. App-level toggle default is ambiguous

  • Location: docs/plans/dm-single-inbox.md:32, :150
  • Issue: The plan says app-level default is "Enabled" (line 32) but doesn't specify what happens on first launch. Does GlobalConvoDefaults.shared.<dmsEnabledDefault> initialize to true or require user opt-in?
  • Privacy concern: DMs being on-by-default means any group member can DM you unless you explicitly disable. That's a privacy-reducing default.
  • Recommendation: Default to disabled and prompt during onboarding ("Do you want to allow DMs from group members?").

💡 Performance Considerations

9. Origin label recomputation on every home-list render

  • Location: docs/plans/dm-single-inbox.md:73-74, UAQ:212
  • Issue: The plan says origin label is "computed live from the full (unfiltered) current shared-groups set" on every render. If the home list shows 20 DMs and each DM recomputes shared on scroll, that's wasteful.
  • Optimization: Store sharedGroupIds: [ConversationId] on DBDm (new table or metadata field) and update only on membership events. Origin label reads from the cached field.

10. Per-group select-list storage is unspecified

  • Location: docs/plans/dm-single-inbox.md:42, UAQ:214
  • Issue: The plan says select-list lives in a "self-addressed XMTP DM stream" but doesn't specify format. If it's one message per add/remove (append-log), a user with 20 allowed members in 5 groups generates 100 messages. If it's one message per group (full-list snapshot), updates are wasteful.
  • Recommendation: Use snapshot format (one message per group with full allowedInboxIds: [String] array). On update, send a new message with the full list; reader uses the newest message per groupId.

🧪 Test Coverage

11. V1 scope is missing test requirements

  • Location: docs/plans/dm-single-inbox.md:233-246 (v1 scope), :228-231 (test plan)
  • Issue: The "Build" checkbox (line 228) has no test breakdown. Given the complexity (consent, stitching, policy evaluation, push filtering), V1 needs:
    • Unit tests:
      • Receiver decision function with all branches (steps 0-6)
      • Policy evaluation (off/everyone/select modes)
      • Shared-groups computation
    • Integration tests:
      • DM stitching: peer has 2 installations → 1 Dm
      • Consent persistence across app restarts
      • NSE welcome filtering
    • Concurrency tests:
      • Multiple DMs arriving simultaneously
      • Membership change during DM delivery
  • Recommendation: Add "Test coverage" as a v1 scope item with checklist.

📋 Documentation Issues

12. Missing ADR reference for consent model

  • Location: docs/plans/dm-single-inbox.md:44, :142
  • Issue: The plan introduces inbox-level and conversation-level consent as a core primitive but doesn't reference an ADR. ConvosCore already uses consent (XMTPClientProvider.swift:142) but there's no architectural decision document explaining the model.
  • Recommendation: Either:
    • Add a new ADR 012 for "Consent Model and Blocking" (recommended)
    • Or add a subsection to this plan explaining the consent model formally

13. "Per-conversation profiles are display, not identity" needs product copy

  • Location: docs/plans/dm-single-inbox.md:126-132, UAQ:218
  • Issue: The plan correctly identifies this as critical to communicate in-product but defers exact copy to UAQ. This is too risky to defer — if the copy is wrong, users will misunderstand the privacy guarantee and feel betrayed when DMs reveal identity.
  • Recommendation: Draft copy before leaving Draft. Suggested text:

    "Your custom profile (name and photo) changes how you appear in this Convo, but anyone in multiple Convos with you can see you're the same person. Custom profiles are for social separation, not anonymity."

  • Put this in:
    • Profile editor helper text
    • First-DM onboarding card
    • Settings → DMs toggle (link to help article)

🔍 Comparison to #398

The plan claims this is ~150 lines vs ~500 lines in #398 due to single-inbox simplification. Reviewing the changes:

What's removed (good):

  • ✅ Custom convos.org/convo_request content type
  • ✅ Group-inbox-based DM back-channel
  • allows_dms profile flag wired to inbox selection (now just a metadata bit)
  • ✅ Self-addressed config stream (now reused for select-list, simpler)

What's kept (policy model is product-driven):

  • ✅ App-level + per-group tri-state (off/everyone/select)
  • ✅ Silent filtering (no Requests box)
  • ✅ Private select-list

The architecture simplification is real and significant. The multi-inbox workarounds are gone because XMTP's native DM primitive does the job.


🎯 Recommendations

Before leaving Draft (required):

  1. Resolve UAQ Onboarding Auth Flow #7 (consent scope under stitching): Test or read XMTPiOS SDK to confirm Dm.consentState() aggregates across stitched groups. If not, document the inbox-level consent pairing requirement more prominently.

  2. Draft product copy for privacy disclaimer: Don't ship v1 without user-facing text explaining "profiles are display, not identity." Review with legal/product.

  3. Extend XMTPClientProvider for inbox-level consent: Add protocol methods for setConsentState(inboxId:state:) and streamConsentStates(). This is a hard blocker for the explicit Block action.

  4. Specify NSE welcome filtering logic: Add pseudocode or reference implementation plan for detecting and dropping welcome messages for already-stitched DMs.

  5. Choose app-level default: Decide if DMs are on-by-default or require opt-in during onboarding. Document privacy reasoning.

For V1 implementation (recommended):

  1. Add test coverage to V1 scope: Unit tests for decision function, integration tests for stitching, concurrency tests for race conditions.

  2. Cache shared-groups computation: Don't recompute shared on every DM event; cache per peerInboxId and invalidate on membership changes.

  3. Specify select-list storage format: Document snapshot vs append-log choice in UAQ Add PostHog for Analytics #4 resolution.

  4. Add consent re-evaluation on membership changes: When a group removes a member, recompute allowedGroups for all DMs with that peer to stop future DMs (existing DM consent stays unchanged).

For V2 (deferred but noted):

  1. Abuse reporting for silently-denied DMs: "Recently blocked" list in settings with group-admin reporting action.

  2. Create ADR 012 for consent model: Formalize inbox-level vs conversation-level consent, blocking semantics, and device-sync behavior.


Summary

This is a well-architected plan that correctly leverages the single-inbox refactor to eliminate unnecessary complexity. The core design — using XMTP's native DM primitive, reusing the profile system, and silent consent filtering — is sound.

Critical blockers before approval:

Recommended additions to V1 scope:

  • Test coverage breakdown
  • Shared-groups caching strategy
  • Consent re-evaluation on membership changes

Scope decision vote: The V1 cut is appropriate. Deferring allow-list UI polish, group spinoffs, and profile-choice sheets is correct — ship the core mechanics first, iterate on UX based on real usage.

Once the 4 critical items are resolved, this plan is ready to hand off to swift-architect for detailed API design.


Macroscope Flagged Issues (pre-existing, not introduced by this PR)

The 3 filtered issues from Macroscope are in ConfigManager.swift, SessionStateMachine.swift, and UnusedConversationCache.swift — all pre-existing code not touched by this plan. They're legitimate bugs worth tracking separately but not blockers for this PR.


• Branch: jarod/dm-single-inbox-plan

@yewreeka yewreeka force-pushed the jarod/dm-single-inbox-plan branch 4 times, most recently from 9b35b91 to e600dd1 Compare April 22, 2026 04:15
Base automatically changed from single-inbox-refactor to dev April 22, 2026 04:56
@yewreeka yewreeka force-pushed the jarod/dm-single-inbox-plan branch 2 times, most recently from 54945db to 91fd59f Compare April 22, 2026 14:20
Adds docs/plans/dm-single-inbox.md — v1 plan for private DMs rebuilt on the
single-inbox identity model (PR #713 / ADR 011).

Core shift from #398:
- No `convos.org/convo_request` content type. Origin signal rides on
  `ConversationCustomMetadata.originGroupId` (immutable, sender-set at DM
  creation, spoof-protected by membership verification on receive).
- No back-channel DM between group inboxes. Peer DMs are native XMTP DMs
  between stable single-inbox inboxIds.
- No new custom consent primitive. Uses `XMTPiOS.PrivatePreferences` for
  per-conversation and per-inboxId consent, which streams across the user's
  devices for free. Requires extending `XMTPClientProvider` to expose
  `client.preferences` (Convos today only uses conversation-level consent).
- `allows_dms: bool` public bit still rides on `ProfileUpdate` metadata, same
  mechanism as #398, unchanged.
- Select-members allow-list still private via self-addressed XMTP DMs.

Policy model:
- App-level toggle = default for newly joined groups.
- Per-group tri-state: off / everyone / select-members.
- New members never auto-populate the select-list.

Product decision baked in: no Requests bucket. Every DM resolves to `.allowed`
or silent `.denied`; `.unknown` is transient only. Home-list query excludes
both `.unknown` and `.denied` to avoid flicker.

Six UAQs left to debate before leaving Draft; v2 deferrals called out
(group spinoffs, explicit profile-choice sheet).
@yewreeka yewreeka force-pushed the jarod/dm-single-inbox-plan branch from 91fd59f to 9c065e8 Compare April 22, 2026 17:37
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