Skip to main content

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_worker under ChatPanel/hub-project/. Spec convention: <name>_spec.rb alongside source. Tests: RAILS_ENV=test bundle exec rspec <path> --tag ~@is_skip_pipeline, bundle exec rubocop, brakeman.

Effort Summary

Area (tasks)FE daysBE daysQA daysTotal
Schema & domain model (T1–T3)2.002.0
Launchpad resilience + clients (T4a–T4b)2.50.53.0
Team-events consumer — split (T5a–T5c)3.51.55.0
Migration runtime (T6–T7)2.50.53.0
V1 guard + V2 endpoints (T8–T11)5.02.07.0
Dormant-division skip + admin non-routability (T14, D13/D15)2.51.03.5
Status via organizations/settings (T15, D16)0.50.51.0
Internal endpoint + obs + regression (T12, T13, T16)2.51.03.5
Grand total21.07.028.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 vs attribute?). 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_id link 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

ActionFileWhat changes
createhub_core/database/core/db/migrate/20260710000000_add_team_columns_to_divisions.rb3 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

  1. Open hub_core/database/core/db/migrate/20220711012607_add_is_contact_masking_to_divisions.rb — mirror its add_column + inline Elasticsearch::Model.client.indices.put_mapping structure.
  2. Add the 3 add_column … :uuid, null: true lines + add_index :divisions, [:organization_id, :team_id], name: "index_divisions_on_organization_id_and_team_id". Do not add a parent_id index (data-only column).
  3. Guard the ES put_mapping with indices.exists? so it no-ops when the index is absent.
  4. Run migrate up and down to confirm both are clean.

Acceptance criteria

  • 3 columns present on divisions; index index_divisions_on_organization_id_and_team_id exists; no parent_id index.
  • RAILS_ENV=test bundle exec rails app:db:migrate succeeds; …:migrate:down reverts 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

DisciplineDays
Backend0.5
QA0
Total0.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_id become 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

ActionFileWhat changes
extendhub_core/app/core/domains/models/division.rbadd the 3 fields to as_indexed_json + mappings (keyword)
extendhub_core/app/core/domains/entities/division.rbadd team_id, parent_team_id, parent_id attributes (+ optional has_channels/channel_count)
extendhub_core/app/core/domains/builders/division.rbmap the 3 new attributes
extendhub_core/app/core/domains/builders/list_division.rbmap the 3 attributes + project has_channels/channel_count from division.channels (D13)

Implementation steps

  1. Open models/division.rb:14-50 — add the 3 keys to as_indexed_json and mappings dynamic: :strict (type keyword).
  2. Add the 3 attributes to entities/division.rb (mirror existing id/organization_id/name).
  3. Map them in builders/division.rb and builders/list_division.rb (mirror existing attribute rows).
  4. 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_json includes the three keyword fields.
  • List builder projects has_channels/channel_count from the preloaded :channels (no extra query); channel_count = 0has_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

DisciplineDays
Backend1.0
QA0
Total1.0

