# Customer channel source adapters

## What it does

`CustomerChannelSources` reads existing PropertyLab channel records into one observation format. It does not contact a provider, send a message, create a customer, or assign a purchase intention. The ingestion repository owns persistence and review applicability. Email uses the sibling `EmailJourneySource` adapter.

## How it works

- `candidateLeadIds()` returns IDs with activity in the last 90 days, open legacy tasks, or existing journey contexts. The importer applies customer lifecycle and scope policy. WhatsApp discovery requires an actual recent nonhistorical inbound message; a broadcast alone is not a reason to import a customer.
- `prime($leads, $viewer)` reads a cohort in bounded batches and stores a request-local projection. Each later `forLead` uses the same eligibility and authorization rules without querying sources per customer.
- `changedLeadIds($since)` returns customers whose source rows, conversations or account restrictions changed, including prior owners of moved source receipts. Storage update time selects the work; it does not replace original occurrence time.
- `forLead($lead, $viewer)` returns eligible observations. Supplying a viewer enforces current lead, channel and connected-account access. A null viewer is internal ingestion only.
- A prior human purchase-applicability decision survives a metadata-only source revision through an audited system-derived match. The original reviewer and review time stay unchanged. Customer, exact source identity, full content, speaker, direction, connected account and engagement must still match; corrected content requires a new applicability decision.
- `current($sourceEvent, $viewer)` resolves the actual source again and checks the revision. Deleted, moved, blocked, private or corrected sources cannot keep supporting the old evidence. Retained sources older than the discovery window are resolved directly; passing 90 days does not revoke a valid historical source.
- WhatsApp exchange peers are resolved from eligible stored history while the timeline emits the latest 90 days. This keeps a verified exchange stable when its earlier message ages out of the visible window. Only the current authorized customer cohort is read, in one batch.
- WhatsApp casts each selected field once per batch. `WhatsappExchangeIndex` keeps the first eligible peer in creation/ID order even when sends arrive out of order; exact quoted replies use a provider-ID index. Long threads no longer require a full thread scan and full model serialization for each row. These are calculation indexes within one authorized thread, not reusable permissions or cached customer decisions.
- Recording and property-analysis reads select only observation/hash dependencies, leaving unrelated provider blobs in their source modules. Complete transcript and analysis digests still detect corrections beyond the displayed preview.
- Email checks global identity ambiguity only for cohort addresses that occur in an actual eligible mailbox header or campaign recipient. Missing email activity exits before owner lookups. Header streaming bypasses model hydration, while every matched body still uses the existing exact-address and owner/purpose visibility checks.
- `reset()` clears request/command memoization. Never register the adapter as a singleton across jobs.
- Stable provider identities become opaque `privacy_key` hashes for suppression after local rows are reimported. WhatsApp uses the connected number and provider message ID; Zoom uses the meeting occurrence UUID. No content or mutable analysis enters those identities. Sources lacking a provider identity retain the catalogue’s local identity fallback.
- `ChannelObservation::make()` fingerprints canonical content with exact identity and case. Object key order does not change a revision; meaningful content, customer, attribution and provider identity changes do.
- An observation's `occurred_at` is the actual recorded occurrence, or null when unavailable. A recording imported today does not become a conversation that happened today. Scheduled appointment time is separate from when the appointment record was created.
- Assertions carry `key`, typed `value`, `origin`, `authority`, `creator_id` and `quote`. The ingestion writer replaces `@self` references with the persisted source event ID. They are source-grounded inputs, not a readiness score.

| Source | Admitted evidence | Boundaries |
| --- | --- | --- |
| WhatsApp | Customer text, sent staff text, request, verified two-way exchange | Direct company threads only; groups, CEO numbers, sandbox, test conversations, blocked contacts, imported messages and revoked messages are excluded. Queued/failed messages are not replies. Automation, templates and acknowledgements cannot confirm a staff exchange. Automatic request resolution requires an explicit substantive quote-reply to the same provider message. |
| Zoom recordings | Actual transcript availability, stored channel analysis and recorded next steps | An AI summary is labelled as stored analysis. Speakers are not assumed. Scheduled duration is never measured customer attendance. A reviewed engagement link may establish exact purchase applicability. |
| Calls | Recording observations plus actual Android connected-call events | A completed device call of at least 60 seconds requires an exact phone match to one customer, a known staff device, and no ignored, internal or test recording before it may establish live contact. A click-to-call action or uploaded recording alone does not. |
| Showroom | Linked conversation recording and reviewed engagement | A declared badge window does not prove customer presence or advisor-attested consultation. |
| Portal | Customer-authored activity, plans, property analyses, chatbot questions and lessons | Activity may support Understanding developing, never confirmed understanding. Staff-created plans do not become customer-authored. PropertyAnalysis `property_purpose` means new project/subsale/auction; it must not become own-stay/investment purpose. |
| Zoom webinar attendance | Matched registrant/email attendance | Name-only matching is excluded. Rejoining is not another consultation. Attendance does not confirm understanding. |
| Concierge | Submitted request and actual status | Draft requests are excluded. Closed/withdrawn/cancelled requests do not demand a reply. |
| Calendar and appointments | Schedule, recorded outcome and exact engagement when present | Schedule, attendance and host acceptance remain separate. Existing automatic closer assignment can write `accepted_at`; it does not prove the host explicitly accepted. |
| Consent | IC presence and signed-consent status | No document bytes, identity numbers, signatures, bearer tokens or storage paths enter the timeline. IC presence is pending verification, not verified identity. |
| Loan screening | Report availability/status | No bank approval, target applicability or authorized specialist conclusion is inferred. Processing failure is not a financing rejection. |

## Explicit appointment hosting

`ChannelReviewRepository::ACCEPT_HOST` records acceptance by the appointment’s explicitly assigned host. It requires an accepted purchase-context source match, the current primary consultation, customer agreement to the same exact appointment time, and a future scheduled appointment. The command never changes customer ownership. A creator is not automatically a host.

The existing `JourneyHandoff` stores the commitment/appointment IDs, immutable acceptance audit, source revision and scheduled time. `AppointmentContextLoader` supplies StagePolicy with a scheduled appointment and accepted host only while those bindings remain current. Cancellation, rescheduling, reassignment, source correction or loss of source access removes that support on the next read. Customer need must separately support the appointment stage; host acceptance alone does not establish readiness.

## Reference usage

`JourneySourceCatalog` combines these observations with email and gives both `ChannelIngestionRepository` and `ChannelWorkspace` a single contract. UI reads must pass their authenticated viewer. Ingestion may use null after verifying canonical customer eligibility under the customer lock. `ChannelContextLoader` supplies only whitelisted rule fields after current source and accepted context checks.

## Related files

- `src/RevenueJourney/Channels/CustomerChannelSources.php`
- `src/RevenueJourney/Channels/ChannelObservation.php`
- `src/RevenueJourney/Channels/WhatsappExchangeIndex.php`
- `src/RevenueJourney/Channels/JourneySourceCatalog.php`
- `src/RevenueJourney/Channels/EmailJourneySource.php`
- `tests/Unit/RevenueJourney/ChannelObservationTest.php`
- `tests/Unit/RevenueJourney/WhatsappExchangeIndexTest.php`
- `tests/Feature/RevenueJourney/CustomerChannelSourcesTest.php`

- `src/RevenueJourney/Repositories/AppointmentContextLoader.php`
- `src/RevenueJourney/Repositories/ChannelReviewRepository.php`
- `tests/Feature/RevenueJourney/AppointmentHostAcceptanceTest.php`
