Skip to main content

RFC: Migrate Chat Divisions to USMAN Teams (Phase 1, Q3)

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Mark sections N/A — reason when truly inapplicable rather than deleting them.

It is also agent-execution-ready: the §1 PRD-to-Schema Derivation (backend RFCs do not require Figma), §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

The YAML frontmatter is the machine-readable index; the Metadata table below is the human-readable governance record. Both must agree on every shared field.

Metadata

FieldValueNotes
StatusRFCIDEA / RFC / ABANDON / AGREED
OwnerCommunication SquadTeam owning the RFC
Author(s)Dandi Pangestu (Communication Squad — Backend)Primary author
ReviewersCommunication Squad Tech Lead · Bifrost Squad Tech LeadTech reviewers across affected squads
Approver(s)Comm Eng Lead · Bifrost Eng Lead · Infosec approver (TBD)Tech leaders + infosec
Submitted Date2026-07-09Date RFC opened for discussion
Last Updated2026-07-12Bump on every material edit
Target Release2026-Q3Quarter
Related DocumentsPRD 51227460370 · Arch-Decision 51249054233 · Launchpad Teams 51213828683 · Division docs 1–8See §0 for the full reconciliation list
Discussion#comm-squad-usman-migration (TBD)Confirm channel before review

Type: backend Sub-type: new-feature

Sections at a Glance

#SectionWhat it answers
0External Context InputsWhich docs drove this RFC and how conflicts were resolved
1OverviewProblem, success criteria, scope, PRD-to-Schema derivation, traceability, decisions, per-story map
2Technical DesignRepo Reading Guide → architecture/ER/state/sequence mermaid → DDL → APIs → integrity/concurrency/async
3High-Availability & SecurityPerf, observability, role×endpoint authz, failure/branch/error catalogs
4Backwards Compatibility & RolloutMigration sequence, flags, Agent Execution Plan, Verification & Rollback Recipe
5Concern, Questions, or Known LimitationsOpen questions and limits
6Comment logsReview trail
7Ready for agent execution§7 gate

0. External Context Inputs

This RFC used the Phase-1 optional external-context pass. Every source below was read; the reconciliation notes flag where a source disagrees with another source or with the live code.

SourceLink / pathWhat this RFC took from itReconciliation
PRD — Division → USMAN Team Migration Phase 1Confluence 51227460370Problem, personas, non-goals, constraints, feature changes (CHG-001), 11 user stories + ACs, rollout stages, observability events, success metrics, dependenciesConflict (resolved): PRD §17 selects a dedicated join table team_division_mapping; the Architecture & Flow Decision doc supersedes this with a team_id column on divisions. This RFC follows the engineering decision (see 1.B D1) and treats the join-table framing (PRD S03, §7.1) as n/a on the chat backend.
Architecture and Flow Decision (Team)Confluence 51249054233Chosen data model (team_id/parent_team_id/parent_id on divisions), V2-endpoint strategy, Kafka-consumer sync strategy, use_qontak_one_team + unified_app flag gating, division-created-on-channel-assignment model, migration-via-Launchpad-bulk-createAuthoritative for architecture. Written by the initiative's engineering owner; aligns with the as-built Launchpad Teams RFC.
Launchpad Teams RFC (as-built)Confluence 51213828683Kafka topic bifrost.team.events.v1, event payloads (TEAM_CREATED/TEAM_MIGRATED/TEAM_UPDATED/TEAM_DELETED), POST /teams/bulk (multi-company, is_migrate, app, app_identifier_id), source-ref idempotency, Heimdall migration triggersAuthoritative for the Launchpad contract (all features merged). Chat consumes/calls these; chat does not modify them.
Division docs 1–8 (current chat)Confluence 51233980656 … 51250725056Current divisions schema, interactors, Redis caches, events/workers, business scenarios, per-channel add_channel_division, HTTP API surface, glossary/flagsCross-checked against live code in §2.0 Source Verification.

Every design decision below traces to one of these sources and to a verified file in the repositories (Detail 2.0 Source Verification).


1. Overview

Qontak Chat groups agents, scopes SLA, filters reports, and segments broadcasts using a Division entity (hub_core). Qontak One introduces a unified Team identity managed in the Launchpad service (Bifrost). This RFC covers the chat backend work to make every Chat Division reference a Launchpad Team, migrate all existing divisions, move member/supervisor management to Launchpad (chat becomes read-only for membership), and keep all routing/SLA/broadcast/ reporting behavior unchanged by preserving division_id as the internal key.

The design deliberately keeps divisions in the chat panel and adds a team_id reference, rather than replacing divisions with teams — this preserves every downstream consumer (rooms.division_id, queue_round_robins.division_id, message_broadcasts.division_id, reporting joins) with zero query rewrites (Arch-Decision doc; see 1.B D1).

Success Criteria

  1. Migration completeness: 100% of active CIDs have every active Chat Division linked to a Launchpad Team (divisions.team_id IS NOT NULL) by end of Q3 2026, verifiable by a SQL count divisions WHERE team_id IS NULL AND <org migrated> = 0.
  2. Zero routing regression: failed-chat-assignment rate shows 0% increase vs the pre-migration baseline within 1 week post-cutover (PRD §14).
  3. Idempotent / resumable migration: re-running the migration worker for any CID creates no duplicate teams and no duplicate team_id links (Launchpad source-ref dedup + chat-side team_id IS NULL guard).
  4. Member sync latency: a Launchpad TEAM_UPDATED event is reflected in user_divisions at p95 ≤ 30 s (PRD §7; SLA set — OQ-3), tracked by the team_member_sync_latency histogram.
  5. Read-only enforcement: in Team mode the chat backend rejects any member/supervisor mutation via the legacy V1 endpoints (PRD MIG-PH1-S01-NEG), verifiable by a request spec asserting a Failure/422.
  6. Dormant-division correctness: a division unassigned from all channels is retained but behaves as if it does not exist for routing processes — it is skipped by agent assignment, notification delivery, and candidate/routing resolution, while validate_division (list access-control) is left unchanged so it is still returned by the divisions list with has_channels: false for the product-requested note (see 1.B D13). Verifiable by specs asserting a channel-less division contributes no candidate agents (FetchUserIdsInDivisionByRoom / GetAllUserDivision), still appears in an assigned actor's list (unchanged validate_division), and lists with channel_count = 0.
  7. Cache-consistency invariant: every division-mutation path introduced by this RFC (Kafka team-event consumer + V2 assign-channel endpoint) recaches Redis through the existing Services::Redis::Divisions::Reset*/Recache* fan-out, matching hub_core's current "always recache on any division/user/channel change" behavior (see 1.B D14). Verifiable by specs asserting the same reset services fire that the legacy repository path fires; no routing cache is left stale beyond one event/request cycle.

Out of Scope

  1. Team hierarchy behavior — columns are added for future hierarchy support but no hierarchy logic ships (PRD Non-Goal 1). Migrated teams are flat.
  2. Historical SLA/Broadcast/Report backfill — not needed on the chat side; chat keeps using division_id internally, so no query-time join and no chat backfill (supersedes PRD §7.1 / S03 join-table framing). Any Launchpad-side Phase B backfill is Bifrost-owned (PRD Non-Goal 3).
  3. Channel/bot routing changesdivision_id remains the routing key; bot config is untouched (PRD Non-Goal 4, MIG-PH1-S02).
  4. CRM Team migration — Chat only (PRD Non-Goal 5).
  5. Frontend work — banner UI, read-only field rendering, tooltips, loading states (PRD UI-PH1-S01/S02, COMM-PH1-S01). This RFC covers only the BE contracts and enforcement those FE stories consume. Front-end is a separate FE RFC (pending — see §5 OQ-9).
  6. Launchpad Teams service changes — the Team API, bulk-create, and event contract are as-built and owned by Bifrost (Launchpad Teams RFC §14 = shipped).

See §0. Primary drivers: PRD 51227460370, Architecture & Flow Decision 51249054233, Launchpad Teams RFC 51213828683.

Assumptions

  1. bifrost.team.events.v1 is live and stable before the chat consumer ships; payloads match the as-built shapes in Launchpad Teams RFC §6.3.
  2. Every migrated org has a Launchpad company_sso_id populated (Launchpad OQ-2 flags pre-backfill gaps; chat treats a blank company_sso_id event as a skip + alert, mirroring Launchpad's own skip behavior).
  3. app_identifier_id on TEAM_MIGRATED/TEAM_CREATED(migrate) equals the chat division_id — this is the mapping key chat uses to set divisions.team_id. Chat sets it when calling POST /private/teams/bulk.
  4. The unified_app capability and per-org use_qontak_one_team flag gate the whole Team flow; orgs without both keep the existing V1 flow, resources, and schema untouched (Arch-Decision doc).
  5. Member management moves entirely to Launchpad in Team mode — chat no longer creates/edits/deletes users on a division; it mirrors the full division roster into user_divisions from team events, matching the current division composition. Correction (2026-07-10): an earlier draft mirrored an agent-only subset; that was wrong. Today's user_divisions already contains both agents and supervisors, so chat mirrors all team members that resolve to a chat agent or supervisor. Admin/Owner team members added to a Launchpad team are also synced into user_divisions for display only — shown read-only in the division member list but never routed (excluded from chat routing by the engine's existing role-gate). See 1.B D3 (agents/SPVs) and D15 (Admin/Owner).

Dependencies

#DependencyOwning teamDeliverable neededBlocking?
1Launchpad Team API + bifrost.team.events.v1BifrostStable topic + payloads (shipped, Launchpad RFC §12)YES
2Launchpad POST /teams/bulk (migrate mode)BifrostMulti-company bulk create with is_migrate/app/app_identifier_id (shipped, BIF-8608/8799)YES
3Server-to-server auth chat→Launchpad Teams APICommRESOLVED (2026-07-10 rev2): reuse the existing chat→Launchpad client pattern — base URL ENV['QONTAK_LAUNCHPAD_API_URL'] + Basic auth ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'], path prefix /private/teams (see D8; verified in apps/launchpad/services/get_last_session.rb). No new base URL, no SSO app token, no Bifrost blocker.NO — resolved
4Launchpad→chat migration trigger reachabilityBifrost + CommChat exposes POST /api/internal/v1/teams/migrate with static API-key auth (see D11); Bifrost's Heimdall trigger must send the agreed X-Chat-Api-Key. Endpoint is implementable now; only the shared-key handshake needs coordination (§5 OQ-2).NO — endpoint unblocked; key handshake is coordination
5unified_app package flag semantics per orgPlatform/BillingConfirm read path for enable_unified_app_package in Team modeNO — mechanism verified (organization.rb:66)
6Qontak One client migration plan (rollout scope)Product/GTMWhich CIDs/plans are in scope for cutoverYES — affects audience (PRD Dep 6)

PRD-to-Schema Derivation (backend-specific — required)

Backend RFCs derive schema and contracts from the PRD as a domain spec. Every §2.3 DDL row and §2.4 endpoint traces back to a row here.

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
"Each Chat Division references a Launchpad Team"divisions.team_id uuid NULLTEAM_MIGRATED/TEAM_CREATED consumer sets it; surfaced in GET /core/v2/divisionsKafka consumer KafkaConsumers::Teams::TeamEvents; migration add_team_columns_to_divisionsArch-Decision doc; PRD S01 AC-4
"Prepare for future team hierarchy"divisions.parent_team_id uuid NULL, divisions.parent_id uuid NULLnone (data-only, no read/write logic this phase)migration only; no interactor reads themArch-Decision doc; PRD Non-Goal 1
"Migrate agents + all SPVs as flat team members; mirror the full roster into the division"user_divisions rows for all members resolving to a chat agent or supervisor (existing table — matches current composition)POST /private/teams/bulk sends all members to Launchpad; TEAM_MIGRATED/TEAM_UPDATED consumer syncs the full roster into user_divisionsconsumer + Repositories::Divisions::UserDivision::Edit(managed_by_team: true) (roster-replace; the new managed_by_team: arg bypasses the supervisor-count check in Team mode — composition owned by Launchpad; D14)PRD S01 AC-2/AC-3; user correction 2026-07-10 (SPVs are in user_divisions today)
"Admin/Owner display: read-only; visible in member list but never included in chat routing"user_divisions rows for members resolving to a chat Admin/Owner (existing table; role lives on users, already indexed as ES divisions.users.role)GET /core/v2/divisions member list returns them with role (read-only in Team mode)consumer syncs them into user_divisions; auto-assign role-gate keeps them non-routable (by_room.rb:19,91); notification role-filter (queue_assign_agent/add.rb:52)PRD "Admin/Owner display"; D15
"division_id preserved as routing key"rooms.division_id, queue_round_robins.division_id, message_broadcasts.division_id (unchanged)unchanged endpoints; FE keeps passing division_idno change — internal queries keep using division_idPRD S02, S03; Arch-Decision doc
"Migration is per-CID, idempotent, resumable"organizations.settings['team_migration_status'] (enum: pending/processing/completed/failed) + divisions.team_id presencePOST /internal/v1/teams/migrate trigger; migration_job_* metricsDivisions::TeamMigrationWorker (Sidekiq) skips divisions with team_id setPRD S01, S04
"Rollback: revert read pointer; preserve team + link"organizations.settings['use_qontak_one_team'] (flag flip only; no data delete)flag toggleServices::Preference / settings flag; migration data retainedPRD S05
"Assignment menu member/supervisor fields read-only after migration"(no new column) enforced via flagreject on POST/PUT /core/v1/divisions/usersinteractor guard qontak_one_team_enabled?(org) → FailurePRD UI-PH1-S01, MIG-PH1-S01-NEG
"Assigning a channel to a Team auto-creates the Chat Division"new divisions row (team_id set) + channel_divisions rowsPUT /core/v2/divisions/channels (body {team_id, channel_ids[]})V2::Divisions::AssignChannelToTeam interactorPRD FLOW-PH1-S01, §10 behavior #2
"Division name editable; sync to Launchpad Team name"divisions.name (existing)PUT /core/v2/divisions/:idPATCH Launchpad team in backgroundV2::Divisions::UpdateDivision + Launchpad::Services::UpdateTeamNamePRD CHG-001, §10 behavior (name)
"Team deleted in Launchpad cascades to Chat Division"delete divisions row + join rowsTEAM_DELETED consumer → Repositories::Divisions::Deleteconsumer + existing delete fan-outPRD §10 behavior #5
"Post-migration banner shown per CID once complete"organizations.settings['team_migration_status'] = 'completed'GET /core/v1/organizations/settings (Interactors::UserViewSettings) exposes team_migration_status via Entities::Settings (D16)consumer sets completed when all divisions mappedPRD COMM-PH1-S01
"Division with no channel is retained (not deleted) when unassigned from all channels"divisions row kept; channel_divisions emptyPUT /core/v2/divisions/channels with empty set keeps the rowV2::Divisions::AssignChannelToTeam (no destroy on empty)Arch-Decision doc "Option Selected"
"A channel-less division behaves as if it does not exist — assignment / notification / routing skip it"no new column — derived dormancy channels.empty? && !general? (via existing Division::<id>::Channels cache)not directly exposed; enforced on the routing/assignment side — assignment (FetchUserIdsInDivisionByRoom, GetAllUserDivision), notification (queue_assign_agent/add) — not in validate_divisionrouting/assignment/notification read paths exclude dormant divisions; validate_division (list access-control) unchanged; reports drop channel-derived rows onlyProduct request; Arch-Decision doc "Option Selected"; D13
"List division still shows a channel-less division, flagged so FE can note it has no channels"none (read-time projection of channel_divisions count)GET /core/v2/divisions returns has_channels: bool + channel_count: int per divisionSupervisorListDivision / V2 list builder (preloads :channels)Product request (FE note); D13
"Any change to a division recaches Redis"no persisted field — cache side-effectn/a (side-effect of consumer + PUT /core/v2/divisions/channels)consumer + AssignChannelToTeam reuse Services::Redis::Divisions::Reset*/Recache* (delegating to existing repositories)hub_core §3.4 always-recache behavior; D14

Detail 1.A — PRD Traceability Matrix

Forward (PRD → RFC):

PRD requirementService / endpoint / jobRFC section
S01 Auto-create Team per Division (migration)Divisions::TeamMigrationWorker + Launchpad::Services::BulkCreateTeam§2.2 Seq A, §2.3, §4.C ch.3–5
S02 Bot routing via division_idno change (verified)§1 Out-of-Scope 3, §2.E
S03 Historical records resolve to teamn/a — chat keeps division_id; no join§0 reconciliation, §1 Out-of-Scope 2
S04 Resumable migrationDivisions::TeamMigrationWorker idempotency§2.C, §2.A
S05 Rollbackflag flip use_qontak_one_team§4 Rollout, §4.D
UI-PH1-S01 Read-only membersV1 member-write guard§2.4 (V1 guards), §3 authz
UI-PH1-S02 Channel editablePUT /core/v2/divisions/channels§2.4 outbound #2
FLOW-PH1-S01 Auto-create division on channel assignV2::Divisions::AssignChannelToTeam§2.2 Seq C, §2.4 #2
COMM-PH1-S01 Banner statusteam_migration_status in settings§2.3, §2.E
MIG-PH1-S01-NEG Cannot edit membersinteractor guard§2.4 (V1 guards), §3.A.1
§10 behavior #3 Member syncTEAM_UPDATED consumer§2.2 Seq B, §2.C
§10 behavior #5 Team delete cascadeTEAM_DELETED consumer§2.2 Seq B, §2.C

Reverse (RFC → PRD):

New endpoint / table / jobPRD need it serves
divisions.team_id columnS01 AC-4 (link division ↔ team)
divisions.parent_team_id / parent_idNon-Goal 1 (future hierarchy prep)
bifrost.team.events.v1 consumer§10 behaviors #3/#5, S01 mapping
POST /internal/v1/teams/migrateS01/S04 (per-CID trigger)
PUT /core/v2/divisions/channelsFLOW-PH1-S01, UI-PH1-S02
PUT /core/v2/divisions/:idCHG-001 (name editable + sync)
V1 member-write guardMIG-PH1-S01-NEG, UI-PH1-S01
organizations.settings['team_migration_status']COMM-PH1-S01 (banner), S04

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired readsRequired writesStatus surface
Assignment menu (member/SPV fields)web (admin)GET /core/v2/divisions (members read-only, incl. team_id)none in Team mode (writes rejected)use_qontak_one_team flag + team_migration_status
Assignment menu (channel field)web (admin)GET /core/v2/divisionsPUT /core/v2/divisions/channelschannel_divisions for the division
Assignment menu (name field)web (admin)GET /core/v2/divisionsPUT /core/v2/divisions/:iddivisions.name
Post-migration bannerweb (admin)GET /core/v1/organizations/settings (UserViewSettings) exposes team_migration_status (D16)banner dismiss = FE/user-prefs (FE-owned)organizations.settings['team_migration_status']
Team picker when assigning channelweb (admin)Launchpad GET /teams direct from FE (Launchpad RFC §5.1.4)Launchpad team list
Migration audit (Engineering)internal/opsmetrics migration_job_*, structured logsteam_migration_status

Role Coverage

PRD roleAuthorization mechanismEndpoints permittedCross-tenant?Audit trail
AdminOAuth2 scope :admin (hub_service) + org-scoped principalGET /core/v2/divisions, PUT /core/v2/divisions/:id, PUT /core/v2/divisions/channelsno (org-scoped via me.organization_id)structured logs + auto_division_created event
OwnerOAuth2 :ownersame as Adminnosame
Supervisor (SPV)OAuth2 :supervisor + validate_division scopeGET /core/v2/divisions (read); writes only if permitted per existing rulesnologs
Agent / MemberOAuth2 :agent/:memberGET /core/v2/divisions (read, own divisions)nologs
BotOAuth2 :botGET /core/v2/divisions (read)nologs
System — migration workernot user-triggered (Sidekiq)none (internal)operates per-orgmigration_job_* metrics + logs
Service — Launchpad (server-to-server)Static API key: header X-Chat-Api-Key == ENV['LAUNCHPAD_MIGRATION_API_KEY'] via validate_launchpad_api_key! (mirrors validate_crm_api_key!)POST /internal/v1/teams/migrateper-CID via company_sso_idlogs + metrics

PRD Section Coverage

PRD §TitleWhere covered / n/a — reason
2Phase Context§1 Overview, §0
3One-liner + Problem§1 Overview
4Cost of not shipping§1 Overview (motivation)
5Target Users + PersonaDetail 1.A Role Coverage
6Non-Goals§1 Out of Scope
7Constraints§1 Assumptions/Dependencies; §3 perf
7.1Data Lifecycle§2.3 retention; join-table rows n/a — chat uses team_id` column, no join table
8Feature Changes (CHG-001)§2.4 (V1 guards + V2 name/channel), §3 authz
9New Features (banner, auto-division)§2.4 #2, §2.3 status; banner FE-owned (BE status only)
10API & Webhook Behavior (#1–#5)§2.2, §2.4, §2.C
11.1System Flow§2.2 sequences
11.2User StoriesDetail 1.C
12Rollout§4 Rollout
12.1Migration Transition Window§4 Compatibility; Phase A/B backfill n/a — Bifrost-owned on chat
13Observability§3 Monitoring
13.1Post-Launch Monitoring§3 Monitoring, §4 Rollout stop-conditions
14Success Metrics§1 Success Criteria, §3 Monitoring
15Launch Plan & Stage Gates§4 Rollout
16Dependencies§1 Dependencies
17Key DecisionsDetail 1.B
18Open Questions§5

Detail 1.B — Decisions Closed

DecisionChosen optionAlternatives rejectedWhy rejected
D1 Division↔Team link storageteam_id (+ parent_team_id, parent_id) columns on divisions (Arch-Decision doc)(a) Dedicated join table team_division_mapping (PRD §17); (b) Replace Division with Team entirely(a) Every chat consumer already keys on division_id; a 1:1 link needs no join row, and a join table adds an indirection + retention/decommission burden with no chat-side query benefit. This overrides PRD §17 — see §0. (b) Bot routing, channel segmentation, SLA/broadcast/reporting all depend on division_id; a full replacement rewrites every consumer at once — too risky.
D2 Per-org gatingunified_app capability AND use_qontak_one_team flag, checked via a centralized helper qontak_one_team_enabled?(organization)Global ENV['USE_USMAN_QUERY'] onlyThe env flag is process-wide (user.rb:83) and cannot gate a per-org staged rollout; use_qontak_one_team stored in organizations.settings (jsonb) matches the existing store_accessor pattern and supports per-CID enablement.
D3 Member/supervisor management in Team modeManaged in Launchpad; chat mirrors the full division roster (all team members resolving to a chat agent or supervisor) into user_divisions from TEAM_MIGRATED/TEAM_UPDATED, exactly matching current division composition; the ≥1-supervisor invariant is bypassed in Team mode (composition owned by Launchpad); V1 member-write endpoints reject(a) Agent-only sync subset (earlier draft); (b) Filter at assignment-engine read time(a) Wrong — reversed per user correction 2026-07-10. user_divisions already contains agents and supervisors today; syncing agent-only would drop SPVs from divisions — a behavior regression. Mirroring the full roster matches current behavior → zero routing regression (SPVs are already handled by the existing assignment engine, unchanged). Admin/Owner team members are handled by D15 (synced into user_divisions for read-only display, never routed). (b) Read-time filtering would require assignment-engine changes; sync-layer mirroring keeps the engine untouched.
D4 Mapping back team_id → divisionConsume bifrost.team.events.v1 TEAM_MIGRATED/TEAM_CREATED(migrate) and match on app_identifier_id (=division_id)Poll Launchpad GET /teams/bulk/{upload_id} for team idsChat already consumes bifrost topics (Karafka group bifrost_launchpad); event-driven mapping reuses that plumbing and is push-based (lower latency, no polling loop).
D5 Migration idempotency / resumabilitySkip divisions where team_id IS NOT NULL; rely on Launchpad source-ref dedup (company_id, source_identifier, reference_id); track per-CID status in organizations.settingsDedicated division_team_migration_jobs table (mirror Launchpad's team_upload_jobs)The team_id IS NULL predicate is itself a durable idempotency key; Launchpad already owns bulk-job status. A chat-side jobs table duplicates that state for little gain this phase (revisit if audit needs richer per-item history).
D6 New endpoints are V2, not V1New division endpoints under /api/core/v2/divisions; V1 endpoints only gain a read-only guardModify V1 endpoints in placeArch-Decision doc: keep V1 untouched for non-Team orgs and align namespacing with Qontak One; V2 mount is a one-line addition (core/v2/routes.rb).
D7 Division-with-no-channel policyKeep the division (with team_id) when all channels are unassigned; do not deleteDelete the division and recreate on next assignmentRecreating mints a new division_id while team_id stays constant → discontinuous history + orphan risk in rooms/reports (Arch-Decision doc "Option Selected").
D8 Server-to-server auth chat→Launchpad Teams APIRESOLVED (2026-07-10 rev2): reuse the existing chat→Launchpad client pattern verbatim — < Repositories::AbstractHttp, @base_url = ENV['QONTAK_LAUNCHPAD_API_URL'], headers: { 'Authorization': ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] } (HTTP Basic), path prefix /private/teams (e.g. POST /private/teams/bulk, GET /private/teams/{id}, PATCH /private/teams/{id}). Confirmed by operator curl + apps/launchpad/services/get_last_session.rb:9,13,15. Wrapped in a new launchpad_circuit_breaker (D12).(a) SSO app token via MekariSso::Services::Redis::Auth + internal-api.mekari.com gateway (an earlier 2026-07-10 draft); (b) per-request user SSO token(a) Reverted per operator guidance. The Teams endpoints are served on Launchpad's /private/* surface, which the existing chat→Launchpad clients already reach with the shared QONTAK_LAUNCHPAD_BASIC_AUTH credential — so the new Teams clients need no new base URL, no SSO app-token plumbing, and no app_name ambiguity; they are identical to get_last_session.rb. (b) Not user-initiated.
D12 Launchpad outbound resilience (circuit breaker + timeout)New launchpad_circuit_breaker in Repositories::Http::CircuitBreakers (circuit_breakers.rb, beside sso_circuit_breaker): Circuitbox.circuit(:launchpad_circuit, { exceptions: [RequestTimeout, RequestError], volume_threshold: 5, error_threshold: 50, time_window: 60, sleep_window: 60, circuit_store: Moneta.new(:Redis, url: ENV['REDIS_W_URL'], expires: true) }). Per-request timeout `ENV['LAUNCHPAD_REQUEST_TIMEOUT']30s. Gated by preference :enable_launchpad_circuit_breaker(mirrors:enable_sso_circuit_breaker`).
D11 Inbound migration-trigger auth (POST /internal/v1/teams/migrate)RESOLVED (2026-07-10): dedicated static API key — new helper validate_launchpad_api_key! (in hub_service api/internal/v1/header_validation.rb) reads header X-Chat-Api-Key and compares to a new ENV['LAUNCHPAD_MIGRATION_API_KEY'], raising OauthBwergemn::Errors::InvalidToken (401) on mismatch — mirroring validate_crm_api_key!. Endpoint contract style mirrors POST /api/internal/v1/crms/auths/tokens.(a) Reuse the existing validate_crm_api_key! + shared ENV['CRM_API_KEY']; (b) OAuth2 user token(a) CRM_API_KEY is a single shared secret reused across CRM/commerce internal callers (header_validation.rb:13) — no caller isolation or independent rotation; a dedicated key isolates the Launchpad caller and lets it rotate independently (also resolves the reviewer's SAS key-sharing concern). (b) Trigger is service-to-service, not a user session.
D9 Soft- vs hard-delete on TEAM_DELETEDHard cascade via existing Repositories::Divisions::Delete (destroys division + join rows, nullifies rooms.division_id via RoomUpdateDivision event)Soft-delete flag on divisionsNo soft-delete exists on divisions today; the existing delete fan-out already orphans rooms safely and notifies chatbot. Adding soft-delete is out of scope.
D10 ES indexing of team_idAdd team_id (keyword) to the divisions ES mapping + as_indexed_json for parity/debugSkip ES entirelyInternal filters use division_id, so ES team_id is not required for queries; it is added cheaply (one doc per division) for observability. Marked low-risk additive — see §2.3.
D13 Behavior of a channel-less division (unassigned from all channels)Keep the row (D7) but treat it as a dormant division: effectively non-existent for runtime processes — agent assignment/routing, notification delivery, and division-required filtering skip it — while the divisions list still returns it with has_channels: false / channel_count: 0 for the product-requested FE note. Dormancy is derived at read time (channels.empty? && !general?), not a stored column. Enforcement leans on the fact that routing is already channel-driven (FetchUserIdsInDivisionByRoom → channel → divisions → users), so a channel-less division falls out of the channel-keyed caches automatically; the explicit work is confined to the routing/assignment side — excluding it from candidate resolution (FetchUserIdsInDivisionByRoom consumers), the general-vs-assigned split (GetAllUserDivision in auto_assign/by_room), and member notification (queue_assign_agent/add). validate_division is deliberately left UNCHANGED — that helper governs list access-control, so a dormant division the actor is assigned to still appears in GET /core/v2/divisions (flagged has_channels:false); putting the dormant skip there would wrongly hide it from a non-admin's list. Reports drop only channel-derived attribution; rooms stamped directly with the dormant division_id remain attributed until re-stamped (3c).(a) Delete the division when its last channel is removed; (b) materialize a divisions.active/status column with its own invalidation; (c) leave members "division-assigned" as today (status quo — no skip)(a) = D7's rejected option: recreating mints a new division_id while team_id stays constant → discontinuous history + orphan rooms/reports. (b) A derived channels.empty? check reuses the existing Division::<id>::Channels cache (which already excludes General by name) and avoids a new column plus a second invalidation path that could drift from channel_divisions. (c) Contradicts the product requirement that a channel-less division "behaves as if it does not exist" — a routing-dead division would still occupy assignment/filter semantics. Member-fallback + surfaces RESOLVED (OQ-13, rev5): a member whose only division is dormant is treated as a general/unscoped agent (3a); the full surface enumeration (3b) and reports nuance (3c) are closed in OQ-13. Only QA regression across those surfaces remains (OQ-8) before the Assignment-Menu Lock stage.
D14 Redis recache on every division change (always-recache invariant)Every new division-mutation path — the Kafka team-event consumer (map / rename / roster-sync / delete) and the V2 assign-channel endpoint — reuses the existing Services::Redis::Divisions::Reset*/Recache* fan-out, matching hub_core's current invariant that the repository layer always invalidates Redis on any division/user/channel mutation (§3.4). Concretely, the consumer delegates its writes to the existing repositories (Divisions::UserDivision::Edit, ChannelDivision::Edit, Divisions::Create, Divisions::Delete) rather than issuing raw UPDATEs that would bypass invalidation; RecacheWorkload fires whenever membership or channel assignment changes. Reconciliation with D3 (supervisor-count bypass): because UserDivision::Edit re-validates supervisor count (division docs §2.2), it gains a new managed_by_team: keyword (default false) that skips that check when true; the team-event consumer always calls it with managed_by_team: true, so a Launchpad roster with zero chat-supervisors syncs without rejection while the standard Reset*/Recache* fan-out still fires. ChannelDivision::Edit and Divisions::Delete are called with their existing signatures (no supervisor rule).(a) Raw UPDATE/INSERT in the consumer with selective (or no) recache; (b) TTL-only — let the 2–4d cache TTL expire; (c) keep UserDivision::Edit's supervisor rule and pre-inject a placeholder supervisor(a) A raw write bypasses the bidirectional maps + workload counters → routing/assignment reads stay stale until TTL (up to 4 days); the existing repositories already encapsulate the exact reset set, so delegating is both less code and regression-proof. (b) 2–4d staleness is unacceptable for routing/notification. (c) pre-injecting a placeholder supervisor would corrupt the roster with a phantom member and re-introduce the exact chat-side supervisor coupling D3 removes — a per-flag bypass on the repo is cleaner and localised. Note: team_id itself is not a Redis routing key (caches key on division/user/channel ids), so setting team_id alone needs no routing-cache reset — but any accompanying membership/channel/name change does, and the safe default is to run the same fan-out the legacy path runs.

| D15 Admin/Owner display-only division membership (PRD "read-only; visible in member list but never included in chat routing") | Sync Launchpad team members who resolve to a chat Admin/Owner into chat's user_divisions (the same store as agents/supervisors) so they appear in the GET /core/v2/divisions member list — which already exposes users.role via the entity + ES divisions.users.role (division docs §1.4–1.5) — as read-only (Team-mode member writes already rejected) and never routable. No assignment-engine change is needed: auto-assign seeds candidates from Models::User.where(role: define_agent_role) = ['agent','member'] (by_room.rb:19,91,100-103) and only ever intersects that role-gated set with division membership, so an Admin/Owner sitting in user_divisions/GetAllUserDivision can never be selected (the general-split all_user - GetAllUserDivision is role-gated too, so admins in it have no effect). The one routing-adjacent path that plucks user_divisions without a role filter — queued-room member notification (queue_assign_agent/add.rb:52) — gains an agent/member role filter so admins aren't pinged for rooms they can't take. This reverses the earlier D3/OQ-11 stance (admins excluded from user_divisions, read FE-direct from Launchpad). | (a) Keep admins out of user_divisions; FE reads them direct from Launchpad (the prior RFC design); (b) store admins in user_divisions + add a role filter at the assignment-engine read path; (c) a separate division_display_members table | (a) chat's division member list reads division.users (through user_divisions) + ES divisions.users, not Launchpad — FE-direct leaves chat's own member-list surface empty of admins and forces a two-source merge; the PRD explicitly wants them in the chat-served list. (b) Unnecessary — the engine already role-gates candidates at the source, so no engine change is required; only the one un-filtered notification pluck needs a filter. (c) a new table + a second member-list read path; user_divisions + the existing role projection reuses the member-list read verbatim. |

| D16 Expose team_migration_status to the FE via the existing organizations/settings endpoint, not GET /core/v2/divisions | The per-CID banner status is read from GET /api/core/v1/organizations/settings (Interactors::UserViewSettingsRepositories::Organizations::SettingsBuilders::SettingsEntities::Settings) — the canonical surface the FE already uses to read every org flag/setting — by adding a team_migration_status attribute to the whitelist Entities::Settings (and mapping it in prepare_response_settings, abstract_builder.rb:88). GET /core/v2/divisions keeps only per-division fields (team_id, has_channels, channel_count) and does not carry the per-org status. | (a) Add team_migration_status to the GET /core/v2/divisions response (earlier draft); (b) a dedicated new status endpoint | (a) team_migration_status is per-organization, not per-division — bolting it onto a paginated per-division list is a category error (which row/page carries it? it duplicates across rows) and forces the FE to read a divisions list just to learn org status. The settings endpoint already aggregates every org flag the FE consumes (use_qontak_one_team will live there too), so the FE reads one place. (b) a new endpoint duplicates what organizations/settings already does; Entities::Settings is the established whitelist for exactly this. |

Honesty note: D1, D3, D6, D7, D8, D11, D12, D13, D14, D15, D16 have explicitly rejected alternatives from the PRD / Arch-Decision docs, product request, operator guidance, or the codebase. D5/D9/D10 record single viable option this phase where no competing option was seriously pursued.

Detail 1.C — Per-Story Change Map

Layer scope values: BE-only, BE + FE consumes existing, BE + FE consumes new, Runtime / behavior, Config, Cross-squad.

Story #TitleLayer scopeBE changes (concrete)Acceptance criteria (verifiable)RFC anchors
MIG-PH1-S01Auto-create Team per DivisionBE-only + Cross-squad (Launchpad)Divisions::TeamMigrationWorker; Launchpad::Services::BulkCreateTeam; consumer sets divisions.team_idrspec: worker builds bulk items for divisions with team_id IS NULL; consumer sets team_id on TEAM_MIGRATED; no duplicate on re-run§2.2 Seq A/B · §2.4 outbound-client · §4.C ch.3,5,6 · PRD-to-Schema r1,r5
MIG-PH1-S02Bot routing via division_idRuntime / behaviornone — verified division_id untouchedrspec regression: existing room/queue routing specs pass unchanged§1 Out-of-Scope 3 · §2.E
MIG-PH1-S03Historical records resolve to teamn/a — chat keeps division_id; no query-time join (see §0). Launchpad-side backfill = Bifrost§0 · §1 Out-of-Scope 2
MIG-PH1-S04Resumable migrationBE-onlyidempotency guard team_id IS NULL; per-CID team_migration_status in settingsrspec: interrupt→resume processes only unmapped divisions; no duplicate team_id§2.A · §2.C · §4.C ch.5
MIG-PH1-S05Rollback per phaseConfigflip use_qontak_one_team off; data retainedrspec/manual: with flag off, reads/writes use legacy V1 path; divisions.team_id + Launchpad teams preserved§4 Rollout · §4.D
UI-PH1-S01Read-only member fields (incl. Admin/Owner display, D15)BE + FE consumes new (FE RFC pending)guard in SupervisorCreateUserDivision/SupervisorEditUserDivisionFailure(:managed_by_team) when qontak_one_team_enabled?; Admin/Owner synced into user_divisions for read-only display, never routable (D15)rspec: POST/PUT /core/v1/divisions/users returns 422 in Team mode; GET /core/v2/divisions returns members read-only incl. team_id and Admin/Owner with role; an admin in user_divisions is never an auto-assign candidate§2.4 V1-guards · §3 authz · §3.A.1 · D15
UI-PH1-S02Channel field editableBE + FE consumes new + Runtime / behaviorPUT /core/v2/divisions/channels; on channel-set change → ChannelDivision::Edit recache (D14) + RecacheWorkload; empty set → division kept but dormant (D13)rspec: channel change persists to channel_divisions; user_divisions unaffected; Redis channel/workload caches reset; emptying all channels keeps the row and it stops contributing candidate agents / drops from division-required filters§2.4 outbound #2 · §2.2 Seq C · §3.A.1 (dormant + recache rows)
FLOW-PH1-S01Auto-create division on channel assignBE + FE consumes new + Cross-squadV2::Divisions::AssignChannelToTeam; Launchpad::Services::GetTeam (name + full member roster)rspec: assigning a channel to a team_id with no division creates one (team_id set, name from team), attaches channels, seeds user_divisions with agents + supervisors + Admin/Owner display (D15), appears in list§2.2 Seq C · §2.4 outbound #2 · PRD-to-Schema r8
COMM-PH1-S01Post-migration bannerBE + FE consumes newexpose team_migration_status on GET /core/v1/organizations/settings (UserViewSettings + Entities::Settings, D16); consumer sets completed when all divisions mappedrspec: GET /core/v1/organizations/settings returns team_migration_status='completed' after full mapping§2.3 · §2.E · PRD-to-Schema r11 · D16
MIG-PH1-S01-NEGCannot edit members via Assignment menuBE-only (guard)backend rejects member/supervisor write in Team moderspec: NEG-2 — direct PUT /core/v1/divisions/users returns 422 with managed_by_team§2.4 V1-guards · §3.A.1

Coverage: all 11 PRD §11.2 stories present. FE-only aspects (banner render, tooltip copy, loading skeletons) are covered in FE RFC (pending — §5 OQ-9).

Two cross-cutting behaviors do not map to a single numbered PRD story — they derive from the Arch-Decision doc's "Option Selected" and a product-team request, and are specified as closed decisions rather than stories: D13 (channel-less division = dormant/skipped, listed with a has_channels flag) and D14 (every division-mutation path recaches Redis). Their verifiable acceptance criteria live in §3.A.1 (Branch & Skip Catalog), §2.E (State Surface), and §4.C (chunks 5, 9, 13). The FE "no channels" note that consumes D13's has_channels field is covered in FE RFC (pending — §5 OQ-9).


2. Technical Design

Detail 2.0 — Repo Reading Guide

Read this first. The agent must understand the existing division code as documentation before writing new code. All three chat repos are involved: hub_core (domain logic), hub_service (Grape HTTP), hub_worker (Sidekiq + Karafka wiring). Business logic lives in hub_core; the other two are shells.

Repo Map (mermaid)

flowchart LR
subgraph launchpad["qontak-launchpad (Bifrost, external)"]
lpapi["Teams API\n/teams, /teams/bulk"]
lpkafka["bifrost.team.events.v1"]
end
subgraph svc["hub_service (Grape HTTP)"]
v2["api/core/v2/divisions/*\n(NEW)"]
intl["api/internal/v1/teams/*\n(NEW)"]
v1["api/core/v1/divisions/*\n(guard added)"]
end
subgraph core["hub_core (domain)"]
intr["interactors/divisions/*\n+ V2::Divisions::* (NEW)"]
repo["repositories/divisions/*"]
cons["events/kafka_consumers/teams/*\n(NEW)"]
lpcli["apps/launchpad/services/*\n(+ BulkCreateTeam/GetTeam/UpdateTeamName)"]
model["models/division.rb + organization.rb"]
redis["services/redis/divisions/*"]
end
subgraph wkr["hub_worker (Sidekiq + Karafka)"]
karafka["karafka.rb (bifrost_launchpad group)"]
sidekiq["Divisions::TeamMigrationWorker (NEW)"]
end
subgraph infra["infrastructure"]
pg[("Postgres: divisions")]
es[("Elasticsearch: divisions")]
rds[("Redis: division caches")]
end
v2 --> intr --> repo --> pg
intl --> sidekiq --> lpcli --> lpapi
lpkafka --> karafka --> cons --> repo
cons --> redis
repo --> es
intr --> lpcli

Existing Code Anchors

Path (repo)Why the agent reads itWhat pattern it teaches
hub_core spec/dummy/db/schema.rb:551-560Current divisions table shapeUUID PK, (organization_id, name) unique index — where new columns land
hub_core database/core/db/migrate/20220711012607_add_is_contact_masking_to_divisions.rbTemplate for adding a column + inline ES mappingRails DSL add_column + put_mapping in one change
hub_core app/core/domains/models/division.rb:6-50Division model associations + ES as_indexed_json/mappingsWhere to add team_id/parent_team_id/parent_id to the index doc
hub_core app/core/domains/entities/division.rb:3-22 + builders/division.rb:3-28Entity + builderAdd new attributes to the immutable entity and builder mapping
hub_core app/core/domains/models/organization.rb:8,66,255-257MAX_DIVISION, store_accessor :settings, can_create_division?How to add a use_qontak_one_team settings accessor + qontak_one_team_enabled? helper
hub_core app/core/domains/services/preference.rb:21,61-71Services::Preference#enabled?(feature, organization_id:)Central per-org flag check (Flipper + Redis allowlist)
hub_core app/core/domains/interactors/user_view_settings.rb + entities/settings.rb + builders/settings.rb + builders/abstract_builder.rb:88 (prepare_response_settings)UserViewSettingsRepositories::Organizations::SettingsBuilders::Settingswhitelist Entities::Settings (attributes sorted alphabetically)Where to add the team_migration_status attribute so GET /core/v1/organizations/settings exposes it to the FE (D16)
hub_core app/core/domains/repositories/divisions/delete.rb:14-82Delete fan-out (destroy + Redis reset + ES reindex + chatbot notify)Template for the TEAM_DELETED handler
hub_core app/core/domains/repositories/divisions/channel_division/edit.rbChannel-set edit: division.channels = channels, delete Channel::*::Divisions/Division::*::Channels, per-user channel reset + RecacheWorkloadThe recache pattern the V2 assign-channel endpoint must reuse (D14); also the transition that makes a division dormant when the set becomes empty (D13)
hub_core app/core/domains/repositories/divisions/user_division/edit.rbRoster edit: recompute cache for every affected user (old ∪ new), reindex, idle-reassignment; also holds the supervisor-count validationThe recache path the team-event roster sync delegates to (D14); gains a new managed_by_team: kwarg (default false) that skips the supervisor-count check when true (D3)
hub_core app/core/domains/services/redis/divisions/* (reset_users_by_division, reset_divisions_by_user, reset_all_user_division, reset_channels_by_user, recache_workload)The Reset*/Recache* primitives + key mapThe exact invalidation set the consumer + V2 endpoint must trigger (D14); key templates in division docs §3.2
hub_core app/core/domains/services/division/fetch_user_ids_in_division_by_room.rbCandidate-agent resolution: room → GetDivisionsByChannelGetUsersByDivisionProves routing is channel-driven — a channel-less division is skipped automatically (D13)
hub_core app/core/domains/repositories/rooms/auto_assign/by_room.rb:19,80-103Candidate resolution: Models::User.where(role: define_agent_role, ...), filter_division/filter_channel_division intersect with user_divisions; define_agent_role = ['agent','member']Where a dormant division's members must NOT count as "division-assigned" (D13); proves the engine role-gates candidates → Admin/Owner in user_divisions are inherently non-routable, no engine change (D15)
hub_core app/core/domains/repositories/queue_assign_agent/add.rb:52,65-66Notifies division members via Models::UserDivision.where(division_id:).pluck(:user_id) (no role filter, :52) + general agents (GroupByRole(['agent','member'])GetAllUserDivision, :65-66)Notification path that must skip a dormant division (D13); and the one un-role-filtered pluck (:52) that must gain a role filter so admins aren't notified (D15)
hub_core app/core/domains/interactors/abstract_iteractor.rb:1142-1156validate_division(actor_id, role, division_ids) — assigned-division filter via GetDivisionsByUserLeft UNCHANGED (D13/Gap-2): it governs list access-control (a dormant division you're assigned to still lists, flagged); the dormant routing skip lives on the assignment consumers, not here — editing this would hide dormant divisions from a non-admin's list
hub_core app/core/domains/interactors/divisions/supervisor_list_division.rb:1-86 + repositories/divisions/all.rbList with preload(:channels, :users) + access controlWhere the list builder projects has_channels/channel_count for the FE note (D13)
hub_core app/core/domains/repositories/divisions/create.rb + user_division/create.rb:19Create + supervisor-count validationWhere the Team-mode validation bypass goes
hub_core app/core/domains/interactors/divisions/supervisor_delete_division.rb:16-26Interactor → repo → publish eventResult-monad + Publishers::* publish pattern
hub_core app/core/events/kafka_consumers/users/user_data_updated.rb:19-61Existing bifrost.user.updates.v1 consumerKarafka consume loop, mark_as_consumed, delegate to repo
hub_core app/core/events/kafka_consumers/launchpad/update_company_settings.rb:64-126Existing bifrost consumer that mutates DB + busts RedisClosest template for a "team event → update division + recache" handler
hub_core app/core/events/kafka_consumers/abstract_sub.rb:3Karafka base consumer< Karafka::BaseConsumer, Dry::Monads, parse_json
hub_core app/apps/launchpad/services/get_last_session.rb:3-17The exact template for the new Teams clients (same base + Basic auth + /private/* path)< Repositories::AbstractHttp; @base_url = ENV['QONTAK_LAUNCHPAD_API_URL']; auth = ENV['QONTAK_LAUNCHPAD_BASIC_AUTH']; get(path: "/private/users/…", headers: { 'Authorization': auth }); parse_response
hub_core app/core/domains/repositories/http/circuit_breakers.rb:5-13sso_circuit_breaker — the pattern to mirror as launchpad_circuit_breaker (D12)Circuitbox.circuit(:sso_circuit, { exceptions: [RequestTimeout, RequestError], sleep_window:, circuit_store: Moneta.new(:Redis, url: ENV['REDIS_W_URL'], expires: true) })
hub_core app/apps/mekari_sso/services/auth.rb:44-64How a client wraps a call in the circuit breakercall_circuitsso_circuit_breaker.run(exception: false) { … raise RequestTimeout if response.timed_out?; raise RequestError if [500,502,503,504].include?(code) }; gated by pref :enable_sso_circuit_breaker
hub_core app/core/workers/centralized_contacts/qontak_one_migration_worker.rb:3-11Existing per-org migration worker< AbstractSidekiqWorker, sidekiq_options queue:, perform(organization_id, batch_size)
hub_worker karafka.rb:450-474bifrost_launchpad consumer group with the 3 live bifrost topicsWhere to add topic 'bifrost.team.events.v1'
hub_worker config/sidekiq.ymlQueue registry (~156 queues)Register the new migration queue before referencing it
hub_worker config/sidekiq_schedule.ymlsidekiq-cron entriesOptional batched-kickoff cron entry
hub_service app/services/api/core/v1/divisions/resources/divisions.rb:24-229V1 division CRUD (scopes, interact_with, Dry::Matcher)Grape endpoint shape to mirror in V2 + where the V1 guard goes
hub_service app/services/api/core/v2/routes.rb:8-10V2 mount list (Reports/Rooms/Messages)Add mount API::Core::V2::Divisions::Routes
hub_service app/services/api/core/v2/helpers.rb:84-93,195interact_with, authorize_user_to_allow_access_resource!Delegation + ownership guard
hub_service app/services/api/internal/v1/header_validation.rb:6-15validate_crm_api_key! (X-Chat-Api-Key/CRM_API_KEY) — template for the new validate_launchpad_api_key!static API-key auth for the migration trigger endpoint (new helper + new ENV, D11)
hub_service app/services/api/internal/v1/auths/resources/crms/auths.rb:3-27Reference internal static-key endpoint (POST /internal/v1/crms/auths/tokens)before { validate_crm_api_key! }, params block, Dry::Matcher delegation, 201 + {status,data} envelope — mirror for POST /internal/v1/teams/migrate

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
bifrost.team.events.v1 (Kafka topic + payloads)reusedAs-built by Bifrost; chat only consumesBifrost
Launchpad POST /private/teams/bulk (migrate mode)reusedAs-built (BIF-8608/8799); chat calls it with Basic authBifrost
Launchpad GET /private/teams/{id} + /membersreusedAs-built; chat calls for auto-createBifrost
Launchpad PATCH /private/teams/{id}reusedAs-built; chat calls on name updateBifrost
bifrost.user.updates.v1 consumerreusedAlready consumed (karafka.rb:465); no changeComm
QONTAK_LAUNCHPAD_API_URL + QONTAK_LAUNCHPAD_BASIC_AUTH Launchpad clientreusedNew Teams clients use the same base URL + Basic credential as get_last_session.rb (D8)Comm
Launchpad /private/teams* surface (/bulk, /{id}, /{id}/members)reusedLaunchpad-owned; chat calls with Basic auth (curl-confirmed)Bifrost
sso_circuit_breaker (circuit_breakers.rb)extendedNew sibling launchpad_circuit_breaker (:launchpad_circuit), dedicated per-dependency circuit (D12)Comm
validate_crm_api_key! static-key pattern (internal/v1/header_validation.rb)extendedNew sibling validate_launchpad_api_key! + ENV['LAUNCHPAD_MIGRATION_API_KEY'] (dedicated key, not shared CRM_API_KEY) (D11)Comm
divisions tableextendedAdd team_id, parent_team_id, parent_idComm
Repositories::Divisions::UserDivision::EditextendedAdd managed_by_team: kwarg (default false) bypassing the supervisor-count validation in Team mode; recache fan-out unchanged (D3/D14)Comm
GET /api/core/v1/organizations/settings (Interactors::UserViewSettingsEntities::Settings)extendedAdd a team_migration_status attribute to the whitelist Entities::Settings so the FE reads the per-org banner status here (D16); endpoint/route/scopes unchangedComm
GET/POST/PUT /core/v1/divisions*extendedAdd read-only guard in Team mode; otherwise unchangedComm
PUT /core/v2/divisions/:id (name)new-with-justificationV1 name-update does not sync to Launchpad; a V2 endpoint isolates Team-mode behavior without touching V1 (D6)Comm
PUT /core/v2/divisions/channels (assign+auto-create)new-with-justificationNo existing endpoint keys off team_id or auto-creates a division; FLOW-PH1-S01 requires itComm
POST /internal/v1/teams/migratenew-with-justificationNo server-to-server per-CID migration trigger exists; Arch-Decision doc requires Launchpad→chat triggerComm
bifrost.team.events.v1 consumer (chat side)new-with-justificationNo team-events consumer exists in chat (verified NOT FOUND); needed for mapping + sync + deleteComm
Divisions::TeamMigrationWorkernew-with-justificationNo resumable per-CID team-migration worker exists (closest is CentralizedContacts::QontakOneMigrationWorker)Comm

Patterns to Follow (and where to find them)

ConcernPattern in repoReference fileDeviation in this RFC?
HTTP handler shapeGrape resource + oauth2 scope + interact_with + Dry::Matcher::ResultMatcherhub_service core/v1/divisions/resources/divisions.rb:50-61none — mirror in V2
Repository / DB accessRepositories::<Resource>::<Action> < AbstractRepository, returns Dry::Monads Success/Failurehub_core repositories/divisions/delete.rb:3none
Kafka consumerKafkaConsumers::<Domain>::<Name> < KafkaConsumers::AbstractSub, manual mark_as_consumedhub_core kafka_consumers/launchpad/update_company_settings.rb:64Add event_id dedup guard (net-new; no existing dedup)
External Launchpad HTTP client (Basic auth)Launchpad::Services::<Action> < Repositories::AbstractHttp; @base_url = ENV['QONTAK_LAUNCHPAD_API_URL']; <verb>(path: "/private/teams…", headers: { 'Authorization': ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] }); parse_responsehub_core apps/launchpad/services/get_last_session.rb:3-17none — new Teams clients follow this pattern exactly (D8)
Circuit breaker (per external dependency)Circuitbox.circuit(:<name>_circuit, {...}) in Repositories::Http::CircuitBreakers; call wrapped in <name>_circuit_breaker.run(exception: false); gated by a :enable_<name>_circuit_breaker prefhub_core repositories/http/circuit_breakers.rb:5-13 + apps/mekari_sso/services/auth.rb:44-64New launchpad_circuit_breaker (:launchpad_circuit) with explicit thresholds + 30s timeout (D12)
Metrics / instrumentationServices::Datadog::CaptureCustomMetric.new(name: :<sym>, tags: ["k:v"], use_env: true).capture(action: :increment|:histogram, count:) — low-cardinality tags; gated by DATADOG_ENABLED + per-metric flaghub_core repositories/contact_lists/create.rb:44, repositories/messages/search.rb:40none — new metrics use this helper (§3 Monitoring)
Internal static-key endpointGrape resource + before { validate_<x>_api_key! } reading X-Chat-Api-Key vs an ENVhub_service internal/v1/auths/resources/crms/auths.rb:3-27 + header_validation.rb:6-15New validate_launchpad_api_key! + dedicated LAUNCHPAD_MIGRATION_API_KEY (not shared CRM_API_KEY) (D11)
Error response shapeDry::Monads Failurethen_raise_error! → Grape error (422/500)hub_service core/v2/helpers.rb:167-180none
Redis cache invalidation (always-recache, D14)Repository layer always resets Redis on any division/user/channel mutation — Services::Redis::Divisions::Reset* (del → rpush → expire 2–4d) + RecacheWorkload when membership/channels change (division docs §3.4)hub_core services/redis/divisions/reset_divisions_by_user.rb:10-19; invalidation-trigger map in division docs §3.4none in mechanism — consumer + V2 assign-channel delegate to the existing repositories (UserDivision::Edit, ChannelDivision::Edit, Divisions::Create/Delete) so the same reset set fires; never raw UPDATE that skips invalidation
Channel-driven candidate resolution (D13)Eligible agents resolved via room → channel → divisions → users; membership split via GetAllUserDivisionhub_core services/division/fetch_user_ids_in_division_by_room.rb; repositories/rooms/auto_assign/by_room.rb (division docs §5.1)New: exclude a dormant (channel-less, non-General) division on the routing/assignment side (GetAllUserDivision split in auto_assign/by_room, FetchUserIdsInDivisionByRoom consumers, queue_assign_agent/add notify); do not touch validate_division (list access-control stays); reports drop channel-derived rows only; channel-keyed paths already skip it
Sidekiq worker< AbstractSidekiqWorker, sidekiq_options queue: …, delegate to interactor/repohub_core workers/centralized_contacts/qontak_one_migration_worker.rb:3Add resumable checkpoint (net-new)
Per-org flagServices::Preference#enabled? OR store_accessor :settingshub_core preference.rb:21, organization.rb:66New centralized qontak_one_team_enabled? combines unified_app + use_qontak_one_team
Migration fileRails DSL, YYYYMMDDHHMMSS_snake.rb, inline put_mapping for EShub_core migrate/20220711012607_add_is_contact_masking_to_divisions.rbnone

Reading Order for the Agent

  1. hub_core spec/dummy/db/schema.rb:551-560 — current divisions shape.
  2. hub_core app/core/domains/models/division.rb — associations + ES mapping.
  3. hub_core app/core/domains/models/organization.rb:8,66,255-257 + services/preference.rb — flags/settings.
  4. hub_core app/core/events/kafka_consumers/launchpad/update_company_settings.rb — bifrost consumer template.
  5. hub_core app/core/events/kafka_consumers/users/user_data_updated.rb — Karafka consume loop.
  6. hub_core app/apps/launchpad/services/get_last_session.rb (Teams-client template, D8) + repositories/http/circuit_breakers.rb & apps/mekari_sso/services/auth.rb:44-64 (circuit-breaker pattern, D12).
  7. hub_core app/core/domains/repositories/divisions/delete.rb — delete fan-out (Redis/ES/chatbot).
  8. hub_core app/core/workers/centralized_contacts/qontak_one_migration_worker.rb — per-org migration worker.
  9. hub_service app/services/api/core/v1/divisions/resources/divisions.rb + core/v2/routes.rb — HTTP shape + V2 mount.
  10. hub_service internal/v1/auths/resources/crms/auths.rb + header_validation.rb — static-key internal endpoint template (D11); then hub_worker karafka.rb:450-474 + config/sidekiq.yml.

Source Verification (anti-hallucination — required)

Anchor / pattern / contractVerified byEvidence
divisions table schemareadschema.rb:551-560: create_table "divisions", id: :uuid; unique index index_divisions_on_organization_id_and_name (:558)
Migration style = Rails DSL + inline ESread20220711012607_add_is_contact_masking_to_divisions.rb:5 add_column :divisions, :is_contact_masking, :boolean; :7-13 inline Elasticsearch::Model.client.indices.put_mapping
Division model associations + ES docreaddivision.rb:6-10 has_many :users, through: :user_divisions; :14-29 as_indexed_json; :32-50 mappings dynamic: :strict
Division entity + builderreadentities/division.rb:3-22 (id/organization_id/name/default/...); builders/division.rb:3-28 acts_as_builder_for_entity Entities::Division
MAX_DIVISION + settingsreadorganization.rb:8 MAX_DIVISION = 300; :66 store_accessor :settings, :enable_unified_app_package; :255-257 can_create_division?
organizations.settings jsonbreadschema.rb:1288 t.jsonb "settings", default: {}, null: false; GIN index :1311
Settings endpoint is a whitelist entity (D16)read hub_coreinteractors/user_view_settings.rb:12-15 Repositories::Organizations::Settings.new(organization_id).call; builders/settings.rb:4 acts_as_builder_for_entity Entities::Settings; entities/settings.rb:4 class Entities::Settings < Entities::AbstractEntity with explicit attribute :… list ("Please sort attributes alphabetically"); builders/abstract_builder.rb:88 def prepare_response_settings — so exposing team_migration_status requires a new whitelisted attribute, not a free pass-through
Central flag helperreadservices/preference.rb:21 enabled?(feature, organization_id:); :61-71 Flipper.enabled?(feature.to_sym) && organization_ids.include?(organization_id)
team_id/parent_team_id/use_qontak_one_team/source_identifier are net-newgrepzero hits in app/, config/, database/ of hub_core (only docs mention team_id)
Existing bifrost consumers live in hub_corereadkafka_consumers/users/user_data_updated.rb:19 < KafkaConsumers::AbstractSub; .../launchpad/update_company_settings.rb:64; .../organizations/company_data_updated.rb:17
bifrost.team.events.v1 NOT consumed yetgrepzero hits for bifrost.team/team.events.v1 in hub_core + hub_worker
Karafka bifrost_launchpad group + live topicsreadhub_worker/karafka.rb:450 consumer_group 'bifrost_launchpad'; :465 topic 'bifrost.user.updates.v1'KafkaConsumers::Users::UserDataUpdated
Consumer offset/idempotencyreadhub_worker/karafka.rb:23/33 automatically_mark_as_consumed = false; consumer calls mark_as_consumed(message) (user_data_updated.rb:25,41,61)
Launchpad Teams-client pattern (Basic auth, /private/*, D8)readapps/launchpad/services/get_last_session.rb:3 < Repositories::AbstractHttp; :9 @base_url = ENV['QONTAK_LAUNCHPAD_API_URL']; :13 auth = ENV['QONTAK_LAUNCHPAD_BASIC_AUTH']; :15 get(path: "/private/users/find_by_email…", headers: { 'Authorization': auth }) — Teams clients mirror this at /private/teams…
Circuit-breaker pattern (D12 template)readrepositories/http/circuit_breakers.rb:5-13 sso_circuit_breakerCircuitbox.circuit(:sso_circuit, { exceptions: [RequestTimeout, RequestError], sleep_window: … , circuit_store: Moneta.new(:Redis, url: ENV['REDIS_W_URL']) }); usage mekari_sso/services/auth.rb:54-63 sso_circuit_breaker.run(exception: false) { raise RequestTimeout if response.timed_out?; raise RequestError if [500,502,504,503].include?(code) }; pref-gated auth.rb:25 enabled?(:enable_sso_circuit_breaker)
Static-key internal endpoint (D11 template)readinternal/v1/auths/resources/crms/auths.rb:3 class, :14 before { validate_crm_api_key! }, :18 post 'tokens', :19-27 Dry::Matcher; header_validation.rb:7 headers['X-Chat-Api-Key'], :13 ENV['CRM_API_KEY'], :14 raise OauthBwergemn::Errors::InvalidToken → 401 (exception_handlers.rb:10,12); success 201 {status:'success',data:...} (success_formatter.rb:3-9)
CRM_API_KEY is a single shared secret (no per-caller registry)greponly header_validation.rb:13 in app code; reused by commerce/resources/{waba,wa_cloud}.rb → justifies a dedicated LAUNCHPAD_MIGRATION_API_KEY (D11)
External HTTP transportgrep/readrepositories/http/request_methods.rb:71 Typhoeus::Request.new(...); :66 headers['Authorization'] = auth; Faraday/RestClient = 0 hits
Division delete fan-outreadrepositories/divisions/delete.rb:22 agent_division.destroy; :38 ResetDivisionsByUser; :43 ResetAllUserDivision; :67 Chatbot::Services::Apis.new.update_division; publish in supervisor_delete_division.rb:26 Publishers::RoomUpdateDivision
Redis key templates + TTLreadservices/redis/divisions/get_divisions_by_user.rb:9 "User::#{@user_id}::Divisions"; reset_divisions_by_user.rb:19 REDIS_W.expire key, [2,3,4].sample.day
Always-recache invariant (D14)division docs §3.4 (code-generated index)invalidation-trigger map: UserDivision::Create/EditResetUsersByDivision+ResetDivisionsByUser(per user)+ResetAllUserDivision; ChannelDivision::Edit → del Channel::*::Divisions/Division::*::Channels+ResetChannelsByUser; Divisions::DeleteResetDivisionsByUser+ResetAllUserDivision; cascade rule "changing a user's divisions must clear that user's channel cache". Confirm exact lines in each repo at implementation.
Routing is channel-driven (D13)division docs §3.5, §5.1 (code-generated index)FetchUserIdsInDivisionByRoom: "resolves users via the room's channel, not the room's stored division_id" (room → GetDivisionsByChannel(channel_id)GetUsersByDivision); auto_assign/by_room.rb uses GetAllUserDivision for the general-vs-division-assigned split. Confirm exact lines at implementation.
Auto-assign is role-gated at the source (D15)read hub_corerepositories/rooms/auto_assign/by_room.rb:19 agents = Models::User.where(organization_id:, role: define_agent_role, is_online: true); :91 all_user = Models::User…where(role: define_agent_role).pluck(:id); :100-103 define_agent_role = ['agent','member'] (or 'agent'). filter_division/filter_channel_division only agents.where(id: user_ids) — an intersection, never a candidate source → Admin/Owner in user_divisions can never be selected (no engine change, D15).
Notification pluck is NOT role-filtered (D15 fix point)read hub_corerepositories/queue_assign_agent/add.rb:52 Models::UserDivision.where(division_id:).pluck(:user_id) (no role join) → would notify admins if present; :65-66 general split uses GroupByRole(['agent','member'])GetAllUserDivision (already role-gated). Only :52 needs an agent/member filter.
Member role already exposed in the member list (D15)division docs §1.4-1.5entities/division.rb users = {id, full_name, role, email}; ES divisions index nested users.role (keyword) — so a member in user_divisions shows in GET /core/v2/divisions with role; no schema change for admin display.
RecacheWorkload on membership/channel change (D13/D14)division docs §3.6recache_workload.rb: total::workload = max_ongoing_chat × (users across the channel's divisions); "Triggered when division membership or channel assignment changes."
List preloads channels (D13 has_channels)division docs §2.1, §5.4SupervisorListDivisionRepositories::Divisions::All "preloads :channels, :users"; channel count is a read-time projection of the already-loaded association (no extra query).
General division is special-cased in caches (D13 dormancy excludes General)division docs §3.2, §3.8caches "deliberately exclude the General division by name (LOWER(divisions.name) != 'general')"; "General division is never cached as a normal division".
Per-org migration worker templatereadworkers/centralized_contacts/qontak_one_migration_worker.rb:3 < AbstractSidekiqWorker; :4 sidekiq_options queue: :centralized_contacts_qontak_one_migration; :6 def perform(organization_id, batch_size)
V1 division CRUD + scopesreadhub_service core/v1/divisions/resources/divisions.rb:49 oauth2 :admin, :owner, :supervisor; :50-61 POST create with Dry::Matcher::ResultMatcher
V2 mount + versionreadcore/v2/config.rb:9 version 'v2', using: :path; core/v2/routes.rb:8-10 mounts Reports/Rooms/Messages
interact_with + ownership guardreadcore/v2/helpers.rb:84-93 interact_with; :195 authorize_user_to_allow_access_resource!; usage divisions.rb:122
Internal service-token authreadinternal/v1/header_validation.rb:6-15 validate_crm_api_key! reads X-Chat-Api-Key vs ENV['CRM_API_KEY']; mounted path POST /api/internal/v1/crms/auths/tokens (config/routes.rb:51, internal/v1/config.rb:9, auths/routes.rb:6)
Metrics convention (OBS anchor)readservices/datadog/capture_custom_metric.rb:4,10-17 CaptureCustomMetric.new(name:, tags:, use_env:).capture(action: :increment|:count|:histogram), gated by DATADOG_ENABLED (:12) + <name>_metric flag (:14,16); real uses contact_lists/create.rb:44 (:upload_contact_status, tags ["status:…"]), messages/search.rb:40 (histogram)
Timeout.timeout precedent = external HTTP only (per-msg-timeout waiver)grep/readonly apps/google/chat/services/space/apis.rb:8 (60s) and apps/qontak_ai/services/online_gambling_checker/apis.rb:9 (3s) — both wrap HTTP; none wrap AR/DB → justifies waiving a consumer Timeout.timeout (§2.C)
Test/lint commands (all 3 repos)readbundle exec rspec app + bundle exec rubocophub_core bitbucket-pipelines.yml:166,95; hub_worker CLAUDE.local.md:13-16; hub_service bitbucket-pipelines.yml:86,166

All anchor rows are backed by an opened file or a grep. No row is inferred from framework heuristics. The auth contracts are now resolved against existing, verified code: outbound = the get_last_session.rb Basic-auth /private/* Launchpad client (D8); circuit breaker = the sso_circuit_breaker Circuitbox pattern (D12); inbound = the crms/auths static-key pattern (D11). No net-new host or token mechanism is introduced — every ENV var already exists except the inbound LAUNCHPAD_MIGRATION_API_KEY and the circuit-breaker tuning vars (§4.A).

Detail 2.1 — Architecture (mermaid)

Component diagram

flowchart TB
admin([Admin - Chat panel FE]) --> gwv2[/hub_service core/v2/divisions/]
lp([Launchpad service]) --> gwint[/hub_service internal/v1/teams/migrate/]
gwv2 --> intr["hub_core V2::Divisions::*"]
gwint --> enq["enqueue Divisions::TeamMigrationWorker"]
enq --> wkr["hub_worker Sidekiq"]
wkr --> bulk["Launchpad::Services::BulkCreateTeam"]
bulk --> lpapi[/Launchpad POST /private/teams/bulk/]
lpapi -. TEAM_MIGRATED .-> topic[["bifrost.team.events.v1"]]
lpassign["V2::Divisions::AssignChannelToTeam"] --> getteam["Launchpad::Services::GetTeam"]
intr --> lpassign
topic --> cons["hub_core KafkaConsumers::Teams::TeamEvents"]
cons --> repo[("Repositories::Divisions::*")]
repo --> pg[("postgres.divisions")]
cons --> rds[("redis division caches")]
repo --> es[("elasticsearch.divisions")]
intr --> repo

Data model (mermaid erDiagram)

erDiagram
organizations ||--o{ divisions : has
divisions ||--o{ user_divisions : has
divisions ||--o{ channel_divisions : has
divisions ||--o{ rooms : "division_id (nullable)"
divisions ||--o{ divisions : "parent_id (nullable, new)"

divisions {
uuid id PK
uuid organization_id FK
string name
boolean default
boolean is_contact_masking
uuid team_id "NEW nullable - Launchpad team ref"
uuid parent_team_id "NEW nullable - Launchpad parent team (hierarchy prep)"
uuid parent_id "NEW nullable - chat parent division (hierarchy prep)"
datetime created_at
datetime updated_at
}
user_divisions {
uuid id PK
uuid user_id FK
uuid division_id FK
}
channel_divisions {
uuid id PK
uuid channel_id FK
uuid division_id FK
}
organizations {
uuid id PK
jsonb settings "use_qontak_one_team, team_migration_status (NEW keys)"
}

State machine — per-CID migration status (organizations.settings['team_migration_status'])

stateDiagram-v2
[*] --> pending: org in scope, flag pre-enable
pending --> processing: TeamMigrationWorker starts (bulk create submitted)
processing --> completed: all active divisions have team_id
processing --> failed: bulk-create error or unresolved division after retries
failed --> processing: worker re-run (resume, skips team_id-set divisions)
completed --> processing: new division added later (re-enters until mapped)
completed --> [*]
stateDiagram-v2
[*] --> unlinked: division exists, team_id NULL
unlinked --> linked: TEAM_MIGRATED/TEAM_CREATED(migrate) w/ app_identifier_id == division_id
linked --> renamed: TEAM_UPDATED update_mask includes name
renamed --> linked
linked --> members_synced: TEAM_UPDATED update_mask includes members
members_synced --> linked
linked --> deleted: TEAM_DELETED (cascade division delete)
deleted --> [*]

Branch & skip flow — team event handling

flowchart TD
ev([bifrost.team.events.v1 message]) --> dedup{event_id already processed?}
dedup -- yes --> skip1[mark_as_consumed; skip]
dedup -- no --> sso{"company_sso_id present & org found & Team mode on?"}
sso -- no --> skip2[log + alert; mark_as_consumed; skip]
sso -- yes --> typ{event_type}
typ -- TEAM_CREATED organic --> skip3[ignore - division created on channel assign]
typ -- TEAM_MIGRATED / CREATED migrate --> mapd[find division by app_identifier_id; set team_id]
typ -- TEAM_UPDATED --> upd[update name if changed; sync full roster - agents, supervisors plus admin/owner display-only]
typ -- TEAM_DELETED --> del[cascade Repositories::Divisions::Delete]
mapd --> recache[reset division Redis caches; reindex ES]
upd --> recache
del --> recache
recache --> done([mark_as_consumed])
skip1 --> done
skip2 --> done
skip3 --> done

Branch & skip flow — dormant (channel-less) division (D13)

A division with zero channels (and not General) is retained but treated as non-existent by runtime processes. Note the split: channel-keyed paths skip it for free; routing/assignment paths need an explicit dormancy check; and list access-control (validate_division) is deliberately left unchanged so the division stays visible in the list (flagged).

flowchart TD
proc([division-touching process]) --> kind{path type}
kind -- "routing / take-next / channel picker\n(channel-keyed)" --> chk1{"Division::id::Channels empty?"}
chk1 -- yes --> auto[skipped automatically - no channel resolves to it]
chk1 -- no --> run1[normal behavior]
kind -- "auto-assign general split\nGetAllUserDivision" --> chk2{"division dormant?\n(channels.empty? and not General)"}
chk2 -- yes --> excl[exclude from division-assigned set - member whose ONLY division is dormant falls back to general - decided D13/OQ-13]
chk2 -- no --> run2[counts as division-assigned]
kind -- "notification to division members\nqueue_assign_agent/add" --> chk3{dormant?}
chk3 -- yes --> nonotif[skip member notification]
chk3 -- no --> notif[notify members]
kind -- "reports attribution" --> chk4{dormant?}
chk4 -- yes --> nofilter[drop channel-derived rooms; historical direct-stamped division_id rooms REMAIN until re-stamped]
chk4 -- no --> filter[normal attribution]
kind -- "list access-control\nvalidate_division (UNCHANGED)" --> keeplist[dormant division the actor is assigned to STILL lists - flagged has_channels=false]
kind -- "list divisions\nGET /core/v2/divisions" --> shown[RETURN division with has_channels=false, channel_count=0 - FE shows note]

Detail 2.2 — Sequence (mermaid, incl. failure paths)

Seq A — Migration trigger + chat-initiated bulk create (per CID)

sequenceDiagram
participant LP as Launchpad (orchestrator)
participant INT as hub_service internal/v1/teams/migrate
participant W as Divisions::TeamMigrationWorker (Sidekiq)
participant DB as Postgres (divisions)
participant BC as Launchpad::Services::BulkCreateTeam
participant LPB as Launchpad POST /private/teams/bulk
LP->>INT: POST /internal/v1/teams/migrate {company_sso_id} (X-Chat-Api-Key)
INT->>INT: validate_launchpad_api_key! (X-Chat-Api-Key == LAUNCHPAD_MIGRATION_API_KEY), resolve org by sso
INT->>W: perform_async(organization_id)
INT-->>LP: 202 Accepted {status: queued}
W->>DB: divisions.where(org, team_id: nil).find_each(batch_size: 100)
W->>DB: settings.team_migration_status = 'processing'
loop batch of unmapped divisions
Note over W,BC: BC uses Basic auth (QONTAK_LAUNCHPAD_BASIC_AUTH), base QONTAK_LAUNCHPAD_API_URL, wrapped in launchpad_circuit_breaker (30s timeout)
W->>BC: bulk items [{company_sso_id, name, member_ids (all agents + supervisors), is_migrate:true, app:'chat', app_identifier_id: division_id}]
BC->>LPB: POST /private/teams/bulk
alt 202 accepted
LPB-->>BC: {upload_id}
BC-->>W: upload_id (mapping arrives async via Seq B)
else 4xx/5xx
LPB-->>BC: error
BC-->>W: Failure (log, retry via Sidekiq, status='failed' after MaxFails)
end
end

Seq B — Team event consumer (map / sync / delete)

sequenceDiagram
participant K as bifrost.team.events.v1
participant C as KafkaConsumers::Teams::TeamEvents (hub_core)
participant DB as Postgres
participant R as Redis division caches
participant ES as Elasticsearch
K->>C: message (event_id, event_type, aggregate_id=team_id, payload)
C->>C: parse, dedup on event_id, check company_sso_id + org + Team mode
alt TEAM_MIGRATED / TEAM_CREATED(migrate)
C->>DB: find division by (org, id = payload.app_identifier_id), UPDATE team_id = aggregate_id
C->>DB: if all active divisions mapped -> settings.team_migration_status='completed'
else TEAM_UPDATED
C->>DB: find division by team_id (index), update name (if in update_mask)
C->>DB: map member sso_ids -> chat users (agents + supervisors + admin/owner display), UserDivision::Edit(managed_by_team: true) (roster replace, supervisor-count bypassed, admins non-routable via engine role-gate)
else TEAM_DELETED
C->>DB: find division by team_id, Repositories::Divisions::Delete (cascade + RoomUpdateDivision)
end
C->>R: ResetDivisionsByUser / ResetAllUserDivision / ResetUsersByDivision
C->>ES: reindex affected division docs
C->>K: mark_as_consumed
Note over C,K: on error -> log + do NOT mark_as_consumed (redelivery), poison after N -> DLQ (OQ-4)

Seq C — Assign channel to team → auto-create division

sequenceDiagram
actor A as Admin
participant H as hub_service PUT /core/v2/divisions/channels
participant I as V2::Divisions::AssignChannelToTeam (hub_core)
participant DB as Postgres
participant GT as Launchpad::Services::GetTeam
A->>H: PUT {team_id, channel_ids[]} (oauth2 :admin/:owner/:supervisor)
H->>H: me.organization_id injected
H->>I: interact_with
I->>DB: find division by (org, team_id)
alt division exists
I->>DB: set channels = channel_ids (channel_divisions)
else no division for team_id
I->>GT: GET /private/teams/{team_id} (+ /members) — Basic auth (QONTAK_LAUNCHPAD_BASIC_AUTH), base QONTAK_LAUNCHPAD_API_URL
alt team found
GT-->>I: {name, members}
I->>DB: create division (team_id set, name), user_divisions = agents + supervisors + admin/owner display, channel_divisions = channel_ids
else team not found / Launchpad error
GT-->>I: Failure
I-->>H: Failure -> 422 "team not found / launchpad unavailable"
end
end
Note over I,DB: recache via existing ChannelDivision::Edit fan-out (D14): del Channel::*::Divisions + Division::*::Channels, ResetChannelsByUser per member, RecacheWorkload
I->>DB: reindex ES, reset Redis (channels/workload)
alt channel_ids empty (all channels unassigned)
Note over I,DB: division kept with team_id but now DORMANT (D13) - skipped by assignment/notification/filter, lists with has_channels=false
end
I-->>H: Success(division entity incl. team_id, has_channels, channel_count)
H-->>A: 200

Detail 2.3 — Database Model (DDL)

Migration follows the repo convention (hub_core database/core/db/migrate/, Rails DSL, YYYYMMDDHHMMSS_snake.rb, inline ES put_mapping). New columns are nullable and additive — safe for orgs not in Team mode.

# database/core/db/migrate/20260710000000_add_team_columns_to_divisions.rb
class AddTeamColumnsToDivisions < ActiveRecord::Migration[6.1]
def change
add_column :divisions, :team_id, :uuid, null: true
add_column :divisions, :parent_team_id, :uuid, null: true
add_column :divisions, :parent_id, :uuid, null: true

# Reverse lookup for TEAM_UPDATED / TEAM_DELETED (which carry aggregate_id = team_id).
add_index :divisions, [:organization_id, :team_id],
name: "index_divisions_on_organization_id_and_team_id"
# NOTE: no index on parent_id this phase — it is a data-only column (hierarchy
# prep, no query reads it yet). Add an index in the phase that introduces the
# hierarchy traversal query, justified by that query (avoids an unused index).

# Additive ES mapping (divisions index is 1 doc/division; low risk). D10.
idx = "#{Rails.env[0..3]}_models_divisions"
if Elasticsearch::Model.client.indices.exists(index: idx)
Elasticsearch::Model.client.indices.put_mapping index: idx, body: {
properties: {
team_id: { type: 'keyword', null_value: 'NULL' },
parent_team_id: { type: 'keyword', null_value: 'NULL' },
parent_id: { type: 'keyword', null_value: 'NULL' }
}
}
end
end
end

No new keys are stored in a dedicated table; two organizations.settings jsonb keys are added via store_accessor (no migration — jsonb column already exists, schema.rb:1288):

# app/core/domains/models/organization.rb (beside :66)
store_accessor :settings, :use_qontak_one_team # bool, per-org Team-mode gate
store_accessor :settings, :team_migration_status # 'pending'|'processing'|'completed'|'failed'
  • Cardinality / growth: divisions ≤ 300 rows/org (MAX_DIVISION), ≤ ~5,000 orgs → < 1.5M rows total; three nullable UUID columns add negligible storage.

  • Example row (post-migration): { id: <uuid>, organization_id: <uuid>, name: "Sales", default: false, team_id: <launchpad-uuid>, parent_team_id: null, parent_id: null }.

  • PII classification: team_id/parent_team_id/parent_id are opaque UUID references — not PII. divisions.name is user-entered team name (low sensitivity, unchanged). No PII added by this RFC.

  • Retention: divisions rows retained for the life of the org (unchanged). team_id cleared only on TEAM_DELETED cascade (row deleted). No join-table retention concern (join table not used — see §0). team_migration_status retained on the org indefinitely (tiny).

  • Per-status lifecycledivisions.team_id link status (no DB enum column; status is derived from team_id presence + team_migration_status):

    StatusVisibilityRetentionRestore semanticsTransitions allowed
    unlinked (team_id NULL)listed normally (legacy behavior)while division existsn/alinked on migrate/create event
    linked (team_id set)listed normally; members read-only in Team modewhile division existsn/a→ renamed / members_synced / deleted
    migration pendingnot user-visibleuntil migration startsn/aprocessing
    migration processingEngineering audit onlyuntil complete/failedresume re-enterscompleted/failed
    migration completeddrives banner (COMM-PH1-S01)indefiniten/aprocessing if new division added
    migration failedEngineering alertuntil resolvedworker re-runprocessing
  • Partition / sharding: none — table is small.

  • NoSQL alternative: rejected — divisions are relational (FKs to org, joins to users/channels/rooms); Postgres is the source of truth (unchanged).

Detail 2.4 — APIs

Base: hub_service Grape. New user-facing endpoints under Core V2 (/api/core/v2, version 'v2', using: :path); the migration trigger under Internal (/api/internal, service-token). All Launchpad calls are outbound HTTP clients (not endpoints we serve) — listed after the tables.

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequest schemaResponse schemaStatus codesIdempotencyVersioningReuse?
/api/core/v2/divisionsGEToauth2 :admin,:owner,:supervisor,:bot,:agent,:member; org-scoped principalquery: pagination, channel_ids[], show_users_with_role, assigned_to_me, ids[] (≤20){ response: [Division{...,team_id, has_channels:bool, channel_count:int}], meta:{pagination} }per-division fields only; per-org team_migration_status is NOT here (D16)200, 401, 422safe (read)v2 (path)extended (reuses SupervisorListDivision)
/api/core/v2/divisions/:idPUToauth2 :admin,:owner,:supervisor; authorize_user_to_allow_access_resource!(Models::Division, id){ name: string }{ response: Division }200, 401, 404, 422name idempotent (same name = no-op)v2new (name-only + Launchpad sync)
/api/core/v2/divisions/channelsPUToauth2 :admin,:owner,:supervisor{ team_id: uuid, channel_ids: uuid[] }{ response: Division }200, 401, 422keyed on (org, team_id) — re-assign is idempotent; empty channel_ids keeps division (D7)v2new (assign + auto-create)

Per-endpoint detail:

  • GET /api/core/v2/divisions — Team-mode list. In Team mode the response marks member/supervisor fields read-only (FE contract) and includes team_id (per-division link). team_migration_status is NOT on this endpoint — the per-org banner status is read from GET /core/v1/organizations/settings (D16). It also returns has_channels (bool) + channel_count (int) per division — a read-time projection of the already-preloaded :channels association (no extra query) — so the FE can render the product-requested "this division has no channels" note on a dormant division (D13). A dormant division is still listed (never hidden); only runtime processes skip it. Reuses Interactors::Divisions::SupervisorListDivision (cursor pagination, is_counted). Example: ?per_page=20&show_users_with_role=agent. Each member in users[] carries its role (existing entity + ES field), so Admin/Owner members synced from Launchpad appear here read-only (D15); the FE division-detail member list must not pass a show_users_with_role filter that excludes admin/owner when it wants to display them.
  • PUT /api/core/v2/divisions/:id — updates divisions.name locally, then enqueues a background name-sync to Launchpad (Launchpad::Services::UpdateTeamNamePATCH /private/teams/{team_id}). If team_id is NULL (not yet migrated) the endpoint still updates the local name and skips the Launchpad call. Rejects the reserved General-division rename (existing rule, Repositories::Divisions::Update). Example req: { "name": "Sales North" }.
  • PUT /api/core/v2/divisions/channels — FLOW-PH1-S01 / UI-PH1-S02. Body { "team_id": "...", "channel_ids": ["..."] }. If no division exists for team_id, fetch the team (name + full member roster) from Launchpad and create it, seeding user_divisions with all members that resolve to a chat agent or supervisor, then set channels. Payload/size limit: channel_ids ≤ org channel count. The channel write delegates to the existing Repositories::Divisions::ChannelDivision::Edit so the standard recache fan-out fires (del Channel::*::Divisions/Division::*::Channels, ResetChannelsByUser per member, RecacheWorkload) — the always-recache invariant (D14). An empty channel_ids is accepted and keeps the division (D7); the division then becomes dormant (D13) and the response reflects has_channels: false.

Inbound webhooks (other services call us)

EndpointMethodAuthN/AuthZSource serviceRequest schemaResponse schemaStatus codesIdempotencyVersioning
/api/internal/v1/teams/migratePOSTstatic API keyX-Chat-Api-Key == ENV['LAUNCHPAD_MIGRATION_API_KEY'] via new validate_launchpad_api_key! (mirrors validate_crm_api_key!); 401 OauthBwergemn::Errors::InvalidToken on mismatch (D11)Launchpad (Heimdall trigger){ company_sso_id: uuid }201 { status:'success', data:{ status:'queued', organization_id } }enqueue is idempotent — worker skips team_id-set divisions; re-trigger safev1 (internal)
bifrost.team.events.v1 (Kafka, not HTTP)consumetopic-level (VPC-internal Kafka)Launchpadenvelope {event_id,event_type,aggregate_id,aggregate_type,version,occurred_at,payload} (Launchpad RFC §6.3)n/a (async)dedup on event_id (Redis SET NX processed_team_event::<event_id>, TTL 7d); per-type handlers idempotentv1

Auth resolved (2026-07-10 rev2). Outbound (we call Launchpad): base ENV['QONTAK_LAUNCHPAD_API_URL'] + HTTP Basic auth ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'], path /private/teams…, wrapped in launchpad_circuit_breaker (D8/D12) — identical to the existing get_last_session.rb client. Inbound (Launchpad calls us): static API key X-Chat-Api-Key/LAUNCHPAD_MIGRATION_API_KEY (D11). The only remaining coordination is that Bifrost's trigger sends the agreed key (§5 OQ-2); the endpoint is implementable now.

Outbound HTTP clients (hub_core → Launchpad; new app/apps/launchpad/services/*)

Common wiring (D8, all three clients — mirrors get_last_session.rb): < Repositories::AbstractHttp; @base_url = ENV['QONTAK_LAUNCHPAD_API_URL']; every request sends headers: { 'Authorization': ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] } (HTTP Basic) with timeout: (ENV['LAUNCHPAD_REQUEST_TIMEOUT'] || 30).to_i; the call is wrapped in launchpad_circuit_breaker.run(exception: false) (D12, when pref :enable_launchpad_circuit_breaker is on — otherwise a plain call); then parse_response(request). Example (bulk, operator-confirmed curl):

POST {QONTAK_LAUNCHPAD_API_URL}/private/teams/bulk
Authorization: Basic <QONTAK_LAUNCHPAD_BASIC_AUTH>
{ "teams": [ { "company_sso_id": "…", "name": "Sales Team",
"member_ids": ["…"], "is_migrate": true, "app": "chat",
"app_identifier_id": "chat-team-123" } ] }
ClientLaunchpad endpoint (reused)Used byNotes
Launchpad::Services::BulkCreateTeamPOST /private/teams/bulkTeamMigrationWorker (Seq A)items carry is_migrate:true, app:'chat', app_identifier_id: division_id, member_ids = all agents + supervisors; returns upload_id (202 async; mapping back via TEAM_MIGRATED)
Launchpad::Services::GetTeamGET /private/teams/{id} (+ /members)AssignChannelToTeam (Seq C)fetch name + full member roster for auto-create
Launchpad::Services::UpdateTeamNamePATCH /private/teams/{id}V2::Divisions::UpdateDivisionbackground name sync

V1 guards (extended, not new endpoints)

  • POST /core/v1/divisions/users and PUT /core/v1/divisions/users (and supervisor assignment): the backing interactors SupervisorCreateUserDivision/SupervisorEditUserDivision return Failure(code: :managed_by_team) (→ HTTP 422) when qontak_one_team_enabled?(organization) is true (MIG-PH1-S01-NEG / UI-PH1-S01).

Detail 2.A — Data Integrity Matrix

Write pathTransaction scopePartial failure behaviorIdempotency key + TTLConsistencyDuplicate-event handlingStale-read handling
Migration: set divisions.team_id (consumer)single-row UPDATE per divisionif update fails, event not mark_as_consumed → redelivery(org, division_id) — skip if team_id already setstrong (Postgres)dedup on event_id; re-set to same value is a no-opRedis reset after write; 2–4d TTL backstop
Bulk create (worker → Launchpad)none local (HTTP call); local status update separateSidekiq retry (MaxFails); status→failed after exhaustionLaunchpad source-ref (company_id, source_identifier=Chat, reference_id=division_id) dedupseventual (event maps back)Launchpad returns existing team, no duplicateworker re-reads team_id IS NULL on resume
Assign channel + auto-create divisionAR transaction: create division + channel_divisions (reuse Divisions::Create pattern)rollback on failure; ES reindex after commit(org, team_id) — existing division reusedstrongn/a (user action, not event)Redis channel/workload reset after commit
Member sync (TEAM_UPDATED)Divisions::UserDivision::Edit(managed_by_team: true) (existing tx; supervisor-count check skipped per D3/D14)event redelivery on failurefull-member-list replace = naturally idempotentstrongdedup on event_idper-user Redis reset (existing cascade)
Team delete cascade (TEAM_DELETED)Divisions::Delete (destroy + join rows)event redelivery; if division already gone, no-opdivision absence = idempotentstrongdedup on event_idRoomUpdateDivision async nullifies rooms.division_id
Name update (V2)single-row UPDATE name; Launchpad PATCH asynclocal commit independent of Launchpad; Launchpad failure logged/retriedsame name = no-opstrong local / eventual remoten/aLaunchpad self-heals on next TEAM_UPDATED

Always-recache invariant (D14). Every write path above that changes membership, channels, or division existence recaches Redis by delegating to the existing repository (UserDivision::Edit, ChannelDivision::Edit, Divisions::Create/Delete) — which already runs the Reset*/Recache* fan-out and RecacheWorkload (division docs §3.4). The consumer must not issue raw UPDATEs for these, or the bidirectional caches + workload counters go stale for up to the 2–4 d TTL. The lone exception is setting team_id alone: team_id is not a Redis routing key, so no routing-cache reset is strictly required — but the ES reindex still runs and, when the same event also changes roster/channels, the delegated recache covers it.

Detail 2.B — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionBehavior on failure
divisions.team_id (one row)team-event consumer (map) vs another consumer redeliverytwo TEAM_MIGRATED for same divisionidempotent UPDATE … WHERE team_id IS NULL (or set-to-same); event_id dedupsecond write is a no-op
divisions row for a team_idAssignChannelToTeam (user) vs consumer auto-mapadmin assigns channel while migration event in flightunique lookup by (org, team_id); if a division already exists it is reused, not duplicatedsecond create path finds existing row → updates channels only
user_divisions for a divisionTEAM_UPDATED member sync vs legacy V1 editlegacy edit while Team mode enablingV1 member edits rejected in Team mode (guard) → only the consumer writes422 on legacy write; consumer is sole writer
divisions.nameV2 name update (user) vs TEAM_UPDATED (rename from Launchpad)admin renames while Launchpad rename arriveslast-writer-wins on name; Launchpad is identity SoT → its TEAM_UPDATED reconcilestransient divergence ≤ one event cycle
organizations.settings['team_migration_status']worker (set processing) vs consumer (set completed)concurrent status writeswrite specific key via store_accessor + with_lock on the org row (pattern: support_tools/services/feature_flag.rb)lock serializes; status is monotonic per run

Detail 2.C — Async Job / Event Consumer Spec

Job/ConsumerTriggerInput shapeRetry (attempts + backoff)DLQ + retentionConcurrencyIdempotency keyPer-msg timeoutPoison-message handling
KafkaConsumers::Teams::TeamEvents (hub_core; wired in hub_worker/karafka.rb bifrost_launchpad group)Kafka bifrost.team.events.v1envelope + payload (Launchpad §6.3)on error: do not mark_as_consumed → Kafka redelivery (at-least-once); after 5 redeliveries → skip-and-alert (see poison handling)app-level skip-after-5 + member_sync_failed alert (net-new; a dedicated DLQ topic is a fast-follow — OQ-4)Karafka group; batch_consuming true (match sibling bifrost topics)event_id — Redis SET NX processed_team_event::<event_id> with 7d TTL (mirrors services/redis/* key style)Waived — no wall-clock per-message timeout (see note below)after 5 failures, log to error sink + mark_as_consumed to avoid blocking partition (mirror UserDataUpdated skip)
Divisions::TeamMigrationWorker (hub_core AbstractSidekiqWorker; queue in hub_worker/config/sidekiq.yml)perform_async(organization_id) from POST /internal/v1/teams/migrate (or ops rake/cron)organization_id (uuid)sidekiq_options queue: :team_migration, retry: 5Sidekiq dead set (default)one job per org; team_migration queue weight 1 (single-lane, throttles Launchpad load — tune with Bifrost)divisions.team_id IS NULL predicate (skip mapped)Sidekiq default (no hard per-job timeout)dead set after retries; alert on migration_job_failed
Background name-sync (inline in worker or a tiny worker)V2 name update{ division_id, team_id, name }Sidekiq retrydead setlowsame name = no-opdefaultlogged; self-heals via TEAM_UPDATED

New queue registration (hub_worker config/sidekiq.yml), e.g. team_migration. New Kafka topic added to the existing bifrost_launchpad group (karafka.rb ~:476):

# hub_worker/karafka.rb — inside consumer_group 'bifrost_launchpad'
topic 'bifrost.team.events.v1' do
consumer KafkaConsumers::Teams::TeamEvents
batch_consuming true
start_from_beginning true
end

Per-message consumer timeout — explicitly WAIVED (with rationale). The TeamEvents handler does only local, bounded work — parse, event_id dedup (SET NX), one find_by, one single-row UPDATE/Divisions::Delete, Redis resets, and an ES reindex; it makes no outbound HTTP call (the Launchpad bulk-create lives in TeamMigrationWorker, and its 30s timeout + launchpad_circuit_breaker are covered by D12). So a wall-clock Timeout.timeout around the handler is unnecessary — and would be an anti-pattern here: the only Timeout.timeout precedent in the repo wraps external HTTP (google/chat/services/space/apis.rb:8 60s; qontak_ai/.../apis.rb:9 3s), never AR/DB work (where Timeout.timeout can corrupt the connection). The effective bounds are: the Karafka group's max_wait_time, the skip-after-5 poison guard, and — the recommended hard cap if one is ever needed — a Postgres statement_timeout (not currently set), NOT Timeout.timeout. Acceptance: a spec asserting a slow/failing handler is redelivered and skipped after 5 attempts (no partition stall).

Detail 2.D — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Orchestrate migration per CIDBifrost (Launchpad)Qontak One migration planPOST /internal/v1/teams/migrate per CIDretriable triggerPRD §11.1 step 1–3, Arch-Decision
2. Enqueue chat migration workerComm (hub_service)POST /internal/v1/teams/migrateSidekiq perform_async202 even if enqueue fails → alertPRD S01/S04
3. Read divisions + call bulk createComm (hub_worker→hub_core)worker performLaunchpad POST /teams/bulkSidekiq retry; status failedPRD S01
4. Create/dedup teams; emit eventsBifrost (Launchpad)POST /private/teams/bulkTEAM_MIGRATED on topicLaunchpad-owned (source-ref dedup)Launchpad RFC §7
5. Map team_id back to divisionComm (hub_core consumer)TEAM_MIGRATEDUPDATE divisions.team_id + recacheredelivery; alert on unresolvedPRD S01 AC-4
6. Ongoing member/name/delete syncComm (hub_core consumer)TEAM_UPDATED/TEAM_DELETEDupdate user_divisions/name / cascade deleteredelivery; DLQ (OQ-4)PRD §10 #3/#5
7. Flip read-only + bannerComm (BE flag) / FEuse_qontak_one_team on + team_migration_status=completedV1 member writes rejected; banner shownfail-open banner (PRD COMM ERR-1)PRD UI-PH1-S01, COMM-PH1-S01

Disagreement to reconcile (blocker until signed off): PRD §17 assigns the link storage to a join table (implying Bifrost owns a team_division_mapping); the Arch-Decision doc assigns it to a chat-owned divisions.team_id column. This RFC follows the Arch-Decision doc (D1). Confirm with Bifrost that no team_division_mapping is expected on either side (§5 OQ-5).

Detail 2.E — State Surface Contract

EntityState field / eventDefault valuesUpdated byRead viaStale window
Divisionteam_idNULL (unlinked)team-event consumer (map)GET /core/v2/divisions≤ event latency (target ≤ 30s)
Divisionmember list (full roster — agents + supervisors, routable; + Admin/Owner display-only, non-routable, D15)existing user_divisions (+ users.role)TEAM_MIGRATED/TEAM_UPDATED consumerGET /core/v2/divisions (shows role), room routing (agents/SPVs only)≤ 30s + Redis TTL (2–4d backstop)
DivisionnameexistingV2 update or TEAM_UPDATEDlist endpoints≤ event/req latency
Divisionhas_channels / channel_count (derived → dormant when 0)channel_count = 0 (dormant)ChannelDivision::Edit (V2 assign-channel) + recacheGET /core/v2/divisions (drives FE "no channels" note)≤ request latency (read-time projection)
Organizationteam_migration_statuspendingworker + consumerGET /core/v1/organizations/settings (UserViewSettings; drives banner; D16)per-CID, updated on completion
Organizationuse_qontak_one_teamfalseops/rollout (settings flag)qontak_one_team_enabled? helperimmediate (settings read)
Roomdivision_idexisting (nullable)unchanged (routing key)existing room endpointsunchanged

Every UI surface in Detail 1.A that shows state (team_id, member list, migration banner) traces to a row here.


3. High-Availability & Security

HA narrative. No new synchronous critical path is added to chat request serving. Migration and sync are async (Sidekiq + Karafka), so Launchpad slowness/outage never blocks chat request threads. When Launchpad is down: routing, SLA, broadcast, and reporting continue on division_id (unchanged); member edits are simply queued as un-processed team events (redelivered when Launchpad/Kafka recover). The V2 assign-channel endpoint degrades gracefully — if Launchpad GET /private/teams/{id} fails, it returns 422 and the admin retries; no partial division is created (transaction rolls back). Full-restart recovery is inherited from the existing Karafka start_from_beginning/offset model and Sidekiq persistence.

Performance Requirement

  • Request path: V2 division endpoints are thin Grape → interactor calls with a single-row read/write + optional one outbound Launchpad call; expected p99 comparable to existing V1 division endpoints. No new sustained-RPS surface.
  • Migration throughput: batched find_each(batch_size: 100) per org (repo idiom); Launchpad bulk cap = 500 items/request (Launchpad §5.1.8) → divisions/org ≤ 300 fit in one bulk call. 5,000 CIDs processed as one Sidekiq job per CID; concurrency governed by the team_migration queue weight (tune to avoid Launchpad rate limits — coordinate with Bifrost).
  • Member-sync propagation: p95 ≤ 30s (PRD §7; SLA set — OQ-3), measured by the team_member_sync_latency histogram; bounded by Kafka consumer lag.
  • Scalability: consumer scales with Karafka partitions (topic partitioned by team_id, ordering per team). Redis caches use the existing 2–4d randomized TTL to avoid stampede.
  • Load test: replay a synthetic bifrost.team.events.v1 batch (create/update/ delete mix) against staging; assert consumer lag drains and team_id mapping completes within target. Migration dry-run on 5–10% sample CIDs (PRD §15).

Monitoring & Alerting

Convention (anchored). Operational metrics are emitted via the existing Services::Datadog::CaptureCustomMetric helper — the repo's standard (e.g. repositories/contact_lists/create.rb:44 Services::Datadog::CaptureCustomMetric.new(name: :upload_contact_status, tags: ["status:#{contact_list.progress}"]).capture; repositories/messages/search.rb:40 uses .capture(action: :histogram, count: …)). So new metrics follow that shape: a symbol name: + low-cardinality tags: (status:, phase:), gated by ENV['DATADOG_ENABLED'] and the per-metric flag the helper requires (ENV['<NAME>_METRIC']='true' when use_env: true, or Flipper pref <name>_metric; capture_custom_metric.rb:12-17). High-cardinality detail (cid, team_id, division_id, reason) is NOT a metric tag — it goes to the structured log line (CustomLogFormat, update_company_settings.rb:76), matching the PRD §13 "Key Events" payloads. The metric name = the PRD §13 event name.

  • Metrics (Datadog, via CaptureCustomMetric):
    • migration_job_completed.capture (increment), tags []; per-CID detail in log.
    • migration_job_failed — increment, tags ["phase:<mapping|bulk_create>"].
    • auto_division_created — increment, tags [].
    • member_sync_completed — increment, tags [].
    • member_sync_failed — increment, tags ["stage:<parse|resolve|persist>"].
    • team_member_sync_latency.capture(action: :histogram, count: <ms>) (USMAN update → user_divisions written), tags [] — tracks the ≤ 30s SLA.
  • RED (from the above + Datadog agent): consumer rate/errors/duration on bifrost.team.events.v1; worker success/failure counts per batch.
  • Alerts (thresholds set — OQ-3 resolved):
    • migration_job_failed rate > 5% of processed CIDs in a single batch run → page both squads (halt migration; PRD §13.1).
    • member_sync_failed > 10 events in a rolling 60-min window (a low starting bar for a ≤ 30s-SLA sync; tune after week-1 baseline) → Eng + PM notification.
    • team_member_sync_latency p95 > 30s over 15 min → Eng notification (SLA breach; Success Criteria #4).
    • assignment-failure-rate increase > 0% vs baseline within 1 week → immediate review (rollback trigger, §4).
  • Dashboard: co-owned Bifrost + Comm (PRD §13); Datadog panels for the new topic's consumer-lag + error-rate, plus the six metrics above.
  • "3am" runbook: if team_id mapping stalls, check (1) Kafka consumer lag on bifrost.team.events.v1, (2) team_migration_status per org, (3) Launchpad bulk-job status via GET /private/teams/bulk/{upload_id}, (4) divisions WHERE team_id IS NULL for the org.

Logging

  • Structured fields per path: organization_id, division_id, team_id, event_id, event_type, company_sso_id (follow existing consumer logs).
  • PII scrubbing: payloads carry only IDs (user_id, sso_id, team_id) — Launchpad publishes no names in events (Launchpad RFC §10). Do not log full member rosters; log counts. No new PII in logs.

Security Implications

  • Threat model: (1) forged migration trigger → mitigated by the dedicated static key X-Chat-Api-Key==LAUNCHPAD_MIGRATION_API_KEY (not the shared CRM_API_KEY), so a leaked CRM key cannot trigger migrations; (2) cross-tenant leak via company_sso_id→org resolution → mitigated by resolving org from the authenticated principal / organization_by_sso and scoping every write to that org; (3) forged Kafka events → topic is VPC-internal (Launchpad RFC §10); (4) SSRF on the outbound Launchpad URL → base URL is ENV-fixed, not user-supplied.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)Permitted methodsTenant scopeAdditional constraintAudit trail
Admin/core/v2/divisions*GET, PUTown org (me.organization_id)authorize_user_to_allow_access_resource! on :id writeslogs + auto_division_created
Owner/core/v2/divisions*GET, PUTown orgsame as Adminsame
Supervisor/core/v2/divisions (GET); PUT per existing spv rulesGET (write gated)own org, validate_division intersectionmember writes blocked in Team modelogs
Agent / Member/core/v2/divisionsGETown org, assigned divisionsread-onlylogs
Bot/core/v2/divisionsGETown orgread-onlylogs
Service — Launchpad/internal/v1/teams/migratePOSTper-CID via company_sso_idX-Chat-Api-Key == LAUNCHPAD_MIGRATION_API_KEY (dedicated key, D11)logs + migration_job_*
System — migration workernone (internal Sidekiq)n/aper-orgnot user-triggeredmigration_job_*

Every role in Detail 1.A appears here. Agents and supervisors are mirrored into user_divisions (matching current behavior, D3) — routing eligibility is then decided by the existing assignment engine, unchanged. Admin/Owner team members are also synced into user_divisions (D15) so they appear read-only in the member list, but are never routable: the assignment engine seeds candidates from role IN ('agent','member') (by_room.rb:19,91), so an admin can never be selected regardless of user_divisions membership.

  • Ownership validation: every division write checks division.organization_id == me.organization_id via authorize_user_to_allow_access_resource! (helpers.rb:195). Consumer writes resolve org from company_sso_id and scope the UPDATE/DELETE to that org.
  • Input validation: team_id/channel_ids[] validated as UUIDs (Grape params); name uses the existing division-name regex (/\A(?:[a-zA-Z0-9][-_()\s]*)+$/i).
  • Injection: ActiveRecord parameterized queries (no raw SQL added); outbound URL is ENV-fixed (QONTAK_LAUNCHPAD_API_URL), not user-supplied.
  • Secrets: QONTAK_LAUNCHPAD_BASIC_AUTH (outbound Basic credential, already provisioned) and LAUNCHPAD_MIGRATION_API_KEY (inbound static key) from deploy-time config / Vault; never logged. The Authorization header value must never appear in logs.
  • Rate limiting: migration trigger is service-to-service; worker concurrency capped by queue weight to respect Launchpad limits.
  • Static analysis: Brakeman runs in CI (all three repos) — must stay clean.
  • ISO 27001/27701: no new PII stored; UUID references only.

Detail 3.A — Failure Mode & Retry Catalog

External callTimeoutRetriesCircuit breakerDLQ + retentionBehavior on persistent failure
Launchpad POST /private/teams/bulk (worker)30s (LAUNCHPAD_REQUEST_TIMEOUT)Sidekiq retry: 5launchpad_circuit_breaker (:launchpad_circuit): opens at error_threshold 50% over time_window 60s once volume_threshold 5 reached; half-open after sleep_window 60s (D12)Sidekiq dead setstatus→failed; migration_job_failed; alert both squads
Launchpad GET /private/teams/{id} (assign-channel)30snone (user-sync); user retrieslaunchpad_circuit_breaker (shared :launchpad_circuit)n/a422 to admin (launchpad_unavailable); no division created
Launchpad PATCH /private/teams/{id} (name)30sSidekiq retrylaunchpad_circuit_breakerdead setlogged; reconciled by next TEAM_UPDATED
Consume bifrost.team.events.v1per-msg wall-clock waived (local DB/Redis/ES only, no outbound HTTP — §2.C note); bounded by group max_wait_timeKafka redelivery (no auto-commit), up to 5n/askip-and-alert after 5 (dedicated DLQ topic = fast-follow, OQ-4)log + mark_as_consumed to unblock partition
POST /internal/v1/teams/migrate (we serve)Grape/Rackcaller retries (idempotent)n/an/a202 or error; caller re-triggers

Circuit breaker (D12) — new launchpad_circuit_breaker beside sso_circuit_breaker, mirroring the Circuitbox pattern (circuit_breakers.rb:5-13) and the call-wrap in mekari_sso/services/auth.rb:44-64:

# app/core/domains/repositories/http/circuit_breakers.rb (add beside sso_circuit_breaker)
def launchpad_circuit_breaker
Circuitbox.circuit(:launchpad_circuit, {
exceptions: [RequestTimeout, RequestError],
volume_threshold: (ENV['LAUNCHPAD_CIRCUIT_BREAKER_VOLUME_THRESHOLD'] || 5).to_i, # min requests in window before it can trip
error_threshold: (ENV['LAUNCHPAD_CIRCUIT_BREAKER_ERROR_THRESHOLD'] || 50).to_i, # % 5xx/timeout to open
time_window: (ENV['LAUNCHPAD_CIRCUIT_BREAKER_TIME_WINDOW'] || 60).to_i, # rolling window (s)
sleep_window: (ENV['LAUNCHPAD_CIRCUIT_BREAKER_SLEEP_WINDOW'] || 60).to_i, # open→half-open after (s); Circuitbox requires >= time_window
circuit_store: Moneta.new(:Redis, url: ENV['REDIS_W_URL'], expires: true)
})
end

Each Launchpad client wraps its request like MekariSso::Services::Auth#call_circuit, and only when pref :enable_launchpad_circuit_breaker is on (else a plain call):

def call_circuit(&blk)
launchpad_circuit_breaker.run(exception: false) do
response = yield # e.g. post(path: "/private/teams/bulk", body:, headers:, timeout: 30)
raise RequestTimeout if response&.timed_out?
raise RequestError if [500, 502, 503, 504].include?(response.code.to_i)
response
end
end
# nil return from .run means the circuit is open → treat as Failure('[Circuitbox] Launchpad unavailable')

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAudit trailUser-visible?
Org not in Team mode (qontak_one_team_enabled? == false)consumer + V2 interactors + V1 guardskip Team logic; legacy V1 path onlylog (debug)no
TEAM_CREATED organic (non-migrate)team consumerignored — division created on channel assignment, not team creationlogno
company_sso_id blank / org not foundconsumerskip event + mark_as_consumed + alertlog + alertno
Division already linked (team_id set)migration worker + consumerskip re-link (idempotency)metricno
Division unassigned from all channels → kept as dormant (D7 + D13)AssignChannelToTeam (empty channel_ids)keep the divisions row + team_id; channel_divisions empty; recache fires (D14)logindirectly (division stays in list, flagged has_channels:false)
Dormant division — agent assignment / routingchannel-keyed resolution (FetchUserIdsInDivisionByRoom) + GetAllUserDivision split (auto_assign/by_room)contributes no candidate agents; its members are not counted as "division-assigned" — a member whose only division is dormant falls back to general (decided, D13/OQ-13)log (debug)no (agents simply not routed via it)
Dormant division — notification deliveryqueue_assign_agent/add (member notify)its members are not notified for that division (no rooms route to it anyway)logno
Dormant division — list access-controlvalidate_division (GetDivisionsByUser) — UNCHANGED (Gap-2)a dormant division the actor is assigned to still appears in GET /core/v2/divisions (flagged); the dormant skip is not applied in this helperyes (listed w/ flag)
Dormant division — reports attributionreport queries (reports/general/unassigned.rb etc.)channel-derived room rows drop; rooms stamped directly with the dormant division_id remain attributed until re-stamped (3c)logno (fewer live rows)
Dormant division — list divisionsGET /core/v2/divisions builderstill returned with has_channels:false/channel_count:0 (product request — FE shows a note)yes (listed w/ note)
Dormant division — other read paths (enumerated, OQ-13)broadcast picker (UserListAllChannelByDivision / list_channels_by_divisions), chatbot division select, ongoing-chat-limit specific_divisions, custom-view filtersa dormant division exposes no channels, so pickers/selects surface nothing routable; limits/custom-views stay configurable but route no rooms; contact-masking still applies to historical roomslogno
Team member resolves to a chat Admin/Ownermember-sync (consumer) → user_divisions; routing engine role-gate; notification role-filtersynced into user_divisions for read-only display (D15) but never routable — engine seeds candidates from role IN ('agent','member') (by_room.rb:19,91), notification role-filtered (queue_assign_agent/add.rb:52). Agents + supervisors are included and routable (D3)log (count)yes (visible read-only in member list; never routed)
V1 member write in Team modeV1 interactor guardreject Failure(:managed_by_team) → 422logyes (422 to caller)

Detail 3.B — Error Response Catalog

Standard chat error shape (Grape then_raise_error!{ errors: [...] }).

EndpointError codeHTTP statusMessageWhenUser-facing?
PUT /core/v2/divisions/channelsteam_not_found422"Team not found in Launchpad"Launchpad GET /teams/{id} 404yes
PUT /core/v2/divisions/channelslaunchpad_unavailable422"Team service unavailable, please retry"Launchpad timeout/5xxyes
PUT /core/v2/divisions/:idgeneral_division_protected422"The General division cannot be renamed"rename of Generalyes
POST /core/v1/divisions/usersmanaged_by_team422"Team members are managed from Qontak One Teams"Team mode enabledyes (drives read-only UI)
POST /internal/v1/teams/migrateunauthorized401invalid/missing X-Chat-Api-Keybad service tokenno (service)
POST /internal/v1/teams/migrateorganization_not_found404company_sso_id not resolvableunknown CIDno (service)

Detail 3.C — Compliance & Data Governance

N/A — no new PII or payment/health data. This RFC adds only opaque UUID references (team_id, parent_team_id, parent_id) and two org settings keys; Kafka payloads carry IDs only (Launchpad RFC §10). Existing division-name and membership data classification is unchanged.


4. Backwards Compatibility and Rollout Plan

Compatibility

  • Existing endpoints: V1 division endpoints keep their request/response shape for non-Team orgs. In Team mode, member-write endpoints return 422 (managed_by_team) — a behavioral change gated per-org by flag, not a shape change. GET /core/v2/divisions shapes are additive (new team_id/has_channels/channel_count fields); team_migration_status is exposed additively on GET /core/v1/organizations/settings via a new Entities::Settings attribute (D16).
  • Schema: three new nullable columns — additive, safe for all orgs; no backfill of existing rows required (they stay team_id NULL = legacy behavior until migrated).
  • division_id: unchanged everywhere — bot routing, SLA, broadcast, reporting keep working with no query changes (the core reason for the column-not-join design, D1).
  • Consumer notification: FE consumes the additive fields; coordinate the FE RFC (OQ-9). Bifrost consumers are unaffected (chat only reads their topic).
  • API version strategy: additive V2 endpoints + additive fields; no deprecation of V1 this phase.

Rollout Strategy

Aligns with PRD §12/§15. Per-org gate: use_qontak_one_team (settings) + unified_app capability.

  • Migration sequence (deploy order):
    1. Ship migration add_team_columns_to_divisions (nullable columns + ES mapping) — inert until used.
    2. Ship hub_core: entity/builder/model updates, qontak_one_team_enabled? helper, Launchpad clients, team-events consumer class, TeamMigrationWorker, V1 guards (guard is behind the flag → inert until flag on).
    3. Ship hub_worker: register team_migration queue + add bifrost.team.events.v1 topic to the bifrost_launchpad group.
    4. Ship hub_service: V2 division routes/resources + internal migrate endpoint (behind flag).
    5. Per-org enablement: run migration (worker maps team_id) → verify → flip use_qontak_one_team on (read-only + banner active).
  • Schema state during migration: columns exist and are populated incrementally as TEAM_MIGRATED events arrive; rows without team_id behave as legacy — no intermediate broken state.
  • Backfill: the "backfill" is the migration worker itself, batched per CID (find_each(batch_size: 100)), rate-limited via queue weight; no bulk row rewrite of historical SLA/broadcast/report data on chat side (D1, Out-of-Scope 2).
  • Feature flag: use_qontak_one_team (default OFF, per-org via organizations.settings); kill-switch = flip OFF → reads/writes revert to V1 path, team_id + Launchpad teams retained.
  • Rollout stages (PRD §15): Internal QA (staging, 5–10% sample dry-run) → Internal Account → Shadow Write (populate team_id, keep V1 read/write primary) → Cutover (flip flag for all in-scope CIDs, ≤ 1-day window) → Assignment-Menu Lock (member writes rejected) → GA + Monitoring.
  • Rollback trigger: assignment-failure-rate increase > 0% vs baseline unresolved within 24h post-cutover; or migration failure rate > 5%/batch (PRD §13.1). Sign-off: Eng Lead + PM + TPM.
  • Rollback mechanism: flip use_qontak_one_team OFF for affected CIDs → V1 path resumes immediately (mid-session safe — no chat/SLA/routing interruption, PRD S05 AC-3). Data written during rollout (team_id, teams) is preserved.
  • Blast radius: worst case = all in-scope CIDs see member fields read-only; routing unaffected (division_id preserved). Rollback is a per-org flag flip.
  • PIC + timeline: per stage per PRD §15 (PM + Eng PIC + TPM both squads).

Detail 4.A — Configuration Contract

Env var / config / flagTypeDefaultRequiredProvisionerSecret?
use_qontak_one_team (org settings key)boolfalseyes (per-org)ops/rollout via settings updateno
team_migration_status (org settings key)string enumpendingset by systemworker/consumerno
QONTAK_LAUNCHPAD_API_URLstring (URL)(deploy)yes (already exists — outbound Teams base, D8)Infrano
QONTAK_LAUNCHPAD_BASIC_AUTHstring(deploy)yes (already exists — outbound Basic credential, D8)Infra (Vault)yes
LAUNCHPAD_REQUEST_TIMEOUTint (s)30no — NEW (outbound per-request timeout, D12)Infrano
LAUNCHPAD_CIRCUIT_BREAKER_{VOLUME_THRESHOLD,ERROR_THRESHOLD,TIME_WINDOW,SLEEP_WINDOW}int5 / 50 / 60 / 60no — NEW (circuit-breaker tuning, D12)Infrano
enable_launchpad_circuit_breaker (preference)boolfalseno — NEW (Flipper pref gating the breaker, mirrors enable_sso_circuit_breaker)ops/rolloutno
LAUNCHPAD_MIGRATION_API_KEYstring(deploy)yes — NEW (inbound migrate endpoint, D11)Infra (Vault)yes
DATADOG_ENABLEDbool(env)yes (already exists — master gate in CaptureCustomMetric)Infrano
Per-metric flags: MIGRATION_JOB_COMPLETED_METRIC, MIGRATION_JOB_FAILED_METRIC, AUTO_DIVISION_CREATED_METRIC, MEMBER_SYNC_COMPLETED_METRIC, MEMBER_SYNC_FAILED_METRIC, TEAM_MEMBER_SYNC_LATENCY_METRICboolfalseno — NEW (each metric gated per capture_custom_metric.rb:13-14 use_env: true)Infrano
team_migration (Sidekiq queue, weight 1)queueyeshub_worker/config/sidekiq.ymlno
bifrost.team.events.v1 (Karafka topic)topicyeshub_worker/karafka.rbno

Detail 4.B — Test Plan (commands the agent will run)

Commands sourced from the repos (do not invent):

LayerCommand (source)What it must prove
hub_core unit/interactor/repobundle exec rspec app (source: hub_core bitbucket-pipelines.yml:166)consumer maps team_id; worker idempotent; V1 guard returns Failure; migration adds columns
hub_core lintbundle exec rubocop (source: hub_core bitbucket-pipelines.yml:95, .overcommit.yml:2-5)style clean (TargetRuby 2.6.3)
hub_core targetedbundle exec rspec app/core/events/kafka_consumers/teamsteam-events consumer specs pass
hub_core targeted (D13/D14)bundle exec rspec app/core/domains/repositories/rooms/auto_assign app/core/domains/services/division app/core/domains/interactors/divisionsdormant division skipped by assignment/notification/filter + listed with has_channels:false; every division-mutation path fires the same Reset*/Recache* services as the legacy path
hub_service request specsbundle exec rspec spec/services/api/core/v2/divisions/resources/divisions_spec.rb (source: hub_service AGENTS.md:12)V2 endpoints: auth scopes, interact_with, ownership guard
hub_service lintbundle exec rubocop (source: hub_service bitbucket-pipelines.yml:86)style clean
hub_workerbundle exec rspec app + bundle exec rubocop (source: hub_worker CLAUDE.local.md:13-16)karafka topic wired; queue registered; worker delegates
Security (all)brakeman --no-exit-on-warn --no-exit-on-error (source: hub_worker CLAUDE.local.md:15)no new warnings
DB migrate (test)RAILS_ENV=test bundle exec rails app:db:migrate (source: CI prep, hub_core bitbucket-pipelines.yml:164)migration up/down clean

Detail 4.C — Agent Execution Plan

Order matters — finish each chunk (acceptance criteria pass) before the next.

OrderChunkFiles to modify/createCommands to runAcceptance criteria (verifiable)
1Migration: add team_id/parent_team_id/parent_id + (org,team_id) index + ES mappinghub_core database/core/db/migrate/20260710000000_add_team_columns_to_divisions.rbRAILS_ENV=test bundle exec rails app:db:migrate; bundle exec rspec app/core/domains/models/division_spec.rb3 columns exist; index index_divisions_on_organization_id_and_team_id present; no parent_id index (data-only); migrate down clean
2Model/entity/builder: expose new columns; ES as_indexed_jsonhub_core models/division.rb, entities/division.rb, builders/division.rb, builders/list_division.rbbundle exec rspec app/core/domains/builders/division_spec.rb; bundle exec rubocopentity has team_id/parent_team_id/parent_id; builder maps them; index doc includes them
3Flag helper: qontak_one_team_enabled?(org) + settings accessorshub_core models/organization.rb, new services/qontak_one_team.rb (or method)bundle exec rspec app/core/domains/models/organization_spec.rbhelper true only when unified_app && use_qontak_one_team; false otherwise
4Launchpad clients (Basic auth, D8) + launchpad_circuit_breaker (D12)hub_core app/apps/launchpad/services/{bulk_create_team,get_team,update_team_name}.rb (+ specs); app/core/domains/repositories/http/circuit_breakers.rb (add method)bundle exec rspec app/apps/launchpad app/core/domains/repositories/httpclients build correct request (stubbed Typhoeus): base ENV['QONTAK_LAUNCHPAD_API_URL'], Authorization: ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'], path /private/teams…, timeout: 30; launchpad_circuit_breaker returns a Circuitbox :launchpad_circuit; breaker-open → Failure; parse_response handled
5Team-events consumerhub_core app/core/events/kafka_consumers/teams/team_events.rb (+ spec)bundle exec rspec app/core/events/kafka_consumers/teamsTEAM_MIGRATED sets team_id; TEAM_UPDATED syncs full roster (agents + supervisors + Admin/Owner as read-only display members, D15) + name; TEAM_DELETED cascades; event_id dedup via Redis SET NX (7d TTL); non-Team org skipped. Recache (D14): roster/delete/name writes delegate to existing repositories (UserDivision::Edit, Divisions::Delete, Divisions::Update) — spec asserts Reset*/Recache* services fire (same as legacy path); no raw UPDATE for membership/channels. Supervisor bypass (D3): consumer calls UserDivision::Edit(managed_by_team: true)spec: a roster with 0 chat-supervisors syncs successfully in Team mode AND the Redis caches (ResetUsersByDivision/ResetDivisionsByUser/ResetAllUserDivision) still reset. Admin display (D15): spec asserts an Admin/Owner in the team roster is written to user_divisions and returned by GET /core/v2/divisions with role (read-only)
6Migration workerhub_core app/core/workers/divisions/team_migration_worker.rb (+ spec); hub_worker config/sidekiq.yml (queue); optional hub_worker/app/workers/* wrapperbundle exec rspec app/core/workers/divisionsworker builds bulk items for team_id IS NULL divisions; skips mapped; sets status; re-run creates no duplicates
7Karafka wiringhub_worker karafka.rb (add topic to bifrost_launchpad)bundle exec rspec app (hub_worker); boot checktopic bifrost.team.events.v1 routed to KafkaConsumers::Teams::TeamEvents
8V1 read-only guardhub_core interactors/divisions/supervisor_create_user_division.rb, supervisor_edit_user_division.rbbundle exec rspec app/core/domains/interactors/divisionsreturns Failure(:managed_by_team) when Team mode; unchanged otherwise
9V2 interactorshub_core interactors/v2/divisions/{update_division,assign_channel_to_team}.rb (+ repos + specs)bundle exec rspec app/core/domains/interactors/v2/divisionsupdate name syncs Launchpad; assign-channel auto-creates division from team; empty channel_ids keeps division (D7); channel write delegates to ChannelDivision::Edit so recache + RecacheWorkload fire (D14 — spec asserts reset services called); response carries has_channels/channel_count (D13)
10V2 HTTP endpointshub_service api/core/v2/divisions/{routes.rb,resources/divisions.rb}; edit api/core/v2/routes.rb (mount)bundle exec rspec spec/services/api/core/v2/divisions/resources/divisions_spec.rbroutes mounted at /api/core/v2/divisions; scopes enforced; authorize_user_to_allow_access_resource! on writes; Dry::Matcher resolves
11Internal migrate endpoint (static key, D11)hub_service api/internal/v1/teams/{routes.rb,resources/teams.rb}; add validate_launchpad_api_key! to internal/v1/header_validation.rb; mount in internal/v1/routes.rbbundle exec rspec spec/services/api/internal/v1/teamsX-Chat-Api-Key==LAUNCHPAD_MIGRATION_API_KEY enforced (401 on mismatch, mirroring crms/auths spec); resolves org by company_sso_id; enqueues TeamMigrationWorker; returns 201/202
12ObservabilityServices::Datadog::CaptureCustomMetric calls in worker + consumer (+ structured logs)bundle exec rspec (stub/assert CaptureCustomMetric receives name:/tags:/capture)six metrics emitted via CaptureCustomMetric with low-cardinality tags (§3); team_member_sync_latency uses action: :histogram; per-CID detail in structured log, not tags
13Dormant-division skip + list has_channels (D13, per OQ-13); admin non-routability (D15); verify recache wiring (D14)hub_core list builder (builders/list_division.rb / repositories/divisions/all.rb projection) for has_channels/channel_count; routing-side dormant skip in repositories/rooms/auto_assign/by_room.rb + services/division/fetch_user_ids_in_division_by_room.rb (or services/redis/divisions/get_all_user_division.rb) + repositories/queue_assign_agent/add.rb + interactors/channel_integrations/user_list_all_channel_by_division.rb; explicitly NOT interactors/abstract_iteractor.rb validate_division (unchanged); D15: add an agent/member role filter to repositories/queue_assign_agent/add.rb:52 member-notification pluck (admins in user_divisions must not be notified); (+ specs)bundle exec rspec app/core/domains/repositories/rooms/auto_assign app/core/domains/repositories/queue_assign_agent app/core/domains/interactors/divisions app/core/domains/services/division app/core/domains/interactors/channel_integrationsPer-surface checklist (channel-less, non-General division): (1) auto-assign → 0 candidate agents (FetchUserIdsInDivisionByRoom); (2) general fallback — an agent whose only division is dormant still receives general/unassigned rooms (3a); (3) notification → members not notified (queue_assign_agent/add); (4) list visibility — an assigned non-admin still sees it in GET /core/v2/divisions with has_channels:false/channel_count:0, and a spec asserts validate_division is not modified; (5) reports — channel-derived rows drop, but a room previously stamped directly with the dormant division_id still counts (3c); (6) broadcast picker / chatbot select expose no channels; (7) General is never dormant; (8) Admin non-routability (D15) — an Admin/Owner in user_divisions yields 0 auto-assign candidates (engine role-gate, no engine change) and is not among queued-room notification recipients (queue_assign_agent/add.rb:52 role-filtered).
14Expose team_migration_status via organizations/settings (D16)hub_core app/core/domains/entities/settings.rb (add attribute :team_migration_status, Types::Strict::String.optional, alphabetical); map the key in builders/abstract_builder.rb:88 prepare_response_settings (or repositories/organizations/settings.rb); (+ user_view_settings_spec.rb)bundle exec rspec app/core/domains/interactors/user_view_settings_spec.rb app/core/domains/builders/settings_spec.rbGET /core/v1/organizations/settings returns team_migration_status (value from organizations.settings, nil/absent when unset); no change to GET /core/v2/divisions response for this key; hub_service endpoint unchanged (route/scopes reused)
15Full regression + lint + security (all repos)all touched filesbundle exec rspec app + bundle exec rubocop + brakeman ... in each repogreen suites; no rubocop/brakeman regressions

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge verification (run in order, per repo):
    1. RAILS_ENV=test bundle exec rails app:db:migrate (hub_core — migration up)
    2. bundle exec rubocop (each repo)
    3. bundle exec rspec app (each repo)
    4. brakeman --no-exit-on-warn --no-exit-on-error (each repo)
  • Post-deploy verification signals:
    • Datadog: bifrost.team.events.v1 consumer lag ≈ 0 and error rate < 0.1% over 15 min after enabling a pilot CID.
    • SQL (pilot org): SELECT count(*) FROM divisions WHERE organization_id = :org AND team_id IS NULL = 0 after migration.
    • Metric migration_job_completed count == number of migrated CIDs; migration_job_failed == 0 (or < 5%/batch).
    • Assignment-failure-rate panel: 0% increase vs baseline (PRD §14) over 24h.
    • Dormant-division check (D13): for a pilot org, unassign all channels from one non-General division and confirm (a) it still appears in GET /core/v2/divisions with has_channels:false, and (b) it draws no auto-assignments (assignment logs show no candidate resolution via it). SQL: SELECT d.id FROM divisions d LEFT JOIN channel_divisions cd ON cd.division_id = d.id WHERE d.organization_id = :org AND LOWER(d.name) <> 'general' AND cd.id IS NULL lists the dormant divisions.
    • Recache check (D14): after a team-event roster/channel change, the affected User::<id>::Divisions / Division::<id>::Users / Division::<id>::Channels Redis keys reflect the new state within one event cycle (not after TTL) — spot-check via redis-cli on a pilot user, or assert in staging replay.
  • Rollback recipe (in order):
    1. Flip use_qontak_one_team OFF for the affected CID(s) (settings update) → chat immediately serves the legacy V1 path; member writes accepted again.
    2. If the new consumer misbehaves: pause the bifrost.team.events.v1 consumer (Karafka) — chat keeps running on division_id; events redeliver on resume.
    3. Do not delete divisions.team_id or Launchpad teams (preserved, PRD S05 AC-2).
    4. Confirm assignment-failure-rate returns to baseline and consumer lag drains within 15 min.
    5. If schema rollback is ever required (unlikely — columns are inert): app:db:migrate:down for 20260710000000 (only after confirming no org depends on team_id).

Detail 4.E — Resource & Cost Notes

  • Compute: one new Sidekiq queue (team_migration) + one new Karafka consumer on an existing group — negligible incremental pods.
  • DB: three nullable UUID columns + one index ((organization_id, team_id)) on divisions (< 1.5M rows) — negligible storage/connection delta. (No parent_id index this phase — data-only column.)
  • Network: outbound Launchpad calls during migration (one bulk/CID) + steady event consumption; no cross-region.
  • Storage growth: ~none beyond the small columns.
  • New infra: none (reuses Kafka, Redis, Sidekiq, Postgres already in place).

5. Concern, Questions, or Known Limitations

#TypeQuestion / limitationOwnerDeadline
OQ-1✅ RESOLVED (2026-07-10 rev2)chat→Launchpad Teams API: base ENV['QONTAK_LAUNCHPAD_API_URL'] + HTTP Basic auth ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] (both already provisioned), path /private/teams…, wrapped in launchpad_circuit_breaker. Reuses the existing get_last_session.rb client pattern verbatim; operator curl confirms POST /private/teams/bulk with Authorization: Basic …. See D8/D12.Comm— (closed)
OQ-2Important (was Blocker)Inbound auth is resolved — static key X-Chat-Api-Key==LAUNCHPAD_MIGRATION_API_KEY (D11); the chat endpoint is implementable now. Remaining coordination: agree the shared key value with Bifrost and confirm their Heimdall trigger sends it (and the per-CID trigger cadence).Comm + BifrostBefore pilot enablement
OQ-3✅ RESOLVED (2026-07-10 rev3)Member-sync SLA = p95 ≤ 30 s (via team_member_sync_latency histogram, alert on p95 > 30s/15min); member_sync_failed alert = > 10 events / rolling 60 min (starting bar, tune after week-1 baseline). Metrics emitted via Services::Datadog::CaptureCustomMetric (§3 Monitoring).Comm Eng + PM— (closed; tune post-launch)
OQ-4Nice-to-have (was Important)Dedicated DLQ topic for bifrost.team.events.v1 (interim: skip-after-5 + alert, specified in §2.C). Add a real DLQ as a fast-follow if the skip-and-alert volume warrants.Comm EngFast-follow post-GA
OQ-5Blocker (sign-off only)Reconcile PRD §17 (dedicated team_division_mapping join table) vs Arch-Decision doc (divisions.team_id column). This RFC follows the column design (D1) and is implementable on it; this is a stakeholder sign-off, not a code blocker. Confirm no join table is expected on either side.PM + Eng Lead (both squads)Before AGREED
OQ-6✅ RESOLVED (2026-07-10)Team mode mirrors the full roster (agents + supervisors) into user_divisions and bypasses the ≥1-supervisor invariant (composition owned by Launchpad); General-division protections unchanged. See D3.Comm Eng— (closed)
OQ-7OpenWhere migration/auto-division-creation failures surface (admin notification vs ops-only) — PRD Open Q #7. Affects whether the consumer emits a user-facing signal.PM + EngBefore dev
OQ-8Open (QA verification)Division-with-no-channel behavior is decided (D13 + OQ-13) — dormant divisions are skipped on the routing/assignment side, still listed with has_channels:false, and validate_division is unchanged. Remaining is QA regression over the enumerated surfaces (§3.A.1): auto-assign candidate pool, general-fallback for members whose only division is dormant, notification, broadcast picker, chatbot division select, reports (channel-derived vs direct-stamped), and non-admin list visibility. No design decision outstanding.Comm Eng + QABefore Assignment-Menu Lock
OQ-9DependencyFE RFC (banner, read-only fields, tooltips, loading states) — needed to consume the additive BE fields: team_id/has_channels/channel_count on GET /core/v2/divisions, team_migration_status on GET /core/v1/organizations/settings (D16), and the 422 read-only contract.FE squadBefore GA
OQ-10OpenRollout scope by plan/tier (PRD Open Q #8) — which CIDs get use_qontak_one_team.PM + GTMBefore cutover
OQ-11✅ RESOLVED (2026-07-11 rev6)Admin-as-team-member is now decided (D15): an Admin/Owner in a Launchpad team is synced into user_divisions and shown read-only in the division member list (GET /core/v2/divisions, which exposes users.role), but is never routable — the assignment engine role-gates candidates (by_room.rb:19,91) and the queued-room notification is role-filtered (queue_assign_agent/add.rb:52). No assignment-engine change.PM + Comm Eng— (decided)
OQ-14Open (bounded audit)Admin non-routability — remaining consumers. Confirm no other consumer of Services::Redis::Divisions::GetUsersByDivision (available-agent listing, participant lookup, take-next eligibility) presents an Admin/Owner as assignable/eligible; add the same role IN ('agent','member') filter to any that does. Auto-assign (by_room.rb) and the general-split are already role-gated; only queue_assign_agent/add.rb:52 needed a fix — this audit confirms completeness.Comm Eng (routing) + QABefore Assignment-Menu Lock
OQ-12✅ MOOT (2026-07-10 rev2)SSO app_name no longer applies — outbound auth reverted from SSO app token to the existing Basic credential (QONTAK_LAUNCHPAD_BASIC_AUTH), and the path is confirmed /private/teams. No SSO token, no app_name, no gateway-path inference.— (closed)
OQ-13✅ RESOLVED (2026-07-10 rev5)Dormant-division semantics closed with the routing owner. 3a — member fallback: a member whose only division is dormant is treated as a general/unscoped agent (still eligible for general/unassigned rooms), consistent with how validate_division already treats a user with no assigned divisions (division docs §2.6). 3b — surfaces (enumerated + decided, §3.A.1): apply the dormancy skip on assignment (FetchUserIdsInDivisionByRoom / GetAllUserDivision), notification (queue_assign_agent/add), broadcast picker (UserListAllChannelByDivision), chatbot division select, ongoing-chat-limit specific_divisions, and custom-view filters; do NOT apply it in validate_division (list access-control — a dormant division you're assigned to still lists, flagged). 3c — reports: channel-derived attribution drops; rooms stamped directly with the dormant division_id remain attributed until re-stamped (no historical backfill this phase). What remains is QA regression across these surfaces — tracked by OQ-8 before the Assignment-Menu Lock stage.Comm Eng (routing) + PM— (decided; QA via OQ-8)

Known limitations: at-least-once event delivery means the consumer must be idempotent on event_id (net-new dedup: Redis SET NX, 7d TTL — §2.C). Last-writer-wins on divisions.name allows ≤ one-event-cycle divergence between a local rename and a Launchpad rename. unified_app/use_qontak_one_team gate is per-org, so mixed Team/non-Team orgs coexist during rollout (intended). Dormancy is derived, not materialized (D13) — a division flips dormant/active the instant its channel set crosses zero, with no stored status to drift; the trade-off is that every routing/assignment read path must apply the channels.empty? check (enumerated + resolved in OQ-13; validate_division list access-control is deliberately left unchanged). Because recache is write-through (D14), routing staleness after any division change is bounded by one event/request cycle, with the 2–4 d TTL only as a backstop.


6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-09Author (draft)Initial backend RFC drafted from PRD + Arch-Decision + Launchpad RFC + division docs; OQ-1/OQ-2/OQ-5 flagged as blockers for AGREED
2026-07-10Reviewer (rfc-reviewer)Scored 7.0 (Strong / HOLD); flagged agent-only member filter, unspecified auth, dedup store, FMC softness
2026-07-10Author + operator guidanceReversed D3 → mirror full roster (agents + supervisors) into user_divisions (SPVs are in user_divisions today). Resolved OQ-2 auth (inbound: dedicated static key LAUNCHPAD_MIGRATION_API_KEY, D11). Pinned dedup store, retry=5, queue weight.
2026-07-10Reviewer (rfc-reviewer v2)Scored 8.5 (Agentic-Ready / PROCEED). Flagged FMC circuit-breaker threshold still unquantified; OQ-12 (SSO app_name) + exact gateway path as integration risks.
2026-07-10Author + operator guidance (rev2)Revised D8 → outbound to Launchpad reverted to the existing Basic-auth client (QONTAK_LAUNCHPAD_API_URL + QONTAK_LAUNCHPAD_BASIC_AUTH, path /private/teams, operator curl-confirmed) — retires OQ-12 and the internal-api inference. Added D12 launchpad_circuit_breaker (Circuitbox :launchpad_circuit, thresholds 5/50%/60s/60s, 30s timeout) — closes the FMC gap.
2026-07-10Reviewer (rfc-reviewer v3)Scored 9.0 (Agentic-Ready / PROCEED). Named 3 polish items to reach 9.5: anchor OBS metric names + set OQ-3 threshold; add/waive consumer per-message timeout; drop the unused parent_id index.
2026-07-10Author (rev3)Applied all 3: OBS metrics anchored to Services::Datadog::CaptureCustomMetric (+ team_member_sync_latency histogram) and OQ-3 thresholds set (p95 ≤ 30s; member_sync_failed > 10/60min); per-message consumer timeout explicitly waived (local-only handler; Timeout.timeout precedent is HTTP-only; recommend Postgres statement_timeout if a hard cap is needed); dropped the unused parent_id index (data-only column).
2026-07-10Author (rev4) + product inputIncorporated two concerns as closed decisions grounded in division docs §2–§5. D13 — a division unassigned from all channels is kept but dormant: skipped by agent assignment, notification, and division-required filtering (leveraging channel-driven routing; explicit skip only on the membership-based surfaces GetAllUserDivision/validate_division/reports), and listed with new has_channels/channel_count fields for the product-requested FE note. D14 — every division-mutation path (Kafka consumer + V2 assign-channel) recaches Redis by delegating to the existing repositories (UserDivision::Edit/ChannelDivision::Edit/Divisions::Create/Delete), matching hub_core's always-recache invariant (§3.4). Added branch/skip diagram, has_channels API field, §3.A.1 rows, exec chunk 13, and OQ-13 (member-fallback + exhaustive surface enumeration).
2026-07-10Author (rev5) + rfc-reviewer v5 (9.0)Closed the 3 rev4 consistency gaps the v5 review flagged. Gap 1 (D14↔D3): UserDivision::Edit gains a managed_by_team: kwarg (default false) that bypasses the supervisor-count check; consumer always passes true; added the "0 chat-supervisors syncs + caches reset" spec (chunk 5); tagged UserDivision::Edit extended. Gap 2 (D13 filter split): validate_division left unchanged for list visibility; dormant skip moved to the routing/assignment consumers (FetchUserIdsInDivisionByRoom/GetAllUserDivision/queue_assign_agent/add/broadcast picker) — updated D13, PRD-to-Schema, anchors, Patterns, branch/skip diagram, §3.A.1 (split into list-access + reports rows). Gap 3 (OQ-13): resolved 3a (member-fallback = general/unscoped), 3b (surfaces enumerated), 3c (reports keep direct-stamped historical rooms); rewrote chunk 13 AC into a per-surface checklist; QA regression remains via OQ-8.
2026-07-11Author (rev6) + product inputIncorporated the PRD requirement "Admin/Owner display: read-only; visible in member list but never included in chat routing" as D15reversing the earlier D3/OQ-11 stance (admins excluded from user_divisions, FE-direct). Grounded in hub_core: auto-assign role-gates candidates (by_room.rb:19,91,100-103 role IN ('agent','member')), so Admin/Owner synced into user_divisions are inherently non-routable — no engine change; the one un-role-filtered path (queue_assign_agent/add.rb:52 queued-room notification) gains a role filter. Updated D3/§3-authz/branch-skip/PRD-to-Schema/anchors/Source-Verification/sequences/State-Surface/UI-PH1-S01/FLOW-PH1-S01/chunks 5 & 13; resolved OQ-11, added OQ-14 (bounded audit of remaining GetUsersByDivision consumers). No schema change (role already on users + ES divisions.users.role).
2026-07-12Author (rev7) + product inputD16 — expose team_migration_status to the FE via the existing GET /core/v1/organizations/settings (Interactors::UserViewSettings), not GET /core/v2/divisions, since the settings endpoint is the canonical org-flag surface and the status is per-org (not per-division). Verified Entities::Settings is a whitelist entity (entities/settings.rb, builders/settings.rb, abstract_builder.rb:88), so exposure needs a new whitelisted attribute (new chunk 14; full-regression renumbered → chunk 15). Removed team_migration_status from the GET /core/v2/divisions response; updated PRD-to-Schema, UI/Consumer Coverage, COMM-PH1-S01, §2.4, State Surface, Compatibility, contracts, anchors, Source Verification, OQ-9. team_id/has_channels/channel_count stay on the divisions list (per-division).

7. Ready for agent execution

Ready for agent execution: yes (for implementation) — the two auth blockers that previously gated chunks 4 and 11 are resolved, the member-sync ambiguity is corrected, and the product concerns (channel-less division behavior + Redis recache + Admin/Owner display + status-via-settings) are closed as D13/D14/D15/D16. All 15 chunks are now implementable without a clarification meeting.

  • OQ-1 (outbound auth) — RESOLVED (rev2): reuse the existing Basic-auth Launchpad client — QONTAK_LAUNCHPAD_API_URL + QONTAK_LAUNCHPAD_BASIC_AUTH, path /private/teams, wrapped in launchpad_circuit_breaker (D8/D12). No new host or token mechanism; OQ-12 retired. Chunk 4 unblocked.
  • OQ-2 (inbound auth) — RESOLVED: dedicated static key LAUNCHPAD_MIGRATION_API_KEY via validate_launchpad_api_key! (D11). Chunk 11 is implementable now; only the shared-key handshake with Bifrost is coordination.
  • FMC circuit breaker — SPECIFIED (D12): launchpad_circuit_breaker (Circuitbox :launchpad_circuit) with quantified thresholds (5 / 50% / 60s / 60s) + 30s per-request timeout — closes the v2-review FMC gap.
  • D3 (member sync) — CORRECTED: mirror the full roster (agents + supervisors) into user_divisions, matching current behavior (zero routing regression).
  • D15 (Admin/Owner display) — SPECIFIED + OQ-11 RESOLVED: Admin/Owner team members are synced into user_divisions for read-only display in the member list (GET /core/v2/divisions exposes users.role) but are never routable — the assignment engine already role-gates candidates (by_room.rb:19,91 role IN ('agent','member')), so no engine change is needed; the one un-role-filtered notification pluck (queue_assign_agent/add.rb:52) gains a role filter. No schema change. Chunks 5 & 13; bounded audit = OQ-14.
  • D13 (channel-less division) — SPECIFIED + OQ-13 RESOLVED: kept but dormant — skipped on the routing/assignment side only (FetchUserIdsInDivisionByRoom / GetAllUserDivision / queue_assign_agent/add / broadcast picker), while validate_division (list access-control) is left unchanged so it still lists, flagged has_channels:false. Member-fallback = general/unscoped (3a); surfaces enumerated (3b); reports keep historical direct-stamped rooms (3c). Chunk 13 now has a per-surface acceptance checklist. Only QA regression remains (OQ-8).
  • D14 (always-recache) — SPECIFIED + D3 RECONCILED: consumer + V2 assign-channel delegate to the existing repositories so the standard Reset*/Recache* + RecacheWorkload fan-out fires (division docs §3.4); UserDivision::Edit gains a managed_by_team: kwarg so the roster sync bypasses the supervisor-count check (D3) — a 0-chat-supervisor roster now syncs and recaches. Chunks 5, 9, 13.

One item remains before production AGREED (not an implementation blocker):

  • OQ-5 — PM + both Eng Leads confirm the divisions.team_id column design (this RFC's D1) over the PRD §17 join table. The code is implementable on the column design today; this is a stakeholder sign-off.

Everything else is execution-ready and satisfies the backend gates:

  • §1 PRD-to-Schema Derivation — complete; every DDL row + endpoint traces to a PRD row.
  • Detail 1.C Per-Story Change Map — all 11 PRD stories mapped once, with verifiable AC and RFC anchors; FE-only aspects marked covered in FE RFC (OQ-9).
  • Repo Reading Guide (Detail 2.0) — anchors named across all 3 repos; contracts classified reuse/extend/new; reading order set.
  • Source Verification table — every anchor/pattern/contract carries concrete evidence (path:line + quoted identifier); unverified items are in §5, not the table.
  • Mermaid diagrams — component, ER, two state machines, branch/skip flow, and three sequence diagrams (incl. failure paths).
  • DDL — complete with per-status lifecycle; every column traces to a PRD-to-Schema row.
  • APIs — outbound (3) + inbound (2) tables + outbound Launchpad client table; every new endpoint tagged reuse/extend/new-with-justification.
  • Data Integrity + Concurrency + Async Consumer specs — complete: retry: 5, event_id dedup via Redis SET NX (7d TTL), skip-after-5 + alert (dedicated DLQ topic is a post-GA fast-follow, OQ-4), idempotency keys per write path.
  • Responsibility Boundary Matrix — complete with the PRD-vs-Arch disagreement surfaced (OQ-5).
  • Failure/Branch/Error catalogs — complete.
  • Configuration Contract — flag named (use_qontak_one_team, default OFF); auth env vars pinned (existing QONTAK_LAUNCHPAD_API_URL + QONTAK_LAUNCHPAD_BASIC_AUTH; new LAUNCHPAD_MIGRATION_API_KEY, LAUNCHPAD_REQUEST_TIMEOUT, circuit-breaker tuning vars, per-metric *_METRIC enable flags + DATADOG_ENABLED).
  • Observability — metrics anchored to Services::Datadog::CaptureCustomMetric with a histogram for the ≤ 30s member-sync SLA; alert thresholds set (OQ-3 resolved).
  • Agent Execution Plan — 15 ordered chunks, each with files + repo-sourced commands + verifiable acceptance criteria (chunk 13 = dormant-division skip + list has_channels; recache verification folded into chunks 5, 9, 13).
  • Verification & Rollback Recipe — runnable commands + named signals + ordered flag-flip rollback.

Implementation can begin now. Before the production cutover / AGREED, close OQ-5 (join-table sign-off) and coordinate the OQ-2 shared-key handshake with Bifrost. (OQ-3 observability thresholds and the per-message-timeout waiver are now resolved in §3 / §2.C.) Dormant-division behavior is specified (D13) and OQ-13 is now resolved (rev5): member-fallback = general/unscoped (3a), surfaces enumerated (3b), reports nuance clarified (3c), and the validate_division list-vs-routing split closed. What remains before the Assignment-Menu Lock stage is QA regression across those surfaces (OQ-8) — not a design gap. A second-pass rfc-reviewer score is recommended to confirm the uplift from the 2026-07-10 revisions.