April 27, 2026

[PRD] Deepen WhatsApp Target Resolution into Canonical Target Facts

Refactor WhatsApp target handling into a canonical target facts module to eliminate scattered normalization logic and ensure consistent behavior across all send paths.

πŸ” Current State (Problem Context)

The WhatsApp target handling currently exhibits fragmented normalization behavior across multiple code paths. The same raw target is processed differently depending on which module receives it, leading to inconsistent behavior and maintenance complexity.

Symptomatic Behaviors

Inconsistent Phone Number Normalization

javascript // Path 1: Message command parsing const target = normalizeForMessage(rawTarget); // Result: +15551234567 β†’ 15551234567

// Path 2: Outbound authorization const auth = checkAllowFrom(rawTarget); // Result: +15551234567 β†’ different normalization

// Path 3: Session routing
const session = resolveSession(rawTarget); // Result: +15551234567 β†’ yet another normalization

Scattered Classification Logic

The chat type (direct/group/newsletter) is determined in multiple locations:

javascript // In message_command.js if (target.includes(’@g.us’)) classifyAs(‘group’);

// In session_routing.js if (jid.endsWith(’@newsletter’)) classifyAs(‘channel’);

// In auth_check.js if (target.startsWith(‘wa.me’)) handlePrefix();

Duplicate Wire JID Conversion

javascript // Wire delivery JID selection is duplicated across: adapter.getWireJID(target); // send adapter routing.resolvePeer(target); // routing module
delivery.selectJID(target, lidMap); // delivery path

Policy Knowledge Distributed

AllowFrom semantics appear in:

  • whatsapp_auth.js for sends
  • whatsapp_actions.js for reactions
  • channel_send.js for channel posts

🧠 Architectural Rationale

Why This Matters

The current architecture violates the single responsibility principle for target handling. Each module that touches WhatsApp targets must understand:

  1. Raw target formats (E.164, JIDs, prefixes, LID)
  2. Normalization rules specific to each path
  3. Chat type classification heuristics
  4. AllowFrom policy evaluation
  5. Wire JID conversion logic
  6. Presence behavior per target type

This creates a maintenance anti-pattern: changing target behavior requires modifying N modules, each with partial context.

The Deep Module Pattern

The solution adopts the “deep module” anti-pattern correction. Rather than shallow helper functions scattered across callers, consolidate target resolution into a single, stable interface:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Canonical Target Facts β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Normalize β”‚β†’β”‚ Classify β”‚β†’β”‚ Authorize β”‚β†’β”‚ Wire JIDβ”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ↓ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Route Peer β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Preserved Contracts

The refactor must preserve existing shipped behavior:

Target TypeAllowFrom BehaviorPresence BehaviorWire JID Source
DirectRespects allowFromEnabledAuth mapping or phone
GroupBypassedEnabledGroup JID
NewsletterBypassedDisabledNewsletter JID
LID-mappedUses LID JIDEnabledAuth mapping
PrefixedStrip prefixVaries by target typeResolved from base

Risk Mitigation

The refactor is safe to execute because:

  • Behavior preservation tests verify no regressions
  • Interface boundaries remain stable
  • WhatsApp-specific logic stays in the plugin
  • No core dependencies are introduced

πŸ› οΈ Implementation Approach

Module Location

Create or deepen the module at:

plugins/whatsapp/src/target-facts.ts

Interface Design

