Skip to main content

RFC: MBA-Aware Foundation (Phase 1)

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, sections 1–6, and Comment logs are mandatory. It is also agent-execution-ready: §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete.

Delivery & project management live elsewhere. This RFC is the technical artifact only. Delivery pointer below reads not yet handed to delivery.

Metadata

FieldValueNotes
StatusDRAFT — open for engineering reviewYAML status: carries the linter enum (draft)
DRIDimas Fauzi HidayatSingle accountable owner
TeamchatbotChatbot squad owns end-to-end incl. hub-core changes (cross-repo, not cross-squad — PRD Dependency 1/1a)
Author(s)Claude (rfc-starter) + Dimas Fauzi HidayatGrounded against hub-core@master + hub-chat@master, 2026-07-19 (ground-prd pass same day)
Reviewerspending — BOT squad BE reviewer; Chat squad for hub-core/hub-chat PR courtesy
Approver(s)pending — BOT squad tech lead
Submitted Date2026-07-19ISO-8601
Last Updated2026-07-19Bump on every material edit
Target Release2026-Q3Carried from PRD target_quarter
Deliverynot yet handed to deliveryNo delivery/timeline.md for this initiative yet
RelatedPRD — Phase 1: MBA-Aware Foundation (v1.6, ground-prd-verified) · ANCHOR
Discussionpending

Type: full-stack (hub-core BE + hub-chat FE) Sub-type: new-capability behind org flag mba_aware_inbox

Sections at a Glance

  1. Overview (incl. §1 PRD-to-Schema Derivation)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequences → Data Model → APIs → integrity / concurrency / async specs)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

When Meta Business Agent (MBA) is enabled on a WhatsApp number, Meta demotes the business app to a standby observer: consumer messages and MBA's replies arrive on the standby webhook field, and control changes arrive on messaging_handovers. hub-core's Kafka webhook consumer (incoming_consumer.rb) routes neither today (repo-wide grep: zero occurrences), so MBA-run conversations are invisible or fragmentary in the Qontak inbox, and with no Thread Control client a conversation taken over by a human can never be returned to MBA.