Assumptions: builders/list_division.rb exists (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

ActionFileWhat changes
extendhub_core/app/core/domains/models/organization.rbadd 2 store_accessor keys (beside :66); add qontak_one_team_enabled?
createhub_core/app/core/domains/services/qontak_one_team.rboptional service wrapping the combined gate (mirrors services/preference.rb style)

Implementation steps

  1. Open models/organization.rb:66 (store_accessor :settings, :enable_unified_app_package) — add the two new accessors the same way.
  2. Implement qontak_one_team_enabled? combining the existing unified_app/enable_unified_app_package read with the new use_qontak_one_team boolean.
  3. If encapsulating in a service, mirror services/preference.rb:21,61-71 for the method shape.
  4. Spec: true only when both signals on; false when either off; default off.

Acceptance criteria

  • qontak_one_team_enabled?(org) true iff unified_app and use_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

DisciplineDays
Backend0.5
QA0
Total0.5

Assumptions: organizations.settings jsonb already exists (verified schema.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

ActionFileWhat changes
extendhub_core/app/core/domains/repositories/http/circuit_breakers.rbadd launchpad_circuit_breaker (mirror sso_circuit_breaker:5-13)
createhub_core/app/apps/launchpad/services/circuit_wrappable.rbshared call_circuit(&blk) module (pref-gated) the clients include
createhub_core/app/core/domains/repositories/http/circuit_breakers_spec.rb (or extend)breaker returns :launchpad_circuit; open → Failure

Implementation steps

  1. Open repositories/http/circuit_breakers.rb:5-13 (sso_circuit_breaker) and apps/mekari_sso/services/auth.rb:44-64 (call_circuit wrap + pref gate :enable_sso_circuit_breaker).
  2. Add launchpad_circuit_breaker with the env-tunable thresholds (LAUNCHPAD_CIRCUIT_BREAKER_*) and the Moneta :Redis store on REDIS_W_URL.
  3. Create circuit_wrappable.rb: call_circuit(&blk) runs the block inside launchpad_circuit_breaker.run(exception: false), raising RequestTimeout on timed_out? and RequestError on 5xx; when pref :enable_launchpad_circuit_breaker is off, call the block directly; a nil return (open) → Failure('[Circuitbox] Launchpad unavailable').
  4. Specs: assert the breaker is a Circuitbox :launchpad_circuit; a forced-open circuit yields Failure; pref-off bypasses the breaker.

Acceptance criteria

  • launchpad_circuit_breaker returns a Circuitbox :launchpad_circuit with thresholds 5/50%/60s/60s (env-overridable).
  • call_circuit returns Failure when 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

DisciplineDays
Backend1.0
QA0
Total1.0

Assumptions: Circuitbox + Moneta already in the repo; mirrors the SSO breaker + call_circuit pattern 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/teams surface (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

ActionFileWhat changes
createhub_core/app/apps/launchpad/services/bulk_create_team.rbPOST /private/teams/bulk (items with is_migrate/app/app_identifier_id/member_ids)
createhub_core/app/apps/launchpad/services/get_team.rbGET /private/teams/{id} (+ /members)
createhub_core/app/apps/launchpad/services/update_team_name.rbPATCH /private/teams/{id}
createhub_core/app/apps/launchpad/services/{bulk_create_team,get_team,update_team_name}_spec.rbrequest-shape + breaker-wrap specs (stubbed Typhoeus)

Implementation steps

  1. Open app/apps/launchpad/services/get_last_session.rb:3-17 — copy its < Repositories::AbstractHttp, @base_url, and headers: { 'Authorization': ENV['QONTAK_LAUNCHPAD_BASIC_AUTH'] } wiring; set the path to /private/teams… and timeout: (ENV['LAUNCHPAD_REQUEST_TIMEOUT'] || 30).to_i.
  2. include Launchpad::Services::CircuitWrappable (T4a) and wrap each request in call_circuit { … }.
  3. Implement parse_response handling for success + error bodies (mirror get_last_session.rb).
  4. Specs: stub Typhoeus; assert base URL, Authorization header, path, timeout: 30; assert an open circuit surfaces Failure.

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_response handles 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

DisciplineDays
Backend1.5
QA0.5
Total2.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-mode TEAM_CREATED) event links its division by setting team_id, and flips the org to completed once 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

ActionFileWhat changes
createhub_core/app/core/events/kafka_consumers/teams/team_events.rbconsume loop; dedup + attempt counter; guards; recache/reindex helpers; TEAM_MIGRATED mapping branch; mark_as_consumed
createhub_core/app/core/events/kafka_consumers/teams/team_events_spec.rbmapping + dedup + skip-path specs

Implementation steps

  1. Open kafka_consumers/launchpad/update_company_settings.rb:64-126 (bifrost consumer that mutates DB + busts Redis) and users/user_data_updated.rb:19-61 (consume loop, mark_as_consumed, skip-after-N).
  2. Build the skeleton: parse envelope; guards (company_sso_id present + org found + qontak_one_team_enabled? from Task 3) — else log/alert + mark_as_consumed.
  3. Dedup (safe default, pending OQ-1): SET NX processed_team_event::<event_id> (7d) set only after success; separate INCR attempts::<event_id> (7d), skip+alert at >= 5.
  4. TEAM_MIGRATED branch: UPDATE divisions SET team_id WHERE (org, id = payload.app_identifier_id) AND team_id IS NULL; then set team_migration_status='completed' if no unmapped divisions remain.
  5. Add the shared recache (ResetDivisionsByUser/ResetAllUserDivision/ResetUsersByDivision) + ES reindex helpers used by all three branches; stub TEAM_UPDATED/TEAM_DELETED to consume-and-return.
  6. On error: do not mark_as_consumed (redelivery). No Timeout.timeout wrap (local-only handler — §2.C waiver).

Acceptance criteria

  • TEAM_MIGRATED/CREATED(migrate) sets team_id by app_identifier_id; redelivery is a no-op (team_id IS NULL guard + 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

DisciplineDays
Backend1.5
QA0.5
Total2.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_UPDATED event syncs the division's name and mirrors the full member roster — agents + supervisors (routable) and Admin/Owner (read-only display) — into user_divisions, via UserDivision::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

ActionFileWhat changes
extendhub_core/app/core/events/kafka_consumers/teams/team_events.rbfill the TEAM_UPDATED branch (name + roster-replace incl. Admin/Owner display)
extendhub_core/app/core/domains/repositories/divisions/user_division/edit.rbadd a managed_by_team: kwarg (default false) that skips the supervisor-count check when true (D14); recache fan-out unchanged
extendhub_core/app/core/events/kafka_consumers/teams/team_events_spec.rbname-sync + roster-replace + 0-supervisor + Admin/Owner-display specs

Implementation steps

  1. Re-open team_events.rb (from T5a) and locate the TEAM_UPDATED stub.
  2. Find the division by (org, team_id) (uses the T1 index); update name only if in the update-mask.
  3. Add the managed_by_team: kwarg to Repositories::Divisions::UserDivision::Edit (read user_division/edit.rb + create.rb:19 for the supervisor-count validation) — when true, skip that check; keep the existing Reset*/Recache* fan-out (D14).
  4. 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.
  5. 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: truea 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

DisciplineDays
Backend1.5
QA0.5
Total2.0

Assumptions: reuses UserDivision::Edit (+ new managed_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_DELETED event cascades to delete the linked chat division and its join rows, nullifying rooms.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

ActionFileWhat changes
extendhub_core/app/core/events/kafka_consumers/teams/team_events.rbfill the TEAM_DELETED branch (cascade delete)
extendhub_core/app/core/events/kafka_consumers/teams/team_events_spec.rbcascade + idempotent-absence specs

Implementation steps

  1. Re-open team_events.rb and locate the TEAM_DELETED stub.
  2. Find the division by (org, team_id); if absent → no-op + mark_as_consumed (idempotent).
  3. Call Repositories::Divisions::Delete (read repositories/divisions/delete.rb:14-82 for the destroy + Redis reset + ES reindex + chatbot notify fan-out) — reuse it, don't reimplement.
  4. Confirm RoomUpdateDivision publish nullifies rooms.division_id (as in supervisor_delete_division.rb:26).

Acceptance criteria

  • TEAM_DELETED deletes the division + join rows via Repositories::Divisions::Delete.
  • Already-deleted division → no-op (idempotent), still mark_as_consumed.
  • rooms.division_id nullified via the existing RoomUpdateDivision event.

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

DisciplineDays
Backend0.5
QA0.5
Total1.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

ActionFileWhat changes
createhub_core/app/core/workers/divisions/team_migration_worker.rbresumable worker; sidekiq_options queue: :team_migration, retry: 5
createhub_core/app/core/workers/divisions/team_migration_worker_spec.rbidempotency + status + no-duplicate specs
extendhub_worker/config/sidekiq.ymlregister team_migration queue (weight 1)

Implementation steps

  1. Open workers/centralized_contacts/qontak_one_migration_worker.rb:3-11 — mirror < AbstractSidekiqWorker, sidekiq_options queue:, perform(organization_id, …).
  2. find_each(batch_size: 100) over team_id IS NULL divisions; set team_migration_status='processing' (with with_lock on the org).
  3. Build bulk items and call BulkCreateTeam (Task 4b); on Failure after Sidekiq retries → status failed.
  4. Register team_migration (weight 1) in hub_worker/config/sidekiq.yml.
  5. 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 NULL divisions; skips mapped ones.
  • Re-run after partial completion produces no duplicate teams/links.
  • team_migration_status moves processing → (completed set by consumer) / failed after retry exhaustion.
  • team_migration queue 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

DisciplineDays
Backend2.0
QA0.5
Total2.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

ActionFileWhat changes
extendhub_worker/karafka.rbadd the topic to consumer_group 'bifrost_launchpad' (~:474) with batch_consuming true, start_from_beginning true

Implementation steps

  1. Open hub_worker/karafka.rb:450-465 — the bifrost_launchpad group and the existing bifrost.user.updates.v1 topic block.
  2. Add the new topic block mirroring the sibling, referencing KafkaConsumers::Teams::TeamEvents.
  3. Boot-check / run the hub_worker suite to confirm routing resolves.

Acceptance criteria

  • bifrost.team.events.v1 is routed to KafkaConsumers::Teams::TeamEvents within bifrost_launchpad.
  • hub_worker boots without route errors.

Test strategy

hub_worker spec/boot check asserts the topic → consumer mapping is registered.

Effort estimate

DisciplineDays
Backend0.5
QA0
Total0.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

ActionFileWhat changes
extendhub_core/app/core/domains/interactors/divisions/supervisor_create_user_division.rbearly Failure(:managed_by_team) in Team mode
extendhub_core/app/core/domains/interactors/divisions/supervisor_edit_user_division.rbsame guard
extendhub_core/…/supervisor_create_user_division_spec.rb, …_edit_…_spec.rbTeam-mode rejection + legacy pass-through specs

Implementation steps

  1. Open interactors/divisions/supervisor_delete_division.rb:16-26 for the interactor→result-monad shape.
  2. At the top of each target interactor, check qontak_one_team_enabled? (Task 3) and return Failure(:managed_by_team) if true.
  3. Specs: Team mode → Failure(:managed_by_team); non-Team → existing behavior intact.

Acceptance criteria

  • POST/PUT member writes return Failure(: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

DisciplineDays
Backend0.5
QA0.5
Total1.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, with role) shown read-only in Team mode. Per-org team_migration_status is 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

ActionFileWhat changes
createhub_service/app/services/api/core/v2/divisions/routes.rbmount the divisions resource (mirror core/v2/messages/routes.rb)
createhub_service/app/services/api/core/v2/divisions/resources/divisions.rbGET action: scopes, org-scope, reuse SupervisorListDivision, Dry::Matcher
extendhub_service/app/services/api/core/v2/routes.rbadd mount API::Core::V2::Divisions::Routes
createhub_service/spec/services/api/core/v2/divisions/resources/divisions_spec.rbGET request specs (scopes, response fields)

Implementation steps

  1. Open core/v1/divisions/resources/divisions.rb:24-229 (V1 shape) and core/v2/messages/routes.rb (V2 mount pattern) + core/v2/helpers.rb:84-93.
  2. Create the V2 resource with the GET action: oauth2 :admin,:owner,:supervisor,:bot,:agent,:member, org-scoped via me.organization_id, delegating to SupervisorListDivision via interact_with.
  3. 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 their role so Admin/Owner appear read-only (D15). Do not add team_migration_status (that lives on the settings endpoint, Task 15).
  4. Mount in core/v2/routes.rb (one line, mirror messages).
  5. 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 carry role (Admin/Owner read-only); reuses SupervisorListDivision (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

DisciplineDays
Backend1.0
QA0.5
Total1.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

ActionFileWhat changes
createhub_core/app/core/domains/interactors/v2/divisions/update_division.rbname update + enqueue Launchpad name-sync (net-new v2/ interactor namespace)
extendhub_service/app/services/api/core/v2/divisions/resources/divisions.rbadd put ':id' with ownership guard, delegate via interact_with
createhub_core/…/interactors/v2/divisions/update_division_spec.rbinteractor specs (sync + NULL-team skip + General-rename reject)
extendhub_service/spec/…/v2/divisions/resources/divisions_spec.rbPUT /:id request spec

Implementation steps

  1. Read repositories/divisions/update.rb + supervisor_edit_division.rb for the update + General-division-protection rule; interactors/v2/divisions/ is a net-new namespace.
  2. Build UpdateDivision: update name; reject reserved General rename (general_division_protected → 422); enqueue UpdateTeamName (Task 4b); if team_id NULL, update local only and skip the remote call.
  3. Add put ':id' to resources/divisions.rb with authorize_user_to_allow_access_resource!(Models::Division, id); delegate via interact_with.
  4. Specs: name syncs to Launchpad; NULL-team skips remote; General rename → 422.

Acceptance criteria

  • PUT /:id renames locally and enqueues a Launchpad PATCH; NULL team_id skips 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

DisciplineDays
Backend1.5
QA0.5
Total2.0

Assumptions: reuses the existing update repo + General-rename rule; UpdateTeamName client 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

ActionFileWhat changes
createhub_core/app/core/domains/interactors/v2/divisions/assign_channel_to_team.rbfind-or-create division from team; set channels
extendhub_service/app/services/api/core/v2/divisions/resources/divisions.rbadd put 'channels', {team_id, channel_ids[]} (UUID validation), delegate
createhub_core/…/interactors/v2/divisions/assign_channel_to_team_spec.rbauto-create + empty-channel + Launchpad-failure specs
extendhub_service/spec/…/v2/divisions/resources/divisions_spec.rbPUT channels request spec

Implementation steps

  1. Read repositories/divisions/create.rb for the create + join-row + publish pattern.
  2. Build AssignChannelToTeam: lookup by (org, team_id); if present, set channels; if absent, GetTeam (Task 4b) → create in a transaction (division + channel_divisions, seed user_divisions with agents + supervisors + Admin/Owner display from the roster, D15); empty channel_ids keeps the division; Launchpad error → Failure (team_not_found/launchpad_unavailable 422), no partial row.
  3. ES reindex + Redis channel/workload reset after commit.
  4. Add put 'channels' to resources/divisions.rb with UUID param validation; delegate via interact_with.
  5. 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), seeds user_divisions (agents + supervisors + Admin/Owner display), attaches channels.
  • Re-assign to an existing (org, team_id) updates channels only (no duplicate division).
  • Empty channel_ids keeps 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

DisciplineDays
Backend2.0
QA0.5
Total2.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

ActionFileWhat changes
createhub_service/app/services/api/internal/v1/teams/routes.rbmount the teams resource (mirror auths/routes.rb)
createhub_service/app/services/api/internal/v1/teams/resources/teams.rbbefore { validate_launchpad_api_key! }; post 'migrate'; resolve org; perform_async
extendhub_service/app/services/api/internal/v1/header_validation.rbadd validate_launchpad_api_key! reading LAUNCHPAD_MIGRATION_API_KEY
extendhub_service/app/services/api/internal/v1/routes.rbmount the teams routes
createhub_service/spec/services/api/internal/v1/teams/…_spec.rb401-on-mismatch + enqueue + org-resolve specs

Implementation steps

  1. Open internal/v1/auths/resources/crms/auths.rb:3-27 + header_validation.rb:6-15 (validate_crm_api_key!) — the exact static-key template.
  2. Add validate_launchpad_api_key! mirroring validate_crm_api_key! but against a new ENV['LAUNCHPAD_MIGRATION_API_KEY'] (dedicated key, not shared CRM_API_KEY); raise OauthBwergemn::Errors::InvalidToken (401) on mismatch.
  3. Build the resource: before guard, { company_sso_id } param, resolve org (404 organization_not_found if unknown), TeamMigrationWorker.perform_async(org.id), respond 202 {status:'success', data:{status:'queued', organization_id}}.
  4. Mount teams routes in internal/v1/routes.rb.
  5. Specs: mirror the crms/auths spec — 401 on bad/missing key, enqueue on success, 404 on unknown CID.

Acceptance criteria

  • X-Chat-Api-Key == LAUNCHPAD_MIGRATION_API_KEY enforced; mismatch → 401.
  • Resolves org by company_sso_id (404 if unknown); enqueues TeamMigrationWorker; 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

DisciplineDays
Backend1.0
QA0.5
Total1.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

ActionFileWhat changes
extendhub_core/app/core/workers/divisions/team_migration_worker.rbmigration_job_completed / migration_job_failed (tags phase:) + logs
extendhub_core/app/core/events/kafka_consumers/teams/team_events.rbmember_sync_completed / member_sync_failed (tags stage:) / auto_division_created / team_member_sync_latency (histogram) + logs

Implementation steps

  1. Open repositories/contact_lists/create.rb:44 and repositories/messages/search.rb:40 — the CaptureCustomMetric.new(name:, tags:, use_env:).capture(action:, count:) shape (incl. histogram).
  2. Add the six .capture calls at the right points in Tasks 5/6; keep tags low-cardinality (status:/phase:/stage:); put cid/team_id in the structured log line (CustomLogFormat), not tags.
  3. Compute team_member_sync_latency (event occurred_atuser_divisions written) and emit as action: :histogram.
  4. Specs assert CaptureCustomMetric receives the expected name:/tags: and that latency uses :histogram.

Acceptance criteria

  • Six metrics emitted via CaptureCustomMetric with low-cardinality tags; team_member_sync_latency uses action: :histogram.
  • High-cardinality detail is in structured logs, not metric tags.
  • Each metric respects DATADOG_ENABLED + its per-metric *_METRIC flag.

Test strategy

Stub CaptureCustomMetric; assert it receives each expected name:/tags:/capture(action:) from the worker and consumer paths.

Effort estimate

DisciplineDays
Backend1.0
QA0
Total1.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_divisions is 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

ActionFileWhat changes
extendhub_core/app/core/domains/repositories/rooms/auto_assign/by_room.rbverify role-gate at :19,91,100-103 (role IN ('agent','member')); exclude dormant divisions from the general-vs-assigned split
extendhub_core/app/core/domains/services/division/fetch_user_ids_in_division_by_room.rbskip a dormant division in candidate resolution
extendhub_core/app/core/domains/services/redis/divisions/get_all_user_division.rb (or its callers)exclude dormant divisions from the "division-assigned" set
extendhub_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
extendhub_core/app/core/domains/interactors/channel_integrations/user_list_all_channel_by_division.rbverify a dormant division surfaces no channels (channel-keyed — likely skips for free)
NOT hub_core/app/core/domains/interactors/abstract_iteractor.rb validate_divisionleft unchanged — list access-control; do not add the dormant/admin skip here (would hide dormant from a non-admin's list)
create/extendmatching *_spec.rb for each touched fileper-surface skip + admin-non-routability specs

Implementation steps

  1. Open auto_assign/by_room.rb:19,80-103 — confirm candidates come from Models::User.where(role: define_agent_role) and division membership is only an intersection; this is why Admin/Owner in user_divisions can never route (verify, add a spec).
  2. Add the dormant exclusion where the division's members feed the candidate/general-split (GetAllUserDivision / FetchUserIdsInDivisionByRoom): a division with an empty Division::<id>::Channels (non-General) contributes no members.
  3. In queue_assign_agent/add.rb:52, change the Models::UserDivision.where(division_id:).pluck(:user_id) pluck to filter to agent/member roles (join users), so Admin/Owner aren't notified; also skip a dormant division.
  4. Leave validate_division untouched — add a spec asserting a non-admin assigned to a now-dormant division still sees it in GET /core/v2/divisions.
  5. Confirm reports (reports/general/unassigned.rb) drop channel-derived rows for a dormant division but keep rooms stamped directly with its division_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_division unchanged — 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_id still count.
  • Admin/Owner in user_divisions → 0 auto-assign candidates (engine role-gate) AND not among notification recipients (queue_assign_agent/add.rb:52 role-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

DisciplineDays
Backend2.5
QA1.0
Total3.5

Assumptions: reuses the existing Division::<id>::Channels cache + 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 in user_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

ActionFileWhat changes
extendhub_core/app/core/domains/entities/settings.rbadd attribute :team_migration_status, Types::Strict::String.optional (alphabetical) — strict whitelist entity
extendhub_core/app/core/domains/builders/abstract_builder.rb (prepare_response_settings:88) or hub_core/app/core/domains/repositories/organizations/settings.rbpopulate the key (value or nil) into the settings hash
extendhub_core/app/core/domains/interactors/user_view_settings_spec.rb (+ builder spec)assert the settings response carries team_migration_status

Implementation steps

  1. Open entities/settings.rb — note it's a strict dry-struct whitelist (attributes sorted alphabetically). Add team_migration_status as Types::Strict::String.optional.
  2. Find where the settings hash is assembled — prepare_response_settings (abstract_builder.rb:88) → Repositories::Organizations::Settings — and set team_migration_status from organization.settings['team_migration_status'].
  3. Spec: GET /core/v1/organizations/settings returns team_migration_status; unset → nil.

Pin (latest review): Types::Strict::String.optional makes the value nullable but the key is still required by a strict struct — so prepare_response_settings must always set it (value or nil). If a truly-absent key is preferred, use attribute? instead. Confirm with the author before merge.

Acceptance criteria

  • GET /core/v1/organizations/settings returns team_migration_status (value from organizations.settings, nil when unset).
  • GET /core/v2/divisions does not carry team_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

DisciplineDays
Backend0.5
QA0.5
Total1.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

ActionFileWhat changes
verifyall touched files (3 repos)green rspec + rubocop + brakeman; no new warnings

Implementation steps

  1. hub_core: RAILS_ENV=test bundle exec rails app:db:migrate, then bundle exec rspec app, bundle exec rubocop, brakeman --no-exit-on-warn --no-exit-on-error.
  2. hub_service + hub_worker: bundle exec rspec app + bundle exec rubocop + brakeman.
  3. 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

DisciplineDays
Backend0.5
QA0.5
Total1.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 in user_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 / itemReason
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.