Skip to content

feat(client): opt-in force_pn_addressing to keep outbound DMs on PN JIDs - #940

Closed
juanlotito wants to merge 1 commit into
oxidezap:mainfrom
juanlotito:feat/optional-pn-dm-addressing
Closed

juanlotito wants to merge 1 commit into
oxidezap:mainfrom
juanlotito:feat/optional-pn-dm-addressing

Conversation

@juanlotito

@juanlotito juanlotito commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in Client::set_force_pn_addressing(true) that keeps outbound DM addressing on the namespace the caller provided instead of upgrading PN → LID. The flag is scoped to the four addressing decisions the outbound path makes — recipient fanout keys (send_message_impl), session creation (resolve_lid_mappings via ensure_e2e_sessions), stanza prep (SendContextResolver::get_lid_for_phone) and session lock keys (build_session_lock_keys, kept aligned with the namespace encryption actually uses so the per-session lock invariant against concurrent inbound decrypt holds) — while inbound decrypt addressing (resolve_encryption_jid on sender JIDs), LID↔PN mapping learning, and inbound session migration stay untouched. Mirrors the existing skip_history_sync runtime flag pattern.

Motivation

On at least one companion registration (personal account, client linked as a companion device), DMs sent with LID addressing are accepted by the server but never delivered: no Delivered receipt ever arrives and the recipient never sees the message. The same message sent with pre-0.6 PN addressing is delivered within ~2 seconds (verified side by side against 0.5.0).

With 0.6's unified addressing there is currently no way to work around this from the caller side:

  • passing a PN JID to send_message doesn't help, because the DM path re-upgrades it internally as soon as the LID mapping is in the cache, and
  • the first DM to an unmapped PN triggers the usync LID query that learns and persists the mapping — so the first message to a fresh PN is delivered and every subsequent one is silently dropped, which made the failure mode quite confusing to diagnose.

Timeline observed live (numbers redacted):

  1. fresh client start → send_message(pn_jid) → delivered ✔
  2. inbound message arrives from that user (mapping learned/warmed)
  3. send_message(pn_jid) → accepted by server, never delivered, no receipt ✘
  4. same on every retry; rolling back to 0.5.0 (PN addressing) restores delivery immediately, receipts arrive in ~2s again ✔

I don't know how widespread this server behavior is (possibly related to account type or registration age), so an opt-in escape hatch seemed like the minimal-surface fix rather than changing default behavior. Happy to rework into a BotBuilder option or config field if you prefer.

Why the flag gates four call sites instead of just resolve_encryption_jid

A first iteration gated only resolve_encryption_jid. That turned out to be both insufficient — prepare_dm_stanza/encrypt_for_devices upgrade PN → LID through a separate path (SendContextResolver::get_lid_for_phone), so participants and enc nodes still went out LID-addressed — and unsafe: session lock keys would have diverged from the namespace encryption actually used, breaking the documented per-session lock invariant (concurrent outbound encrypt and inbound decrypt of the same peer would no longer serialize on the same mutex). The final shape keeps every outbound addressing decision consistent on PN while leaving the inbound half of the pipeline stock.

Validation

  • cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings (0 warnings), cargo test --workspace --exclude e2e-tests (all suites green) — same commands as the Rust CI workflow.
  • New unit test test_force_pn_addressing_scopes_to_outbound_paths asserting the outbound paths keep PN with the flag on, that inbound resolve_encryption_jid is deliberately NOT gated, and that turning the flag off restores stock behavior.
  • Live validation on the affected deployment: with the flag, repeated DMs (warm mapping cache — the previously failing condition) all produced Delivered receipts within ~2s, including full inbound → reply round-trips; without it, silent drops as described above.

Default behavior is unchanged — the flag is off unless a caller opts in.

Review in cubic

Some companion registrations observe that DMs addressed via LID are
accepted by the server but never delivered to the recipient (no
Delivered receipt ever arrives), while the same message sent with
pre-0.6 PN addressing is delivered within seconds. The unified LID
addressing introduced in 0.6 upgrades PN targets to LID whenever a
mapping is known (and the DM send path learns mappings via usync on
first contact), which leaves such deployments with no way to reach
affected chats: the first message to a fresh PN goes through, every
subsequent one is silently dropped.

