Skip to main content

[PRD] Communication Consent — Meta Marketing Opt-in/out (CDP Customer Detail)

This is a NEW PRD under PRD CDP Q4 2026. It builds directly on the Q2 backend that already persists marketing opt-in/out per customer number × account-channel in contact-service. Scope this round: display Meta-service marketing opt-in/out on the CDP Customer Detail page. WhatsApp Call consent is explicitly out of scope.

HEADER BLOCK

FieldValue
PMZhelia Alifa
PRD Version1.11
StatusDRAFT
PRD TypeNEW
SquadCDP Squad
ParentPRD CDP Q4 2026
RFC LinkTBD
FigmaCommunication consent stateDRAFT, needs adjustment (see §11)
Labelsepic:qontak-cdp | module:customers | feature:communication-consent
Last Updated2026-07-27

Table of Contents


1. One-liner + Problem

One-liner: Show each customer's marketing opt-in/out per phone number × Meta account-channel on the CDP Customer Detail page, so teams target only consented contacts.

Problem: In Q2, contact-service started persisting marketing opt-in/out consent per customer number × account-channel (updated via a Kafka event from Broadcast/Chat). But that consent is invisible in the product — it lives only in the chat_data documents and is not rendered anywhere for a user. As a result, agents and marketers cannot tell which of a customer's numbers, under which business account, have opted in or out of marketing. They risk broadcasting to opted-out numbers (compliance/spam risk) or wasting spend on numbers that never consented, and CS agents have no consent context during a conversation. The data is captured but not usable.

Second gap — no way to acquire consent from the profile. When a customer has never been asked (marketing_opt_in = null → shown as "-"), there is no action on Customer Detail to request it. To collect consent an agent must leave the profile, switch to the Chat app, manually build a recipient list containing that one customer, find the right opt-in template, and start a broadcast — several manual steps in another product. So the profile shows "no consent yet" but offers no way to fix it. This round adds a "request consent via broadcast" action to the empty state that seeds the recipient list and opens the WhatsApp broadcast composer pre-filled for that customer.


2. What Happens If We Don't Build This

Theme: data transparency. The consent is captured in Q2 but is not visible where decisions are made — on the customer's own profile.

  • No consent status on the customer detail page. Today there is no indication on Customer Detail of whether a customer's number is opted in or out. To find out, a user must leave the profile, go to the Customers index, and filter/segment by "marketing opt-in" — an indirect, list-level workaround that never surfaces the per-number × per-account status (or the opt-in timestamp) for the customer in front of them.
  • Compliance & deliverability risk — ongoing, every campaign. Without per-customer visibility, agents/marketers keep messaging opted-out numbers → spam reports, WhatsApp quality-rating drops, and wasted broadcast spend on each send, not once.
  • Wasted effort per interaction. CS/Sales agents context-switch to the index + filter mid-conversation just to answer "can we message this number?", or skip the check entirely and guess.
  • The Q2 backend stays write-only. Consent is written but never read back into the product — no ROI on shipped work — and it blocks the broader "Communication consent" tab (Email, future channels) from its first slice.
  • The transparency gap widens over time. As more numbers and Meta accounts accrue opt-in/out events, the distance between what the system knows and what the user can see keeps growing, and the profile becomes less trustworthy as a single source of truth.

3. Target Users + Persona Context

PersonaRoleGoalPainWorkaround
Primary — Marketing / Campaign ManagerRuns WhatsApp/Meta broadcasts from QontakOnly target numbers that have opted in, per business accountCannot see per-number/per-account consent before sendingGuesswork or exporting broadcast logs manually
Secondary — CS / Sales AgentHandles the customer on the CDP detail pageKnow a customer's marketing consent while interactingConsent is invisible on the profileAsk the customer again / ignore

4. Non-Goals

  1. WhatsApp Call consent is out of scope — the "WhatsApp call" column in the Figma is not built this round (no backing field in chat_data today).
  2. No manual editing/toggling of opt-in from CDP. Consent is system-managed and set only via the Kafka event pipeline (see §9). CDP is read-only for consent this phase.
  3. Email marketing consent (the "Email" section in the Figma) is out of scope this phase — different data model; future slice.
  4. Producing opt-in events for Instagram DM / Facebook Messenger is out of scope — only WhatsApp currently emits marketing opt-in events (see §9 gap G2).
  5. No changes to the Broadcast/Chat producer side or the consent write path.
  6. No bulk export of consent in this phase.
  7. CDP does not build a broadcast composer. The "Request consent via broadcast" action reuses the existing WhatsApp broadcast create page in the Chat app; CDP only creates the single-customer recipient list and opens that page pre-filled.
  8. CDP does not send the broadcast. The user reviews and sends it on the broadcast page — CDP never auto-sends, and does not track delivery/consent outcome (the resulting opt-in/out flows back via the existing Q2 Kafka pipeline → §9).
  9. Bulk / multi-customer consent requests are out of scope — the action seeds a list with only the current customer (single contact_id).

5. Scope Changes