typescript interface WhatsAppTargetFactsInput { rawTarget: string; // User-supplied target allowFrom?: string[]; // Current allowFrom policy authMapping?: Map<string, string>; // Phone→LID JID mapping }

interface WhatsAppTargetFacts { // Normalization normalizedTarget: string; // Canonical form

// Classification chatType: ‘direct’ | ‘group’ | ‘channel’; isNewsletter: boolean;

// Authorization outboundAuthorized: boolean; authorizationReason?: string;

// Routing routePeer: RoutePeerFacts;

// Delivery wireJid: string; useComposingPresence: boolean;

// Metadata originalFormats: string[]; // What formats appeared in input }

Step-by-Step Implementation

Phase 1: Create Canonical Module

typescript // plugins/whatsapp/src/target-facts.ts

export function resolveTargetFacts( input: WhatsAppTargetFactsInput ): WhatsAppTargetFacts { // 1. Normalize raw target const normalized = normalizeTarget(input.rawTarget);

// 2. Classify chat type const chatType = classifyChatType(normalized); const isNewsletter = chatType === ‘channel’;

// 3. Determine authorization const authDecision = resolveOutboundAuthorization( normalized, chatType, input.allowFrom );

// 4. Compute route peer const routePeer = buildRoutePeer(normalized, chatType);

// 5. Select wire JID const wireJid = resolveWireJid(normalized, input.authMapping);

// 6. Determine presence behavior const useComposingPresence = !isNewsletter;

return { normalizedTarget: normalized, chatType, isNewsletter, outboundAuthorized: authDecision.authorized, authorizationReason: authDecision.reason, routePeer, wireJid, useComposingPresence, originalFormats: extractFormats(input.rawTarget) }; }

Phase 2: Migrate Callers

Replace local target interpretation in:

typescript // plugins/whatsapp/src/message-command.ts // BEFORE function parseCommand(raw: string) { const target = raw.includes(’:’) ? raw.split(’:’)[0] : raw; const normalized = target.startsWith(‘whatsapp:’) ? target.slice(9) : target; // … send path }

// AFTER function parseCommand(raw: string) { const facts = resolveTargetFacts({ rawTarget: raw }); // … send path using facts.wireJid, facts.chatType, etc. }

typescript // plugins/whatsapp/src/session-routing.ts // BEFORE function routeToSession(target: string) { const peer = target.includes(’@g.us’) ? { type: ‘group’, jid: target } : { type: ‘direct’, jid: normalizePhone(target) }; }

// AFTER function routeToSession(target: string) { const facts = resolveTargetFacts({ rawTarget: target }); return { type: facts.chatType, jid: facts.routePeer.jid }; }

Phase 3: Consolidate Authorization

typescript // plugins/whatsapp/src/outbound-auth.ts // BEFORE: Duplicated allowFrom logic in each caller

// AFTER export function checkOutboundAuth( facts: WhatsAppTargetFacts, allowFrom: string[] ): AuthResult { // Newsletter and group targets bypass direct allowFrom if (facts.chatType !== ‘direct’) { return { authorized: true, reason: ‘bypass_non_direct’ }; }

// Empty allowFrom preserves current behavior (allow all) if (!allowFrom?.length) { return { authorized: true, reason: ’no_policy’ }; }

// Wildcard handling if (allowFrom.includes(’*’)) { return { authorized: true, reason: ‘wildcard’ }; }

// Normalized matching return normalizeAndMatch(facts.normalizedTarget, allowFrom); }

Phase 4: Wire JID Resolution with LID Awareness

typescript function resolveWireJid( normalized: string, authMapping?: Map<string, string> ): string { // Newsletter and group JIDs pass through unchanged if (normalized.includes(’@newsletter’) || normalized.includes(’@g.us’)) { return normalized; }

// Check for LID auth mapping if (authMapping?.has(normalized)) { return authMapping.get(normalized); }

// Direct numbers need JID suffix if (/^\d+$/.test(normalized)) { return ${normalized}@s.whatsapp.net; }

return normalized; }

Branch and Commit Strategy

bash

Create worktree

git worktree add ../whatsapp-target-refactor -b refactor/whatsapp-target-facts

Branch naming convention

refactor/whatsapp-target-facts

Commit messages (conventional commits)

git commit -m “refactor(whatsapp): add canonical target facts module” git commit -m “refactor(whatsapp): migrate message-command to target-facts” git commit -m “refactor(whatsapp): migrate session-routing to target-facts” git commit -m “refactor(whatsapp): consolidate authorization in target-facts” git commit -m “refactor(whatsapp): add LID-aware wire JID resolution” git commit -m “test(whatsapp): add behavior preservation tests for target-facts” git commit -m “refactor(whatsapp): remove scattered target helpers”

πŸ§ͺ Verification & Acceptance Criteria

Test Scenarios for Behavior Preservation

Scenario 1: Direct Number Normalization

bash

Input: E.164 with prefix

/msg whatsapp:+15551234567 “Hello”

Expected: Same behavior as current shipped code

Verify: Target resolves to 15551234567@s.whatsapp.net

Exit code: 0

Scenario 2: Group JID Recognition

bash

Input: Group JID

/msg whatsapp:123456789-987654321@g.us “Hello”

Expected: Identified as group, bypasses allowFrom

Verify: facts.chatType === ‘group’

Verify: facts.outboundAuthorized === true regardless of allowFrom

Scenario 3: Newsletter JID Recognition

bash

Input: Newsletter JID

/msg whatsapp:newsletter@newsletter WA

Expected:

- Identified as channel/newsletter

- bypasses allowFrom

- useComposingPresence === false

Verify: facts.isNewsletter === true

Verify: facts.useComposingPresence === false

Scenario 4: Prefix Stripping

bash

Input: Prefixed target

/msg whatsapp:whatsapp:15551234567 “Hello”

Expected: Prefix stripped canonically

Verify: facts.normalizedTarget === ‘15551234567’

Verify: behavior matches unprefixed input

Scenario 5: AllowFrom Direct Restriction

bash

Config: allowFrom: [“15551234567”]

Input: /msg whatsapp:15551234567 “Hello”

Expected: Authorized (exact match)

Verify: facts.outboundAuthorized === true

Input: /msg whatsapp:1555987654 “Hello”

Expected: Blocked

Verify: facts.outboundAuthorized === false

Verify: actionable error message displayed

Scenario 6: Empty AllowFrom (Current Behavior)

bash

Config: allowFrom: []

Input: /msg whatsapp:15551234567 “Hello”

Expected: All direct targets allowed (no policy change)

Verify: facts.outboundAuthorized === true

Verify: matches shipped behavior pre-refactor

Scenario 7: LID-Aware Send

bash

Config: authMapping = { “15551234567” β†’ “lid-xxx@s.whatsapp.net” }

Input: /msg whatsapp:15551234567 “Hello”

Expected: Wire JID uses LID mapping

Verify: facts.wireJid === “lid-xxx@s.whatsapp.net

Verify: message lands in correct conversation

Scenario 8: Cross-Path Consistency

javascript // Message tool, channel send, action, and routing all use same facts const msgFacts = resolveTargetFacts({ rawTarget: target }); const channelFacts = resolveTargetFacts({ rawTarget: target }); const actionFacts = resolveTargetFacts({ rawTarget: target }); const routingFacts = resolveTargetFacts({ rawTarget: target });

// All must produce identical canonical facts assert.deepEqual(msgFacts, channelFacts); assert.deepEqual(msgFacts, actionFacts); assert.deepEqual(msgFacts, routingFacts);

Verification Commands

bash

Run target-facts specific tests

npm test – –grep “target-facts”

Run behavior preservation tests

npm test – –grep “whatsapp.*behavior”

Verify no regressions in integration

npm run test:integration – –plugin whatsapp

Manual verification script

node scripts/verify-target-consistency.js

Success Criteria Checklist

  • resolveTargetFacts() accepts raw target and returns canonical facts
  • All existing send paths produce identical output to pre-refactor behavior
  • Group and newsletter targets bypass direct allowFrom checks
  • Newsletter targets skip composing presence
  • LID mappings correctly influence wire JID selection
  • Invalid target errors remain user-facing with actionable messages
  • Target tests collapse around the new interface as test surface
  • No new core dependencies introduced
  • WhatsApp-specific logic remains plugin-local

⚠️ Considerations & Edge Cases

Critical Edge Cases

1. Prefix Stacking

whatsapp:whatsapp:15551234567

Should normalize to 15551234567, not whatsapp:15551234567 or wa.me:15551234567.

2. Mixed Format Input

15551234567@g.us // Is this a group or malformed direct?

The module should classify based on JID suffix, not assume phone format.

3. LID JID Handling When No Mapping

Input: lid-abc@s.whatsapp.net Config: authMapping = {}

Should preserve the LID JID as-is, not attempt phone normalization.

4. Legacy JID Formats

15551234567@s.whatsapp.net // Old format 15551234567:32@s.whatsapp.net // With device ID

Both should resolve to same canonical target.

5. Empty and Whitespace Targets

Input: " " or ""

Should produce actionable error, not silent normalization.

Known Risks

RiskMitigation
Callers caching raw targetEnsure facts object is returned, not mutated
Partial migration leaving callersUse TypeScript to enforce interface compliance
Performance regressionCache frequently-resolved targets (optional optimization)
Test gapsAdd property-based tests for format coverage

Do Not Introduce

  • Seam for single adapter: The target facts module should be deep, not a speculative interface
  • Core dependencies: WhatsApp behavior stays in the WhatsApp plugin
  • Breaking changes: All existing caller APIs must remain functional
ModuleCurrent RolePost-Refactor Role
message-command.tsTarget parsing for sendsCaller: use target-facts
session-routing.tsPeer selection for routingCaller: use target-facts.routePeer
outbound-auth.tsAllowFrom checkingCaller: use target-facts.outboundAuthorized
channel-send.tsChannel message sendsCaller: use target-facts.wireJid
actions.tsMessage reactionsCaller: use target-facts for authorization
delivery.tsWire JID selectionCaller: use target-facts.wireJid
presence.tsComposing presence updatesCaller: use target-facts.useComposingPresence

Dependencies

  • No new core dependencies: WhatsApp-specific logic stays in plugin
  • TypeScript: For interface enforcement across callers
  • Existing test infrastructure: For behavior preservation tests

Commit History Context

The refactor preserves the conceptual history:

  • Original target parsing (maintained)
  • Auth normalization additions (absorbed into canonical module)
  • LID mapping work (integrated into wire JID resolution)
  • Newsletter handling (centralized in chatType classification)

Reviewer Checklist

  • Implementation done on dedicated worktree and branch
  • Branch name follows convention: refactor/whatsapp-target-facts
  • Commit messages follow conventional commit style
  • Behavior preserved through focused tests
  • No regressions in shipped behavior
  • Interface is the test surface
  • Plugin boundaries respected (no accidental SDK contracts)

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.