Add Client::set_force_pn_addressing(true) as a runtime opt-out scoped
to the outbound path only, so all four addressing decisions a DM send
makes stay on the namespace the caller provided: recipient fanout keys
(send_message_impl), session creation (resolve_lid_mappings via
ensure_e2e_sessions), stanza prep participants/enc nodes
(SendContextResolver::get_lid_for_phone), and session lock keys
(build_session_lock_keys, which must key the same namespace the
encryption uses to preserve the per-session lock invariant against
concurrent inbound decrypt). Inbound decrypt addressing
(resolve_encryption_jid on sender JIDs), LID mapping learning, and
inbound session migration are deliberately untouched.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 86b6620b-15a0-4490-8462-82c080261324

📥 Commits

Reviewing files that changed from the base of the PR and between f548d1f and 55c092a.

📒 Files selected for processing (4)
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/lid_pn.rs
  • src/send.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added an option to keep direct-message addressing in the phone-number namespace.
    • You can now turn this behavior on or off and check whether it’s enabled.
  • Bug Fixes

    • Direct-message routing now stays consistent when phone-number addressing is forced.
    • Session lock key generation now matches the selected addressing mode.
    • Existing address resolution behavior remains unchanged for inbound decryption.

Walkthrough

The PR adds a force_pn_addressing atomic flag to Client with getter/setter methods. When enabled, outbound DM addressing paths (get_lid_for_phone, resolve_lid_mappings, send_message_impl, build_session_lock_keys) skip PN→LID upgrading and keep the phone-number namespace, while inbound decrypt addressing is unaffected.

Changes

Force PN Addressing

Layer / File(s) Summary
Flag definition and accessors
src/client.rs
Adds force_pn_addressing: AtomicBool field (initialized to false) and set_force_pn_addressing/force_pn_addressing_enabled methods.
LID resolution short-circuits
src/client/context_impl.rs, src/client/lid_pn.rs
get_lid_for_phone and resolve_lid_mappings return early, skipping PN→LID cache lookups, when the flag is enabled; adds a test verifying the flag scopes to outbound paths only.
Send path namespace preservation
src/send.rs
send_message_impl and build_session_lock_keys use the raw JID directly instead of resolve_encryption_jid when forced PN addressing is enabled.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Look, this is the kind of precise, surgical control I want to see across all our messaging infra — a clean flag, wired consistently through every addressing decision point, with a test that actually proves the boundary holds. Companion registration edge cases matter, and this closes the gap without breaking inbound decrypt paths. That's the level of rigor we need. Ship it, but I want the send.rs overlap with those other PRs resolved before it goes out — can't have two teams fighting over the same lock-key logic.

Possibly related PRs

Suggested labels: api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: an opt-in client flag to keep outbound DMs on PN JIDs.
Description check ✅ Passed The description is directly aligned with the changeset and explains the new flag, its scope, motivation, and validation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/client/context_impl.rs">

<violation number="1" location="src/client/context_impl.rs:51">
P2: The four outbound `force_pn_addressing` gate points read the flag independently with `Ordering::Relaxed`, and there are `.await` suspension points between them in `send_message_impl` (e.g., between the `recipient_bare` computation at line ~1175, `ensure_e2e_sessions` at ~1387, `build_session_lock_keys` at ~1406, and `prepare_dm_stanza` which calls `get_lid_for_phone`). If `set_force_pn_addressing` is toggled concurrently during a send, different stages of the same send may observe opposite values. This recreates the namespace/session-lock divergence the PR explicitly tries to avoid. Snap-shotting the flag once at the start of each send and passing that decision through all four call sites would actually preserve the invariant.</violation>

<violation number="2" location="src/client/context_impl.rs:51">
P2: The `force_pn_addressing_enabled` gate in `get_lid_for_phone` is applied unconditionally inside the shared `SendContextResolver` implementation, which is consumed by `encrypt_for_devices` for any list of device JIDs. Because the method receives only a `phone_user` string with no send-context (e.g., DM vs. group vs. broadcast), enabling the flag will suppress LID resolution for every outbound encrypted send, not just direct messages. The PR describes this as a DM workaround, so the scope should be verified: if group or broadcast flows still require LID addressing, this global suppression could emit PN-addressed `enc` nodes in contexts that expect LID. Consider scoping the gate to DM-only sends or confirming that all callers of `get_lid_for_phone` are safe to receive PN addressing when the flag is on.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