Controlled vocab: Backend · Frontend · Mobile · Infra · Data · Design · Docs · None.

  • Backendcontact-service: a read contract that returns chat_data consent grouped by account-channel, filtered to Meta channels, including account_uniq_id, account_business_name, channel_target, phone_number, marketing_opt_in, marketing_opt_in_timestamp. (Data already exists; today's serializer exposes only a flat WhatsApp list — see §9 gap G1.)
  • Frontendqontak-customer-fe: new "Communication consent" tab on Customer Detail → "META services" section with the Marketing opt-in view (grouped list + expand + filter + loading/empty states). No consent-editing UI.
  • Frontend — Customer Detail Activity log tab: render marketing opt-in / opt-out change entries (phone number · status · channel · account · timestamp), shown whenever a consent status change is recorded.
  • Backendcontact-service: clarify the opt-in/out activity-log title copy (see §9 G5). The log itself is already emitted per event; the corrected read contract (G1) also fixes the phone_number mapping (G7).
  • Frontendqontak-customer-fe: in the "no consent yet" empty state (customer never asked, marketing_opt_in = null), add a "Request consent via broadcast" action. On click, CDP calls the Core API to create a single-customer recipient list, then opens the WhatsApp broadcast composer in a new tab (cross-app → chat.qontak.com) with that recipient list pre-selected. The agent picks the message template manually on the composer. (The cross-app new-tab pattern already exists — DrawerSendCampaign.vue:98.)
  • Backend / integration — reuse the Qontak Core API POST /contacts/contact_lists/direct_select (recipient-list create — exists) and the WA broadcast create page /campaign/whatsapp/create (exists). No new CDP endpoint.
  • Frontend (cross-squad, hub-chat) — the broadcast create page must read a query param (contact_list_id) and pre-populate one field: the Recipient list = the just-created single-customer list. This is net-new — the page ignores the URL today (see §9 gap G9). Owned by the Chat/Broadcast squad. Message-template auto-fill is NOT in scope this phase (not possible yet) — the agent selects the opt-in template manually; a future enhancement can auto-select the most recent is_marketing_opt_in template (§9.1, §13 OQ-7).
  • Design — Figma frames need adjustment (§11) before FE build.

6. Constraints

ConstraintValue
PlatformQontak CDP — Web only.
Read/writeRead-only consent display; gated by existing customer-view permission (customers_customers_view ≥ OWNED ONLY).
Data freshnessConsent reflects whatever the Q2 Kafka pipeline has written; near-real-time on the write side, no new SLA introduced here.
Channels in scopeMeta services only — whatsapp (has data today), instagram_dm, facebook_messenger (grouping ready, data pending — G2).
Feature flagcdp_communication_consent_tab — default OFF; flipped per rollout stage (§14).
PerformanceConsent-tab render p95 ≤ 3s; consent read API p95 ≤ 2s.
Plan / tierGrowth & Enterprise (Qontak One).

7. Success Metrics

MetricDefinitionBaselineTarget
⭐ Consent visibility% of Customer Detail views (for customers with ≥1 Meta chat account) where the consent tab renders opt-in data without error0% (not shown)≥ 99% render success
AdoptionWeekly active users opening the Communication consent tab0Growing WoW post-GA

8. Dependencies

DependencyOwnerNeededBlocking?
Q2 consent write pipeline (Kafka send_marketing_opt_inchat_data)CDP (contact-service)Already shipped — source of the dataYES (met)
Read contract extension (grouped-by-account, Meta filter)CDP (contact-service)New — see §9/§10YES
Figma finalizationDesignAdjust draft frames (§11)YES for FE
IG DM / Messenger opt-in event productionChat/Broadcast squadsFor non-WhatsApp Meta channels to show dataNo (deferred, G2)
Recipient-list create API (POST /contacts/contact_lists/direct_select)Qontak Core APIAlready shipped — CDP calls it to create a single-customer list (§9/§10 #4)YES (met) for OPT-S08
WA broadcast create page (/campaign/whatsapp/create, chat.qontak.com)Chat/Broadcast (hub-chat)Already shipped — CDP opens it in a new tab (§10 #5)YES (met) for OPT-S08
Broadcast page query-param prefill (contact_list_id → preselect recipient list)Chat/Broadcast (hub-chat)NET-NEW — page ignores the URL today (G9); needed to preselect the recipient listYES — blocks OPT-S08 recipient auto-fill
Opt-in-request WhatsApp template (agent-selected)Marketing / CS ops (per CID)The agent picks an opt-in template manually on the composer; a template must exist to send, but CDP does not pre-select it this phaseNo (agent selects manually)

9. Code Grounding & Feasibility (contact-service)

Grounded against contact-service @ master (pulled 2026-07-24). Evidence in path:line.

What already exists (reuse — do NOT rebuild):

  • Consent data modelinternal/app/repository/contact/base.go:117-133. ChatData carries channel_id, account_uniq_id, channel_target, phone_number, account_business_name, marketing_opt_in *bool (nullable → tri-state: null / true / false), marketing_opt_in_timestamp *time.Time. The consent is stored per chat_data element = per (number × account-channel) — exactly the granularity this PRD needs.

    In-code comment (base.go:126-127): "These fields are system-managed (read-only from CDP) and updated via Kafka consumer events." → confirms Non-Goal #2.

  • Write path (shipped Q2) — Kafka topic send_marketing_opt_in (cmd/consumer/consumer.go:26-27,124-125) → internal/app/kafka_consumer/marketing_opt_in.gointernal/app/service/marketing_opt_in_service.go (ProcessOptInEvent: validate → Redis idempotency → update) → internal/app/repository/contact/update_marketing_opt_in.go (UpdateChatDataMarketingOptIn: positional $elemMatch update on composite key contact_id + channel_id + account_uniq_id). Activity log via CreateActivityLogForMarketingOptIn.
  • Event payloadinternal/app/payload/marketing_opt_in_event.go: MarketingOptInEvent{ channel_id, contact_id, marketing_opt_in, phone_number, qontak_customer_id, account_business_name, account_uniq_id, channel_target, timestamp, … }.
  • Meta channel constantsinternal/app/service/field_properties/default.go:20-23: whatsapp, facebook_messenger, instagram_dm, instagram_comment. → defines the "META services" filter set for channel_target.
  • Current read serializerinternal/app/repository/contact/create_serializer.go:177-181: WhatsappMarketingOptedInSerializer{ phone_number, marketing_opt_in, business_phone_name }, exposed as phone_marketing_opt_in[] on the contact detail response. ⚠️ phone_number is populated from chat.AccountUniqID (create_serializer.go:310), not the real phone number (see G7).
  • Opt-in/out activity log (shipped Q2)internal/app/service/activity_log.go:793 CreateActivityLogForMarketingOptIn inserts an ActivityLog on every opt-in/out event: action = opt-in/opt-out, category = campaign, associated_name = account business name, plus an associated_changes chat_data before/after snapshot from buildChatDataChanges (the to snapshot carries phone_number, channel_target, marketing_opt_in, marketing_opt_in_timestamp). Title via activity_log_serializer.go:291-318 (generateTitle). → The entry is created only when an event arrives, i.e. on a status change (matches the requirement).

Verdict:Buildable with no schema migration. The consent data is already captured at the right granularity; the work is (a) a BE read/serializer change (G1) and (b) a new read-only FE tab. No write path, no migration.

Fixes applied — all identified gaps closed in-spec (the working gap table was removed once fixes landed; full history in the repo git log):

  • G1 — flat WhatsApp serializer → grouped, Meta-filtered read contract → §10 #1 + OPT-S06
  • G7 — response phone_number was account_uniq_id → return the real phone → OPT-S06 AC-2
  • G8 — serializer dropped null records → include as "-" (tri-state) → §10 #1 + OPT-S01/S02
  • G5 — awkward activity-log title → consent-framed copy → §11 + OPT-S07 AC-3
  • G2 — IG DM / Messenger have no data yet → render Meta grouping now (WhatsApp-populated) + explicit empty state → §17 decision (OQ-2 closed)
  • G3 — WhatsApp-call has no backing field → out of scope (Non-Goal #1)
  • G4 — no email-consent model → out of scope (Non-Goal #3)
  • G6 — log from lacks the prior value → accepted (action conveys the new state; write-path change out of scope, Non-Goal #5)

Grounded against hub-chat and qontak-customer-fe @ master (2026-07-27). Evidence in path:line. The recipient-list + broadcast backends live in the Qontak Core API (not in the FE repos); the FE calls verified below.

What already exists (reuse — do NOT rebuild):

  • "Not yet asked" statecontact-service chat_data.marketing_opt_in *bool (base.go:130) is null when the customer has never been asked → the "-" empty state (§11). This is the exact condition that gates the button.
  • Recipient-list create (single-step, inline contacts)POST /api/core/v1/contacts/contact_lists/direct_select, called from hub-chat features/campaign/contacts/views/components/DialogSubmitSelect.vue:156 with payload { name, contact_ids: string[] }; a c360 variant direct_select_c360 (.../select-contact-modal-unified-360/components/DialogSubmitSelect.vue:160) can also carry marketing_opt_in: { value, channel_ids }. Returns a contact_list object (features/campaign/contacts/views/@types/contact.d.ts:2-19) with id, name, contacts_count, target_channel: "wa" | "email", progress. → A single-customer list = { name, contact_ids: ["<contactId>"] } in one call. No separate "add contact" step.
  • WA broadcast create page — route /campaign/whatsapp/create (hub-chat/pages/campaign/whatsapp/create.vuefeatures/campaign/whatsapp-campaign/views/CreatePage.vue), served by hub-chat = "Qontak Chat" at chat.qontak.com (configs/production.json:10) — a different app/host than CDP (customer.qontak.com). The broadcast references the list via contact_list_id: recipient.id (CreatePage.vue:394) and the template via message_template_id: template.id (CreatePage.vue:393); recipient options come from GET /contacts/contact_lists, templates from GET /templates/whatsapp (useCreateCampaign.ts:60,95).
  • Template auto-fill is NOT in scope this phase (not possible yet) — the agent selects the opt-in template manually on the composer. (Future enhancement, for reference: the template list API already accepts is_marketing_opt_in: boolean + order_by/order_direction (templates/whatsapp-new/@types/whatsapp.ts:39-55), templates carry is_marketing_opt_in?: boolean + created_at (whatsapp.ts:34,45), and there's an app-config gate app_config.is_using_marketing_opt_in (useWhatsappListsFilter.ts:44-47) — so a later phase could auto-select the most recent marketing-opt-in template via ?is_marketing_opt_in=true&order_by=created_at&order_direction=desc&limit=1. Deferred here — see §13 OQ-7.)*
  • Cross-app "open in new tab" pattern (already in CDP)qontak-customer-fe/features/customers/views/components/segment/DrawerSendCampaign.vue:98: window.open(${config.BASE_URL}/campaign/whatsapp/create?segment_id=${segmentId}, '_blank') where config.BASE_URL = https://chat.qontak.com. → CDP already knows how to seed a list-launch and open the composer cross-app; OPT-S08 follows the same pattern with contact_list_id.

Verdict:Buildable — CDP side is small (create list → open URL with the recipient pre-selected). The recipient-list API and the broadcast page both already exist. One net-new cross-squad gap (G9) blocks the recipient auto-fill.

  • G9 (NEW — cross-squad, blocking for recipient auto-fill) — hub-chat's CreatePage.vue does not read route.query at all (CreatePage.vue:304 uses useRouter() only; a repo-wide search for segment_id/segmentId consumers returns zero). So the segment_id CDP already appends is silently ignored today, and there is no query-param prefill support. → hub-chat must add logic to read the URL and preselect the Recipient list by contact_list_id (CampaignSetup.vue, DropdownRecipient.vue). Owned by the Chat/Broadcast squad. Until G9 lands, CDP can create the list and open the page, but the recipient will not auto-fill → OPT-S08 AC-4 depends on G9. (Template preselect is out of scope — the agent picks it manually.)
  • G10 (confirm with Core team)contact_list carries a progress field and the bulk/upload create paths can be "in progress"; direct_select returns a synchronous "Recipient list created" toast. Confirm a direct_select list is immediately usable by a broadcast (not briefly processing) so the pre-selected list resolves on the composer. → §13 OQ-8.

10. API & Behavior

Behavioral contract in plain language; method/path/schema finalized in RFC.

#BehaviorTriggered byReturnsNotes
1Get communication consent for a customerCustomer Detail → Communication consent tab loadsConsent grouped by account-channel (account_uniq_id, account_business_name, channel_target), each with a list of { phone_number, marketing_opt_in, marketing_opt_in_timestamp } where marketing_opt_in is tri-state (true=opt-in · false=opt-out · null="-"); filtered to Meta channel_target values (whatsapp, instagram_dm, facebook_messenger). Includes accounts/numbers with null (rendered as "-").Read-only. Source: chat_data (§9). No WhatsApp-call, no email.
2Filter by communication typeUser picks a channel in the "All communication type" dropdownSame shape, filtered to the chosen channel_target.Default = all Meta channels.
3List opt-in/out change historyCustomer Detail → Activity log tabThe existing activity-log feed filtered to category = campaign, action ∈ {opt-in, opt-out}; each entry carries the chat_data change snapshot (phone, channel_target, status, timestamp) + title.Already emitted per event (§9). FE renders/filters; corrected title per G5.
4Create single-customer recipient listUser clicks Request consent via broadcast (empty state)CDP calls POST /api/core/v1/contacts/contact_lists/direct_select with { name, contact_ids: [<this customer's contact_id>] }; response returns the contact_list { id, target_channel: "wa", … }.Existing Core API (§9.1). name auto-generated (e.g. "Opt-in request — {customer} — {date}"). On non-2xx → inline error, no redirect (OPT-S08 AC-6).
5Open WA broadcast composer with recipient pre-selectedRecipient list created successfullyCDP opens {CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id} in a new tab (chat.qontak.com). The composer auto-fills one field: Recipient list = the new single-customer list. The agent selects the message template manually.Cross-app (§9.1). ⚠️ Recipient auto-fill requires hub-chat to read the param — net-new (G9). Template auto-fill is out of scope this phase (not possible yet).

RFC to resolve: extend contact-detail response vs. dedicated GET /contacts/{id}/communication-consent; pagination/expansion when an account has many numbers (Figma ">10 channels" state); exact Meta channel_target allow-list; the exact query-param contract for the recipient pre-fill (contact_list_id) — coordinate with the Chat/Broadcast squad (G9); recipient-list name convention + whether direct_select_c360 should carry marketing_opt_in context (OPT-S08).


11. Feature & UI — from Figma (draft)

The Figma is DRAFT and needs adjustment before FE build. Frames reviewed: Loading · Blank (not found) · Default <5 channels · Default >10 channels.

Consent status is tri-state (per phone number × account-channel) — grounded on chat_data.marketing_opt_in *bool (base.go:130):

marketing_opt_inMeaningStatus shown
null (no record)Customer has not allowed/disallowed yet"-" (empty)
trueCustomer allowed to receive broadcastMarketing opt-in
falseCustomer not allowed to receive broadcastMarketing opt-out

⚠️ Today the serializer drops null records (create_serializer.go:308), so the "-" state is invisible — the new read contract (G1/G8) must include Meta accounts/numbers even when marketing_opt_in is null.

Route: existing Customer Detail (/customers/{customer_id}) → new Communication consent tab (no new page/route). Opt-in/out change entries render in the existing Activity log tab.

Customer Detail → Communication consent tab → META services section: "List of META services the customers give communication consent." A table grouped by account-channel:

ElementIn FigmaThis PRD
Channels column (account name + business phone, e.g. "Central Perk Promo · +6285817999478")✓ Build (grouped by account-channel)
Marketing opt-in column ("2 phone numbers" → expandable list)Build — primary scope
WhatsApp call column ("1 phone number")Out of scope (Non-Goal #1)
"All communication type" filter✓ Build (Meta channels)
Loading / Empty / Error (read fails) / scaling (>10) states✓ (loading/blank/scaling)✓ Build — incl. explicit error state
"Request consent via broadcast" CTA in the "no consent yet" empty state✗ (not in draft)Build — new scope (OPT-S08)
Activity log tab — opt-in/out change entries (phone · status · channel · account · time)✓ (tab exists)Build — new scope
Email section (marketing email)✗ Out of scope this phase (Non-Goal #3)

Figma adjustments needed (flag to Design):

  1. Remove/hide the WhatsApp-call column for this release (or mark clearly as a later phase).
  2. Clarify the expand interaction — clicking "2 phone numbers" should reveal each phone with its opt-in/out status + timestamp (the timestamp is available in data but not shown in the draft).
  3. IG DM / Messenger empty state (decided, G2) — show these Meta channels with an explicit "no consent data yet" state; release copy notes only WhatsApp has data today.
  4. Email section — split into its own future frame so it isn't implied as in-scope.
  5. Activity log — define the opt-in/out entry layout: title ("this customer allowed to receive broadcast (opt-in)" / "this customer not allowed to receive broadcast (opt-out)") + detail line phone · channel · status · timestamp.
  6. Tri-state — the "Marketing opt-in" column/expansion must represent all three states (opt-in / opt-out / "-"), not just opted-in counts.
  7. Empty-state CTA — in the "No WhatsApp communication consent yet" empty state (the state in the current build), add a "Request consent via broadcast" button (primary). Define its loading state (while the recipient list is being created) and its error state (list-create failed). Copy suggestion: button "Request consent via broadcast"; helper "Send this customer a WhatsApp broadcast asking them to opt in or out." → On the destination composer (WA Create campaign page), only the Recipient list field auto-fills (the new single-customer list); the agent selects the Message template (e.g. marketing_opt_in) and fills campaign name / sender / send options, then clicks Send campaign. (Template auto-fill is out of scope this phase — not possible yet.)

Component tree (new components in bold):

CustomerDetailPage (existing)
└── CommunicationConsentTab (new)
├── CommunicationTypeFilter (new) — Meta channels
├── MetaServicesConsentTable (new)
│ └── AccountChannelRow (new) — business name + phone + opt-in summary
│ └── PhoneConsentList (new, expandable) — phone · tri-state status · timestamp
└── ConsentStateView (new) — loading / empty / error / success
└── RequestConsentButton (new) — in empty state; creates single-customer
recipient list (direct_select) → opens WA broadcast composer (new tab, cross-app)
ActivityLogTab (existing)
└── ConsentActivityEntry (new) — opt-in/out title + phone · channel · status · time

UI state diagram (consent tab):

stateDiagram-v2
[*] --> Loading
Loading --> Success: data resolved
Loading --> Empty: no Meta consent data
Loading --> Error: read API non-2xx
Error --> Loading: retry
Success --> Success: filter / expand row

12. System Flow + User Stories + ACs

Flow type: User Journey.

  1. Agent opens Customer Detail for a customer.
  2. Agent clicks the Communication consent tab.
  3. FE calls the consent read API (§10 #1).
  4. BE reads the customer's chat_data, keeps Meta channel_target elements, groups by account-channel, returns per-phone tri-state (opt-in / opt-out / "-") + timestamp.
  5. FE renders the META services table (grouped), each account showing the count of opted-in numbers under Marketing opt-in.
  6. Agent clicks "N phone numbers" → expands to the phone list with opt-in/out + timestamp.
  7. Agent optionally filters by communication type.

Failure / edge branches:

  • 3a. Consent read API returns non-2xx → FE shows an inline error state with retry (OPT-S04 AC-4); consent_api_error fires.
  • 4a. Customer has no Meta accountsempty state (OPT-S04 AC-2), not an error.
  • 6a. Account with 0 opted-in numbers → summary cell shows "-" (OPT-S01 AC-2).

Flow type: User Journey (cross-app).

  1. Agent opens Customer Detail → Communication consent tab for a customer who has no consent yet (marketing_opt_in = null → "-").
  2. FE shows the "No WhatsApp communication consent yet" empty state with a "Request consent via broadcast" button.
  3. Agent clicks the button → button enters loading.
  4. CDP calls POST /contacts/contact_lists/direct_select with { name, contact_ids: [<customer contact_id>] } (§10 #4).
  5. Core API returns the new contact_list { id, target_channel: "wa" }.
  6. CDP opens a new browser tab at {CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id} (§10 #5).
  7. The Chat app's broadcast composer loads with the Recipient list pre-selected to the new single-customer list (requires G9). The agent selects the Message template manually (template auto-fill out of scope this phase) and fills the remaining fields (campaign name, sender, sending options).
  8. Agent reviews and sends the broadcast in the Chat app (CDP does not send — Non-Goal #8).
  9. Customer replies opt-in/out → the existing Q2 Kafka pipeline (§9) updates chat_data → the consent status later appears on the CDP tab + Activity log.

Failure / edge branches:

  • 4a. direct_select returns non-2xx → inline error on the button, no new tab opened (OPT-S08 AC-6); consent_request_error fires.
  • 6a. Pop-up blocked by the browser → surface a fallback link to open the composer manually.
  • 7a. G9 not yet shipped → the composer opens but the recipient is not auto-filled (agent selects it manually) — the flow still works, degraded (OPT-S08 AC-4 gated on G9).
  • Gating. Button shown only when the user can create broadcasts / has the required permission (OPT-S08 AC-2) and only in the not-yet-asked ("-") state (AC-1).

12.2 User Stories

All ACs in strict Given/When/Then. Priority uses MoSCoW.

User StoryPriorityMockupTechnical NotesAcceptance Criteria
[OPT-S01] View Meta marketing opt-in on Customer Detail

As a marketer/agent, I want to see each customer's marketing opt-in per Meta account-channel, so I know who consented before targeting.
Must HaveFigma "Default <5"Read-only; source chat_data (§9); grouped by account_uniq_id.AC-1 (Section renders):
Given a customer with ≥ 1 Meta chat account,
When I open the Communication consent tab,
Then I see a "META services" section listing each account-channel (business name + business phone) with a Marketing opt-in column.

AC-2 (Account summary, tri-state):
Given an account-channel where 2 numbers have marketing_opt_in = true and 1 is null,
When the section renders,
Then that account's Marketing opt-in cell shows "2 phone numbers"; And Given an account where no number has a true/false decision, Then the cell shows "-".

AC-3 (Meta filter):
Given the customer also has non-Meta chat accounts,
When the section renders,
Then only channel_target in {whatsapp, instagram_dm, facebook_messenger} is listed and non-Meta channels are excluded.

AC-4 (Read-only):
Given the consent section is displayed,
When I inspect any row,
Then no edit/toggle control is shown (consent is system-managed).

AC-5 (Error path):
Given the consent read API returns a non-2xx,
When the tab loads,
Then an inline error state with a retry action is shown (not a blank table) and consent_api_error fires.
[OPT-S02] Expand an account-channel to see phone numbers

As a marketer/agent, I want to expand an account to see which numbers opted in/out and when.
Must HaveFigma "Default <5"Per-phone {phone_number, marketing_opt_in, marketing_opt_in_timestamp}.AC-1 (Expand):
Given an account-channel row showing "N phone numbers",
When I click that link,
Then the row expands to list each phone number under that account-channel.

AC-2 (Tri-state status + time):
Given an expanded account-channel,
When the list renders,
Then each phone shows exactly one status — Marketing opt-in (true), Marketing opt-out (false), or "-" (null) — and shows marketing_opt_in_timestamp when a decision exists.

AC-3 (Null visible + distinct):
Given a number with marketing_opt_in = null,
When the list renders,
Then it is shown as "-" (not hidden), and an opted-out number is shown as Marketing opt-out, visually distinct from opt-in.

AC-4 (Error path):
Given the phone list for an account fails to load,
When I expand it,
Then an inline error/retry state is shown for that account and consent_api_error fires.
[OPT-S03] Filter by communication type

As a user, I want to filter the consent list by channel.
Should HaveFigma filter dropdownFilter on channel_target.AC-1 (Default):
Given the consent tab is open,
When it first renders,
Then the "communication type" filter defaults to all Meta channels.

AC-2 (Apply):
Given the filter dropdown is open,
When I select a specific channel (e.g. whatsapp),
Then the table shows only account-channels with that channel_target.

AC-3 (Persist):
Given I have applied a filter,
When I expand or collapse rows within the tab,
Then the filter selection persists.

AC-4 (Empty result):
Given a filter that matches no account-channel,
When it is applied,
Then an empty state ("no Meta accounts for this channel") is shown, not an error.
[OPT-S04] Loading / empty / error / scaling states

As a user, I want clear states when data is loading, absent, failing, or large.
Must HaveFigma "Loading", "Blank (not found)", ">10 channels"State machine per §11.AC-1 (Loading):
Given the consent read is in flight,
When the tab is opening,
Then a loading state is shown until data resolves.

AC-2 (Empty):
Given the customer has no Meta consent data,
When the tab loads,
Then a blank/empty state is shown (not an error).

AC-3 (Error):
Given the consent read API returns a non-2xx,
When the tab loads,
Then an error state with a retry action is shown and consent_api_error fires.

AC-4 (Scaling):
Given a customer with > 10 account-channels,
When the tab loads,
Then the list stays usable via scroll/pagination and meets the render budget (§6, p95 ≤ 3s).
[OPT-S05] Permission gating

As an admin, I want the consent view gated by customer-view permission.
Must HaveReuse customers_customers_view.AC-1 (No view permission):
Given a user without customers_customers_view,
When they open Customer Detail,
Then the Communication consent tab and its data are not shown.

AC-2 (Owned only):
Given a user with customers_customers_view = OWNED ONLY,
When they open a customer they do not own,
Then consent data is not shown for that customer.

AC-3 (All access):
Given a user with customers_customers_view = ALL,
When they open any customer,
Then consent data is shown.

AC-4 (Unauthorized API):
Given a direct consent API call from a user without permission,
When it is processed,
Then it returns 403 and no consent data.
[OPT-S06] (BE) Grouped, Meta-filtered consent read contract

As the FE, I need a read API returning consent grouped by account-channel, Meta-filtered, tri-state, with the real phone + timestamp.
Must HaveCloses gaps G1, G7, G8. No schema change.AC-1 (Grouping):
Given a customer with Meta chat_data,
When the consent read API is called,
Then the response groups by account_uniq_id and includes account_business_name + channel_target.

AC-2 (Real phone — fixes G7):
Given a chat_data element,
When it is serialized,
Then the response returns the real phone_number (not account_uniq_id), plus marketing_opt_in and marketing_opt_in_timestamp.

AC-3 (Tri-state incl null — fixes G8):
Given a chat_data element with marketing_opt_in = null,
When it is serialized,
Then it is included and represented as null ("-"), not dropped.

AC-4 (Meta filter):
Given non-Meta chat_data,
When the API responds,
Then only Meta channel_target values are returned; WhatsApp-call and email are excluded.

AC-5 (Scope):
Given a requester with OWNED-ONLY scope,
When they request consent for a customer they do not own,
Then the API denies or omits per scope.
[OPT-S07] Opt-in/out change history in Activity log

As an agent/marketer, I want every marketing opt-in/opt-out change to appear in the customer's Activity log, so I can audit when and how consent changed per number & account.
Must HaveFigma "Activity log" tabLog already emitted per event (§9); FE renders category = campaign + action = opt-in/opt-out.AC-1 (Appears on change only):
Given a marketing opt-in/out event is processed for the customer,
When I open the Activity log tab,
Then a new entry appears for that change; And Given no status change occurred, When I view the log, Then no new consent entry is created.

AC-2 (Detail data):
Given a consent activity-log entry,
When it renders,
Then it shows phone number, opt-in/out status, channel, account business name, and timestamp.

AC-3 (Title copy):
Given an opt-in entry,
When it renders,
Then the title reads "this customer allowed to receive broadcast (opt-in)"; And Given an opt-out entry, Then the title reads "this customer not allowed to receive broadcast (opt-out)".

AC-4 (Read-only, chronological):
Given the activity log,
When entries render,
Then they are read-only and ordered by timestamp (newest first).
[OPT-S08] Request consent via broadcast (empty state)

As an agent/marketer, when a customer has not been asked for marketing consent yet, I want a one-click way to send them a WhatsApp opt-in request, so I can collect consent without leaving the profile and manually building a list.
Must HaveFigma empty-state (needs CTA added, §11 item 7)Creates single-customer list via direct_select (§9.1/§10 #4) → opens WA composer new tab (§10 #5). Auto-fill needs G9 (hub-chat).AC-1 (Button visibility — not-yet state):
Given a customer whose Meta consent is "no consent yet" (marketing_opt_in = null / "-"),
When the Communication consent empty state renders,
Then a "Request consent via broadcast" button is shown; And Given the customer already has an opt-in or opt-out decision on a number, Then the button is not the primary path for that number (the status is shown instead).

AC-2 (Permission gate):
Given a user without permission to create broadcasts (or without customers_customers_view for the customer),
When the empty state renders,
Then the button is hidden/disabled.

AC-3 (Create single-customer list):
Given I click the button,
When CDP handles the click,
Then it calls POST /contacts/contact_lists/direct_select with exactly one contact_id (this customer) and a generated list name, and shows a loading state until the call resolves.

AC-4 (Open composer, auto-fill recipient list — new tab):
Given the recipient list is created successfully,
When CDP receives the contact_list id,
Then it opens {CHAT_BASE_URL}/campaign/whatsapp/create?contact_list_id={id} in a new tab, and (given G9 is shipped) the composer's Recipient list field is pre-selected to that new single-customer list.

AC-5 (Template selected manually — auto-fill out of scope):
Given the composer opens,
When it loads,
Then the Message template field is not pre-filled by CDP this phase (template auto-fill is not possible yet); And the agent selects the opt-in template manually before sending.

AC-6 (Create failure):
Given direct_select returns a non-2xx,
When the click is handled,
Then an inline error is shown on the button, no new tab is opened, and consent_request_error fires.

AC-7 (No auto-send):
Given the composer is opened,
When the tab loads,
Then CDP has not sent any broadcast — the user must review and send in the Chat app (Non-Goal #8).

Cross-squad requirement (tracked as G9, not a CDP story): hub-chat's /campaign/whatsapp/create page must read the URL and pre-populate one field — the Recipient list from contact_list_id. Without G9, OPT-S08 AC-4 degrades to "composer opens, agent selects the recipient manually" (§12.1b branch 7a). Message-template auto-fill is out of scope this phase (not possible yet); a future enhancement could auto-select the most recent is_marketing_opt_in template (§9.1, OQ-7). Owned by the Chat/Broadcast squad; must be sequenced with this PRD.


13. Open Questions

#TypeQuestionOwnerDeadline
1OpenExtend the existing contact-detail response, or add a dedicated GET .../communication-consent endpoint?Eng (CDP)RFC
2OpenFor IG DM / Messenger under "META services" — ship with an empty state now, or scope release copy to WhatsApp only until those channels emit opt-in events (G2)? Answered (v1.7): render Meta grouping now, WhatsApp-populated; IG/Messenger show "no consent data yet" empty state (§17 decision).PM + DesignClosed
3OpenShould opted-out numbers be listed (with an "opted out" state) or only opted-in ones? Answered (v1.3): tri-state — opt-in / opt-out / "-" all shown (§11).PM + DesignClosed
4Assumptionchannel_target reliably holds one of the Meta constants for all Meta chat accounts.Eng (CDP)RFC
5OpenEmail marketing consent + WhatsApp-call consent — confirm both are separate future PRDs.PMPost-GA
6RiskG9 sequencing: hub-chat's broadcast create page ignores the URL today — it must add contact_list_id query-param pre-fill so OPT-S08 can preselect the recipient list. Mitigation: the flow degrades gracefully (composer opens, agent selects the recipient manually) if G9 lands later; coordinate sequencing with the Chat/Broadcast squad and gate OPT-S08 AC-4 on it.PM + Chat/BroadcastRFC
7DeferredTemplate auto-fill (future enhancement): not possible this phase, so the agent selects the opt-in template manually. When feasible, auto-select the most recent is_marketing_opt_in template (?is_marketing_opt_in=true&order_by=created_at&order_direction=desc, gated by is_using_marketing_opt_in — §9.1). Open for that future slice: hub-chat resolves it (recommended) vs. CDP resolves the id; and the fallback when no such template exists.PM + Chat/BroadcastFuture
8Opendirect_select immediacy (G10): is a list created via direct_select immediately usable by a broadcast, or can it be briefly "in progress" (contact_list.progress)? If the latter, does the composer handle a still-processing list gracefully?Eng (CDP) + Core teamRFC
9OpenRecipient-list naming convention for auto-created single-customer lists (e.g. "Opt-in request — {customer} — {date}") — and cleanup/retention of these one-off lists so they don't clutter the lists index.PM + Chat/BroadcastPre-build

14. Rollout

ItemDetail
Feature flagcdp_communication_consent_tab — default OFF (see §6 Constraints); flipped per rollout stage.
Rollout sequenceStage 1 — Internal: enable for the CDP squad's own org only. → Stage 2 — GA: enable for all Growth & Enterprise CIDs. No Beta — read-only, additive, low-risk change.
Backward compatibilityYes — fully backward compatible. Additive read-only tab; no change to existing Customer Detail behavior, no write path, no contract change for existing consumers of the contact-detail response.
MigrationNone. Reads existing chat_data; no schema change and no data migration, so there is no old/new data transition window to manage.
RollbackFlip cdp_communication_consent_tabOFF to instantly hide the tab; no data side-effects (read-only). Semantic-regression rollback N/A (no AI/ML/ranking output).

15. Observability

Events

EventTriggerProperties
consent_tab_viewedUser opens the Communication consent tab on Customer Detailcompany_id, user_id, customer_id, meta_account_count
consent_account_expandedUser expands an account-channel to reveal phone numberscompany_id, user_id, account_uniq_id, channel_target, phone_count
consent_filter_appliedUser changes the "communication type" filtercompany_id, user_id, channel_target
consent_api_errorThe consent read API returns a non-2xxcompany_id, customer_id, status_code, endpoint
consent_request_clickedUser clicks "Request consent via broadcast" (OPT-S08)company_id, user_id, customer_id
consent_request_openedRecipient list created + composer tab openedcompany_id, user_id, customer_id, contact_list_id
consent_request_errordirect_select list-create fails (no redirect)company_id, customer_id, status_code

Dashboard owner: CDP Squad (CDP analytics board).

Alerts:

  • consent_api_error rate > 2% of consent-tab loads over a 5-minute window → page CDP on-call.
  • Consent-tab render p95 > 3sCDP Slack alert.

Post-launch monitoring cadence: Review adoption (consent_tab_viewed) and error rate weekly for the first month post-GA, then monthly. Investigate if consent_api_error > 2% for 2 consecutive days OR render success < 99% (§7 metric).


16. Launch Plan & Stage Gates

StageAudienceDurationSuccess gate
InternalCDP squad's own Qontak org (~10 users)2–3 daysNo console/API errors; consent tab renders opt-in / opt-out / "-" correctly for test customers spanning ≥ 2 Meta accounts
GAAll Growth & Enterprise CIDs (flag ON 100%)Rolling over ~1 week⭐ Consent-visibility ≥ 99% render success (§7 metric); consent_api_error < 2%; no open P1 bugs

No Beta stage — read-only, additive, low-risk change (per rollout decision, §14 / §17).


17. Key Decisions + Alternatives Rejected

17a — Decisions Made

DateDecisionRationale
2026-07-24Read-only display in CDP (no manual toggle)Consent is system-managed via the Kafka pipeline (base.go:126-127); a CDP write path would create a competing source of truth
2026-07-24Tri-state status — null→"-", true→opt-in, false→opt-outMatches the marketing_opt_in *bool model (base.go:130); lets users distinguish "never decided" from "opted out"
2026-07-24Meta services only this phase (whatsapp / instagram_dm / facebook_messenger)Focus the first slice; Meta channel constants already exist (field_properties/default.go:20-23)
2026-07-24Reuse chat_data — no schema change/migrationAll fields (phone, channel, status, timestamp) already persisted; work is a read contract + FE tab
2026-07-24Ship Internal → GA, no BetaRead-only additive change is low-risk; a Beta stage adds process cost without de-risking
2026-07-24Render Meta grouping now, populated from WhatsApp data; IG DM / Messenger show a "no consent data yet" empty stateOnly WhatsApp emits opt-in events today (G2); Meta grouping future-proofs IG/Messenger with no rework — closes OQ-2
2026-07-27Add a "Request consent via broadcast" action in the not-yet-asked empty stateCloses the "captured but can't acquire" gap — the profile can now collect consent, not just display it
2026-07-27Reuse the existing Chat broadcast composer (open /campaign/whatsapp/create cross-app in a new tab) — do not build a composer in CDPThe composer, recipient-list API, and cross-app new-tab pattern (DrawerSendCampaign.vue:98) already exist; CDP only seeds the list + opens the URL
2026-07-27Create a single-customer recipient list via direct_select (one contact_id), CDP does not auto-sendMatches the existing single-step list-create API (§9.1); keeps CDP read-only on the write/consent path (send + outcome stay in Chat/Kafka)
2026-07-27Auto-fill only the Recipient list (the new single-customer list); the agent selects the template manuallyTemplate auto-fill is not possible yet — descoped this phase; recipient pre-select is the achievable win. A future slice can add template auto-select (OQ-7)

17b — Alternatives Rejected

AlternativeWhy RejectedDate
Manual opt-in/out toggle in CDPBreaks the single system-of-record; consent must flow from the Broadcast/Chat pipeline2026-07-24
Show only opted-in numbers (hide opt-out & "-")Loses the compliance signal — users must see who opted out and who never decided2026-07-24
WhatsApp-only display (no Meta grouping)Meta grouping future-proofs IG DM / Messenger with no rework when they emit events2026-07-24
Include WhatsApp-call consent nowNo backing field in chat_data (G3); needs a separate producer — deferred2026-07-24
Build a broadcast composer inside CDPDuplicates the Chat app's composer + recipient-list APIs; high cost, owned elsewhere2026-07-27
Auto-send the opt-in broadcast from CDPRemoves human review of message/recipient; send stays a deliberate action in Chat2026-07-27
Pass raw contact_id to the composer (skip creating a list)The composer takes a contact_list_id, not raw contacts (CreatePage.vue:394); a list must exist first2026-07-27

18. Appendix — Current Payloads (evidence / recall with engineers)

Grounded from contact-service @ master (2026-07-24). These are the actual current shapes — attach as recall/evidence in the RFC/engineering sync.

A. Contact-detail response — current marketing opt-in block (create_serializer.go:304-312)

"phone_marketing_opt_in": [
{
"phone_number": "628581799xxxx",
"marketing_opt_in": true,
"business_phone_name": "Central Perk Promo"
}
]

⚠️ G7: phone_number here is actually chat_data.account_uniq_id (create_serializer.go:310), not the customer's real phone. The block is also flat (not grouped by account) and omits channel_target + marketing_opt_in_timestamp. → this is exactly what the new read contract (G1) must fix.

B. Inbound Kafka event — source of truth (payload/marketing_opt_in_event.go, topic send_marketing_opt_in)

{
"channel_id": "chn_123",
"contact_id": "ct_456",
"marketing_opt_in": false,
"phone_number": "628111102938",
"qontak_customer_id": "665f0a1b2c3d4e5f60718293",
"account_business_name": "Central Perk Promo",
"account_uniq_id": "628581799xxxx",
"channel_target": "whatsapp",
"timestamp": "2026-01-06T10:12:00Z",
"marketing_opt_in_timestamp": "2026-01-06T10:12:00Z",
"broadcast_id": "bc_789",
"email_sender": "agent@centralperk.id"
}

C. Resulting activity-log entry (activity_log.go:793 + activity_log_serializer.go)

{
"timestamp": "2026-01-06T10:12:00Z",
"action": "opt-out",
"category": "campaign",
"associated_type": "campaign",
"associated_name": "Central Perk Promo",
"title": "opted out to marketing",
"associated_changes": [
{
"field": "chat_data",
"from": [
{ "id": "ct_456", "channel_id": "chn_123", "account_uniq_id": "628581799xxxx", "channel_target": "whatsapp" }
],
"to": [
{ "id": "ct_456", "channel_id": "chn_123", "account_uniq_id": "628581799xxxx", "channel_target": "whatsapp",
"phone_number": "628111102938", "account_business_name": "Central Perk Promo",
"marketing_opt_in": false, "marketing_opt_in_timestamp": "2026-01-06T10:12:00Z" }
]
}
]
}

Notes: title is awkward (G5 → suggest "this customer not allowed to receive broadcast (opt-out)"). The from snapshot has no prior marketing_opt_in (G6), so the entry shows the resulting state; action conveys opt-in vs opt-out. Everything the UI needs (phone, channel, status, timestamp) is in the to snapshot.

D. Request-consent — recipient-list create request (POST /api/core/v1/contacts/contact_lists/direct_select; grounded from hub-chat DialogSubmitSelect.vue:156-173)

{
"name": "Opt-in request — Eka Setiawan — 2026-01-06",
"contact_ids": ["665f0a1b2c3d4e5f60718293"]
}

Response is a contact_list object (contact.d.ts:2-19): { "id": "cl_abc123", "name": "...", "contacts_count": 1, "target_channel": "wa", "progress": ... }. The c360 variant (direct_select_c360) can additionally carry marketing_opt_in: { value, channel_ids }.

E. Request-consent — composer redirect URL (built by CDP; consumed by hub-chat CreatePage.vue — pre-fill is net-new, G9)

https://chat.qontak.com/campaign/whatsapp/create?contact_list_id=cl_abc123

Auto-fills one field: Recipient list = cl_abc123 (the new single-customer list). The agent selects the Message template manually (template auto-fill out of scope this phase — OQ-7). ⚠️ G9: CreatePage.vue:304 uses useRouter() only and never reads route.query; a repo-wide search for segment_id consumers returns zero. So the param is ignored today — hub-chat must add recipient pre-fill logic to CampaignSetup.vue / DropdownRecipient.vue. The broadcast itself is POST /api/core/v1/{org}/broadcasts/whatsapp with contact_list_id + message_template_id (useCreateCampaign.ts:233, CreatePage.vue:393-394) — sent by the user in the Chat app, not by CDP.


PRD CHANGELOG

VersionDateBySectionTypeSummary
1.112026-07-27ClaudeS5, S8, S9.1, S10, S11, S12, S13, S15, S17, S18MODIFIEDRemoved the message-template auto-fill (not possible yet) — the composer now auto-fills only the Recipient list (?contact_list_id={id}); the agent selects the opt-in template manually. Template auto-select kept as a documented future enhancement (OQ-7, §9.1). Updated §5/§8 deps, G9 (recipient-only), §10 #5 URL, §11 item 7, §12.1b, OPT-S08 AC-4/AC-5, decisions, consent_request_opened event, Appendix E.
1.102026-07-27ClaudeS5, S8, S9.1, S10, S11, S12, S13, S17, S18MODIFIEDSharpened the composer auto-fill to exactly 2 fields per the WA Create-campaign UI: (1) Recipient list = the new single-customer list; (2) Message template = the most recent is_marketing_opt_in template. Grounded the marketing-opt-in template as a queryable typeGET /templates/whatsapp?is_marketing_opt_in=true&order_by=created_at&order_direction=desc (whatsapp-new/@types/whatsapp.ts:39-55, useWhatsappListsFilter.ts:61-65,110), gated by app-config is_using_marketing_opt_in. Updated G9 (hub-chat resolves template; CDP passes prefill_template=marketing_opt_in), §10 #5 URL, OPT-S08 AC-4/AC-5, OQ-7 (now resolution-owner only), 2 decisions, Appendix E.
1.92026-07-27ClaudeS1, S4, S5, S8, S9.1, S10, S11, S12, S13, S15, S17, S18MODIFIEDAdded scope: "Request consent via broadcast" for the not-yet-asked (null/"-") state — a CTA that creates a single-customer recipient list and opens the WA broadcast composer pre-filled. Grounded against hub-chat + qontak-customer-fe (§9.1): recipient-list create (direct_select), composer route (/campaign/whatsapp/create, cross-app chat.qontak.com), and the existing new-tab pattern (DrawerSendCampaign.vue:98) all exist. New grounded gap G9 — hub-chat's create page ignores route.query, so auto-fill (contact_list_id/message_template_id) is net-new (cross-squad). Added story OPT-S08 (7 ACs), flow §12.1b, API #4/#5, dependencies, decisions, OQ-6/7/8/9, events, and Appendix D/E.
1.82026-07-24ClaudeS9MODIFIEDRemoved the now-redundant Gaps-to-close table (all fixes applied); replaced with a compact "fixes applied" list so the G1–G8 references elsewhere still resolve. Kept the grounding evidence + feasibility verdict.
1.72026-07-24ClaudeS9 (gaps), S13, S17MODIFIEDApplied/annotated the proposed fix for every gap (status tags G1–G8 in §9); G2 decided — render Meta grouping now, WhatsApp-populated, IG/Messenger empty state (added §17 decision, closed OQ-2, tightened §11 item 3). All gaps now closed in-spec.
1.62026-07-24ClaudeS2MODIFIEDReframed "What Happens If We Don't Build" around the data-transparency theme: today opt-in/out is not shown on Customer Detail — users must leave the profile, go to the Customers index and filter/segment by marketing opt-in; added time/magnitude signals (ongoing per-campaign compliance + WhatsApp quality-rating risk, widening gap).
1.52026-07-24ClaudeS1, S4, §11, §12MODIFIEDRewrote all 7 stories' ACs into strict Given/When/Then + added an error-path AC per story (clears Partial verdicts); added §12.1 flow type + failure branches; added §11 route, component tree, UI state diagram, explicit error state; added Constraints performance + plan tier; trimmed one-liner to ≤ 25 words.
1.42026-07-24ClaudeS14–S17 (new)MODIFIEDAdded the four mandatory NEW-PRD sections: Rollout (flag cdp_communication_consent_tab, Internal→GA, no Beta, backward-compatible, no migration), Observability (4 events + alerts + weekly cadence), Launch Plan & Stage Gates (Internal → GA), Key Decisions + Alternatives Rejected. Added feature-flag row to Constraints; renumbered Appendix to §18.
1.32026-07-24ClaudeS9, S10, S11, S12MODIFIEDDefined tri-state consent status (grounded on marketing_opt_in *bool, base.go:130): null→"-" (not yet allowed/allowed), true→Marketing opt-in, false→Marketing opt-out. Added gap G8 (serializer drops null today → "-" invisible; create_serializer.go:308) and applied all P0/P1 fixes into the spec (§10/§11/§12). Updated OPT-S01/S02/S06 ACs + §10 read contract to include null accounts; closed OQ-3.
1.22026-07-24ClaudeS9, S11, S12, S14MODIFIEDUpdated activity-log title copy to the consent-framed pattern: opt-in → "this customer allowed to receive broadcast (opt-in)", opt-out → "this customer not allowed to receive broadcast (opt-out)" (G5, OPT-S07 AC-3).
1.12026-07-24Claude§9, §12, §18MODIFIEDAdded opt-in/out Activity-log scope (entry per status change; shows phone · status · channel · account · timestamp). Grounded the activity log as already-emitted (activity_log.go:793); recorded gaps G5 (title copy), G6 (from-snapshot lacks prior value), G7 (phone_number=account_uniq_id). Added §18 Appendix with the 3 real payloads.
1.02026-07-24ClaudeAllCREATEDNEW PRD: display Meta-service marketing opt-in/out on CDP Customer Detail (read-only), building on the Q2 contact-service consent pipeline. Grounded against contact-service master (§9) — buildable with no schema migration; main gap is a grouped/Meta-filtered read contract (G1). WhatsApp-call (Non-Goal #1) and Email (Non-Goal #3) out of scope; Figma flagged as draft needing adjustment (§11).