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
| Field | Value | Notes |
|---|---|---|
| Status | DRAFT — open for engineering review | YAML status: carries the linter enum (draft) |
| DRI | Dimas Fauzi Hidayat | Single accountable owner |
| Team | chatbot | Chatbot 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 Hidayat | Grounded against hub-core@master + hub-chat@master, 2026-07-19 (ground-prd pass same day) |
| Reviewers | pending — BOT squad BE reviewer; Chat squad for hub-core/hub-chat PR courtesy | |
| Approver(s) | pending — BOT squad tech lead | |
| Submitted Date | 2026-07-19 | ISO-8601 |
| Last Updated | 2026-07-19 | Bump on every material edit |
| Target Release | 2026-Q3 | Carried from PRD target_quarter |
| Delivery | not yet handed to delivery | No delivery/timeline.md for this initiative yet |
| Related | PRD — Phase 1: MBA-Aware Foundation (v1.6, ground-prd-verified) · ANCHOR | |
| Discussion | pending |
Type: full-stack (hub-core BE + hub-chat FE)
Sub-type: new-capability behind org flag mba_aware_inbox
Sections at a Glance
- Overview (incl. §1 PRD-to-Schema Derivation)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequences → Data Model → APIs → integrity / concurrency / async specs)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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
IncomingConsumerevent switch routestandby(consumer messages, MBA echoes, receipts) andmessaging_handoversinto the existing room/message persistence path. - Control state: an additive
rooms.mba_control_statecolumn (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 viaX-API-Versionheader — a sibling of thewa_cloudGraph client, per Meta MBA docs v2.0.0) exposing one method this phase: Thread Controlrelease(passis reserved/non-functional per the docs). - Suppression: MBA-controlled rooms carry their control state on the
existing
receive_message_from_customermessage-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):
- The hand-back verb is
release, notpass(Meta docs v2.0.0: onlyreleasefunctions). - There is no dead-letter queue infrastructure in hub-core (repo-wide
grep for
dead_letter= zero; unknown consumer events aremark_as_consumedno-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" tag | ADR-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 events | Detail 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 choice | Decided here: ADR-5 |
§6 note: ChatList has no tag prop — new prop or data injection | Decided 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-1 | Route standby/messaging_handovers as new cases in the existing IncomingConsumer switch — no new consumer/topic |
| ADR-2 | rooms.mba_control_state additive column; handover webhook is source of truth; agent send flips optimistically |
| ADR-3 | Handover markers persist as system messages on the room timeline (reuse persistence/retention/render path) — resolves PRD OQ-1 |
| ADR-4 | Unroutable standby events quarantine via the existing WebhookEventLog pattern + 14-day cleanup — hub-core has no DLQ |
| ADR-5 | Suppression = control state carried on the message-interaction webhook payload + guard at chatbot dispatch — never blanket-suppress the fan-out |
| ADR-6 | New 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-8 | mba_aware_inbox checked at consumer entry, same shape as the existing is_enabled_kafka? per-org gate |
Detail 1.C — Per-Story Change Map
| Story | Repo | Surfaces touched |
|---|---|---|
| MBA-S01 persist standby | hub-core | incoming_consumer.rb (+2 cases), new wa_cloud standby interactor, messages/creates/customer.rb reuse, WebhookEventLog quarantine |
| MBA-S02 control state | hub-core | rooms migration, handover interactor, system-message marker write |
| MBA-S03 inbox state | hub-chat | ChatView.vue marker variant, RoomMessage.vue sender label, new MbaControlBanner.vue, ChatList.vue tag |
| MBA-S04 take over | hub-core + hub-chat | optimistic flip on send path; banner state swap |
| MBA-S05 hand back | hub-core + hub-chat | MBA client release; composer action + toast/retry |
| MBA-S06 flag | hub-core + hub-chat | consumer gate; UI render gate from room payload |
| MBA-S01-NEG suppression | hub-core + chatbot | webhook payload field; dispatch guard |
| MBA-S02-NEG no config UI | — | guard 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
| Service | Responsibility in this RFC | Third-party connections |
|---|---|---|
| hub-core (Rails engine in host app) | Consume/persist standby + handover events; control state; quarantine; MBA client release; webhook payload field | Meta api.facebook.com (new), existing Graph URL (unchanged) |
| chatbot service | One guard: skip dispatch when payload says mba_control_state=meta_agent | none new |
| hub-chat (Nuxt) | Banner, labels, markers, tag, return action | none |
| Kafka / Postgres | Unchanged 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 ondata.channel_event_type(incoming_consumer.rb:22, cases:24–121:messages,calls,account_update,flows, …; unknown events fall tomark_as_consumedat: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.
standbyandmessaging_handoversbecome two new cases dispatching to newwa_cloudinteractors, mirroring howmessagesdispatches toCustomerSendMessage(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_controlin 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.
- A — column on
- Decision. Option A:
mba_control_statestring column, valuesmeta_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–258variant==='info'; marker helperchatViewMarkers.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_eventstable ✚ 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 —
WebhookEventLogrows only ─ log model, not room-timeline renderable; rejected outright.
- A — system-message rows (new message type
- 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_handovermessages to the existinginfovariant; 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 aremark_as_consumedno-ops). An event-log pattern exists:Models::WebhookEventLog < AbstractModelLogwith an extensibleeventenum (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_unroutableonWebhookEventLog, storing the raw payload + WABA id; a scheduled cleanup purges rows older than 14 days. Emitsmba_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–91→services/webhooks/message_interaction.rb:86–119) to all registered webhook subscribers — the chatbot service and customer integrations. The chatbot side dispatches viaProcessIncomingMessageWorker→process_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_stateto 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(andhas_mba_historyfor the ambiguity rule) to thereceive_message_from_customerpayload; the chatbot dispatch entry returns early whenmeta_agent(or unknown-with-MBA-history → suppress +mba_suppression_ambiguous, per PRD S7 #7). - Consequences. PRD Scope Changes line for
chatbotactivates 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_cloudclient (services/apis.rb:3–10) is pinned toENV['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 onapi.facebook.comwith header versioning (X-API-Version) — different base, different versioning scheme. - Options. Extend
wa_cloudclient (─ 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_mbaclient class following theRepositories::AbstractHttpshape ofwa_cloud/services/apis.rb, reusing the system-user/BISU token resolution ofapis_adapter.rb. One method this phase:release_thread_control(phone_number_id:, to:)→POST /business/whatsapp/phone_numbers/{phone_number_id}/thread_controlwith{messaging_product: "whatsapp", action: "release", to: <consumer>}.passis not implemented (reserved per docs v2.0.0). - Reversibility. High.
ADR-7 — "Meta AI" tag rendered FE-side in ChatList.vue
- Context. Ground-prd:
ChatListhas notagprop — tags render fromroom.tagsdata via theroomItemTagscomputed (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
roomItemTagsto append a localMeta AIchip when the room payload hasmba_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 flagmba_aware_inboxneeds 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 mechanismis_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):
| # | File | What to learn |
|---|---|---|
| 1 | hub-core/app/core/events/kafka_consumers/webhook_outboxes/incoming_consumer.rb | The channel_event_type switch (:22–121), org gate (:9–12), mark_as_consumed semantics — the two new cases mirror messages (:72–81) |
| 2 | hub-core/app/core/events/kafka_consumers/abstract_sub.rb | Karafka base; find is_enabled_kafka?'s backing store from here (ADR-8) |
| 3 | hub-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 |
| 4 | hub-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 |
| 5 | hub-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) |
| 6 | hub-core/app/core/domains/services/webhooks/message_interaction.rb (:86–119) | Fan-out delivery — confirm payload shape before extending |
| 7 | hub-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 |
| 8 | hub-core/app/core/domains/models/webhook_event_log.rb (:3–23) | Enum shape for the mba_standby_unroutable quarantine value |
| 9 | hub-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 |
| 10 | hub-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 / pattern | Evidence |
|---|---|
| Consumer switch + org gate | IncomingConsumer#consume, data.channel_event_type, is_enabled_kafka? call, mark_as_consumed — incoming_consumer.rb:6–12, 22, 119 |
| Karafka batch base | class KafkaConsumers::AbstractSub < Karafka::BaseConsumer — abstract_sub.rb:3 |
standby/messaging_handovers absent | repo-wide grep, zero matches (ground-prd agent, 2026-07-19) |
| Thread-control absent | repo-wide grep `thread_control |
| Persistence chain | Publishers::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 target | deliver_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–16 → process_incoming_message_with_resolve.rb |
| Receipt/external-id matching pattern | statuses sub-branch routes to SystemMessageStatusNotification (incoming_consumer.rb:81) — the idempotency lookup S01/AC-4 reuses |
| WebhookEventLog enum | Models::WebhookEventLog < AbstractModelLog, extensible event enum — webhook_event_log.rb:3–23 |
| wa_cloud client + tokens | base 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 convention | ActiveRecord::Migration[6.1], uuid ids — database/core/db/migrate/20251231143551_create_waba_accounts.rb |
| Test commands | bundle 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 anchors | ChatList.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 contract | MBA 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
NULLbut 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):
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).- 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 valuemeta_business_agentfor echoes — enum-value additions on existing columns, no new table. The implementing engineer confirms the exact enum/attribute names frommodels/message.rb(anchor row 4 territory) before writing the migration — the model file is large and its type attributes are the source of truth. WebhookEventLogenum +event: mba_standby_unroutable(no schema change — string-backed enum perwebhook_event_log.rb).
Detail 2.4 — APIs
Outbound (new):
| Call | Contract | Timeout | Retry | Failure 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} | 5s | none automatic — user-facing manual retry (PRD ERR-1/ERR-2); 3 consecutive failures → guidance toast + §10 alert | state unchanged, toast, mba_release_control_failed w/ Meta code |
Extended:
| Surface | Change |
|---|---|
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
| Concern | Mechanism |
|---|---|
| Duplicate standby delivery | idempotent upsert on external message id (same lookup family as the receipts path) — S01/AC-4 |
| Out-of-order / duplicate handover events | latest source_timestamp wins; equal-or-older events only append no state change — S02/AC-3 |
| Cross-tenant leakage | org/channel resolution via the existing ChannelIntegration webhook lookup only; unresolvable → quarantine, never best-guess (PRD Tenancy constraint, Gate 1 hard check) |
| Optimistic flip drift | forward-only flip; webhook reconciles; drift is bounded by Meta's handover event delivery |
Detail 2.B — Concurrency Collision Map
| Collision | Handling |
|---|---|
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 batch | cases process in partition order (ADR-1 keeps one consumer, preserving per-room ordering) |
Detail 2.C — Async Job / Event Consumer Spec
| Unit | Trigger | Emits (observability, PRD §10) |
|---|---|---|
standby case → standby interactor | Kafka event | mba_standby_message_persisted (org_id, room_id, sender_type, lag_ms) / mba_standby_unroutable (waba_id, reason) |
messaging_handovers case → handover interactor | Kafka event | mba_handover_event (org_id, room_id, new_controller, source_timestamp) |
| send-path flip | agent send | mba_take_over |
| release handler | agent action | mba_release_control_succeeded/_failed |
| chatbot guard | interaction webhook | mba_suppression_fired / mba_suppression_ambiguous |
| quarantine cleanup | scheduled (daily) | purge mba_standby_unroutable rows > 14 days |
Detail 2.D — Responsibility Boundary Matrix
| Step | Owner |
|---|---|
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 field | Chatbot squad, in hub-core (Chat squad = PR review courtesy) |
| Dispatch guard | Chatbot squad, in chatbot repo |
| Inbox UI | Chatbot squad, in hub-chat |
| Thread-control behavior on Meta's side | Meta (contract tests pin the documented payloads) |
Detail 2.E — State Surface Contract
| Surface | Reads | Writes |
|---|---|---|
| hub-chat banner / composer action / tag | room.mba_control_state (+ message list for markers) | none — actions call existing send API / new release action |
| chatbot guard | payload mba_control_state, has_mba_history | none |
| Consumer interactors | event payloads | rooms.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
ChannelIntegrationlookup; 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
standbyis 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
messagesfield; same storage, same retention; quarantine rows purge at 14 days (ADR-4).
Detail 3.A — Failure Mode & Retry Catalog
| Failure | Behavior |
|---|---|
| Unroutable standby event | quarantine + log, consumed (no Kafka retry loop) |
| Postgres write fails mid-batch | Karafka default: not marked consumed → redelivery; idempotency (2.A) absorbs the replay |
| Thread Control 4xx/5xx/timeout | no auto-retry; user toast + manual retry; ≥3 failures → §10 alert + "check MBA status" guidance |
| Handover event for unknown room | quarantine + mba_handover_unroutable log (PRD S02/ERR-1) |
| Receipt for unknown message | logged, skipped (existing receipts semantics) |
Detail 3.A.1 — Branch & Skip Catalog
| Branch | Condition → outcome |
|---|---|
| Flag OFF | log-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 redelivery | idempotent no-op |
| Release while not holding control | Meta 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
| Key | Default | Meaning |
|---|---|---|
mba_aware_inbox (per org) | OFF | OFF = consume + log only; ON = persist + UI + payload field. Kill switch (flipping OFF stops new persistence, keeps existing rows) |
| Meta app webhook fields | must include standby, messaging_handovers | app-level, all WABAs at once — ops precondition (Chunk 0) |
wa_mba client env | reuses FACEBOOK_SYSTEM_USER_ACCESS_TOKEN / per-org override | no new secret class |
Detail 4.B — Test Plan (commands from the repos)
- hub-core:
bundle exec rspec(engine suite againstspec/dummy/—AGENTS.md); new co-located specs per interactor/repository touched: happy path, validation failure, wrongorganization_id, external-service failure (house spec checklist,AGENTS.md:19–22). - chatbot:
bundle exec rspecfor 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
| # | Chunk | Files / actions | Acceptance criteria (assertable) |
|---|---|---|---|
| 0 | Ops precondition | Meta app dashboard: add standby + messaging_handovers webhook fields (Platform ops) | test webhook delivery shows both fields arriving on the outbox topic |
| 1 | Flag + client scaffold | read Reading Order 1–2; implement mba_aware_inbox? (mirror is_enabled_kafka? backing); new app/apps/wa_mba/services/apis.rb + spec | flag toggles consumer behavior in spec; client builds correct URL/headers/body (webmock spec) |
| 2 | Standby case — messages/echoes | new consumer case + wa_cloud standby interactor reusing TransactionCustomerSendMessage room/message entries; sender type meta_business_agent; idempotency on external id | specs: consumer message persisted ≤ existing path parity; echo labeled; duplicate external id → 1 row; flag OFF → 0 rows |
| 3 | Quarantine + tenancy | WebhookEventLog enum value + write path + daily purge job; multi-org isolation spec | unroutable event → quarantine row + zero message rows; purge deletes >14d rows; multi-org spec: zero cross-tenant writes |
| 4 | Handover case + control state + markers | rooms migration (partial index); handover interactor (timestamp rule); mba_handover system message | specs: state transitions incl. out-of-order events; marker row written; unknown room → quarantine |
| 5 | Optimistic flip + payload field | send path flip; customer.rb payload + serializer exposure | specs: send flips meta_agent→app only; payload carries field + has_mba_history |
| 6 | Release action | hub-core action endpoint wiring release → client → state+marker; failure path | specs: 2xx path flips state + marker; 4xx/timeout leaves state, returns error shape |
| 7 | chatbot dispatch guard | early return in process_incoming_message_with_resolve entry on payload field; ambiguity rule | specs: meta_agent → no dispatch + mba_suppression_fired; ambiguous → suppress + mba_suppression_ambiguous; NULL/no-history → unchanged |
| 8 | hub-chat: banner, label, markers | MbaControlBanner.vue (pattern: ChatRoomLockedBanner), RoomMessage sender label, chatViewMarkers/ChatView variant mapping for mba_handover | vitest: banner states from mba_control_state; marker renders centered; label shows on echo messages |
| 9 | hub-chat: tag + composer action | roomItemTags append; InputView "Return to Meta agent" action + toast/retry wiring | vitest: tag appears iff meta_agent; action visible iff app; error toast on failure response |
| 10 | Observability + fixtures | emit §10 events with named properties; contract-test fixtures | events 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
| # | Item | Disposition |
|---|---|---|
| 1 | Exact message-type/sender enum attribute names in models/message.rb | Confirm in Chunk 4 from the model file (anchor); intentionally not frozen here — the model is the source of truth |
| 2 | is_enabled_kafka? backing store | Read in Chunk 1 (Reading Order #2); flag mirrors it (ADR-8) |
| 3 | Meta 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 |
| 4 | SLA 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 |
| 5 | pass (reverse handover initiation) unavailable | Meta-side limitation; hand-back = release only this phase |
6. Comment logs
| Date | Author | Note |
|---|---|---|
| 2026-07-19 | Claude (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.