async fn get_lid_for_phone(&self, phone_user: &str) -> Option<String> {
// Reporting no mapping keeps stanza prep (participants, enc nodes) on
// the PN namespace (see Client::set_force_pn_addressing).
if self.force_pn_addressing_enabled() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The four outbound force_pn_addressing gate points read the flag independently with Ordering::Relaxed, and there are .await suspension points between them in send_message_impl (e.g., between the recipient_bare computation at line ~1175, ensure_e2e_sessions at ~1387, build_session_lock_keys at ~1406, and prepare_dm_stanza which calls get_lid_for_phone). If set_force_pn_addressing is toggled concurrently during a send, different stages of the same send may observe opposite values. This recreates the namespace/session-lock divergence the PR explicitly tries to avoid. Snap-shotting the flag once at the start of each send and passing that decision through all four call sites would actually preserve the invariant.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/context_impl.rs, line 51:

<comment>The four outbound `force_pn_addressing` gate points read the flag independently with `Ordering::Relaxed`, and there are `.await` suspension points between them in `send_message_impl` (e.g., between the `recipient_bare` computation at line ~1175, `ensure_e2e_sessions` at ~1387, `build_session_lock_keys` at ~1406, and `prepare_dm_stanza` which calls `get_lid_for_phone`). If `set_force_pn_addressing` is toggled concurrently during a send, different stages of the same send may observe opposite values. This recreates the namespace/session-lock divergence the PR explicitly tries to avoid. Snap-shotting the flag once at the start of each send and passing that decision through all four call sites would actually preserve the invariant.</comment>

<file context>
@@ -46,6 +46,11 @@ impl SendContextResolver for Client {
     async fn get_lid_for_phone(&self, phone_user: &str) -> Option<String> {
+        // Reporting no mapping keeps stanza prep (participants, enc nodes) on
+        // the PN namespace (see Client::set_force_pn_addressing).
+        if self.force_pn_addressing_enabled() {
+            return None;
+        }
</file context>

async fn get_lid_for_phone(&self, phone_user: &str) -> Option<String> {
// Reporting no mapping keeps stanza prep (participants, enc nodes) on
// the PN namespace (see Client::set_force_pn_addressing).
if self.force_pn_addressing_enabled() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The force_pn_addressing_enabled gate in get_lid_for_phone is applied unconditionally inside the shared SendContextResolver implementation, which is consumed by encrypt_for_devices for any list of device JIDs. Because the method receives only a phone_user string with no send-context (e.g., DM vs. group vs. broadcast), enabling the flag will suppress LID resolution for every outbound encrypted send, not just direct messages. The PR describes this as a DM workaround, so the scope should be verified: if group or broadcast flows still require LID addressing, this global suppression could emit PN-addressed enc nodes in contexts that expect LID. Consider scoping the gate to DM-only sends or confirming that all callers of get_lid_for_phone are safe to receive PN addressing when the flag is on.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/context_impl.rs, line 51:

<comment>The `force_pn_addressing_enabled` gate in `get_lid_for_phone` is applied unconditionally inside the shared `SendContextResolver` implementation, which is consumed by `encrypt_for_devices` for any list of device JIDs. Because the method receives only a `phone_user` string with no send-context (e.g., DM vs. group vs. broadcast), enabling the flag will suppress LID resolution for every outbound encrypted send, not just direct messages. The PR describes this as a DM workaround, so the scope should be verified: if group or broadcast flows still require LID addressing, this global suppression could emit PN-addressed `enc` nodes in contexts that expect LID. Consider scoping the gate to DM-only sends or confirming that all callers of `get_lid_for_phone` are safe to receive PN addressing when the flag is on.</comment>

<file context>
@@ -46,6 +46,11 @@ impl SendContextResolver for Client {
     async fn get_lid_for_phone(&self, phone_user: &str) -> Option<String> {
+        // Reporting no mapping keeps stanza prep (participants, enc nodes) on
+        // the PN namespace (see Client::set_force_pn_addressing).
+        if self.force_pn_addressing_enabled() {
+            return None;
+        }
</file context>

@juanlotito juanlotito closed this Jul 2, 2026
@juanlotito
juanlotito deleted the feat/optional-pn-dm-addressing branch July 2, 2026 02:31
@juanlotito

Copy link
Copy Markdown
Contributor Author

Update — new evidence, and rebase incoming.

I cherry-picked #731 onto v0.6.0 plus this branch's flag to isolate variables, and re-tested on the affected companion registration with the ack logging #731 added:

This PR currently conflicts with main after the send-path restructuring; I'll rebase and reimplement the flag against current main shortly. Given the 400s above, an opt-in PN addressing escape hatch still seems necessary for registrations like this one — but if you'd rather approach it differently (e.g. automatic fallback to PN on 400-nack), happy to rework in that direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant