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 — reasonwhen 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
| Field | Value | Notes |
|---|---|---|
| Status | RFC | IDEA / RFC / ABANDON / AGREED |
| Owner | Communication Squad | Team owning the RFC |
| Author(s) | Dandi Pangestu (Communication Squad — Backend) | Primary author |
| Reviewers | Communication Squad Tech Lead · Bifrost Squad Tech Lead | Tech reviewers across affected squads |
| Approver(s) | Comm Eng Lead · Bifrost Eng Lead · Infosec approver (TBD) | Tech leaders + infosec |
| Submitted Date | 2026-07-09 | Date RFC opened for discussion |
| Last Updated | 2026-07-12 | Bump on every material edit |
| Target Release | 2026-Q3 | Quarter |
| Related Documents | PRD 51227460370 · Arch-Decision 51249054233 · Launchpad Teams 51213828683 · Division docs 1–8 | See §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
| # | Section | What it answers |
|---|---|---|
| 0 | External Context Inputs | Which docs drove this RFC and how conflicts were resolved |
| 1 | Overview | Problem, success criteria, scope, PRD-to-Schema derivation, traceability, decisions, per-story map |
| 2 | Technical Design | Repo Reading Guide → architecture/ER/state/sequence mermaid → DDL → APIs → integrity/concurrency/async |
| 3 | High-Availability & Security | Perf, observability, role×endpoint authz, failure/branch/error catalogs |
| 4 | Backwards Compatibility & Rollout | Migration sequence, flags, Agent Execution Plan, Verification & Rollback Recipe |
| 5 | Concern, Questions, or Known Limitations | Open questions and limits |
| 6 | Comment logs | Review trail |
| 7 | Ready 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.
| Source | Link / path | What this RFC took from it | Reconciliation |
|---|---|---|---|
| PRD — Division → USMAN Team Migration Phase 1 | Confluence 51227460370 | Problem, personas, non-goals, constraints, feature changes (CHG-001), 11 user stories + ACs, rollout stages, observability events, success metrics, dependencies | Conflict (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 51249054233 | Chosen 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-create | Authoritative for architecture. Written by the initiative's engineering owner; aligns with the as-built Launchpad Teams RFC. |
| Launchpad Teams RFC (as-built) | Confluence 51213828683 | Kafka 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 triggers | Authoritative for the Launchpad contract (all features merged). Chat consumes/calls these; chat does not modify them. |
| Division docs 1–8 (current chat) | Confluence 51233980656 … 51250725056 | Current divisions schema, interactors, Redis caches, events/workers, business scenarios, per-channel add_channel_division, HTTP API surface, glossary/flags | Cross-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
- 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 countdivisions WHERE team_id IS NULL AND <org migrated>= 0. - Zero routing regression: failed-chat-assignment rate shows 0% increase vs the pre-migration baseline within 1 week post-cutover (PRD §14).
- Idempotent / resumable migration: re-running the migration worker for any
CID creates no duplicate teams and no duplicate
team_idlinks (Launchpad source-ref dedup + chat-sideteam_id IS NULLguard). - Member sync latency: a Launchpad
TEAM_UPDATEDevent is reflected inuser_divisionsat p95 ≤ 30 s (PRD §7; SLA set — OQ-3), tracked by theteam_member_sync_latencyhistogram. - 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. - 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 withhas_channels: falsefor 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 (unchangedvalidate_division), and lists withchannel_count = 0. - 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
- Team hierarchy behavior — columns are added for future hierarchy support but no hierarchy logic ships (PRD Non-Goal 1). Migrated teams are flat.
- Historical SLA/Broadcast/Report backfill — not needed on the chat side;
chat keeps using
division_idinternally, 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). - Channel/bot routing changes —
division_idremains the routing key; bot config is untouched (PRD Non-Goal 4, MIG-PH1-S02). - CRM Team migration — Chat only (PRD Non-Goal 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).
- Launchpad Teams service changes — the Team API, bulk-create, and event contract are as-built and owned by Bifrost (Launchpad Teams RFC §14 = shipped).
Related Documents
See §0. Primary drivers: PRD 51227460370, Architecture & Flow Decision 51249054233, Launchpad Teams RFC 51213828683.
Assumptions
bifrost.team.events.v1is live and stable before the chat consumer ships; payloads match the as-built shapes in Launchpad Teams RFC §6.3.- Every migrated org has a Launchpad
company_sso_idpopulated (Launchpad OQ-2 flags pre-backfill gaps; chat treats a blankcompany_sso_idevent as a skip + alert, mirroring Launchpad's own skip behavior). app_identifier_idonTEAM_MIGRATED/TEAM_CREATED(migrate)equals the chatdivision_id— this is the mapping key chat uses to setdivisions.team_id. Chat sets it when callingPOST /private/teams/bulk.- The
unified_appcapability and per-orguse_qontak_one_teamflag gate the whole Team flow; orgs without both keep the existing V1 flow, resources, and schema untouched (Arch-Decision doc). - 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_divisionsfrom team events, matching the current division composition. Correction (2026-07-10): an earlier draft mirrored an agent-only subset; that was wrong. Today'suser_divisionsalready 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 intouser_divisionsfor 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
| # | Dependency | Owning team | Deliverable needed | Blocking? |
|---|---|---|---|---|
| 1 | Launchpad Team API + bifrost.team.events.v1 | Bifrost | Stable topic + payloads (shipped, Launchpad RFC §12) | YES |
| 2 | Launchpad POST /teams/bulk (migrate mode) | Bifrost | Multi-company bulk create with is_migrate/app/app_identifier_id (shipped, BIF-8608/8799) | YES |
| 3 | Server-to-server auth chat→Launchpad Teams API | Comm | RESOLVED (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 |
| 4 | Launchpad→chat migration trigger reachability | Bifrost + Comm | Chat 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 |
| 5 | unified_app package flag semantics per org | Platform/Billing | Confirm read path for enable_unified_app_package in Team mode | NO — mechanism verified (organization.rb:66) |
| 6 | Qontak One client migration plan (rollout scope) | Product/GTM | Which CIDs/plans are in scope for cutover | YES — 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 / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| "Each Chat Division references a Launchpad Team" | divisions.team_id uuid NULL | TEAM_MIGRATED/TEAM_CREATED consumer sets it; surfaced in GET /core/v2/divisions | Kafka consumer KafkaConsumers::Teams::TeamEvents; migration add_team_columns_to_divisions | Arch-Decision doc; PRD S01 AC-4 |
| "Prepare for future team hierarchy" | divisions.parent_team_id uuid NULL, divisions.parent_id uuid NULL | none (data-only, no read/write logic this phase) | migration only; no interactor reads them | Arch-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_divisions | consumer + 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_id | no change — internal queries keep using division_id | PRD S02, S03; Arch-Decision doc |
| "Migration is per-CID, idempotent, resumable" | organizations.settings['team_migration_status'] (enum: pending/processing/completed/failed) + divisions.team_id presence | POST /internal/v1/teams/migrate trigger; migration_job_* metrics | Divisions::TeamMigrationWorker (Sidekiq) skips divisions with team_id set | PRD S01, S04 |
| "Rollback: revert read pointer; preserve team + link" | organizations.settings['use_qontak_one_team'] (flag flip only; no data delete) | flag toggle | Services::Preference / settings flag; migration data retained | PRD S05 |
| "Assignment menu member/supervisor fields read-only after migration" | (no new column) enforced via flag | reject on POST/PUT /core/v1/divisions/users | interactor guard qontak_one_team_enabled?(org) → Failure | PRD 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 rows | PUT /core/v2/divisions/channels (body {team_id, channel_ids[]}) | V2::Divisions::AssignChannelToTeam interactor | PRD FLOW-PH1-S01, §10 behavior #2 |
| "Division name editable; sync to Launchpad Team name" | divisions.name (existing) | PUT /core/v2/divisions/:id → PATCH Launchpad team in background | V2::Divisions::UpdateDivision + Launchpad::Services::UpdateTeamName | PRD CHG-001, §10 behavior (name) |
| "Team deleted in Launchpad cascades to Chat Division" | delete divisions row + join rows | TEAM_DELETED consumer → Repositories::Divisions::Delete | consumer + existing delete fan-out | PRD §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 mapped | PRD COMM-PH1-S01 |
| "Division with no channel is retained (not deleted) when unassigned from all channels" | divisions row kept; channel_divisions empty | PUT /core/v2/divisions/channels with empty set keeps the row | V2::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_division | routing/assignment/notification read paths exclude dormant divisions; validate_division (list access-control) unchanged; reports drop channel-derived rows only | Product 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 division | SupervisorListDivision / V2 list builder (preloads :channels) | Product request (FE note); D13 |
| "Any change to a division recaches Redis" | no persisted field — cache side-effect | n/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 requirement | Service / endpoint / job | RFC 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_id | no change (verified) | §1 Out-of-Scope 3, §2.E |
| S03 Historical records resolve to team | n/a — chat keeps division_id; no join | §0 reconciliation, §1 Out-of-Scope 2 |
| S04 Resumable migration | Divisions::TeamMigrationWorker idempotency | §2.C, §2.A |
| S05 Rollback | flag flip use_qontak_one_team | §4 Rollout, §4.D |
| UI-PH1-S01 Read-only members | V1 member-write guard | §2.4 (V1 guards), §3 authz |
| UI-PH1-S02 Channel editable | PUT /core/v2/divisions/channels | §2.4 outbound #2 |
| FLOW-PH1-S01 Auto-create division on channel assign | V2::Divisions::AssignChannelToTeam | §2.2 Seq C, §2.4 #2 |
| COMM-PH1-S01 Banner status | team_migration_status in settings | §2.3, §2.E |
| MIG-PH1-S01-NEG Cannot edit members | interactor guard | §2.4 (V1 guards), §3.A.1 |
| §10 behavior #3 Member sync | TEAM_UPDATED consumer | §2.2 Seq B, §2.C |
| §10 behavior #5 Team delete cascade | TEAM_DELETED consumer | §2.2 Seq B, §2.C |
Reverse (RFC → PRD):
| New endpoint / table / job | PRD need it serves |
|---|---|
divisions.team_id column | S01 AC-4 (link division ↔ team) |
divisions.parent_team_id / parent_id | Non-Goal 1 (future hierarchy prep) |
bifrost.team.events.v1 consumer | §10 behaviors #3/#5, S01 mapping |
POST /internal/v1/teams/migrate | S01/S04 (per-CID trigger) |
PUT /core/v2/divisions/channels | FLOW-PH1-S01, UI-PH1-S02 |
PUT /core/v2/divisions/:id | CHG-001 (name editable + sync) |
| V1 member-write guard | MIG-PH1-S01-NEG, UI-PH1-S01 |
organizations.settings['team_migration_status'] | COMM-PH1-S01 (banner), S04 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads | Required writes | Status 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/divisions | PUT /core/v2/divisions/channels | channel_divisions for the division |
| Assignment menu (name field) | web (admin) | GET /core/v2/divisions | PUT /core/v2/divisions/:id | divisions.name |
| Post-migration banner | web (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 channel | web (admin) | Launchpad GET /teams direct from FE (Launchpad RFC §5.1.4) | — | Launchpad team list |
| Migration audit (Engineering) | internal/ops | metrics migration_job_*, structured logs | — | team_migration_status |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted | Cross-tenant? | Audit trail |
|---|---|---|---|---|
| Admin | OAuth2 scope :admin (hub_service) + org-scoped principal | GET /core/v2/divisions, PUT /core/v2/divisions/:id, PUT /core/v2/divisions/channels | no (org-scoped via me.organization_id) | structured logs + auto_division_created event |
| Owner | OAuth2 :owner | same as Admin | no | same |
| Supervisor (SPV) | OAuth2 :supervisor + validate_division scope | GET /core/v2/divisions (read); writes only if permitted per existing rules | no | logs |
| Agent / Member | OAuth2 :agent/:member | GET /core/v2/divisions (read, own divisions) | no | logs |
| Bot | OAuth2 :bot | GET /core/v2/divisions (read) | no | logs |
| System — migration worker | not user-triggered (Sidekiq) | none (internal) | operates per-org | migration_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/migrate | per-CID via company_sso_id | logs + metrics |
PRD Section Coverage
| PRD § | Title | Where covered / n/a — reason |
|---|---|---|
| 2 | Phase Context | §1 Overview, §0 |
| 3 | One-liner + Problem | §1 Overview |
| 4 | Cost of not shipping | §1 Overview (motivation) |
| 5 | Target Users + Persona | Detail 1.A Role Coverage |
| 6 | Non-Goals | §1 Out of Scope |
| 7 | Constraints | §1 Assumptions/Dependencies; §3 perf |
| 7.1 | Data Lifecycle | §2.3 retention; join-table rows n/a — chat uses team_id` column, no join table |
| 8 | Feature Changes (CHG-001) | §2.4 (V1 guards + V2 name/channel), §3 authz |
| 9 | New Features (banner, auto-division) | §2.4 #2, §2.3 status; banner FE-owned (BE status only) |
| 10 | API & Webhook Behavior (#1–#5) | §2.2, §2.4, §2.C |
| 11.1 | System Flow | §2.2 sequences |
| 11.2 | User Stories | Detail 1.C |
| 12 | Rollout | §4 Rollout |
| 12.1 | Migration Transition Window | §4 Compatibility; Phase A/B backfill n/a — Bifrost-owned on chat |
| 13 | Observability | §3 Monitoring |
| 13.1 | Post-Launch Monitoring | §3 Monitoring, §4 Rollout stop-conditions |
| 14 | Success Metrics | §1 Success Criteria, §3 Monitoring |
| 15 | Launch Plan & Stage Gates | §4 Rollout |
| 16 | Dependencies | §1 Dependencies |
| 17 | Key Decisions | Detail 1.B |
| 18 | Open Questions | §5 |
Detail 1.B — Decisions Closed
| Decision | Chosen option | Alternatives rejected | Why rejected |
|---|---|---|---|
| D1 Division↔Team link storage | team_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 gating | unified_app capability AND use_qontak_one_team flag, checked via a centralized helper qontak_one_team_enabled?(organization) | Global ENV['USE_USMAN_QUERY'] only | The 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 mode | Managed 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 → division | Consume 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 ids | Chat 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 / resumability | Skip 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.settings | Dedicated 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 V1 | New division endpoints under /api/core/v2/divisions; V1 endpoints only gain a read-only guard | Modify V1 endpoints in place | Arch-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 policy | Keep the division (with team_id) when all channels are unassigned; do not delete | Delete the division and recreate on next assignment | Recreating 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 API | RESOLVED (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_DELETED | Hard cascade via existing Repositories::Divisions::Delete (destroys division + join rows, nullifies rooms.division_id via RoomUpdateDivision event) | Soft-delete flag on divisions | No 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_id | Add team_id (keyword) to the divisions ES mapping + as_indexed_json for parity/debug | Skip ES entirely | Internal 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::UserViewSettings → Repositories::Organizations::Settings → Builders::Settings → Entities::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 phasewhere 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 # | Title | Layer scope | BE changes (concrete) | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| MIG-PH1-S01 | Auto-create Team per Division | BE-only + Cross-squad (Launchpad) | Divisions::TeamMigrationWorker; Launchpad::Services::BulkCreateTeam; consumer sets divisions.team_id | rspec: 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-S02 | Bot routing via division_id | Runtime / behavior | none — verified division_id untouched | rspec regression: existing room/queue routing specs pass unchanged | §1 Out-of-Scope 3 · §2.E |
| MIG-PH1-S03 | Historical records resolve to team | n/a — chat keeps division_id; no query-time join (see §0). Launchpad-side backfill = Bifrost | — | — | §0 · §1 Out-of-Scope 2 |
| MIG-PH1-S04 | Resumable migration | BE-only | idempotency guard team_id IS NULL; per-CID team_migration_status in settings | rspec: interrupt→resume processes only unmapped divisions; no duplicate team_id | §2.A · §2.C · §4.C ch.5 |
| MIG-PH1-S05 | Rollback per phase | Config | flip use_qontak_one_team off; data retained | rspec/manual: with flag off, reads/writes use legacy V1 path; divisions.team_id + Launchpad teams preserved | §4 Rollout · §4.D |
| UI-PH1-S01 | Read-only member fields (incl. Admin/Owner display, D15) | BE + FE consumes new (FE RFC pending) | guard in SupervisorCreateUserDivision/SupervisorEditUserDivision → Failure(: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-S02 | Channel field editable | BE + FE consumes new + Runtime / behavior | PUT /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-S01 | Auto-create division on channel assign | BE + FE consumes new + Cross-squad | V2::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-S01 | Post-migration banner | BE + FE consumes new | expose team_migration_status on GET /core/v1/organizations/settings (UserViewSettings + Entities::Settings, D16); consumer sets completed when all divisions mapped | rspec: 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-NEG | Cannot edit members via Assignment menu | BE-only (guard) | backend rejects member/supervisor write in Team mode | rspec: 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_channelsflag) 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'shas_channelsfield iscovered 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 inhub_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 it | What pattern it teaches |
|---|---|---|
hub_core spec/dummy/db/schema.rb:551-560 | Current divisions table shape | UUID PK, (organization_id, name) unique index — where new columns land |
hub_core database/core/db/migrate/20220711012607_add_is_contact_masking_to_divisions.rb | Template for adding a column + inline ES mapping | Rails DSL add_column + put_mapping in one change |
hub_core app/core/domains/models/division.rb:6-50 | Division model associations + ES as_indexed_json/mappings | Where 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-28 | Entity + builder | Add new attributes to the immutable entity and builder mapping |
hub_core app/core/domains/models/organization.rb:8,66,255-257 | MAX_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-71 | Services::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) | UserViewSettings → Repositories::Organizations::Settings → Builders::Settings → whitelist 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-82 | Delete 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.rb | Channel-set edit: division.channels = channels, delete Channel::*::Divisions/Division::*::Channels, per-user channel reset + RecacheWorkload | The 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.rb | Roster edit: recompute cache for every affected user (old ∪ new), reindex, idle-reassignment; also holds the supervisor-count validation | The 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 map | The 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.rb | Candidate-agent resolution: room → GetDivisionsByChannel → GetUsersByDivision | Proves 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-103 | Candidate 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-66 | Notifies 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-1156 | validate_division(actor_id, role, division_ids) — assigned-division filter via GetDivisionsByUser | Left 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.rb | List with preload(:channels, :users) + access control | Where 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:19 | Create + supervisor-count validation | Where the Team-mode validation bypass goes |
hub_core app/core/domains/interactors/divisions/supervisor_delete_division.rb:16-26 | Interactor → repo → publish event | Result-monad + Publishers::* publish pattern |
hub_core app/core/events/kafka_consumers/users/user_data_updated.rb:19-61 | Existing bifrost.user.updates.v1 consumer | Karafka consume loop, mark_as_consumed, delegate to repo |
hub_core app/core/events/kafka_consumers/launchpad/update_company_settings.rb:64-126 | Existing bifrost consumer that mutates DB + busts Redis | Closest template for a "team event → update division + recache" handler |
hub_core app/core/events/kafka_consumers/abstract_sub.rb:3 | Karafka base consumer | < Karafka::BaseConsumer, Dry::Monads, parse_json |
hub_core app/apps/launchpad/services/get_last_session.rb:3-17 | The 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-13 | sso_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-64 | How a client wraps a call in the circuit breaker | call_circuit → sso_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-11 | Existing per-org migration worker | < AbstractSidekiqWorker, sidekiq_options queue:, perform(organization_id, batch_size) |
hub_worker karafka.rb:450-474 | bifrost_launchpad consumer group with the 3 live bifrost topics | Where to add topic 'bifrost.team.events.v1' |
hub_worker config/sidekiq.yml | Queue registry (~156 queues) | Register the new migration queue before referencing it |
hub_worker config/sidekiq_schedule.yml | sidekiq-cron entries | Optional batched-kickoff cron entry |
hub_service app/services/api/core/v1/divisions/resources/divisions.rb:24-229 | V1 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-10 | V2 mount list (Reports/Rooms/Messages) | Add mount API::Core::V2::Divisions::Routes |
hub_service app/services/api/core/v2/helpers.rb:84-93,195 | interact_with, authorize_user_to_allow_access_resource! | Delegation + ownership guard |
hub_service app/services/api/internal/v1/header_validation.rb:6-15 | validate_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-27 | Reference 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
| Contract | Status | Justification | Owner |
|---|---|---|---|
bifrost.team.events.v1 (Kafka topic + payloads) | reused | As-built by Bifrost; chat only consumes | Bifrost |
Launchpad POST /private/teams/bulk (migrate mode) | reused | As-built (BIF-8608/8799); chat calls it with Basic auth | Bifrost |
Launchpad GET /private/teams/{id} + /members | reused | As-built; chat calls for auto-create | Bifrost |
Launchpad PATCH /private/teams/{id} | reused | As-built; chat calls on name update | Bifrost |
bifrost.user.updates.v1 consumer | reused | Already consumed (karafka.rb:465); no change | Comm |
QONTAK_LAUNCHPAD_API_URL + QONTAK_LAUNCHPAD_BASIC_AUTH Launchpad client | reused | New Teams clients use the same base URL + Basic credential as get_last_session.rb (D8) | Comm |
Launchpad /private/teams* surface (/bulk, /{id}, /{id}/members) | reused | Launchpad-owned; chat calls with Basic auth (curl-confirmed) | Bifrost |
sso_circuit_breaker (circuit_breakers.rb) | extended | New sibling launchpad_circuit_breaker (:launchpad_circuit), dedicated per-dependency circuit (D12) | Comm |
validate_crm_api_key! static-key pattern (internal/v1/header_validation.rb) | extended | New sibling validate_launchpad_api_key! + ENV['LAUNCHPAD_MIGRATION_API_KEY'] (dedicated key, not shared CRM_API_KEY) (D11) | Comm |
divisions table | extended | Add team_id, parent_team_id, parent_id | Comm |
Repositories::Divisions::UserDivision::Edit | extended | Add 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::UserViewSettings → Entities::Settings) | extended | Add a team_migration_status attribute to the whitelist Entities::Settings so the FE reads the per-org banner status here (D16); endpoint/route/scopes unchanged | Comm |
GET/POST/PUT /core/v1/divisions* | extended | Add read-only guard in Team mode; otherwise unchanged | Comm |
PUT /core/v2/divisions/:id (name) | new-with-justification | V1 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-justification | No existing endpoint keys off team_id or auto-creates a division; FLOW-PH1-S01 requires it | Comm |
POST /internal/v1/teams/migrate | new-with-justification | No server-to-server per-CID migration trigger exists; Arch-Decision doc requires Launchpad→chat trigger | Comm |
bifrost.team.events.v1 consumer (chat side) | new-with-justification | No team-events consumer exists in chat (verified NOT FOUND); needed for mapping + sync + delete | Comm |
Divisions::TeamMigrationWorker | new-with-justification | No resumable per-CID team-migration worker exists (closest is CentralizedContacts::QontakOneMigrationWorker) | Comm |
Patterns to Follow (and where to find them)
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| HTTP handler shape | Grape resource + oauth2 scope + interact_with + Dry::Matcher::ResultMatcher | hub_service core/v1/divisions/resources/divisions.rb:50-61 | none — mirror in V2 |
| Repository / DB access | Repositories::<Resource>::<Action> < AbstractRepository, returns Dry::Monads Success/Failure | hub_core repositories/divisions/delete.rb:3 | none |
| Kafka consumer | KafkaConsumers::<Domain>::<Name> < KafkaConsumers::AbstractSub, manual mark_as_consumed | hub_core kafka_consumers/launchpad/update_company_settings.rb:64 | Add 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_response | hub_core apps/launchpad/services/get_last_session.rb:3-17 | none — 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 pref | hub_core repositories/http/circuit_breakers.rb:5-13 + apps/mekari_sso/services/auth.rb:44-64 | New launchpad_circuit_breaker (:launchpad_circuit) with explicit thresholds + 30s timeout (D12) |
| Metrics / instrumentation | Services::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 flag | hub_core repositories/contact_lists/create.rb:44, repositories/messages/search.rb:40 | none — new metrics use this helper (§3 Monitoring) |
| Internal static-key endpoint | Grape resource + before { validate_<x>_api_key! } reading X-Chat-Api-Key vs an ENV | hub_service internal/v1/auths/resources/crms/auths.rb:3-27 + header_validation.rb:6-15 | New validate_launchpad_api_key! + dedicated LAUNCHPAD_MIGRATION_API_KEY (not shared CRM_API_KEY) (D11) |
| Error response shape | Dry::Monads Failure → then_raise_error! → Grape error (422/500) | hub_service core/v2/helpers.rb:167-180 | none |
| 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.4 | none 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 GetAllUserDivision | hub_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/repo | hub_core workers/centralized_contacts/qontak_one_migration_worker.rb:3 | Add resumable checkpoint (net-new) |
| Per-org flag | Services::Preference#enabled? OR store_accessor :settings | hub_core preference.rb:21, organization.rb:66 | New centralized qontak_one_team_enabled? combines unified_app + use_qontak_one_team |
| Migration file | Rails DSL, YYYYMMDDHHMMSS_snake.rb, inline put_mapping for ES | hub_core migrate/20220711012607_add_is_contact_masking_to_divisions.rb | none |
Reading Order for the Agent
hub_corespec/dummy/db/schema.rb:551-560— currentdivisionsshape.hub_coreapp/core/domains/models/division.rb— associations + ES mapping.hub_coreapp/core/domains/models/organization.rb:8,66,255-257+services/preference.rb— flags/settings.hub_coreapp/core/events/kafka_consumers/launchpad/update_company_settings.rb— bifrost consumer template.hub_coreapp/core/events/kafka_consumers/users/user_data_updated.rb— Karafka consume loop.hub_coreapp/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).hub_coreapp/core/domains/repositories/divisions/delete.rb— delete fan-out (Redis/ES/chatbot).hub_coreapp/core/workers/centralized_contacts/qontak_one_migration_worker.rb— per-org migration worker.hub_serviceapp/services/api/core/v1/divisions/resources/divisions.rb+core/v2/routes.rb— HTTP shape + V2 mount.hub_serviceinternal/v1/auths/resources/crms/auths.rb+header_validation.rb— static-key internal endpoint template (D11); thenhub_workerkarafka.rb:450-474+config/sidekiq.yml.
Source Verification (anti-hallucination — required)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
divisions table schema | read | schema.rb:551-560: create_table "divisions", id: :uuid; unique index index_divisions_on_organization_id_and_name (:558) |
| Migration style = Rails DSL + inline ES | read | 20220711012607_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 doc | read | division.rb:6-10 has_many :users, through: :user_divisions; :14-29 as_indexed_json; :32-50 mappings dynamic: :strict |
| Division entity + builder | read | entities/division.rb:3-22 (id/organization_id/name/default/...); builders/division.rb:3-28 acts_as_builder_for_entity Entities::Division |
MAX_DIVISION + settings | read | organization.rb:8 MAX_DIVISION = 300; :66 store_accessor :settings, :enable_unified_app_package; :255-257 can_create_division? |
organizations.settings jsonb | read | schema.rb:1288 t.jsonb "settings", default: {}, null: false; GIN index :1311 |
| Settings endpoint is a whitelist entity (D16) | read hub_core | interactors/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 helper | read | services/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-new | grep | zero hits in app/, config/, database/ of hub_core (only docs mention team_id) |
| Existing bifrost consumers live in hub_core | read | kafka_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 yet | grep | zero hits for bifrost.team/team.events.v1 in hub_core + hub_worker |
Karafka bifrost_launchpad group + live topics | read | hub_worker/karafka.rb:450 consumer_group 'bifrost_launchpad'; :465 topic 'bifrost.user.updates.v1' → KafkaConsumers::Users::UserDataUpdated |
| Consumer offset/idempotency | read | hub_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) | read | apps/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) | read | repositories/http/circuit_breakers.rb:5-13 sso_circuit_breaker → Circuitbox.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) | read | internal/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) | grep | only 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 transport | grep/read | repositories/http/request_methods.rb:71 Typhoeus::Request.new(...); :66 headers['Authorization'] = auth; Faraday/RestClient = 0 hits |
| Division delete fan-out | read | repositories/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 + TTL | read | services/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/Edit → ResetUsersByDivision+ResetDivisionsByUser(per user)+ResetAllUserDivision; ChannelDivision::Edit → del Channel::*::Divisions/Division::*::Channels+ResetChannelsByUser; Divisions::Delete → ResetDivisionsByUser+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_core | repositories/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_core | repositories/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.5 | entities/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.6 | recache_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.4 | SupervisorListDivision → Repositories::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.8 | caches "deliberately exclude the General division by name (LOWER(divisions.name) != 'general')"; "General division is never cached as a normal division". |
| Per-org migration worker template | read | workers/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 + scopes | read | hub_service core/v1/divisions/resources/divisions.rb:49 oauth2 :admin, :owner, :supervisor; :50-61 POST create with Dry::Matcher::ResultMatcher |
| V2 mount + version | read | core/v2/config.rb:9 version 'v2', using: :path; core/v2/routes.rb:8-10 mounts Reports/Rooms/Messages |
interact_with + ownership guard | read | core/v2/helpers.rb:84-93 interact_with; :195 authorize_user_to_allow_access_resource!; usage divisions.rb:122 |
| Internal service-token auth | read | internal/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) | read | services/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/read | only 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) | read | bundle exec rspec app + bundle exec rubocop — hub_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.rbBasic-auth/private/*Launchpad client (D8); circuit breaker = thesso_circuit_breakerCircuitbox pattern (D12); inbound = thecrms/authsstatic-key pattern (D11). No net-new host or token mechanism is introduced — every ENV var already exists except the inboundLAUNCHPAD_MIGRATION_API_KEYand 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 --> [*]
State machine — per-division team link (divisions.team_id)
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_idare opaque UUID references — not PII.divisions.nameis user-entered team name (low sensitivity, unchanged). No PII added by this RFC. -
Retention:
divisionsrows retained for the life of the org (unchanged).team_idcleared only onTEAM_DELETEDcascade (row deleted). No join-table retention concern (join table not used — see §0).team_migration_statusretained on the org indefinitely (tiny). -
Per-status lifecycle —
divisions.team_idlink status (no DB enum column; status is derived fromteam_idpresence +team_migration_status):Status Visibility Retention Restore semantics Transitions allowed unlinked(team_id NULL)listed normally (legacy behavior) while division exists n/a → linkedon migrate/create eventlinked(team_idset)listed normally; members read-only in Team mode while division exists n/a → renamed / members_synced / deleted migration pendingnot user-visible until migration starts n/a → processingmigration processingEngineering audit only until complete/failed resume re-enters → completed/failedmigration completeddrives banner (COMM-PH1-S01) indefinite n/a → processingif new division addedmigration failedEngineering alert until resolved worker re-run → processing -
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)
| Endpoint | Method | AuthN/AuthZ | Request schema | Response schema | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/api/core/v2/divisions | GET | oauth2 :admin,:owner,:supervisor,:bot,:agent,:member; org-scoped principal | query: 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, 422 | safe (read) | v2 (path) | extended (reuses SupervisorListDivision) |
/api/core/v2/divisions/:id | PUT | oauth2 :admin,:owner,:supervisor; authorize_user_to_allow_access_resource!(Models::Division, id) | { name: string } | { response: Division } | 200, 401, 404, 422 | name idempotent (same name = no-op) | v2 | new (name-only + Launchpad sync) |
/api/core/v2/divisions/channels | PUT | oauth2 :admin,:owner,:supervisor | { team_id: uuid, channel_ids: uuid[] } | { response: Division } | 200, 401, 422 | keyed on (org, team_id) — re-assign is idempotent; empty channel_ids keeps division (D7) | v2 | new (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 includesteam_id(per-division link).team_migration_statusis NOT on this endpoint — the per-org banner status is read fromGET /core/v1/organizations/settings(D16). It also returnshas_channels(bool) +channel_count(int) per division — a read-time projection of the already-preloaded:channelsassociation (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. ReusesInteractors::Divisions::SupervisorListDivision(cursor pagination,is_counted). Example:?per_page=20&show_users_with_role=agent. Each member inusers[]carries itsrole(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 ashow_users_with_rolefilter that excludesadmin/ownerwhen it wants to display them.PUT /api/core/v2/divisions/:id— updatesdivisions.namelocally, then enqueues a background name-sync to Launchpad (Launchpad::Services::UpdateTeamName→PATCH /private/teams/{team_id}). Ifteam_idis 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 forteam_id, fetch the team (name + full member roster) from Launchpad and create it, seedinguser_divisionswith 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 existingRepositories::Divisions::ChannelDivision::Editso the standard recache fan-out fires (delChannel::*::Divisions/Division::*::Channels,ResetChannelsByUserper member,RecacheWorkload) — the always-recache invariant (D14). An emptychannel_idsis accepted and keeps the division (D7); the division then becomes dormant (D13) and the response reflectshas_channels: false.
Inbound webhooks (other services call us)
| Endpoint | Method | AuthN/AuthZ | Source service | Request schema | Response schema | Status codes | Idempotency | Versioning |
|---|---|---|---|---|---|---|---|---|
/api/internal/v1/teams/migrate | POST | static API key — X-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 safe | v1 (internal) | |
bifrost.team.events.v1 (Kafka, not HTTP) | consume | topic-level (VPC-internal Kafka) | Launchpad | envelope {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 idempotent | v1 |
✅ Auth resolved (2026-07-10 rev2). Outbound (we call Launchpad): base
ENV['QONTAK_LAUNCHPAD_API_URL']+ HTTP Basic authENV['QONTAK_LAUNCHPAD_BASIC_AUTH'], path/private/teams…, wrapped inlaunchpad_circuit_breaker(D8/D12) — identical to the existingget_last_session.rbclient. Inbound (Launchpad calls us): static API keyX-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" } ] }
| Client | Launchpad endpoint (reused) | Used by | Notes |
|---|---|---|---|
Launchpad::Services::BulkCreateTeam | POST /private/teams/bulk | TeamMigrationWorker (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::GetTeam | GET /private/teams/{id} (+ /members) | AssignChannelToTeam (Seq C) | fetch name + full member roster for auto-create |
Launchpad::Services::UpdateTeamName | PATCH /private/teams/{id} | V2::Divisions::UpdateDivision | background name sync |
V1 guards (extended, not new endpoints)
POST /core/v1/divisions/usersandPUT /core/v1/divisions/users(and supervisor assignment): the backing interactorsSupervisorCreateUserDivision/SupervisorEditUserDivisionreturnFailure(code: :managed_by_team)(→ HTTP 422) whenqontak_one_team_enabled?(organization)is true (MIG-PH1-S01-NEG / UI-PH1-S01).
Detail 2.A — Data Integrity Matrix
| Write path | Transaction scope | Partial failure behavior | Idempotency key + TTL | Consistency | Duplicate-event handling | Stale-read handling |
|---|---|---|---|---|---|---|
Migration: set divisions.team_id (consumer) | single-row UPDATE per division | if update fails, event not mark_as_consumed → redelivery | (org, division_id) — skip if team_id already set | strong (Postgres) | dedup on event_id; re-set to same value is a no-op | Redis reset after write; 2–4d TTL backstop |
| Bulk create (worker → Launchpad) | none local (HTTP call); local status update separate | Sidekiq retry (MaxFails); status→failed after exhaustion | Launchpad source-ref (company_id, source_identifier=Chat, reference_id=division_id) dedups | eventual (event maps back) | Launchpad returns existing team, no duplicate | worker re-reads team_id IS NULL on resume |
| Assign channel + auto-create division | AR transaction: create division + channel_divisions (reuse Divisions::Create pattern) | rollback on failure; ES reindex after commit | (org, team_id) — existing division reused | strong | n/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 failure | full-member-list replace = naturally idempotent | strong | dedup on event_id | per-user Redis reset (existing cascade) |
Team delete cascade (TEAM_DELETED) | Divisions::Delete (destroy + join rows) | event redelivery; if division already gone, no-op | division absence = idempotent | strong | dedup on event_id | RoomUpdateDivision async nullifies rooms.division_id |
| Name update (V2) | single-row UPDATE name; Launchpad PATCH async | local commit independent of Launchpad; Launchpad failure logged/retried | same name = no-op | strong local / eventual remote | n/a | Launchpad 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 theReset*/Recache*fan-out andRecacheWorkload(division docs §3.4). The consumer must not issue rawUPDATEs for these, or the bidirectional caches + workload counters go stale for up to the 2–4 d TTL. The lone exception is settingteam_idalone:team_idis 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
| Resource | Writers | Collision scenario | Resolution | Behavior on failure |
|---|---|---|---|---|
divisions.team_id (one row) | team-event consumer (map) vs another consumer redelivery | two TEAM_MIGRATED for same division | idempotent UPDATE … WHERE team_id IS NULL (or set-to-same); event_id dedup | second write is a no-op |
divisions row for a team_id | AssignChannelToTeam (user) vs consumer auto-map | admin assigns channel while migration event in flight | unique lookup by (org, team_id); if a division already exists it is reused, not duplicated | second create path finds existing row → updates channels only |
user_divisions for a division | TEAM_UPDATED member sync vs legacy V1 edit | legacy edit while Team mode enabling | V1 member edits rejected in Team mode (guard) → only the consumer writes | 422 on legacy write; consumer is sole writer |
divisions.name | V2 name update (user) vs TEAM_UPDATED (rename from Launchpad) | admin renames while Launchpad rename arrives | last-writer-wins on name; Launchpad is identity SoT → its TEAM_UPDATED reconciles | transient divergence ≤ one event cycle |
organizations.settings['team_migration_status'] | worker (set processing) vs consumer (set completed) | concurrent status writes | write 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/Consumer | Trigger | Input shape | Retry (attempts + backoff) | DLQ + retention | Concurrency | Idempotency key | Per-msg timeout | Poison-message handling |
|---|---|---|---|---|---|---|---|---|
KafkaConsumers::Teams::TeamEvents (hub_core; wired in hub_worker/karafka.rb bifrost_launchpad group) | Kafka bifrost.team.events.v1 | envelope + 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: 5 | Sidekiq 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 retry | dead set | low | same name = no-op | default | logged; 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
TeamEventshandler does only local, bounded work — parse,event_iddedup (SET NX), onefind_by, one single-rowUPDATE/Divisions::Delete, Redis resets, and an ES reindex; it makes no outbound HTTP call (the Launchpad bulk-create lives inTeamMigrationWorker, and its 30s timeout +launchpad_circuit_breakerare covered by D12). So a wall-clockTimeout.timeoutaround the handler is unnecessary — and would be an anti-pattern here: the onlyTimeout.timeoutprecedent in the repo wraps external HTTP (google/chat/services/space/apis.rb:860s;qontak_ai/.../apis.rb:93s), never AR/DB work (whereTimeout.timeoutcan corrupt the connection). The effective bounds are: the Karafka group'smax_wait_time, the skip-after-5 poison guard, and — the recommended hard cap if one is ever needed — a Postgresstatement_timeout(not currently set), NOTTimeout.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 / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Orchestrate migration per CID | Bifrost (Launchpad) | Qontak One migration plan | POST /internal/v1/teams/migrate per CID | retriable trigger | PRD §11.1 step 1–3, Arch-Decision |
| 2. Enqueue chat migration worker | Comm (hub_service) | POST /internal/v1/teams/migrate | Sidekiq perform_async | 202 even if enqueue fails → alert | PRD S01/S04 |
| 3. Read divisions + call bulk create | Comm (hub_worker→hub_core) | worker perform | Launchpad POST /teams/bulk | Sidekiq retry; status failed | PRD S01 |
| 4. Create/dedup teams; emit events | Bifrost (Launchpad) | POST /private/teams/bulk | TEAM_MIGRATED on topic | Launchpad-owned (source-ref dedup) | Launchpad RFC §7 |
5. Map team_id back to division | Comm (hub_core consumer) | TEAM_MIGRATED | UPDATE divisions.team_id + recache | redelivery; alert on unresolved | PRD S01 AC-4 |
| 6. Ongoing member/name/delete sync | Comm (hub_core consumer) | TEAM_UPDATED/TEAM_DELETED | update user_divisions/name / cascade delete | redelivery; DLQ (OQ-4) | PRD §10 #3/#5 |
| 7. Flip read-only + banner | Comm (BE flag) / FE | use_qontak_one_team on + team_migration_status=completed | V1 member writes rejected; banner shown | fail-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-owneddivisions.team_idcolumn. This RFC follows the Arch-Decision doc (D1). Confirm with Bifrost that noteam_division_mappingis expected on either side (§5 OQ-5).
Detail 2.E — State Surface Contract
| Entity | State field / event | Default values | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Division | team_id | NULL (unlinked) | team-event consumer (map) | GET /core/v2/divisions | ≤ event latency (target ≤ 30s) |
| Division | member list (full roster — agents + supervisors, routable; + Admin/Owner display-only, non-routable, D15) | existing user_divisions (+ users.role) | TEAM_MIGRATED/TEAM_UPDATED consumer | GET /core/v2/divisions (shows role), room routing (agents/SPVs only) | ≤ 30s + Redis TTL (2–4d backstop) |
| Division | name | existing | V2 update or TEAM_UPDATED | list endpoints | ≤ event/req latency |
| Division | has_channels / channel_count (derived → dormant when 0) | channel_count = 0 (dormant) | ChannelDivision::Edit (V2 assign-channel) + recache | GET /core/v2/divisions (drives FE "no channels" note) | ≤ request latency (read-time projection) |
| Organization | team_migration_status | pending | worker + consumer | GET /core/v1/organizations/settings (UserViewSettings; drives banner; D16) | per-CID, updated on completion |
| Organization | use_qontak_one_team | false | ops/rollout (settings flag) | qontak_one_team_enabled? helper | immediate (settings read) |
| Room | division_id | existing (nullable) | unchanged (routing key) | existing room endpoints | unchanged |
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 theteam_migrationqueue 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_latencyhistogram; 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.v1batch (create/update/ delete mix) against staging; assert consumer lag drains andteam_idmapping 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_divisionswritten), 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_failedrate > 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_latencyp95 > 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_idmapping stalls, check (1) Kafka consumer lag onbifrost.team.events.v1, (2)team_migration_statusper org, (3) Launchpad bulk-job status viaGET /private/teams/bulk/{upload_id}, (4)divisions WHERE team_id IS NULLfor 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 sharedCRM_API_KEY), so a leaked CRM key cannot trigger migrations; (2) cross-tenant leak viacompany_sso_id→org resolution → mitigated by resolving org from the authenticated principal /organization_by_ssoand 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
| Role | Endpoint(s) | Permitted methods | Tenant scope | Additional constraint | Audit trail |
|---|---|---|---|---|---|
| Admin | /core/v2/divisions* | GET, PUT | own org (me.organization_id) | authorize_user_to_allow_access_resource! on :id writes | logs + auto_division_created |
| Owner | /core/v2/divisions* | GET, PUT | own org | same as Admin | same |
| Supervisor | /core/v2/divisions (GET); PUT per existing spv rules | GET (write gated) | own org, validate_division intersection | member writes blocked in Team mode | logs |
| Agent / Member | /core/v2/divisions | GET | own org, assigned divisions | read-only | logs |
| Bot | /core/v2/divisions | GET | own org | read-only | logs |
| Service — Launchpad | /internal/v1/teams/migrate | POST | per-CID via company_sso_id | X-Chat-Api-Key == LAUNCHPAD_MIGRATION_API_KEY (dedicated key, D11) | logs + migration_job_* |
| System — migration worker | none (internal Sidekiq) | n/a | per-org | not user-triggered | migration_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 intouser_divisions(D15) so they appear read-only in the member list, but are never routable: the assignment engine seeds candidates fromrole IN ('agent','member')(by_room.rb:19,91), so an admin can never be selected regardless ofuser_divisionsmembership.
- Ownership validation: every division write checks
division.organization_id == me.organization_idviaauthorize_user_to_allow_access_resource!(helpers.rb:195). Consumer writes resolve org fromcompany_sso_idand scope theUPDATE/DELETEto that org. - Input validation:
team_id/channel_ids[]validated as UUIDs (Grapeparams);nameuses 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) andLAUNCHPAD_MIGRATION_API_KEY(inbound static key) from deploy-time config / Vault; never logged. TheAuthorizationheader 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 call | Timeout | Retries | Circuit breaker | DLQ + retention | Behavior on persistent failure |
|---|---|---|---|---|---|
Launchpad POST /private/teams/bulk (worker) | 30s (LAUNCHPAD_REQUEST_TIMEOUT) | Sidekiq retry: 5 | launchpad_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 set | status→failed; migration_job_failed; alert both squads |
Launchpad GET /private/teams/{id} (assign-channel) | 30s | none (user-sync); user retries | launchpad_circuit_breaker (shared :launchpad_circuit) | n/a | 422 to admin (launchpad_unavailable); no division created |
Launchpad PATCH /private/teams/{id} (name) | 30s | Sidekiq retry | launchpad_circuit_breaker | dead set | logged; reconciled by next TEAM_UPDATED |
Consume bifrost.team.events.v1 | per-msg wall-clock waived (local DB/Redis/ES only, no outbound HTTP — §2.C note); bounded by group max_wait_time | Kafka redelivery (no auto-commit), up to 5 | n/a | skip-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/Rack | caller retries (idempotent) | n/a | n/a | 202 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 trigger | Where checked | Downstream effect | Audit trail | User-visible? |
|---|---|---|---|---|
Org not in Team mode (qontak_one_team_enabled? == false) | consumer + V2 interactors + V1 guard | skip Team logic; legacy V1 path only | log (debug) | no |
TEAM_CREATED organic (non-migrate) | team consumer | ignored — division created on channel assignment, not team creation | log | no |
company_sso_id blank / org not found | consumer | skip event + mark_as_consumed + alert | log + alert | no |
Division already linked (team_id set) | migration worker + consumer | skip re-link (idempotency) | metric | no |
| 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) | log | indirectly (division stays in list, flagged has_channels:false) |
| Dormant division — agent assignment / routing | channel-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 delivery | queue_assign_agent/add (member notify) | its members are not notified for that division (no rooms route to it anyway) | log | no |
| Dormant division — list access-control | validate_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 helper | — | yes (listed w/ flag) |
| Dormant division — reports attribution | report 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) | log | no (fewer live rows) |
| Dormant division — list divisions | GET /core/v2/divisions builder | still 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 filters | a 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 rooms | log | no |
| Team member resolves to a chat Admin/Owner | member-sync (consumer) → user_divisions; routing engine role-gate; notification role-filter | synced 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 mode | V1 interactor guard | reject Failure(:managed_by_team) → 422 | log | yes (422 to caller) |
Detail 3.B — Error Response Catalog
Standard chat error shape (Grape then_raise_error! → { errors: [...] }).
| Endpoint | Error code | HTTP status | Message | When | User-facing? |
|---|---|---|---|---|---|
PUT /core/v2/divisions/channels | team_not_found | 422 | "Team not found in Launchpad" | Launchpad GET /teams/{id} 404 | yes |
PUT /core/v2/divisions/channels | launchpad_unavailable | 422 | "Team service unavailable, please retry" | Launchpad timeout/5xx | yes |
PUT /core/v2/divisions/:id | general_division_protected | 422 | "The General division cannot be renamed" | rename of General | yes |
POST /core/v1/divisions/users | managed_by_team | 422 | "Team members are managed from Qontak One Teams" | Team mode enabled | yes (drives read-only UI) |
POST /internal/v1/teams/migrate | unauthorized | 401 | invalid/missing X-Chat-Api-Key | bad service token | no (service) |
POST /internal/v1/teams/migrate | organization_not_found | 404 | company_sso_id not resolvable | unknown CID | no (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/divisionsshapes are additive (newteam_id/has_channels/channel_countfields);team_migration_statusis exposed additively onGET /core/v1/organizations/settingsvia a newEntities::Settingsattribute (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):
- Ship migration
add_team_columns_to_divisions(nullable columns + ES mapping) — inert until used. - 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). - Ship hub_worker: register
team_migrationqueue + addbifrost.team.events.v1topic to thebifrost_launchpadgroup. - Ship hub_service: V2 division routes/resources + internal migrate endpoint (behind flag).
- Per-org enablement: run migration (worker maps
team_id) → verify → flipuse_qontak_one_teamon (read-only + banner active).
- Ship migration
- Schema state during migration: columns exist and are populated
incrementally as
TEAM_MIGRATEDevents arrive; rows withoutteam_idbehave 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 viaorganizations.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_teamOFF 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 / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
use_qontak_one_team (org settings key) | bool | false | yes (per-org) | ops/rollout via settings update | no |
team_migration_status (org settings key) | string enum | pending | set by system | worker/consumer | no |
QONTAK_LAUNCHPAD_API_URL | string (URL) | (deploy) | yes (already exists — outbound Teams base, D8) | Infra | no |
QONTAK_LAUNCHPAD_BASIC_AUTH | string | (deploy) | yes (already exists — outbound Basic credential, D8) | Infra (Vault) | yes |
LAUNCHPAD_REQUEST_TIMEOUT | int (s) | 30 | no — NEW (outbound per-request timeout, D12) | Infra | no |
LAUNCHPAD_CIRCUIT_BREAKER_{VOLUME_THRESHOLD,ERROR_THRESHOLD,TIME_WINDOW,SLEEP_WINDOW} | int | 5 / 50 / 60 / 60 | no — NEW (circuit-breaker tuning, D12) | Infra | no |
enable_launchpad_circuit_breaker (preference) | bool | false | no — NEW (Flipper pref gating the breaker, mirrors enable_sso_circuit_breaker) | ops/rollout | no |
LAUNCHPAD_MIGRATION_API_KEY | string | (deploy) | yes — NEW (inbound migrate endpoint, D11) | Infra (Vault) | yes |
DATADOG_ENABLED | bool | (env) | yes (already exists — master gate in CaptureCustomMetric) | Infra | no |
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_METRIC | bool | false | no — NEW (each metric gated per capture_custom_metric.rb:13-14 use_env: true) | Infra | no |
team_migration (Sidekiq queue, weight 1) | queue | — | yes | hub_worker/config/sidekiq.yml | no |
bifrost.team.events.v1 (Karafka topic) | topic | — | yes | hub_worker/karafka.rb | no |
Detail 4.B — Test Plan (commands the agent will run)
Commands sourced from the repos (do not invent):
| Layer | Command (source) | What it must prove |
|---|---|---|
| hub_core unit/interactor/repo | bundle 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 lint | bundle exec rubocop (source: hub_core bitbucket-pipelines.yml:95, .overcommit.yml:2-5) | style clean (TargetRuby 2.6.3) |
| hub_core targeted | bundle exec rspec app/core/events/kafka_consumers/teams | team-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/divisions | dormant 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 specs | bundle 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 lint | bundle exec rubocop (source: hub_service bitbucket-pipelines.yml:86) | style clean |
| hub_worker | bundle 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.
| Order | Chunk | Files to modify/create | Commands to run | Acceptance criteria (verifiable) |
|---|---|---|---|---|
| 1 | Migration: add team_id/parent_team_id/parent_id + (org,team_id) index + ES mapping | hub_core database/core/db/migrate/20260710000000_add_team_columns_to_divisions.rb | RAILS_ENV=test bundle exec rails app:db:migrate; bundle exec rspec app/core/domains/models/division_spec.rb | 3 columns exist; index index_divisions_on_organization_id_and_team_id present; no parent_id index (data-only); migrate down clean |
| 2 | Model/entity/builder: expose new columns; ES as_indexed_json | hub_core models/division.rb, entities/division.rb, builders/division.rb, builders/list_division.rb | bundle exec rspec app/core/domains/builders/division_spec.rb; bundle exec rubocop | entity has team_id/parent_team_id/parent_id; builder maps them; index doc includes them |
| 3 | Flag helper: qontak_one_team_enabled?(org) + settings accessors | hub_core models/organization.rb, new services/qontak_one_team.rb (or method) | bundle exec rspec app/core/domains/models/organization_spec.rb | helper true only when unified_app && use_qontak_one_team; false otherwise |
| 4 | Launchpad 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/http | clients 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 |
| 5 | Team-events consumer | hub_core app/core/events/kafka_consumers/teams/team_events.rb (+ spec) | bundle exec rspec app/core/events/kafka_consumers/teams | TEAM_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) |
| 6 | Migration worker | hub_core app/core/workers/divisions/team_migration_worker.rb (+ spec); hub_worker config/sidekiq.yml (queue); optional hub_worker/app/workers/* wrapper | bundle exec rspec app/core/workers/divisions | worker builds bulk items for team_id IS NULL divisions; skips mapped; sets status; re-run creates no duplicates |
| 7 | Karafka wiring | hub_worker karafka.rb (add topic to bifrost_launchpad) | bundle exec rspec app (hub_worker); boot check | topic bifrost.team.events.v1 routed to KafkaConsumers::Teams::TeamEvents |
| 8 | V1 read-only guard | hub_core interactors/divisions/supervisor_create_user_division.rb, supervisor_edit_user_division.rb | bundle exec rspec app/core/domains/interactors/divisions | returns Failure(:managed_by_team) when Team mode; unchanged otherwise |
| 9 | V2 interactors | hub_core interactors/v2/divisions/{update_division,assign_channel_to_team}.rb (+ repos + specs) | bundle exec rspec app/core/domains/interactors/v2/divisions | update 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) |
| 10 | V2 HTTP endpoints | hub_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.rb | routes mounted at /api/core/v2/divisions; scopes enforced; authorize_user_to_allow_access_resource! on writes; Dry::Matcher resolves |
| 11 | Internal 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.rb | bundle exec rspec spec/services/api/internal/v1/teams | X-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 |
| 12 | Observability | Services::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 |
| 13 | Dormant-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_integrations | Per-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). |
| 14 | Expose 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.rb | GET /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) |
| 15 | Full regression + lint + security (all repos) | all touched files | bundle exec rspec app + bundle exec rubocop + brakeman ... in each repo | green suites; no rubocop/brakeman regressions |
Detail 4.D — Verification & Rollback Recipe
- Pre-merge verification (run in order, per repo):
RAILS_ENV=test bundle exec rails app:db:migrate(hub_core — migration up)bundle exec rubocop(each repo)bundle exec rspec app(each repo)brakeman --no-exit-on-warn --no-exit-on-error(each repo)
- Post-deploy verification signals:
- Datadog:
bifrost.team.events.v1consumer 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_completedcount == 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/divisionswithhas_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 NULLlists the dormant divisions. - Recache check (D14): after a team-event roster/channel change, the affected
User::<id>::Divisions/Division::<id>::Users/Division::<id>::ChannelsRedis keys reflect the new state within one event cycle (not after TTL) — spot-check viaredis-clion a pilot user, or assert in staging replay.
- Datadog:
- Rollback recipe (in order):
- Flip
use_qontak_one_teamOFF for the affected CID(s) (settings update) → chat immediately serves the legacy V1 path; member writes accepted again. - If the new consumer misbehaves: pause the
bifrost.team.events.v1consumer (Karafka) — chat keeps running ondivision_id; events redeliver on resume. - Do not delete
divisions.team_idor Launchpad teams (preserved, PRD S05 AC-2). - Confirm assignment-failure-rate returns to baseline and consumer lag drains within 15 min.
- If schema rollback is ever required (unlikely — columns are inert):
app:db:migrate:downfor20260710000000(only after confirming no org depends onteam_id).
- Flip
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)) ondivisions(< 1.5M rows) — negligible storage/connection delta. (Noparent_idindex 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
| # | Type | Question / limitation | Owner | Deadline |
|---|---|---|---|---|
| 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-2 | Important (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 + Bifrost | Before 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-4 | Nice-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 Eng | Fast-follow post-GA |
| OQ-5 | Blocker (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-7 | Open | Where 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 + Eng | Before dev |
| OQ-8 | Open (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 + QA | Before Assignment-Menu Lock |
| OQ-9 | Dependency | FE 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 squad | Before GA |
| OQ-10 | Open | Rollout scope by plan/tier (PRD Open Q #8) — which CIDs get use_qontak_one_team. | PM + GTM | Before 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-14 | Open (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) + QA | Before 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-09 | Author (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-10 | Reviewer (rfc-reviewer) | Scored 7.0 (Strong / HOLD); flagged agent-only member filter, unspecified auth, dedup store, FMC softness |
| 2026-07-10 | Author + operator guidance | Reversed 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-10 | Reviewer (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-10 | Author + 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-10 | Reviewer (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-10 | Author (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-10 | Author (rev4) + product input | Incorporated 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-10 | Author (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-11 | Author (rev6) + product input | Incorporated the PRD requirement "Admin/Owner display: read-only; visible in member list but never included in chat routing" as D15 — reversing 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-12 | Author (rev7) + product input | D16 — 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 inlaunchpad_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_KEYviavalidate_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_divisionsfor read-only display in the member list (GET /core/v2/divisionsexposesusers.role) but are never routable — the assignment engine already role-gates candidates (by_room.rb:19,91role 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), whilevalidate_division(list access-control) is left unchanged so it still lists, flaggedhas_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*+RecacheWorkloadfan-out fires (division docs §3.4);UserDivision::Editgains amanaged_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_idcolumn 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_iddedup via RedisSET 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 (existingQONTAK_LAUNCHPAD_API_URL+QONTAK_LAUNCHPAD_BASIC_AUTH; newLAUNCHPAD_MIGRATION_API_KEY,LAUNCHPAD_REQUEST_TIMEOUT, circuit-breaker tuning vars, per-metric*_METRICenable flags +DATADOG_ENABLED). - Observability — metrics anchored to
Services::Datadog::CaptureCustomMetricwith 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.