This RFC makes the inbox MBA-aware, entirely inside existing surfaces:

  • Consume + persist: two new cases in the existing IncomingConsumer event switch route standby (consumer messages, MBA echoes, receipts) and messaging_handovers into the existing room/message persistence path.
  • Control state: an additive rooms.mba_control_state column (meta_agent | app, NULL for non-MBA rooms), written only by the handover consumer (source of truth) and an optimistic agent-send flip.
  • Hand-back: a new MBA API client (base api.facebook.com, versioned via X-API-Version header — a sibling of the wa_cloud Graph client, per Meta MBA docs v2.0.0) exposing one method this phase: Thread Control release (pass is reserved/non-functional per the docs).
  • Suppression: MBA-controlled rooms carry their control state on the existing receive_message_from_customer message-interaction webhook payload; the chatbot service guards its dispatch on it (ADR-5 resolves the PRD's open suppression-point choice).
  • Inbox UI (hub-chat): control banner, "Meta Business Agent" sender label, centered handover timeline markers, "Return to Meta agent" action, and a "Meta AI" room-list tag — all on existing components/patterns verified in the repo (ground-prd 2026-07-19).

Everything ships behind the per-org flag mba_aware_inbox (default OFF; OFF = consume + log only, no persistence/UI).

Two grounding corrections carried into this RFC (vs earlier drafts of the PRD, both already folded into PRD v1.6):

  1. The hand-back verb is release, not pass (Meta docs v2.0.0: only release functions).
  2. There is no dead-letter queue infrastructure in hub-core (repo-wide grep for dead_letter = zero; unknown consumer events are mark_as_consumed no-ops). ADR-4 defines the quarantine mechanism this RFC builds instead; the PRD's "existing dead-letter path" wording maps to it.

Detail 1.A — PRD Traceability Matrix

PRD element (v1.6)RFC section(s)
S7 #1–#3 standby consume/persist (MBA-S01, AC-1..4, ERR-1)ADR-1, ADR-4, Detail 2.2 Seq-1, Detail 2.3, §4 Chunk 2–3
S7 #4 handover consume → control state (MBA-S02)ADR-2, Detail 2.2 Seq-2, Detail 2.3, §4 Chunk 4
S7 #5 agent send = optimistic takeover (MBA-S04)ADR-2, Detail 2.2 Seq-2, §4 Chunk 5
S7 #6 Thread Control release (MBA-S05)ADR-6, Detail 2.4, Detail 2.2 Seq-2, §4 Chunk 6
S7 #7 suppression (MBA-S01-NEG)ADR-5, Detail 2.2 Seq-1, §4 Chunk 7
§6 inbox UI (MBA-S03) + "Meta AI" tagADR-3, ADR-7, §4 Chunk 8–9
§5 flag mba_aware_inbox (MBA-S06)ADR-8, Detail 4.A, §4 Chunk 1
§5 tenancy / §12 Gate 1 zero cross-tenant§3 Security, Detail 2.A
§10 observability eventsDetail 2.C, Detail 4.A
§15 OQ-1 (marker/participant modeling — "final call in the RFC")Decided here: ADR-3
Scope note: suppression point is an RFC choiceDecided here: ADR-5
§6 note: ChatList has no tag prop — new prop or data injectionDecided here: ADR-7

PRD sections with no RFC-side work: §9 Rollout stages, §11 metrics targets, §12 gates (delivery/QA artifacts — referenced, not re-specified); §13 Dependencies (tracked in PRD; Dep 3 webhook field subscription is an ops task restated in §4 Chunk 0).

Detail 1.B — Key Decisions Summary

#Decision (full ADRs in §2)
ADR-1Route standby/messaging_handovers as new cases in the existing IncomingConsumer switch — no new consumer/topic
ADR-2rooms.mba_control_state additive column; handover webhook is source of truth; agent send flips optimistically
ADR-3Handover markers persist as system messages on the room timeline (reuse persistence/retention/render path) — resolves PRD OQ-1
ADR-4Unroutable standby events quarantine via the existing WebhookEventLog pattern + 14-day cleanup — hub-core has no DLQ
ADR-5Suppression = control state carried on the message-interaction webhook payload + guard at chatbot dispatch — never blanket-suppress the fan-out
ADR-6New MBA API client (api.facebook.com, X-API-Version) as a sibling of the wa_cloud Graph client; method: Thread Control release
ADR-7"Meta AI" tag rendered FE-side by extending roomItemTags in ChatList.vue — no server-side synthetic tag injection
ADR-8mba_aware_inbox checked at consumer entry, same shape as the existing is_enabled_kafka? per-org gate

Detail 1.C — Per-Story Change Map

StoryRepoSurfaces touched
MBA-S01 persist standbyhub-coreincoming_consumer.rb (+2 cases), new wa_cloud standby interactor, messages/creates/customer.rb reuse, WebhookEventLog quarantine
MBA-S02 control statehub-corerooms migration, handover interactor, system-message marker write
MBA-S03 inbox statehub-chatChatView.vue marker variant, RoomMessage.vue sender label, new MbaControlBanner.vue, ChatList.vue tag
MBA-S04 take overhub-core + hub-chatoptimistic flip on send path; banner state swap
MBA-S05 hand backhub-core + hub-chatMBA client release; composer action + toast/retry
MBA-S06 flaghub-core + hub-chatconsumer gate; UI render gate from room payload
MBA-S01-NEG suppressionhub-core + chatbotwebhook payload field; dispatch guard
MBA-S02-NEG no config UIguard rail only — nothing built

2. Technical Design

Infrastructure Topology

No new services, pods, databases, or queues. The feature rides the existing webhook ingestion topology end to end; the only new external call is Thread Control release to api.facebook.com.

flowchart LR
subgraph Meta
WA["WhatsApp Cloud webhooks<br/>(messages, statuses, standby, messaging_handovers)"]
TC["api.facebook.com<br/>Thread Control (release)"]
end
subgraph Existing["Existing Qontak topology (unchanged shape)"]
GW["Webhook receiver / outbox"] --> K[("Kafka<br/>webhook_outboxes topic")]
K --> C["IncomingConsumer<br/>(Karafka, batch)"]
C --> DB[("Postgres<br/>rooms, messages, webhook_event_logs")]
C --> MI["Message-interaction webhook fan-out"]
MI --> BOT["chatbot service<br/>(bot flow / AI agent dispatch)"]
MI --> EXT["Customer webhook subscribers"]
DB --> API["hub-core APIs"] --> FE["hub-chat inbox (Nuxt)"]
end
WA --> GW
FE -- "Return to Meta agent" --> API -- "release" --> TC
ServiceResponsibility in this RFCThird-party connections
hub-core (Rails engine in host app)Consume/persist standby + handover events; control state; quarantine; MBA client release; webhook payload fieldMeta api.facebook.com (new), existing Graph URL (unchanged)
chatbot serviceOne guard: skip dispatch when payload says mba_control_state=meta_agentnone new
hub-chat (Nuxt)Banner, labels, markers, tag, return actionnone
Kafka / PostgresUnchanged topology; additive column + one new enum value + log rows

Technical Decisions (ADR format)

ADR-1 — Consume standby + messaging_handovers in the existing IncomingConsumer

  • Context. All WhatsApp Cloud webhook traffic already flows outbox → Kafka → KafkaConsumers::WebhookOutboxes::IncomingConsumer (< KafkaConsumers::AbstractSub < Karafka::BaseConsumer), which dispatches on data.channel_event_type (incoming_consumer.rb:22, cases :24–121: messages, calls, account_update, flows, …; unknown events fall to mark_as_consumed at :119).
  • Options.
    • A — new cases in the existing switch ✚ same ordering/retry semantics, same org gate, zero infra; ─ switch grows.
    • B — dedicated consumer/topic for MBA events ✚ isolation; ─ new topic + deployment surface, splits per-room ordering between two consumers (handover vs message order races), duplicated org-resolution code.
  • Decision. Option A. standby and messaging_handovers become two new cases dispatching to new wa_cloud interactors, mirroring how messages dispatches to CustomerSendMessage (incoming_consumer.rb:72–81).
  • Consequences. Per-room event ordering preserved within the existing partition strategy; standby volume rides existing consumer capacity (see Detail 4.E).
  • Reversibility. High — removing the cases restores today's no-op.

ADR-2 — Control state as an additive rooms.mba_control_state column

  • Context. No control-state concept exists (grep: zero matches for control_state/room_control in hub-core). The PRD requires banner state ≤ 2s after a handover event and a suppression guard on every inbound dispatch — both are hot reads.
  • Options.
    • A — column on rooms ✚ one indexed read on paths that already load the room; survives room list queries; ─ migration (additive, nullable).
    • B — derive at read time from stored handover events ✚ no migration; ─ every dispatch/banner read scans events; ambiguity when zero events.
  • Decision. Option A: mba_control_state string column, values meta_agent | app, NULL = non-MBA room. Writes: handover consumer (authoritative, latest-event-timestamp wins per PRD S02/AC-3) and the optimistic agent-send flip (reconciled by the next handover event).
  • Consequences. The message-interaction payload and room API responses can expose it cheaply (ADR-5, FE).
  • Reversibility. High — column is additive and nullable.

ADR-3 — Handover markers persist as system messages (resolves PRD OQ-1)

  • Context. The PRD renders control changes as centered timeline markers. hub-chat already renders centered system markers from the message list via a variant switch (ChatView.vue:244–258 variant==='info'; marker helper chatViewMarkers.ts:15); messages already carry retention, ordering, and room-deletion lifecycle.
  • Options.
    • A — system-message rows (new message type mba_handover, payload: direction, actor, source timestamp) ✚ timeline ordering, retention, FE render path all reused; ─ adds a non-conversational row type to messages.
    • B — separate room_events table ✚ clean modeling; ─ new read path + FE merge logic + its own retention/deletion wiring — exactly what the PRD's "no orphaned artifacts" constraint tries to avoid.
    • C — WebhookEventLog rows only ─ log model, not room-timeline renderable; rejected outright.
  • Decision. Option A. The MBA echo sender is likewise modeled on the existing message sender/participant mechanism with a new sender type (meta_business_agent) rather than a new participant entity — same reasoning: reuse the render/retention path (this closes PRD §15 OQ-1).
  • Consequences. FE maps mba_handover messages to the existing info variant; no new FE data fetching.
  • Reversibility. Medium — rows exist once written; type is additive.

ADR-4 — Unroutable-event quarantine via WebhookEventLog + 14-day cleanup

  • Context. PRD ERR-1 demands unroutable standby events are "never silently dropped" with 14-day retention. hub-core has no dead-letter queue (grep dead_letter = zero; unknown events today are mark_as_consumed no-ops). An event-log pattern exists: Models::WebhookEventLog < AbstractModelLog with an extensible event enum (webhook_event_log.rb:3–23).
  • Options.
    • A — WebhookEventLog row + structured log ✚ queryable, replayable-by-hand, reuses an existing model family; ─ enum addition.
    • B — new Kafka DLQ topic ✚ true DLQ semantics; ─ new infra + consumer for a low-volume failure class (only events whose WABA resolves to no ChannelIntegration).
    • C — log-line only ─ fails the PRD's "queryable, retained" intent.
  • Decision. Option A: new enum value mba_standby_unroutable on WebhookEventLog, storing the raw payload + WABA id; a scheduled cleanup purges rows older than 14 days. Emits mba_standby_unroutable (§10).
  • Reversibility. High.

ADR-5 — Suppression: control state on the webhook payload; guard at chatbot dispatch (resolves the PRD's suppression-point choice)

  • Context. Bot/AI dispatch does not execute in hub-core: inbound messages fan out via deliver_message_interaction_webhook! (messages/creates/customer.rb:42, :80–91services/webhooks/message_interaction.rb:86–119) to all registered webhook subscribers — the chatbot service and customer integrations. The chatbot side dispatches via ProcessIncomingMessageWorkerprocess_incoming_message_with_resolve.rb.
  • Options.
    • A — suppress the whole fan-out at hub-core ✚ single-repo change; ─ silences every subscriber, not just the bot — customer integrations would stop receiving MBA-room messages entirely. Unacceptable side effect.
    • B — add mba_control_state to the interaction payload; chatbot guards its own dispatch ✚ fan-out intact for all subscribers, explicit signal, ambiguity rule implementable where dispatch happens; ─ a small chatbot-repo change (PRD scope note anticipated this: "no changes this phase unless the RFC lands suppression chatbot-side").
    • C — chatbot queries hub-core per message ─ adds a synchronous cross-service read on the hottest path.
  • Decision. Option B. hub-core adds mba_control_state (and has_mba_history for the ambiguity rule) to the receive_message_from_customer payload; the chatbot dispatch entry returns early when meta_agent (or unknown-with-MBA-history → suppress + mba_suppression_ambiguous, per PRD S7 #7).
  • Consequences. PRD Scope Changes line for chatbot activates its "unless" clause — one guard, no other chatbot change.
  • Reversibility. High — payload field is additive; guard is a flag-safe early return.

ADR-6 — New MBA API client, sibling of the wa_cloud Graph client

  • Context. The wa_cloud client (services/apis.rb:3–10) is pinned to ENV['GRAPH_FACEBOOK_URL'] + path versioning, with system-user token auth (apis_adapter.rb:4–19; per-org override supported). Meta's MBA/Thread Control surface lives on api.facebook.com with header versioning (X-API-Version) — different base, different versioning scheme.
  • Options. Extend wa_cloud client (─ mixes two base URLs/version schemes in one class) vs new sibling client (✚ clean; Phases 2–3 add their endpoint groups onto it).
  • Decision. New wa_mba client class following the Repositories::AbstractHttp shape of wa_cloud/services/apis.rb, reusing the system-user/BISU token resolution of apis_adapter.rb. One method this phase: release_thread_control(phone_number_id:, to:)POST /business/whatsapp/phone_numbers/{phone_number_id}/thread_control with {messaging_product: "whatsapp", action: "release", to: <consumer>}. pass is not implemented (reserved per docs v2.0.0).
  • Reversibility. High.

ADR-7 — "Meta AI" tag rendered FE-side in ChatList.vue

  • Context. Ground-prd: ChatList has no tag prop — tags render from room.tags data via the roomItemTags computed (ChatList.vue:253–278, 294, template :195–207).
  • Options. Server-injected synthetic tag into room.tags (─ synthetic value contaminates real tag data consumed elsewhere, e.g. tag management) vs FE-computed append (✚ zero data contamination, one computed change).
  • Decision. Extend roomItemTags to append a local Meta AI chip when the room payload has mba_control_state === 'meta_agent'. Requires the room list payload to carry the field (ADR-2 makes that a cheap read).
  • Reversibility. High.

ADR-8 — Flag gate at consumer entry, is_enabled_kafka?-shaped

  • Context. The consumer already gates per organization at entry: is_enabled_kafka?(data.organization_id)mark_as_consumed + next (incoming_consumer.rb:9–12). The PRD flag mba_aware_inbox needs the same shape with different OFF behavior (log-only, not skip).
  • Decision. mba_aware_inbox?(organization_id) checked inside the two new cases: OFF → emit observability events + mark_as_consumed (no persistence, no payload field); ON → full behavior. Storage backing follows the same mechanism is_enabled_kafka? uses (read its implementation in Chunk 1 — Reading Order #2 — and mirror it; do not invent a new flag store).
  • Reversibility. By design — the flag is the kill switch (PRD Gate 3).

Minimum ADR coverage check: storage → ADR-2/3/4 · sync/async → ADR-1/5 · caching → none added (n/a — hot reads ride the room row) · third-party → ADR-6 · consistency → ADR-2 (webhook authoritative, optimistic reconcile) · multi-tenancy → §3 Security · reuse-vs-new → every ADR states it.

Detail 2.0 — Repo Reading Guide

Repo Map (the slice this RFC touches):

flowchart TD
subgraph hubcore["hub-core"]
IC["app/core/events/kafka_consumers/webhook_outboxes/incoming_consumer.rb"]
WI["app/apps/wa_cloud/interactors/* (new: standby + handover interactors)"]
TX["app/apps/wa_cloud/services/transaction_customer_send_message.rb"]
MC["app/core/domains/repositories/messages/creates/customer.rb"]
MIW["app/core/domains/services/webhooks/message_interaction.rb"]
WEL["app/core/domains/models/webhook_event_log.rb"]
APIS["app/apps/wa_cloud/services/apis.rb + apis_adapter.rb"]
MBAC["app/apps/wa_mba/services/apis.rb (NEW client)"]
end
subgraph chatbot["chatbot service"]
PIW["ProcessIncomingMessageWorker → process_incoming_message_with_resolve.rb (guard)"]
end
subgraph hubchat["hub-chat"]
CV["features/.../chat/views/ChatView.vue + chatViewMarkers.ts"]
RM["features/.../chat/components/RoomMessage.vue"]
CL["features/inbox/components/ChatList.vue"]
CRB["ChatRoomLockedBanner.vue pattern → new MbaControlBanner.vue"]
IV["features/.../input/views/InputView.vue"]
end
IC --> WI --> MC
WI --> WEL
MC --> MIW --> PIW
APIS -. "pattern for" .-> MBAC
MC -. "room+message payloads" .-> CV
CV --> RM

Existing Code Anchors (every path verified in-repo, 2026-07-19):

#FileWhat to learn
1hub-core/app/core/events/kafka_consumers/webhook_outboxes/incoming_consumer.rbThe channel_event_type switch (:22–121), org gate (:9–12), mark_as_consumed semantics — the two new cases mirror messages (:72–81)
2hub-core/app/core/events/kafka_consumers/abstract_sub.rbKarafka base; find is_enabled_kafka?'s backing store from here (ADR-8)
3hub-core/app/apps/wa_cloud/interactors/customer_send_message.rb (:100) + app/core/events/subscribers/wa_cloud_inbound_message.rb (:13)Publisher/subscriber hop the standby interactor mirrors
4hub-core/app/apps/wa_cloud/services/transaction_customer_send_message.rb (create_room :397, create_message :416)Room + message creation entry the standby path reuses
5hub-core/app/core/domains/repositories/messages/creates/customer.rb (:19, webhook fan-out :42, :80–91)Where the payload gains mba_control_state (ADR-5)
6hub-core/app/core/domains/services/webhooks/message_interaction.rb (:86–119)Fan-out delivery — confirm payload shape before extending
7hub-core/app/apps/wa_cloud/services/apis.rb (:3–10, subscribe :63–70) + apis_adapter.rb (:4–19)Client + token-resolution shape the new wa_mba client copies
8hub-core/app/core/domains/models/webhook_event_log.rb (:3–23)Enum shape for the mba_standby_unroutable quarantine value
9hub-chat/features/inbox/features/main-section/features/chat/views/ChatView.vue (:64–77, :244–258) + chatViewMarkers.ts (:15)The info/time centered-marker variant the handover marker maps onto
10hub-chat/features/inbox/components/ChatList.vue (:253–278, :294) + .../ChatRoomLockedBanner.vue + .../input/views/InputView.vue (:93–201)Tags computed (ADR-7), banner pattern, composer action row

Reading Order for the Agent: rows 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 (BE), then 9 → 10 (FE).

Existing API check: no new inbound HTTP endpoint is proposed. Outbound Thread Control is new-with-justification (no thread-control call exists anywhere in hub-core — verified absent; justification: Meta-side capability, different host/versioning → ADR-6). The message-interaction webhook is extended (additive payload field). Room/room-list read APIs are extended (expose mba_control_state).

Source Verification:

Claimed anchor / patternEvidence
Consumer switch + org gateIncomingConsumer#consume, data.channel_event_type, is_enabled_kafka? call, mark_as_consumedincoming_consumer.rb:6–12, 22, 119
Karafka batch baseclass KafkaConsumers::AbstractSub < Karafka::BaseConsumerabstract_sub.rb:3
standby/messaging_handovers absentrepo-wide grep, zero matches (ground-prd agent, 2026-07-19)
Thread-control absentrepo-wide grep `thread_control
Persistence chainPublishers::WaCloudInboundMessage...publish (customer_send_message.rb:100) → queue incoming.message.wa.cloud (wa_cloud_inbound_message.rb:13) → TransactionCustomerSendMessage create_room :397 / Repositories::Messages::Creates::Customer.call :416
Fan-out + guard targetdeliver_message_interaction_webhook! (customer.rb:80–91), event receive_message_from_customer, delivery message_interaction.rb:86–119; chatbot dispatch process_incoming_message_worker.rb:15–16process_incoming_message_with_resolve.rb
Receipt/external-id matching patternstatuses sub-branch routes to SystemMessageStatusNotification (incoming_consumer.rb:81) — the idempotency lookup S01/AC-4 reuses
WebhookEventLog enumModels::WebhookEventLog < AbstractModelLog, extensible event enum — webhook_event_log.rb:3–23
wa_cloud client + tokensbase URL/env + auth apis.rb:3–10; per-org system-user token apis_adapter.rb:4–19; LOCKBOX for channel secrets channel_integration.rb:167–169
Migration conventionActiveRecord::Migration[6.1], uuid ids — database/core/db/migrate/20251231143551_create_waba_accounts.rb
Test commandsbundle exec rspec (hub-core AGENTS.md:11–19; engine tests run against spec/dummy/); hub-chat pnpm test (vitest), pnpm lint (package.json scripts)
FE anchorsChatList.vue:253–278, 294 (no tag prop; roomItemTags); ChatView.vue:64–77, 244–258; chatViewMarkers.ts:15; RoomMessage.vue:10–49, 76–83; ChatRoomLockedBanner.vue + mount ChatRoomView.vue:90–101; InputView.vue:93–201; Pinia + Nuxt (package.json)
Meta contractMBA docs v2.0.0 fetched 2026-07-19: thread_control endpoint/enum (release only), standby/messaging_handovers webhook fields, subscribe calls send no field list (app-level config) — PRD §7 build map

Detail 2.1 — Architecture

Control-state lifecycle (the invariant everything hangs on):

stateDiagram-v2
[*] --> NULL_state: room has no MBA history
NULL_state --> meta_agent: first standby event persisted
meta_agent --> app_optimistic: agent sends reply (optimistic flip)
app_optimistic --> app: messaging_handovers confirms (to app)
app_optimistic --> meta_agent: send fails — flip reverted
app --> meta_agent: release succeeds, or handover event (to meta)
meta_agent --> app: messaging_handovers (to app)
note right of app_optimistic: reconciled by webhook —<br/>webhook is source of truth,<br/>latest event timestamp wins
  • Writers of mba_control_state: handover interactor (authoritative), send path (optimistic, forward-only), release success handler.
  • Readers: message-interaction payload builder, room/room-list serializers, suppression guard (chatbot side).
  • Ambiguity rule (PRD S7 #7): state NULL but room has ≥1 MBA message → treat as suppress + mba_suppression_ambiguous.

Detail 2.2 — Sequence diagrams

Seq-1 — standby ingestion + suppression (happy + failure):

sequenceDiagram
autonumber
participant M as Meta webhook
participant K as Kafka outbox topic
participant C as IncomingConsumer
participant P as StandbyMessage interactor (new)
participant DB as Postgres
participant W as Message-interaction fan-out
participant B as chatbot dispatch (guard)

M->>K: standby (consumer msg or MBA echo)
K->>C: batch consume
C->>C: flag check mba_aware_inbox (ADR-8)
alt flag OFF
C->>C: emit log event, mark_as_consumed
else flag ON
C->>P: dispatch new standby case
alt org/channel resolves
P->>DB: create room if new (control = meta_agent), persist message (idempotent on external id)
P->>W: deliver_message_interaction_webhook! (payload + mba_control_state)
W->>B: receive_message_from_customer
B->>B: control = meta_agent — skip bot/AI dispatch (ADR-5)
else unroutable
P->>DB: WebhookEventLog mba_standby_unroutable (14d) + log
end
end

Seq-2 — take-over and hand-back (happy + failure):

sequenceDiagram
autonumber
actor A as CS Agent (hub-chat)
participant H as hub-core API
participant DB as Postgres
participant MBA as wa_mba client (new)
participant M as api.facebook.com
participant C as IncomingConsumer

A->>H: send reply (existing outbound path)
H->>DB: optimistic mba_control_state = app
M-->>C: messaging_handovers (to app)
C->>DB: confirm state, write mba_handover system message (marker)
A->>H: click Return to Meta agent
H->>MBA: release_thread_control(phone_number_id, to)
MBA->>M: POST thread_control action=release (timeout 5s, no auto-retry)
alt 2xx
M-->>MBA: ok
H->>DB: mba_control_state = meta_agent + marker message
H-->>A: banner reverts (within 3s p95)
M-->>C: messaging_handovers (to meta_agent) — reconfirms
else error or timeout
MBA-->>H: error + Meta code
H-->>A: toast + manual retry, state unchanged
H->>H: log mba_release_control_failed
end

Detail 2.3 — Database Model (DDL)

Additive only; Rails AR migrations per repo convention (ActiveRecord::Migration[6.1], uuid ids):

  1. add_column :rooms, :mba_control_state, :string, null: true + add_index :rooms, :mba_control_state, where: "mba_control_state IS NOT NULL" (partial index — most rooms stay NULL).
  2. Message rows: new type value mba_handover (marker payload: direction, actor, source_timestamp in the message's existing jsonb payload attribute) and new sender type value meta_business_agent for echoes — enum-value additions on existing columns, no new table. The implementing engineer confirms the exact enum/attribute names from models/message.rb (anchor row 4 territory) before writing the migration — the model file is large and its type attributes are the source of truth.
  3. WebhookEventLog enum + event: mba_standby_unroutable (no schema change — string-backed enum per webhook_event_log.rb).

Detail 2.4 — APIs

Outbound (new):

CallContractTimeoutRetryFailure behavior
POST /business/whatsapp/phone_numbers/{id}/thread_control (api.facebook.com, X-API-Version: 1.0.0)body {messaging_product: "whatsapp", action: "release", to}5snone automatic — user-facing manual retry (PRD ERR-1/ERR-2); 3 consecutive failures → guidance toast + §10 alertstate unchanged, toast, mba_release_control_failed w/ Meta code

Extended:

SurfaceChange
receive_message_from_customer interaction payload+ mba_control_state (meta_agent|app|null), + has_mba_history (bool) — additive, ignored by consumers that don't read them
Room / room-list serializers+ mba_control_state so hub-chat banner/tag/composer read one field

Inbound HTTP: none new (webhook events arrive via the existing outbox → Kafka pipeline, not a new endpoint).

Detail 2.A — Data Integrity Matrix

ConcernMechanism
Duplicate standby deliveryidempotent upsert on external message id (same lookup family as the receipts path) — S01/AC-4
Out-of-order / duplicate handover eventslatest source_timestamp wins; equal-or-older events only append no state change — S02/AC-3
Cross-tenant leakageorg/channel resolution via the existing ChannelIntegration webhook lookup only; unresolvable → quarantine, never best-guess (PRD Tenancy constraint, Gate 1 hard check)
Optimistic flip driftforward-only flip; webhook reconciles; drift is bounded by Meta's handover event delivery

Detail 2.B — Concurrency Collision Map

CollisionHandling
Agent send (optimistic → app) races handover event (→ meta_agent)webhook wins by timestamp rule; UI re-renders from server state
Two agents act on the same room (send vs release)release requires current state app; a stale release gets Meta's "caller must hold control" error → toast path
Batch consume: message + handover for the same room in one batchcases process in partition order (ADR-1 keeps one consumer, preserving per-room ordering)

Detail 2.C — Async Job / Event Consumer Spec

UnitTriggerEmits (observability, PRD §10)
standby case → standby interactorKafka eventmba_standby_message_persisted (org_id, room_id, sender_type, lag_ms) / mba_standby_unroutable (waba_id, reason)
messaging_handovers case → handover interactorKafka eventmba_handover_event (org_id, room_id, new_controller, source_timestamp)
send-path flipagent sendmba_take_over
release handleragent actionmba_release_control_succeeded/_failed
chatbot guardinteraction webhookmba_suppression_fired / mba_suppression_ambiguous
quarantine cleanupscheduled (daily)purge mba_standby_unroutable rows > 14 days

Detail 2.D — Responsibility Boundary Matrix

StepOwner
Webhook field subscription (standby, messaging_handovers) in the Meta app config — app-level, hits all WABAs at once (both subscribe calls send no field list: wa_embedded_signup/apis.rb:94–104, wa_cloud/apis.rb:63–70)Platform ops (PRD Dep 3) — precondition, §4 Chunk 0
Consume/persist/control-state/quarantine/client/payload fieldChatbot squad, in hub-core (Chat squad = PR review courtesy)
Dispatch guardChatbot squad, in chatbot repo
Inbox UIChatbot squad, in hub-chat
Thread-control behavior on Meta's sideMeta (contract tests pin the documented payloads)

Detail 2.E — State Surface Contract

SurfaceReadsWrites
hub-chat banner / composer action / tagroom.mba_control_state (+ message list for markers)none — actions call existing send API / new release action
chatbot guardpayload mba_control_state, has_mba_historynone
Consumer interactorsevent payloadsrooms.mba_control_state, messages, WebhookEventLog

Role × endpoint authorization: release action = agents with room access + supervisors; read-only roles never see the action (PRD S05 permission model). No other new authorization surface (n/a — no new inbound endpoint).


3. High-Availability & Security

  • No new public surface. Events arrive via the existing outbox→Kafka pipeline; the only new egress is api.facebook.com (ADR-6) using the existing system-user/BISU token resolution — no new credential class, no tokens in payloads or logs.
  • Tenancy (Gate 1 hard check). Org resolution exclusively via the existing ChannelIntegration lookup; unresolvable events quarantine (ADR-4). A multi-org test asserting zero cross-tenant persistence is a §4 Chunk 3 acceptance criterion.
  • Blast radius of the app-level webhook subscription. Once standby is subscribed at the Meta app, every WABA's standby traffic flows in, including orgs with the flag OFF — the OFF path must be cheap (log + mark_as_consumed) and load-tested at consumer level (Detail 4.E).
  • Failure isolation. Both new cases rescue-and-quarantine per event; a malformed standby payload must never poison the batch (Karafka batch continues, event marked consumed after quarantine).
  • PII. Standby payloads contain message content — identical sensitivity to the existing messages field; same storage, same retention; quarantine rows purge at 14 days (ADR-4).

Detail 3.A — Failure Mode & Retry Catalog

FailureBehavior
Unroutable standby eventquarantine + log, consumed (no Kafka retry loop)
Postgres write fails mid-batchKarafka default: not marked consumed → redelivery; idempotency (2.A) absorbs the replay
Thread Control 4xx/5xx/timeoutno auto-retry; user toast + manual retry; ≥3 failures → §10 alert + "check MBA status" guidance
Handover event for unknown roomquarantine + mba_handover_unroutable log (PRD S02/ERR-1)
Receipt for unknown messagelogged, skipped (existing receipts semantics)

Detail 3.A.1 — Branch & Skip Catalog

BranchCondition → outcome
Flag OFFlog-only, skip persistence/payload field/UI
Non-MBA room (NULL state, no MBA history)zero behavior change anywhere
Ambiguous control (NULL + MBA history)suppress + mba_suppression_ambiguous
Echo redeliveryidempotent no-op
Release while not holding controlMeta error → toast path (no state change)

Detail 3.B — Error Response Catalog

User-facing: one new error string — "Couldn't return the conversation to Meta Business Agent. Try again." (+ retry affordance; after 3 failures append MBA status guidance). All other errors ride existing send-error UX. No new API error codes are minted (no new inbound endpoint).

Detail 3.C — Compliance & Data Governance

Standby content = customer conversation data, same class as existing messages (no new category). Quarantine rows carry raw payloads → 14-day purge is the governance control. Room deletion cascades already cover marker messages (ADR-3 reuses message lifecycle — PRD "no orphaned artifacts").


4. Backwards Compatibility and Rollout Plan

Fully backward compatible: additive column, additive enum values, additive payload fields, new consumer cases for previously-ignored events. Flag OFF reproduces today's behavior except structured logging. No data migration or backfill (PRD §9: history before flag-ON is not reconstructed).

Rollout stages/gates live in the PRD (§9/§12) and, once handed off, in delivery/ — not restated here.

Detail 4.A — Configuration Contract

KeyDefaultMeaning
mba_aware_inbox (per org)OFFOFF = consume + log only; ON = persist + UI + payload field. Kill switch (flipping OFF stops new persistence, keeps existing rows)
Meta app webhook fieldsmust include standby, messaging_handoversapp-level, all WABAs at once — ops precondition (Chunk 0)
wa_mba client envreuses FACEBOOK_SYSTEM_USER_ACCESS_TOKEN / per-org overrideno new secret class

Detail 4.B — Test Plan (commands from the repos)

  • hub-core: bundle exec rspec (engine suite against spec/dummy/AGENTS.md); new co-located specs per interactor/repository touched: happy path, validation failure, wrong organization_id, external-service failure (house spec checklist, AGENTS.md:19–22).
  • chatbot: bundle exec rspec for the dispatch-guard spec (suppress / ambiguous / pass-through).
  • hub-chat: pnpm test (vitest) for banner, marker variant mapping, tag computed, composer action; pnpm lint.
  • Contract tests: recorded fixtures for standby (message, echo, receipt), messaging_handovers, and thread_control release success/error — pinned to Meta docs v2.0.0 payloads (PRD risk #4 mitigation).

Detail 4.C — Agent Execution Plan

#ChunkFiles / actionsAcceptance criteria (assertable)
0Ops preconditionMeta app dashboard: add standby + messaging_handovers webhook fields (Platform ops)test webhook delivery shows both fields arriving on the outbox topic
1Flag + client scaffoldread Reading Order 1–2; implement mba_aware_inbox? (mirror is_enabled_kafka? backing); new app/apps/wa_mba/services/apis.rb + specflag toggles consumer behavior in spec; client builds correct URL/headers/body (webmock spec)
2Standby case — messages/echoesnew consumer case + wa_cloud standby interactor reusing TransactionCustomerSendMessage room/message entries; sender type meta_business_agent; idempotency on external idspecs: consumer message persisted ≤ existing path parity; echo labeled; duplicate external id → 1 row; flag OFF → 0 rows
3Quarantine + tenancyWebhookEventLog enum value + write path + daily purge job; multi-org isolation specunroutable event → quarantine row + zero message rows; purge deletes >14d rows; multi-org spec: zero cross-tenant writes
4Handover case + control state + markersrooms migration (partial index); handover interactor (timestamp rule); mba_handover system messagespecs: state transitions incl. out-of-order events; marker row written; unknown room → quarantine
5Optimistic flip + payload fieldsend path flip; customer.rb payload + serializer exposurespecs: send flips meta_agent→app only; payload carries field + has_mba_history
6Release actionhub-core action endpoint wiring release → client → state+marker; failure pathspecs: 2xx path flips state + marker; 4xx/timeout leaves state, returns error shape
7chatbot dispatch guardearly return in process_incoming_message_with_resolve entry on payload field; ambiguity rulespecs: meta_agent → no dispatch + mba_suppression_fired; ambiguous → suppress + mba_suppression_ambiguous; NULL/no-history → unchanged
8hub-chat: banner, label, markersMbaControlBanner.vue (pattern: ChatRoomLockedBanner), RoomMessage sender label, chatViewMarkers/ChatView variant mapping for mba_handovervitest: banner states from mba_control_state; marker renders centered; label shows on echo messages
9hub-chat: tag + composer actionroomItemTags append; InputView "Return to Meta agent" action + toast/retry wiringvitest: tag appears iff meta_agent; action visible iff app; error toast on failure response
10Observability + fixturesemit §10 events with named properties; contract-test fixturesevents assert in specs; bundle exec rspec + pnpm test + pnpm lint all green

Detail 4.D — Verification & Rollback Recipe

Pre-merge: bundle exec rspec (hub-core, chatbot) · pnpm test && pnpm lint (hub-chat) · contract fixtures green.

Post-deploy (staging, flag ON for test org): send message to the MBA-enabled test WABA → assert room + message rows (mba_standby_message_persisted lag < 5s) · take over → handover event confirms + marker row · release → state reverts + messaging_handovers reconfirms → next customer message arrives on standby (PRD S05/AC-2) · bot-flow org: zero dispatch on MBA-controlled room · flag-OFF org: zero rows, log events only.

Rollback: flip mba_aware_inbox OFF (org or global) — consumption degrades to log-only instantly, no deploy; existing rows remain (by design, PRD S06/ERR-1). Full code rollback safe: additive column/enum values tolerate old code. Do not unsubscribe the app-level webhook fields as a rollback step unless MBA is abandoned entirely — it would blind every org at once.

Detail 4.E — Resource & Cost Notes

Standby traffic ≈ mirrors message volume of MBA-enabled numbers (each consumer message + each MBA reply + receipts). Until adoption, volume ≈ test WABAs only. Watch consumer lag from Stage 0 (mba_standby_message_persisted.lag_ms, alert at p95 > 60s per PRD §10); partition/scaling posture unchanged.


5. Concern, Questions, or Known Limitations

#ItemDisposition
1Exact message-type/sender enum attribute names in models/message.rbConfirm in Chunk 4 from the model file (anchor); intentionally not frozen here — the model is the source of truth
2is_enabled_kafka? backing storeRead in Chunk 1 (Reading Order #2); flag mirrors it (ADR-8)
3Meta pilot payloads may drift vs docs v2.0.0 (PRD risk #4)Contract fixtures pinned; Stage 0 on a live allowlisted number before any org rollout
4SLA semantics of MBA rooms (PRD OQ-2) and "Return" action placement (OQ-3)Product/design calls — do not block build; composer placement per PRD §6 default
5pass (reverse handover initiation) unavailableMeta-side limitation; hand-back = release only this phase

6. Comment logs

DateAuthorNote
2026-07-19Claude (rfc-starter)Initial draft. Grounded against hub-core@master, hub-chat@master, chatbot repo (dispatch entry), and Meta MBA docs v2.0.0; all anchors from the same-day ground-prd verification pass (4 parallel repo agents). All 5 mermaid blocks validated with mmdc before save.

7. Ready for agent execution

Yes, with two ops/owner preconditions tracked outside the code chunks:

  • Chunk 0 (Meta app webhook fields) is Platform ops, not the coding agent.
  • Stage-0 verification needs the Meta-allowlisted test WABA (PRD Dep 2) — build does not block on it (contract fixtures cover development).

§7 gates: anchors verified ✅ · decisions closed (no TBD; PRD's two open implementation choices resolved in ADR-3/ADR-5) ✅ · execution chunks with files/commands/assertable criteria ✅ · verification & rollback recipe ✅ · mermaid validated ✅. Optional next: rfc-reviewer second pass.