Task Breakdown — Migrate Chat Divisions to USMAN Teams (Phase 1, Q3)
Source RFC:
rfc-migrate-division-to-usman-team.md(backend, new-feature; rev7 review 9.5 Agentic-Ready — 16 decisions / 15 execution chunks). Slicing: by execution chunk (backend-only — FE is a separate RFC, OQ-9). Mode: actionable-now, whole RFC. Repos (verified checked out):hub_core,hub_service,hub_workerunderChatPanel/hub-project/. Spec convention:<name>_spec.rbalongside source. Tests:RAILS_ENV=test bundle exec rspec <path> --tag ~@is_skip_pipeline,bundle exec rubocop,brakeman.
Effort Summary
| Area (tasks) | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Schema & domain model (T1–T3) | — | 2.0 | 0 | 2.0 |
| Launchpad resilience + clients (T4a–T4b) | — | 2.5 | 0.5 | 3.0 |
| Team-events consumer — split (T5a–T5c) | — | 3.5 | 1.5 | 5.0 |
| Migration runtime (T6–T7) | — | 2.5 | 0.5 | 3.0 |
| V1 guard + V2 endpoints (T8–T11) | — | 5.0 | 2.0 | 7.0 |
| Dormant-division skip + admin non-routability (T14, D13/D15) | — | 2.5 | 1.0 | 3.5 |
Status via organizations/settings (T15, D16) | — | 0.5 | 0.5 | 1.0 |
| Internal endpoint + obs + regression (T12, T13, T16) | — | 2.5 | 1.0 | 3.5 |
| Grand total | — | 21.0 | 7.0 | 28.0 |
Confidence: high. The RFC is rev7 Agentic-Ready (9.5); all 16 decisions resolved, every path verified against the live repos, patterns exist. The heaviest new work is T14 (dormant-division skip + admin non-routability), which touches several shared routing files (
by_room.rb,queue_assign_agent/add.rb) — carries regression risk, hence the per-surface specs + a 1.0-day QA line. Small unknowns carrying a safe default: T5a's dedup/attempt-counter mechanism (reviewer OQ-1), T12's success status code (201 vs 202), and T15's dry-struct key semantics (value-optional vsattribute?). No FE work (separate FE RFC, OQ-9).
Definition of Done (from §1 Success Criteria)
100% of in-scope CIDs have every active division linked (team_id IS NOT NULL); zero routing regression vs baseline; migration idempotent/resumable; member-sync p95 ≤ 30s; legacy V1 member writes rejected in Team mode.
Task 1: [BE] Migration — add team columns + index + ES mapping (MIG-PH1-S01)
After this, a division row can hold a
team_idlink to a Launchpad Team (and hierarchy-prep columns), with a reverse-lookup index.
Status: ✅ Actionable
What to build
A Rails migration adding three nullable UUID columns (team_id, parent_team_id, parent_id) to divisions, one composite index (organization_id, team_id), and an additive Elasticsearch put_mapping — all inert for non-Team orgs.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/core/db/migrate/20260710000000_add_team_columns_to_divisions.rb | 3 nullable UUID cols; index index_divisions_on_organization_id_and_team_id; no parent_id index; inline ES put_mapping for the 3 keyword fields |
Implementation steps
- Open
hub_core/database/core/db/migrate/20220711012607_add_is_contact_masking_to_divisions.rb— mirror itsadd_column+ inlineElasticsearch::Model.client.indices.put_mappingstructure. - Add the 3
add_column … :uuid, null: truelines +add_index :divisions, [:organization_id, :team_id], name: "index_divisions_on_organization_id_and_team_id". Do not add aparent_idindex (data-only column). - Guard the ES
put_mappingwithindices.exists?so it no-ops when the index is absent. - Run migrate up and down to confirm both are clean.
Acceptance criteria
- 3 columns present on
divisions; indexindex_divisions_on_organization_id_and_team_idexists; noparent_idindex. -
RAILS_ENV=test bundle exec rails app:db:migratesucceeds;…:migrate:downreverts cleanly. - ES mapping call guarded by index existence (no crash when index missing).
Test strategy
Model spec asserts the 3 attributes exist and are nullable; schema reflects the composite index and the absence of a parent_id index.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: reuses the existing add-column+put_mapping migration template; no backfill (columns start NULL).
Run to verify
RAILS_ENV=test bundle exec rails app:db:migrate && bundle exec rspec app/core/domains/models/division_spec.rb
Depends on
- None (foundational).
Task 2: [BE] Expose team columns on model / entity / builder + ES doc (MIG-PH1-S01, COMM-PH1-S01)
The new
team_id/parent_team_id/parent_idbecome first-class attributes returned by division reads and indexed in ES.
Status: ✅ Actionable
What to build
Add the three columns to the immutable Division entity + builder mapping, and include them in the model's as_indexed_json/mappings. Also add has_channels (bool) + channel_count (int) to the list builder as a read-time projection of the already-preloaded :channels association — no extra query (D13, for the dormant-division FE note).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/models/division.rb | add the 3 fields to as_indexed_json + mappings (keyword) |
| extend | hub_core/app/core/domains/entities/division.rb | add team_id, parent_team_id, parent_id attributes (+ optional has_channels/channel_count) |
| extend | hub_core/app/core/domains/builders/division.rb | map the 3 new attributes |
| extend | hub_core/app/core/domains/builders/list_division.rb | map the 3 attributes + project has_channels/channel_count from division.channels (D13) |
Implementation steps
- Open
models/division.rb:14-50— add the 3 keys toas_indexed_jsonandmappings dynamic: :strict(typekeyword). - Add the 3 attributes to
entities/division.rb(mirror existingid/organization_id/name). - Map them in
builders/division.rbandbuilders/list_division.rb(mirror existing attribute rows). - Update the builder + model specs to assert the new attributes are populated and appear in the index doc.
Acceptance criteria
- Entity exposes
team_id,parent_team_id,parent_id; builder maps all three. -
as_indexed_jsonincludes the three keyword fields. - List builder projects
has_channels/channel_countfrom the preloaded:channels(no extra query);channel_count = 0→has_channels: false(D13).
Test strategy
Builder spec asserts each new attribute is carried record → entity; model spec asserts the indexed JSON includes the three keys.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0 |
| Total | 1.0 |
Assumptions:
builders/list_division.rbexists (verify during step 3); pure attribute plumbing, no logic.
Run to verify
bundle exec rspec app/core/domains/builders/division_spec.rb app/core/domains/entities app/core/domains/models/division_spec.rb && bundle exec rubocop
Depends on
- Task 1 (columns must exist).
Task 3: [BE] Per-org Team-mode gate — qontak_one_team_enabled? + settings accessors (MIG-PH1-S05, D2)
A single helper tells every code path whether an org is in Team mode, and two settings keys store the per-org flag + migration status.
Status: ✅ Actionable
What to build
Add store_accessor :settings, :use_qontak_one_team and :team_migration_status to Organization, plus a centralized qontak_one_team_enabled?(organization) returning true only when the unified_app capability and use_qontak_one_team are both on.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/models/organization.rb | add 2 store_accessor keys (beside :66); add qontak_one_team_enabled? |
| create | hub_core/app/core/domains/services/qontak_one_team.rb | optional service wrapping the combined gate (mirrors services/preference.rb style) |
Implementation steps
- Open
models/organization.rb:66(store_accessor :settings, :enable_unified_app_package) — add the two new accessors the same way. - Implement
qontak_one_team_enabled?combining the existingunified_app/enable_unified_app_packageread with the newuse_qontak_one_teamboolean. - If encapsulating in a service, mirror
services/preference.rb:21,61-71for the method shape. - Spec: true only when both signals on; false when either off; default off.
Acceptance criteria
-
qontak_one_team_enabled?(org)true iffunified_appanduse_qontak_one_team; false otherwise (default off). - Both settings keys readable/writable via
store_accessor.
Test strategy
Organization spec: truth table over the two flags (4 cases); asserts default off.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions:
organizations.settingsjsonb already exists (verifiedschema.rb:1288); no migration.
Run to verify
bundle exec rspec app/core/domains/models/organization_spec.rb
Depends on
- None (can run in parallel with Tasks 1–2).
Task 4a: [BE] launchpad_circuit_breaker + call_circuit infra (D12)
Shared resilience plumbing: a dedicated Launchpad circuit breaker and a pref-gated call wrapper the Teams clients reuse.
Status: ✅ Actionable
What to build
Add launchpad_circuit_breaker (Circuitbox :launchpad_circuit, thresholds 5/50%/60s/60s, Moneta Redis store) beside sso_circuit_breaker, plus a shared call_circuit(&blk) wrapper (raises RequestTimeout/RequestError, returns Failure when the circuit is open) gated by pref :enable_launchpad_circuit_breaker.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/http/circuit_breakers.rb | add launchpad_circuit_breaker (mirror sso_circuit_breaker:5-13) |
| create | hub_core/app/apps/launchpad/services/circuit_wrappable.rb | shared call_circuit(&blk) module (pref-gated) the clients include |
| create | hub_core/app/core/domains/repositories/http/circuit_breakers_spec.rb (or extend) | breaker returns :launchpad_circuit; open → Failure |
Implementation steps
- Open
repositories/http/circuit_breakers.rb:5-13(sso_circuit_breaker) andapps/mekari_sso/services/auth.rb:44-64(call_circuitwrap + pref gate:enable_sso_circuit_breaker). - Add
launchpad_circuit_breakerwith the env-tunable thresholds (LAUNCHPAD_CIRCUIT_BREAKER_*) and the Moneta:Redisstore onREDIS_W_URL. - Create
circuit_wrappable.rb:call_circuit(&blk)runs the block insidelaunchpad_circuit_breaker.run(exception: false), raisingRequestTimeoutontimed_out?andRequestErroron 5xx; when pref:enable_launchpad_circuit_breakeris off, call the block directly; anilreturn (open) →Failure('[Circuitbox] Launchpad unavailable'). - Specs: assert the breaker is a Circuitbox
:launchpad_circuit; a forced-open circuit yieldsFailure; pref-off bypasses the breaker.
Acceptance criteria
-
launchpad_circuit_breakerreturns a Circuitbox:launchpad_circuitwith thresholds 5/50%/60s/60s (env-overridable). -
call_circuitreturnsFailurewhen the circuit is open; bypasses the breaker when the pref is off. - Distinct from
sso_circuit_breaker(no shared circuit).
Test strategy
Unit spec drives the breaker directly: asserts the circuit name/thresholds, that an open circuit → Failure, and that the pref gate toggles wrapping.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0 |
| Total | 1.0 |
Assumptions: Circuitbox + Moneta already in the repo; mirrors the SSO breaker +
call_circuitpattern exactly; internal infra (no user-facing behavior → QA 0).
Run to verify
bundle exec rspec app/core/domains/repositories/http app/apps/launchpad/services/circuit_wrappable_spec.rb
Depends on
- None (foundational infra; needed by T4b).
Task 4b: [BE] Launchpad Teams HTTP clients — Basic auth (D8)
Chat can call Launchpad's
/private/teamssurface (bulk-create, get, rename) over the existing Basic-auth client, each call protected by the T4a breaker.
Status: ✅ Actionable (on T4a)
What to build
Three Repositories::AbstractHttp clients mirroring get_last_session.rb (base QONTAK_LAUNCHPAD_API_URL, Authorization: QONTAK_LAUNCHPAD_BASIC_AUTH, /private/teams…, 30s timeout), each including CircuitWrappable (T4a) to wrap the request.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/launchpad/services/bulk_create_team.rb | POST /private/teams/bulk (items with is_migrate/app/app_identifier_id/member_ids) |
| create | hub_core/app/apps/launchpad/services/get_team.rb | GET /private/teams/{id} (+ /members) |
| create | hub_core/app/apps/launchpad/services/update_team_name.rb | PATCH /private/teams/{id} |
| create | hub_core/app/apps/launchpad/services/{bulk_create_team,get_team,update_team_name}_spec.rb | request-shape + breaker-wrap specs (stubbed Typhoeus) |
Implementation steps
- Open
app/apps/launchpad/services/get_last_session.rb:3-17— copy its< Repositories::AbstractHttp,@base_url, andheaders: { 'Authorization': ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] }wiring; set the path to/private/teams…andtimeout: (ENV['LAUNCHPAD_REQUEST_TIMEOUT'] || 30).to_i. include Launchpad::Services::CircuitWrappable(T4a) and wrap each request incall_circuit { … }.- Implement
parse_responsehandling for success + error bodies (mirrorget_last_session.rb). - Specs: stub Typhoeus; assert base URL,
Authorizationheader, path,timeout: 30; assert an open circuit surfacesFailure.
Acceptance criteria
- Each client issues the correct verb/path with base
QONTAK_LAUNCHPAD_API_URL+ Basic auth header + 30s timeout (asserted against a stubbed request). - Each call is wrapped in
call_circuit(T4a) — open circuit →Failure. -
parse_responsehandles success + error bodies.
Test strategy
Stub Typhoeus at the transport layer; the key assertion is the outgoing request (path, headers, timeout) and that a forced-open circuit yields a Failure monad.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: three near-identical clients copying
get_last_session.rb; breaker/wrapper delivered by T4a.
Run to verify
bundle exec rspec app/apps/launchpad/services
Depends on
- Task 4a (breaker +
CircuitWrappable).
Task 5a: [BE] Team-events consumer — skeleton + TEAM_MIGRATED mapping (MIG-PH1-S01, COMM-PH1-S01)
A
TEAM_MIGRATED(or migrate-modeTEAM_CREATED) event links its division by settingteam_id, and flips the org tocompletedonce every division is mapped.
Status: ⚠️ Partially blocked — the poison/dedup attempt-counter mechanism is unpinned in §2.C (reviewer OQ-1). Buildable now with the safe default below; confirm with the author before merge.
What to build
KafkaConsumers::Teams::TeamEvents < KafkaConsumers::AbstractSub: the consume loop, JSON parse, event_id dedup + attempt counter, org/company_sso_id/Team-mode guards, Redis recache + ES reindex helpers, manual mark_as_consumed, and the TEAM_MIGRATED/TEAM_CREATED(migrate) branch. TEAM_UPDATED/TEAM_DELETED are stubbed to no-op-and-consume, filled by T5b/T5c.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/events/kafka_consumers/teams/team_events.rb | consume loop; dedup + attempt counter; guards; recache/reindex helpers; TEAM_MIGRATED mapping branch; mark_as_consumed |
| create | hub_core/app/core/events/kafka_consumers/teams/team_events_spec.rb | mapping + dedup + skip-path specs |
Implementation steps
- Open
kafka_consumers/launchpad/update_company_settings.rb:64-126(bifrost consumer that mutates DB + busts Redis) andusers/user_data_updated.rb:19-61(consume loop,mark_as_consumed, skip-after-N). - Build the skeleton: parse envelope; guards (
company_sso_idpresent + org found +qontak_one_team_enabled?from Task 3) — else log/alert +mark_as_consumed. - Dedup (safe default, pending OQ-1):
SET NX processed_team_event::<event_id>(7d) set only after success; separateINCR attempts::<event_id>(7d), skip+alert at>= 5. TEAM_MIGRATEDbranch:UPDATE divisions SET team_id WHERE (org, id = payload.app_identifier_id) AND team_id IS NULL; then setteam_migration_status='completed'if no unmapped divisions remain.- Add the shared recache (
ResetDivisionsByUser/ResetAllUserDivision/ResetUsersByDivision) + ES reindex helpers used by all three branches; stubTEAM_UPDATED/TEAM_DELETEDto consume-and-return. - On error: do not
mark_as_consumed(redelivery). NoTimeout.timeoutwrap (local-only handler — §2.C waiver).
Acceptance criteria
-
TEAM_MIGRATED/CREATED(migrate)setsteam_idbyapp_identifier_id; redelivery is a no-op (team_id IS NULLguard + dedup). - Sets
team_migration_status='completed'when the last division is mapped. - Non-Team org / blank
company_sso_id→ skip +mark_as_consumed+ alert. - Redis reset + ES reindex fire after the write.
- (pending OQ-1) attempt counter + dedup ordering implemented per the safe default; author confirms.
Test strategy
Feed a synthetic TEAM_MIGRATED envelope; assert the team_id UPDATE + recache calls + mark_as_consumed; a duplicate event_id asserts a no-op; a failing handler asserts no mark_as_consumed.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses the existing consumer template + Redis reset services; the shared skeleton + counter is the bulk of the effort, the map branch is a single-row UPDATE.
Run to verify
bundle exec rspec app/core/events/kafka_consumers/teams
Depends on
- Tasks 1–2 (column), Task 3 (flag helper).
Task 5b: [BE] Team-events consumer — TEAM_UPDATED name + full-roster sync (§10 #3, MIG-PH1-S01, D14, D15)
A
TEAM_UPDATEDevent syncs the division's name and mirrors the full member roster — agents + supervisors (routable) and Admin/Owner (read-only display) — intouser_divisions, viaUserDivision::Edit(managed_by_team: true).
Status: ✅ Actionable (on T5a)
What to build
Implement the TEAM_UPDATED branch inside team_events.rb: update divisions.name when the update-mask includes it, and roster-replace user_divisions from the event's members (resolving sso_ids → chat users) via UserDivision::Edit(managed_by_team: true) — which bypasses the ≥1-supervisor invariant (D14). The roster includes agents + supervisors and Admin/Owner as read-only display members (D15); Admin/Owner are non-routable via the engine's existing role-gate (enforced/verified in Task 14, not here).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/events/kafka_consumers/teams/team_events.rb | fill the TEAM_UPDATED branch (name + roster-replace incl. Admin/Owner display) |
| extend | hub_core/app/core/domains/repositories/divisions/user_division/edit.rb | add a managed_by_team: kwarg (default false) that skips the supervisor-count check when true (D14); recache fan-out unchanged |
| extend | hub_core/app/core/events/kafka_consumers/teams/team_events_spec.rb | name-sync + roster-replace + 0-supervisor + Admin/Owner-display specs |
Implementation steps
- Re-open
team_events.rb(from T5a) and locate theTEAM_UPDATEDstub. - Find the division by
(org, team_id)(uses the T1 index); updatenameonly if in the update-mask. - Add the
managed_by_team:kwarg toRepositories::Divisions::UserDivision::Edit(readuser_division/edit.rb+create.rb:19for the supervisor-count validation) — whentrue, skip that check; keep the existingReset*/Recache*fan-out (D14). - Resolve member sso_ids → chat users and roster-replace via
UserDivision::Edit(managed_by_team: true). Include agents + supervisors (routable) AND Admin/Owner (read-only display, D15) — do not exclude admins; their non-routability is enforced by the assignment engine's role-gate in Task 14. - Reuse the T5a recache/reindex helpers.
Acceptance criteria
- Name updates only when the update-mask includes it.
- Full roster mirrored into
user_divisions: agents + supervisors + Admin/Owner as read-only display (D15). - Supervisor-count invariant bypassed via
managed_by_team: true— a 0-chat-supervisor roster syncs successfully AND the Redis caches (ResetUsersByDivision/ResetDivisionsByUser/ResetAllUserDivision) still reset (D14). - Redis per-user reset + ES reindex fire after the write.
Test strategy
Feed a TEAM_UPDATED with a mixed roster (agents, supervisors, an admin, and a 0-supervisor case); assert UserDivision::Edit is called with managed_by_team: true, receives agents + supervisors + Admin/Owner (display), syncs even with 0 supervisors, resets the caches, and that a name-only mask leaves membership untouched.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses
UserDivision::Edit(+ newmanaged_by_team:kwarg); net-new work is sso→user resolution, the validation-bypass kwarg, and Admin/Owner display.
Run to verify
bundle exec rspec app/core/events/kafka_consumers/teams
Depends on
- Task 5a (consumer skeleton — shared file
team_events.rb, do after 5a, not in parallel).
Task 5c: [BE] Team-events consumer — TEAM_DELETED cascade (§10 #5)
A
TEAM_DELETEDevent cascades to delete the linked chat division and its join rows, nullifyingrooms.division_id.
Status: ✅ Actionable (on T5a)
What to build
Implement the TEAM_DELETED branch: find the division by team_id and run the existing Repositories::Divisions::Delete fan-out (destroy + join rows + RoomUpdateDivision + chatbot notify); no-op if the division is already gone.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/events/kafka_consumers/teams/team_events.rb | fill the TEAM_DELETED branch (cascade delete) |
| extend | hub_core/app/core/events/kafka_consumers/teams/team_events_spec.rb | cascade + idempotent-absence specs |
Implementation steps
- Re-open
team_events.rband locate theTEAM_DELETEDstub. - Find the division by
(org, team_id); if absent → no-op +mark_as_consumed(idempotent). - Call
Repositories::Divisions::Delete(readrepositories/divisions/delete.rb:14-82for the destroy + Redis reset + ES reindex + chatbot notify fan-out) — reuse it, don't reimplement. - Confirm
RoomUpdateDivisionpublish nullifiesrooms.division_id(as insupervisor_delete_division.rb:26).
Acceptance criteria
-
TEAM_DELETEDdeletes the division + join rows viaRepositories::Divisions::Delete. - Already-deleted division → no-op (idempotent), still
mark_as_consumed. -
rooms.division_idnullified via the existingRoomUpdateDivisionevent.
Test strategy
Feed a TEAM_DELETED; assert Repositories::Divisions::Delete invoked for the matched division and that a second delivery (division gone) is a clean no-op.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: reuses the existing division delete fan-out wholesale; only the lookup-by-
team_id+ absence guard is new.
Run to verify
bundle exec rspec app/core/events/kafka_consumers/teams
Depends on
- Task 5a (consumer skeleton — shared file
team_events.rb; sequence after 5b to avoid edit conflicts).
Task 6: [BE] Migration worker — resumable per-CID bulk create (MIG-PH1-S01, S04)
Triggering migration for a CID reads its unmapped divisions and submits them to Launchpad in one bulk call; re-running skips already-linked divisions.
Status: ✅ Actionable
What to build
Divisions::TeamMigrationWorker < AbstractSidekiqWorker (perform(organization_id)) that batches divisions.where(team_id: nil), builds bulk items (is_migrate:true, app:'chat', app_identifier_id: division_id, member_ids = all agents + supervisors), calls Launchpad::Services::BulkCreateTeam, and manages team_migration_status.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/workers/divisions/team_migration_worker.rb | resumable worker; sidekiq_options queue: :team_migration, retry: 5 |
| create | hub_core/app/core/workers/divisions/team_migration_worker_spec.rb | idempotency + status + no-duplicate specs |
| extend | hub_worker/config/sidekiq.yml | register team_migration queue (weight 1) |
Implementation steps
- Open
workers/centralized_contacts/qontak_one_migration_worker.rb:3-11— mirror< AbstractSidekiqWorker,sidekiq_options queue:,perform(organization_id, …). find_each(batch_size: 100)overteam_id IS NULLdivisions; setteam_migration_status='processing'(withwith_lockon the org).- Build bulk items and call
BulkCreateTeam(Task 4b); onFailureafter Sidekiq retries → statusfailed. - Register
team_migration(weight 1) inhub_worker/config/sidekiq.yml. - Specs: builds items only for unmapped divisions; re-run creates no duplicates; status transitions correct.
Acceptance criteria
- Worker builds bulk items only for
team_id IS NULLdivisions; skips mapped ones. - Re-run after partial completion produces no duplicate teams/links.
-
team_migration_statusmovesprocessing→ (completedset by consumer) /failedafter retry exhaustion. -
team_migrationqueue registered.
Test strategy
Stub BulkCreateTeam; assert the item set equals the unmapped divisions and that a second perform with some rows now mapped submits only the remainder.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses the existing per-org migration worker template +
find_each; Launchpad client from Task 4b.
Run to verify
bundle exec rspec app/core/workers/divisions
Depends on
- Task 4b (BulkCreateTeam), Tasks 2–3.
Task 7: [BE] Karafka wiring — route bifrost.team.events.v1 to the consumer (MIG-PH1-S01)
The new topic is registered so hub_worker actually delivers team events to the Task 5 consumer.
Status: ✅ Actionable
What to build
Add a topic 'bifrost.team.events.v1' block to the existing bifrost_launchpad consumer group in karafka.rb, pointing at KafkaConsumers::Teams::TeamEvents.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_worker/karafka.rb | add the topic to consumer_group 'bifrost_launchpad' (~:474) with batch_consuming true, start_from_beginning true |
Implementation steps
- Open
hub_worker/karafka.rb:450-465— thebifrost_launchpadgroup and the existingbifrost.user.updates.v1topic block. - Add the new
topicblock mirroring the sibling, referencingKafkaConsumers::Teams::TeamEvents. - Boot-check / run the hub_worker suite to confirm routing resolves.
Acceptance criteria
-
bifrost.team.events.v1is routed toKafkaConsumers::Teams::TeamEventswithinbifrost_launchpad. - hub_worker boots without route errors.
Test strategy
hub_worker spec/boot check asserts the topic → consumer mapping is registered.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: one-block config change beside an identical existing topic.
Run to verify
bundle exec rspec app # in hub_worker
Depends on
- Task 5a (consumer class must exist).
Task 8: [BE] V1 read-only member guard in Team mode (UI-PH1-S01, MIG-PH1-S01-NEG)
In Team mode, legacy V1 member/supervisor writes are rejected with 422
managed_by_team, so membership can only change via Launchpad.
Status: ✅ Actionable
What to build
Guard SupervisorCreateUserDivision/SupervisorEditUserDivision to return Failure(code: :managed_by_team) when qontak_one_team_enabled?(organization) is true; unchanged otherwise.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/interactors/divisions/supervisor_create_user_division.rb | early Failure(:managed_by_team) in Team mode |
| extend | hub_core/app/core/domains/interactors/divisions/supervisor_edit_user_division.rb | same guard |
| extend | hub_core/…/supervisor_create_user_division_spec.rb, …_edit_…_spec.rb | Team-mode rejection + legacy pass-through specs |
Implementation steps
- Open
interactors/divisions/supervisor_delete_division.rb:16-26for the interactor→result-monad shape. - At the top of each target interactor, check
qontak_one_team_enabled?(Task 3) and returnFailure(:managed_by_team)if true. - Specs: Team mode →
Failure(:managed_by_team); non-Team → existing behavior intact.
Acceptance criteria
-
POST/PUTmember writes returnFailure(:managed_by_team)(→ 422) when Team mode on. - Behavior unchanged when Team mode off.
Test strategy
Interactor spec toggles the flag and asserts Failure(:managed_by_team) vs Success.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: guard is a 2-line early return per interactor; flag helper from Task 3.
Run to verify
bundle exec rspec app/core/domains/interactors/divisions
Depends on
- Task 3 (flag helper).
Task 9: [BE] GET /api/core/v2/divisions — list endpoint (+ V2 mount) (UI-PH1-S01, D13, D15)
Admins/agents read divisions via V2 with per-division
team_id+has_channels/channel_count; members (incl. Admin/Owner, withrole) shown read-only in Team mode. Per-orgteam_migration_statusis NOT here — it moves to the settings endpoint (Task 15, D16).
Status: ✅ Actionable
What to build
Establish the V2 divisions resource + routes + mount, and implement the GET action reusing Interactors::Divisions::SupervisorListDivision, adding per-division team_id + has_channels + channel_count to the response (from the T2 builder). Members are returned read-only in Team mode, including Admin/Owner with role (D15). This task creates the shared V2 resource file that T10/T11 extend. It does not add team_migration_status (per-org → Task 15).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_service/app/services/api/core/v2/divisions/routes.rb | mount the divisions resource (mirror core/v2/messages/routes.rb) |
| create | hub_service/app/services/api/core/v2/divisions/resources/divisions.rb | GET action: scopes, org-scope, reuse SupervisorListDivision, Dry::Matcher |
| extend | hub_service/app/services/api/core/v2/routes.rb | add mount API::Core::V2::Divisions::Routes |
| create | hub_service/spec/services/api/core/v2/divisions/resources/divisions_spec.rb | GET request specs (scopes, response fields) |
Implementation steps
- Open
core/v1/divisions/resources/divisions.rb:24-229(V1 shape) andcore/v2/messages/routes.rb(V2 mount pattern) +core/v2/helpers.rb:84-93. - Create the V2 resource with the
GETaction:oauth2 :admin,:owner,:supervisor,:bot,:agent,:member, org-scoped viame.organization_id, delegating toSupervisorListDivisionviainteract_with. - Shape the response to include
team_id+has_channels+channel_count(from the T2 builder); support the existing query params (per_page,show_users_with_role, etc.). Members carry theirroleso Admin/Owner appear read-only (D15). Do not addteam_migration_status(that lives on the settings endpoint, Task 15). - Mount in
core/v2/routes.rb(one line, mirrormessages). - Request spec: scope enforcement + presence of the new fields in the payload.
Acceptance criteria
- Route mounted at
GET /api/core/v2/divisions; scopes enforced; org-scoped. - Response includes per-division
team_id+has_channels+channel_count; members carryrole(Admin/Owner read-only); reusesSupervisorListDivision(cursor pagination unchanged). - Response does NOT carry
team_migration_status(per-org status → Task 15 / settings endpoint, D16). - V2 resource file + mount exist for T10/T11 to extend.
Test strategy
Request spec stubs SupervisorListDivision (hub_service convention) and asserts scope handling + that the payload carries team_id/has_channels/channel_count and members with role, and that it does not include team_migration_status.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: reuses the existing list interactor (no new interactor); mirrors the V2 messages resource skeleton.
Run to verify
bundle exec rspec spec/services/api/core/v2/divisions/resources/divisions_spec.rb
Depends on
- Task 2 (team fields on entity/builder), Task 3 (flag helper).
Task 10: [BE] PUT /api/core/v2/divisions/:id — rename endpoint + interactor (CHG-001)
Admins can rename a division; the change persists locally and syncs the name to the linked Launchpad team in the background.
Status: ✅ Actionable
What to build
Add the PUT /:id action to the V2 resource and its V2::Divisions::UpdateDivision interactor together — update divisions.name (rejecting the reserved General rename), then enqueue Launchpad::Services::UpdateTeamName (PATCH /private/teams/{id}); skip the remote call if team_id is NULL.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/domains/interactors/v2/divisions/update_division.rb | name update + enqueue Launchpad name-sync (net-new v2/ interactor namespace) |
| extend | hub_service/app/services/api/core/v2/divisions/resources/divisions.rb | add put ':id' with ownership guard, delegate via interact_with |
| create | hub_core/…/interactors/v2/divisions/update_division_spec.rb | interactor specs (sync + NULL-team skip + General-rename reject) |
| extend | hub_service/spec/…/v2/divisions/resources/divisions_spec.rb | PUT /:id request spec |
Implementation steps
- Read
repositories/divisions/update.rb+supervisor_edit_division.rbfor the update + General-division-protection rule;interactors/v2/divisions/is a net-new namespace. - Build
UpdateDivision: updatename; reject reserved General rename (general_division_protected→ 422); enqueueUpdateTeamName(Task 4b); ifteam_idNULL, update local only and skip the remote call. - Add
put ':id'toresources/divisions.rbwithauthorize_user_to_allow_access_resource!(Models::Division, id); delegate viainteract_with. - Specs: name syncs to Launchpad; NULL-team skips remote; General rename → 422.
Acceptance criteria
-
PUT /:idrenames locally and enqueues a LaunchpadPATCH; NULLteam_idskips the remote call. - Reserved General division rename →
general_division_protected(422). - Ownership guard enforced on the write.
Test strategy
Interactor spec stubs UpdateTeamName and asserts enqueue-on-sync + skip-on-NULL; request spec asserts ownership guard + 422 on General rename.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses the existing update repo + General-rename rule;
UpdateTeamNameclient from Task 4b.
Run to verify
bundle exec rspec app/core/domains/interactors/v2/divisions/update_division_spec.rb spec/services/api/core/v2/divisions/resources/divisions_spec.rb
Depends on
- Task 9 (shared file
resources/divisions.rb+ mount — do after T9), Task 4b (UpdateTeamName), Task 3.
Task 11: [BE] PUT /api/core/v2/divisions/channels — assign-channel + auto-create endpoint + interactor (FLOW-PH1-S01, UI-PH1-S02)
Admins can assign channels to a team; if no chat division exists for that team yet, it is auto-created from the team (name + full roster) and the channels attached.
Status: ✅ Actionable
What to build
Add the PUT channels action to the V2 resource and its V2::Divisions::AssignChannelToTeam interactor — find-or-create the division by (org, team_id); on create, fetch name + roster via GetTeam, create in an AR transaction (division + channel_divisions, seed user_divisions with agents + supervisors + Admin/Owner display, D15); empty channel_ids keeps the division (D7, dormant); Launchpad failure → 422 with no partial row.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/domains/interactors/v2/divisions/assign_channel_to_team.rb | find-or-create division from team; set channels |
| extend | hub_service/app/services/api/core/v2/divisions/resources/divisions.rb | add put 'channels', {team_id, channel_ids[]} (UUID validation), delegate |
| create | hub_core/…/interactors/v2/divisions/assign_channel_to_team_spec.rb | auto-create + empty-channel + Launchpad-failure specs |
| extend | hub_service/spec/…/v2/divisions/resources/divisions_spec.rb | PUT channels request spec |
Implementation steps
- Read
repositories/divisions/create.rbfor the create + join-row + publish pattern. - Build
AssignChannelToTeam: lookup by(org, team_id); if present, set channels; if absent,GetTeam(Task 4b) → create in a transaction (division +channel_divisions, seeduser_divisionswith agents + supervisors + Admin/Owner display from the roster, D15); emptychannel_idskeeps the division; Launchpad error →Failure(team_not_found/launchpad_unavailable422), no partial row. - ES reindex + Redis channel/workload reset after commit.
- Add
put 'channels'toresources/divisions.rbwith UUID param validation; delegate viainteract_with. - Specs: auto-create seeds roster + channels; re-assign reuses the existing division; empty channels keeps the row; Launchpad failure rolls back.
Acceptance criteria
- Assigning a channel to a team with no division creates one (
team_id+ name from team), seedsuser_divisions(agents + supervisors + Admin/Owner display), attaches channels. - Re-assign to an existing
(org, team_id)updates channels only (no duplicate division). - Empty
channel_idskeeps the division; Launchpad failure → 422, no partial row.
Test strategy
Interactor spec stubs GetTeam and asserts transactional create-or-reuse + rollback-on-failure; request spec asserts UUID validation + delegation + error envelope.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses the division create repo +
GetTeam(Task 4b); the find-or-create transaction is the net-new logic.
Run to verify
bundle exec rspec app/core/domains/interactors/v2/divisions/assign_channel_to_team_spec.rb spec/services/api/core/v2/divisions/resources/divisions_spec.rb
Depends on
- Task 9 (shared file
resources/divisions.rb+ mount — do after T9), Task 4b (GetTeam), Tasks 2–3.
Task 12: [BE] Internal migration-trigger endpoint — static-key auth (MIG-PH1-S01, S04, D11)
Launchpad's Heimdall can POST a per-CID trigger that enqueues the chat migration worker, authenticated by a dedicated static key.
Status: ⚠️ Partially blocked — the endpoint + auth are fully buildable now; only the shared key value must be agreed with Bifrost and their trigger confirmed to send it (OQ-2, pre-pilot coordination). Also pin the success status to 202 (spec currently conflicts 201/202 — ACV nit).
What to build
POST /api/internal/v1/teams/migrate with a new validate_launchpad_api_key! (header X-Chat-Api-Key vs new ENV['LAUNCHPAD_MIGRATION_API_KEY'], 401 on mismatch), resolving the org by company_sso_id and enqueuing TeamMigrationWorker.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_service/app/services/api/internal/v1/teams/routes.rb | mount the teams resource (mirror auths/routes.rb) |
| create | hub_service/app/services/api/internal/v1/teams/resources/teams.rb | before { validate_launchpad_api_key! }; post 'migrate'; resolve org; perform_async |
| extend | hub_service/app/services/api/internal/v1/header_validation.rb | add validate_launchpad_api_key! reading LAUNCHPAD_MIGRATION_API_KEY |
| extend | hub_service/app/services/api/internal/v1/routes.rb | mount the teams routes |
| create | hub_service/spec/services/api/internal/v1/teams/…_spec.rb | 401-on-mismatch + enqueue + org-resolve specs |
Implementation steps
- Open
internal/v1/auths/resources/crms/auths.rb:3-27+header_validation.rb:6-15(validate_crm_api_key!) — the exact static-key template. - Add
validate_launchpad_api_key!mirroringvalidate_crm_api_key!but against a newENV['LAUNCHPAD_MIGRATION_API_KEY'](dedicated key, not sharedCRM_API_KEY); raiseOauthBwergemn::Errors::InvalidToken(401) on mismatch. - Build the resource:
beforeguard,{ company_sso_id }param, resolve org (404organization_not_foundif unknown),TeamMigrationWorker.perform_async(org.id), respond202{status:'success', data:{status:'queued', organization_id}}. - Mount teams routes in
internal/v1/routes.rb. - Specs: mirror the
crms/authsspec — 401 on bad/missing key, enqueue on success, 404 on unknown CID.
Acceptance criteria
-
X-Chat-Api-Key==LAUNCHPAD_MIGRATION_API_KEYenforced; mismatch → 401. - Resolves org by
company_sso_id(404 if unknown); enqueuesTeamMigrationWorker; returns 202 queued. - (pre-pilot, OQ-2) shared key value agreed with Bifrost and their trigger confirmed.
Test strategy
Request spec mirrors crms/auths_spec: asserts 401 on key mismatch, worker perform_async invoked on success, 404 on unresolvable CID.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: static-key pattern copied from
crms/auths; worker from Task 6.
Run to verify
bundle exec rspec spec/services/api/internal/v1/teams
Depends on
- Task 6 (worker to enqueue). External: OQ-2 shared-key handshake (pre-pilot, not pre-code).
Task 13: [BE] Observability — 6 Datadog metrics + histogram + structured logs (§13, OQ-3)
The team sees migration/sync health in Datadog: completion/failure counters, an auto-division counter, member-sync counters, and a latency histogram for the ≤30s SLA.
Status: ✅ Actionable
What to build
Emit the six metrics via Services::Datadog::CaptureCustomMetric (low-cardinality tags only) from the worker + consumer, plus structured log lines carrying the high-cardinality detail (cid, team_id, …).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/workers/divisions/team_migration_worker.rb | migration_job_completed / migration_job_failed (tags phase:) + logs |
| extend | hub_core/app/core/events/kafka_consumers/teams/team_events.rb | member_sync_completed / member_sync_failed (tags stage:) / auto_division_created / team_member_sync_latency (histogram) + logs |
Implementation steps
- Open
repositories/contact_lists/create.rb:44andrepositories/messages/search.rb:40— theCaptureCustomMetric.new(name:, tags:, use_env:).capture(action:, count:)shape (incl. histogram). - Add the six
.capturecalls at the right points in Tasks 5/6; keep tags low-cardinality (status:/phase:/stage:); putcid/team_idin the structured log line (CustomLogFormat), not tags. - Compute
team_member_sync_latency(eventoccurred_at→user_divisionswritten) and emit asaction: :histogram. - Specs assert
CaptureCustomMetricreceives the expectedname:/tags:and that latency uses:histogram.
Acceptance criteria
- Six metrics emitted via
CaptureCustomMetricwith low-cardinality tags;team_member_sync_latencyusesaction: :histogram. - High-cardinality detail is in structured logs, not metric tags.
- Each metric respects
DATADOG_ENABLED+ its per-metric*_METRICflag.
Test strategy
Stub CaptureCustomMetric; assert it receives each expected name:/tags:/capture(action:) from the worker and consumer paths.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0 |
| Total | 1.0 |
Assumptions: instrumentation only; helper + convention already exist; touches files from Tasks 5–6.
Run to verify
bundle exec rspec app/core/workers/divisions app/core/events/kafka_consumers/teams
Depends on
- Task 5b (member-sync metrics), Task 6 (worker metrics).
Task 14: [BE] Dormant-division routing skip + admin non-routability (D13, D15)
A channel-less ("dormant") division stops contributing to routing/notification while staying visible in the list; and an Admin/Owner in
user_divisionsis never routed or notified.
Status: ✅ Actionable (after T2, T5b, T9)
What to build
Exclude a dormant (channel-less, non-General) division on the routing/assignment side only, and add an agent/member role filter to the one un-role-filtered notification pluck — leaving validate_division (list access-control) unchanged so dormant divisions still list (flagged has_channels:false from T2/T9). Verify Admin/Owner in user_divisions are never candidates (the engine already role-gates at the source).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/rooms/auto_assign/by_room.rb | verify role-gate at :19,91,100-103 (role IN ('agent','member')); exclude dormant divisions from the general-vs-assigned split |
| extend | hub_core/app/core/domains/services/division/fetch_user_ids_in_division_by_room.rb | skip a dormant division in candidate resolution |
| extend | hub_core/app/core/domains/services/redis/divisions/get_all_user_division.rb (or its callers) | exclude dormant divisions from the "division-assigned" set |
| extend | hub_core/app/core/domains/repositories/queue_assign_agent/add.rb | :52 add an agent/member role filter to the member-notification pluck (admins not notified, D15) + skip dormant divisions |
| extend | hub_core/app/core/domains/interactors/channel_integrations/user_list_all_channel_by_division.rb | verify a dormant division surfaces no channels (channel-keyed — likely skips for free) |
| — | NOT hub_core/app/core/domains/interactors/abstract_iteractor.rb validate_division | left unchanged — list access-control; do not add the dormant/admin skip here (would hide dormant from a non-admin's list) |
| create/extend | matching *_spec.rb for each touched file | per-surface skip + admin-non-routability specs |
Implementation steps
- Open
auto_assign/by_room.rb:19,80-103— confirm candidates come fromModels::User.where(role: define_agent_role)and division membership is only an intersection; this is why Admin/Owner inuser_divisionscan never route (verify, add a spec). - Add the dormant exclusion where the division's members feed the candidate/general-split (
GetAllUserDivision/FetchUserIdsInDivisionByRoom): a division with an emptyDivision::<id>::Channels(non-General) contributes no members. - In
queue_assign_agent/add.rb:52, change theModels::UserDivision.where(division_id:).pluck(:user_id)pluck to filter toagent/memberroles (joinusers), so Admin/Owner aren't notified; also skip a dormant division. - Leave
validate_divisionuntouched — add a spec asserting a non-admin assigned to a now-dormant division still sees it inGET /core/v2/divisions. - Confirm reports (
reports/general/unassigned.rb) drop channel-derived rows for a dormant division but keep rooms stamped directly with itsdivision_id(no backfill this phase).
Acceptance criteria (per-surface checklist)
- Dormant (channel-less, non-General) division → 0 auto-assign candidates (
FetchUserIdsInDivisionByRoom). - An agent whose only division is dormant falls back to general/unassigned rooms.
- Dormant division members are not notified (
queue_assign_agent/add). -
validate_divisionunchanged — an assigned non-admin still sees the dormant division in the list (spec asserts the helper is not modified). - Reports: channel-derived rows drop; rooms stamped directly with the dormant
division_idstill count. - Admin/Owner in
user_divisions→ 0 auto-assign candidates (engine role-gate) AND not among notification recipients (queue_assign_agent/add.rb:52role-filtered), D15. - General division is never treated as dormant.
Test strategy
Unit-spec each routing surface: a channel-less division yields no candidates and no notifications; an admin in user_divisions is never selected/notified; a non-admin's list still returns the dormant division. Assert validate_division is not given a dormant-exclusion.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 1.0 |
| Total | 3.5 |
Assumptions: reuses the existing
Division::<id>::Channelscache + the already-role-gated engine (no new column, no engine rewrite); spans several shared routing files → higher QA for regression. Closes RFC OQ-8 / OQ-14 QA verification.
Run to verify
bundle exec rspec app/core/domains/repositories/rooms/auto_assign app/core/domains/repositories/queue_assign_agent app/core/domains/services/division app/core/domains/interactors/channel_integrations
Depends on
- Task 2 (
has_channels/channel_count), Task 5b (Admin/Owner inuser_divisions), Task 9 (list surface).
Task 15: [BE] Expose team_migration_status via organizations/settings (COMM-PH1-S01, D16)
The FE post-migration banner reads migration status from the canonical org-settings endpoint, not the divisions list.
Status: ✅ Actionable (after T3, T5a)
What to build
Add team_migration_status to the whitelist Entities::Settings and map it in the settings builder, so the existing GET /api/core/v1/organizations/settings (Interactors::UserViewSettings) returns it. No new endpoint, route, or scopes.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/entities/settings.rb | add attribute :team_migration_status, Types::Strict::String.optional (alphabetical) — strict whitelist entity |
| extend | hub_core/app/core/domains/builders/abstract_builder.rb (prepare_response_settings:88) or hub_core/app/core/domains/repositories/organizations/settings.rb | populate the key (value or nil) into the settings hash |
| extend | hub_core/app/core/domains/interactors/user_view_settings_spec.rb (+ builder spec) | assert the settings response carries team_migration_status |
Implementation steps
- Open
entities/settings.rb— note it's a strict dry-struct whitelist (attributes sorted alphabetically). Addteam_migration_statusasTypes::Strict::String.optional. - Find where the settings hash is assembled —
prepare_response_settings(abstract_builder.rb:88) →Repositories::Organizations::Settings— and setteam_migration_statusfromorganization.settings['team_migration_status']. - Spec:
GET /core/v1/organizations/settingsreturnsteam_migration_status; unset →nil.
Pin (latest review):
Types::Strict::String.optionalmakes the value nullable but the key is still required by a strict struct — soprepare_response_settingsmust always set it (value ornil). If a truly-absent key is preferred, useattribute?instead. Confirm with the author before merge.
Acceptance criteria
-
GET /core/v1/organizations/settingsreturnsteam_migration_status(value fromorganizations.settings,nilwhen unset). -
GET /core/v2/divisionsdoes not carryteam_migration_status(belongs to Task 9). - Endpoint route + scopes unchanged (admin/owner/supervisor/agent/member/bot).
Test strategy
Interactor/builder spec asserts the settings entity includes team_migration_status with the value from organizations.settings, and nil when the key is unset.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: whitelist-attribute add + mapping; hub_service endpoint unchanged; low risk.
Run to verify
bundle exec rspec app/core/domains/interactors/user_view_settings_spec.rb app/core/domains/builders
Depends on
- Task 3 (settings accessor), Task 5a (consumer writes
team_migration_status).
Task 16: [BE] Full regression + lint + security across all three repos
Confirms the whole change set is green — tests, style, and Brakeman — before merge/deploy.
Status: ✅ Actionable
What to build
Run the full suite, RuboCop, and Brakeman in hub_core, hub_service, and hub_worker; fix any regressions introduced by Tasks 1–15.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| verify | all touched files (3 repos) | green rspec + rubocop + brakeman; no new warnings |
Implementation steps
- hub_core:
RAILS_ENV=test bundle exec rails app:db:migrate, thenbundle exec rspec app,bundle exec rubocop,brakeman --no-exit-on-warn --no-exit-on-error. - hub_service + hub_worker:
bundle exec rspec app+bundle exec rubocop+ brakeman. - Triage and fix any failures or new Brakeman warnings.
Acceptance criteria
- All three repos: green rspec, clean rubocop, no new Brakeman warnings.
- Migration up/down clean in a fresh test DB.
Test strategy
Whole-suite regression; the assertion is a green pipeline-equivalent locally in each repo.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: no large refactors; failures are localized to the new code.
Run to verify
bundle exec rspec app && bundle exec rubocop && brakeman --no-exit-on-warn --no-exit-on-error
Depends on
- All tasks (1–15).
Ordering rationale
- Foundation first (T1→T2→T3): the column, its entity/index exposure, and the Team-mode gate unblock the consumer, guard, and V2 endpoints. Start T4a (circuit breaker) → T4b (clients) in parallel with T1–T3 — the clients are the highest-leverage unblocker (needed by T6, T10, T11).
- Consumer chain is strictly sequential (T5a→T5b→T5c): they share
team_events.rb, so 5a lands the skeleton + mapping, then 5b (sync) and 5c (delete) extend it one after another — do not parallelize. T7 (Karafka wiring) only needs the 5a skeleton to exist. - V2 endpoints are sequential on the shared resource (T9→T10, T9→T11): T9 creates
resources/divisions.rb+ the mount; T10 and T11 each add one endpoint + its interactor. Run T10/T11 after T9; parallelize them only if the team coordinates edits to the shared resource file. - New session-driven tasks (T14, T15): T14 (dormant-division skip + admin non-routability) depends on T2 (
has_channels), T5b (admins inuser_divisions), and T9 — schedule it after those; it touches shared routing files, so run it as a focused slice with its per-surface specs and coordinate against T5/T6 edits. T15 (status via settings) depends on T3 + T5a and can run any time after them, in parallel with the V2 endpoints. - Critical path runs through T4a→T4b→T6 (write path) and the consumer chain T5a→T5b (~3.5d), plus the two heaviest slices — the assign-channel endpoint (T11, 2.5d) and the routing-side dormant/admin work (T14, 3.5d, highest regression risk). Staff T4a, T5a, and T1 first; queue T14 once T2/T5b/T9 land.
- Push externally now: OQ-2 shared migration key (gates T12 at pilot), OQ-5 column-vs-join sign-off (gates production AGREED). Neither blocks starting any task. Confirm during the relevant tasks: T5a's dedup default (OQ-1), T12's 201→202, T15's dry-struct key semantics, and T14's admin-audit completeness (OQ-14, before Assignment-Menu Lock).
Skipped stories
| Story / item | Reason |
|---|---|
| MIG-PH1-S02 (bot routing) | No code — division_id verified unchanged; covered by the T16 regression pass. |
| MIG-PH1-S03 (historical records → team) | n/a on chat — keeps division_id, no join; Launchpad-side backfill is Bifrost-owned. |
| UI-PH1-S01 / S02 / COMM-PH1-S01 (banner render, read-only field rendering, tooltips) | FE-only — separate FE RFC (OQ-9). BE halves covered by T8 (guard), T9 (list + has_channels + Admin/Owner display), T11 (channel), T14 (dormant/admin non-routability), T15 (team_migration_status via settings). |
| MIG-PH1-S05 (rollback) | Config action — flip use_qontak_one_team OFF; operational runbook in §4.D, no build task. |