Skip to main content

RFC: Qontak One Team Migration — Phase 1: Legacy Team Migration

Document Conventions (do not remove)

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

It is also agent-execution-ready: §1 Design References (FE) + §1 PRD-to-Schema Derivation (BE), §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan

  • Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

Delivery & project management live elsewhere. This RFC is the technical artifact only — no staffing, effort, timeline, or rollout schedule. Those live in the initiative's delivery/ folder. Until this RFC is handed to delivery, the Delivery row reads not yet handed to delivery.

Grounding note (anti-hallucination). Every file path, struct, function, route, migration filename, and command below was read in the live worktrees at /Users/mekari/Documents/repos/qontak-launchpad (Go BE, branch main) and /Users/mekari/Documents/repos/qontak-launchpad-fe (Nuxt 4 FE, branch master) on 2026-06-30. Detail 2.0's Source Verification table carries the per-anchor evidence. Where the PRD's described behaviour already exists or does not match the code, this RFC says so explicitly rather than restating the PRD.

Metadata

FieldValueNotes
StatusRFC (DRAFT)Human label; YAML status: carries linter enum draft
DRIGrehastaAccountable RFC owner (Backend). Per-task staffing lives in delivery/ once handed off.
TeambifrostAdvisory squad slug carried from PRD / initiative README
Author(s)Grehasta (BE), Syafrizal M. (FE)Initiative implementors (initiative README)
ReviewersBifrost Tech Lead, Chat Squad Tech Lead, CRM Squad Tech LeadCross-squad: Chat & CRM own the source read APIs
Approver(s)Bifrost Tech Lead, Infosec approverMigration touches access/membership data
Submitted Date2026-06-30ISO-8601
Last Updated2026-06-30ISO-8601; bump on every material edit
Target Release2026-Q3Quarter
Target Quarter2026-Q3Advisory; carried from PRD
Deliverynot yet handed to deliveryUpdate once delivery/timeline.md references this RFC
Related../prds/prd-qontak-one-team-migration.md, ../prds/prd-qontak-one-team-anchor.mdSource PRD + ANCHOR
DiscussionTBD — pending Bifrost engineering channelAlerts route to #bifrost-alerts (PRD §12)

Type: full-stack Frontend sub-type: enhancement (extends the existing InviteUsers.vue team field + invite store) Backend sub-type: new-feature (new columns, extended bulk-create migration path + trigger, invite team-assignment write)

Sections at a Glance

  1. Overview (problem, success criteria, Design References [FE], PRD-to-Schema Derivation [BE], traceability, decisions, per-story change map)
  2. Technical Design (Repo Reading Guide → end-to-end mermaid → DDL → APIs → cross-layer contract verification → data flow)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Qontak One is centralizing User Management into Launchpad (the usman module). Today, team structures are fragmented: Chat keeps "Divisions" and CRM keeps "Teams", each in its own system, with no unified record in Launchpad and no way to trace where a Launchpad team came from. This RFC implements Phase 1 — Legacy Team Migration:

  1. Add source tracking (source_identifier, reference_id) to the Launchpad teams table so every team records whether it was migrated from Chat, migrated from CRM, or created natively in Launchpad.
  2. Provide an idempotent migration receive path (extending the existing POST /teams/bulk) plus a trigger to Chat/CRM: each squad maps its own active Divisions/Teams and pushes them in (carrying an optional app_identifier_id = the source division/team id), and Launchpad creates source-prefixed teams (Chat - <name>, CRM - <name>). On each migrated create, Launchpad emits a separate TEAM_MIGRATED Kafka event carrying app + app_identifier_id + the new Launchpad team_id, so each source squad can map its own division/team back to the centralized Launchpad team.

Naming convention (request/event ↔ storage). On the wire (bulk-create request + the TEAM_MIGRATED event) the source-app vocabulary is used: app (chat/crm) and app_identifier_id (the app's own division/team id, optional string). These persist into Launchpad's storage vocabulary: appteams.source_identifier (Chat/CRM/Launchpad), app_identifier_idteams.reference_id. appsource_identifier is already the established mapping in the code (create.go:84); app_identifier_idreference_id is the net-new half.

  1. Enforce team assignment on new user invitations when the migration is enabled for a CID.
  2. Stamp the already-existing auto-created "General" team on CID creation with source_identifier = 'Launchpad', reference_id = NULL.

All of the above is gated behind a new one_team_migration_enabled flag (global, default off) layered on top of the existing unified_app gate.

Material grounding deltas the reviewer must know up front (full evidence in §2.0):

  • OTM-S02 ("General" team auto-create on CID creation) is already implemented. companies/migrate.go:148 and migrate_full.go:393 already call teamCreator.CreateTeamForCompany(ctx, newCompany.ID, companySsoID, "general"). The remaining work is (a) stamping the new source columns, (b) reconciling the name casing "general" vs the PRD's "General", and (c) the PRD's "retried asynchronously" — not implemented today (current behaviour is non-fatal log-and-continue, no retry).
  • The FE invite team field already exists (InviteUsers.vue field-teams, gated by teamsStore.isTeamMenuVisible), but it is optional and the invite store drops teamIds before the HTTP POST (useUsers.ts inviteUser only forwards email, fullName, inviterSsoID, nik, phone, roleId, staffLevel). So teams never reach the backend today.
  • OTM-S03 (team sidebar URL /qontakcrm/team/launchpad/team) does not live in either launchpad repo. The real Launchpad route is /user_management/teams; neither /qontakcrm/team nor /launchpad/team appears anywhere in qontak-launchpad-fe. This story is out of scope for the Launchpad repos and is tracked as a cross-squad item — see §5 OQ-3.
  • Migration is a push model, and Launchpad's existing bulk endpoint already supports it. Per service planning (PRD v1.2), each source squad maps its own data and calls Launchpad's create endpoints — Launchpad does not read Chat/CRM data. The existing POST /iag/v1/teams/bulk (request.CreateTeam) already carries IsMigrate bool (json:"is_migrate") and App string (json:"app", validated chat/crm), runs async via EnqueueBulkCreateTeam/ProcessBulkCreateTeam, prefixes the name on collision (item.App + "-" + item.Name, create.go:84), and already emits the TEAM_CREATED Kafka event (createSingleTeammaybePublishTeamCreatedEvent, events.go:130, topic TopicTeamEventsV1, gated by FeaturePublishTeamUpdate). What it does not do yet: accept an app_identifier_id, persist source_identifier/reference_id (the app is used only for name-prefixing; CreateTeamParams is still {CompanyID, Name, ParentID}), enforce reference_id-based idempotency, or emit a migration-specific event. So the net-new Launchpad work is extending the existing create path (add app_identifier_id, persist source cols, idempotency, emit TEAM_MIGRATED instead of TEAM_CREATED for migrate-mode), plus a trigger to Chat/CRM (contract owned by them — PRD §15).

Success Criteria

  1. Migration command processes all active CIDs and, for each, creates one Launchpad team per active Chat Division and CRM Team with correct prefix, source_identifier, and reference_id. Re-running creates zero duplicates (idempotent on reference_id).
  2. Migration failure rate ≤ 1% of teams attempted (PRD §13); a failed record is logged with CID + reason and the command continues.
  3. For CIDs with one_team_migration_enabled = true, 100% of successful new invitations carry a team assignment (team_users row written); invitations with no team are rejected with a typed validation error.
  4. The auto-created "General" team on new CID creation carries source_identifier = 'Launchpad', reference_id = NULL.
  5. Toggling one_team_migration_enabled = false restores the prior invite behaviour (team optional, not enforced) with no redeploy.

Out of Scope

  1. Migrating individual user-to-team memberships — only team structures are migrated (PRD §5.1).
  2. Modifying the existing Chat Divisions UI (PRD §5.2).
  3. Merging duplicate team names across Chat and CRM — each migrates as a separate prefixed team (PRD §5.3).
  4. Migrating inactive/archived Divisions/Teams (PRD §5.4).
  5. Retroactively enforcing team assignment on existing users (PRD §5.5).
  6. The Chat/CRM SUPPORT PRDs that build the Division/Team read endpoints (PRD §5.6) — consumed here as dependencies.
  7. Billing/quota model changes (PRD §5.7).
  8. OTM-S03 team sidebar URL change — not a qontak-launchpad/-fe change (see §5 OQ-3). Carried as deferred — cross-squad (legacy CRM app) in Detail 1.C.

Assumptions

  1. Migration is a push model (PRD v1.2): each source squad maps its own active Divisions/Teams and calls Launchpad's existing bulk/single create endpoint with is_migrate=true, app=chat|crm, and a new reference_id. Launchpad does not read Chat/CRM data. The trigger endpoint each squad exposes (so Launchpad can kick off their migration) is not yet defined — see §5 OQ-1.
  2. reference_id is unique per source_identifier per company (a Chat division_id and a CRM team_id may collide numerically), so idempotency keys on (company_id, source_identifier, reference_id) — confirmed by the PRD's prefix-don't-merge decision (PRD §16, OTM-S05/AC-3).
  3. Migration is triggered by Launchpad (PRD §9 behavior #5) but executed by each squad (mapping + the create calls); Launchpad provides the trigger + the idempotent create + the Kafka events. The existing bulk path is already async (EnqueueBulkCreateTeam returns 202 + an upload job) and worker-processed (ProcessBulkCreateTeam).
  4. one_team_migration_enabled is a global flag (PRD §6), evaluated through the existing qontak-preferences service the same way launchpad_show_team_menu is today.
  5. The PRD's team_id (singular) on invite is reconciled against the FE's existing multi-select teamIds — see §5 OQ-2; this RFC specifies the backend to accept teamIds: [] and require ≥ 1 when enforced.

Dependencies

DependencyOwning teamDeliverableAvailabilityBlocking?
Chat squad migrationChat SquadChat maps its own active Divisions, calls Launchpad's bulk create (is_migrate, app=chat, app_identifier_id=division_id), and consumes TEAM_MIGRATED to map division→Launchpad teamneeds building (by Chat)YES
CRM squad migration (mapping)CRM SquadCRM maps its own active Teams, calls Launchpad's create (app=crm, app_identifier_id=team_id), and consumes TEAM_MIGRATED to map team→Launchpad teamneeds building (by CRM)YES
Chat/CRM migration-trigger endpointChat + CRM SquadsEndpoint Launchpad calls to kick off each squad's migration (contract: path, auth, payload)needs definingYES (shapes the trigger; see §5 OQ-1)
CRM-side migration completionCRM SquadCRM Teams migrated before Phase 2 staged rolloutneeds buildingYES (gates Phase 2)
qontak-preferences flag launchpad_one_team_migrationBifrost (provision)New global flagexists (service); flag value needs provisioningYES
@mekari/pixel3 1.0.8Design SystemMpFormControl/MpInput/MpPopover etc.exists (package.json:64)no
Figma frames for invite enforcement + empty/error statesDesignFrame linksneeds design (PRD §8 "TBD — pending design")no (FE is enhancement of an existing form; see §1 Design References)

Design References (frontend half — required)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
/user/invite mandatory Team dropdownn/a — design pending (PRD §8 "Figma: TBD")@mekari/pixel3@1.0.8 (package.json:64)TBD — pending designSurface already exists as field-teams in InviteUsers.vue:196; this is a required-state + payload-wiring enhancement, not a new frame. Pixel-faithful to current form.
/launchpad/team route render + redirect (OTM-S03)n/a — out of repoRoute absent from qontak-launchpad-fe (real route /user_management/teams). Cross-squad; see §5 OQ-3.

The invite enhancement reuses existing Pixel form components and the existing field-teams layout; it does not introduce a new frame. The empty/error copy strings (PRD §8 UI States) need design sign-off but do not block coding — they are tracked in §3.C Error Message Catalog. No frontend chunk is started against an imagined new frame.

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

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event / command)Enforced whereSource
A team records which system it came fromteams.source_identifier ('Chat'/'CRM'/'Launchpad')migration command + CreateTeam write + invite/General writesDB CHECK constraint + write pathsPRD §8 OTM-S04
A team links back to its source recordteams.reference_id (text, nullable)migration command writewrite paths; NULL for nativePRD §8 OTM-S04
Migrating a Chat Division yields Chat - <name>teams.name, teams.source_identifier='Chat' (from app), teams.reference_id=<division_id> (from app_identifier_id)Chat calls POST /teams/bulk (is_migrate, app=chat, app_identifier_id)createSingleTeam persist + unique partial indexPRD §8/§10 OTM-S05/AC-1
Migrating a CRM Team yields CRM - <name>teams.name, teams.source_identifier='CRM', teams.reference_id=<team_id> (from app_identifier_id)CRM calls POST /teams/bulk (app=crm, app_identifier_id)createSingleTeam persist + unique partial indexPRD §10 OTM-S05/AC-2
Re-running migration creates no duplicatesunique index on (company_id, source_identifier, reference_id)create path skips on conflictDB unique index + createSingleTeam pre-checkPRD §10 OTM-S05/AC-3
Only ACTIVE source records migraten/a — owned by source squad's mappingsource squad submits active onlyenforced in Chat/CRM (not Launchpad)PRD §10 OTM-S05/AC-4
Launchpad triggers each squad's migrationn/a (no persistence)outbound trigger call to Chat/CRMnew client methods (api/chat, api/crm)PRD §9 behavior #5
Source squad maps its division/team → centralized teamn/a (event carries it)TEAM_MIGRATED Kafka event (team_id + app + app_identifier_id)createSingleTeam emits TEAM_MIGRATED for migrate-modePRD §12 team_migrated; this case's requirement
New invite requires a team (when enabled)team_users(user_id, team_id) row(s)POST /iag/v1/users/sso_inviteUserService.SsoInvite validation + CreateTeamUserPRD §10 OTM-S01/AC-1,AC-2,ERR-1
Existing users without a team unaffectedn/a — no backfilln/ano enforcement on update pathsPRD §10 OTM-S01/NEG-1
"General" team auto-created on CID creationteams row (name='General', source_identifier='Launchpad', reference_id=NULL)CompanyService.MigrateCompanyCreateTeamForCompany (exists)companies/migrate.go:148, create.go:18PRD §10 OTM-S02/AC-1
Migration toggled per the rollout flagn/a (preference value)qontak-preferences launchpad_one_team_migrationpreference.IsEnabled checks at invite + UI visibilityPRD §6/§11

Every §2.3 DDL row and §2.4 endpoint traces back to a row here.

Detail 1.A — PRD Traceability (cross-layer)

Composite AC ids are story-qualified (<STORY-ID>/AC-n).

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
OTM-S01/AC-1§2.A InviteUsers.vue team field required-state; §2.Cflag read GET /teams/menu-visibility (§2.4)
OTM-S01/AC-2§2.A useUsers.ts inviteUser forwards teamIdsPOST /iag/v1/users/sso_invite writes team_users (§2.4)
OTM-S01/ERR-1§3.C error message "Please select a team to continue"§3.B ErrBadRequest-shaped 400 (§2.4)
OTM-S01/ERR-2§2.C error state + retry on team-list fetchGET /teams failure (§3.A)
OTM-S01/NEG-1n/a — no FE change on existing-user updateno enforcement on update paths (§2.4)
OTM-S02/AC-1n/a — backend onlyCreateTeamForCompany stamps source cols (§2.3, §2.4)
OTM-S02/AC-2team appears in field-teams listGET /teams returns General (§2.4)
OTM-S02/ERR-1n/anon-fatal create + new async retry (§2.F)
OTM-S03/AC-1..3n/a — out of repo (§5 OQ-3)n/a
OTM-S04/AC-1..4n/a — backend schemateams.source_identifier/reference_id + CHECK (§2.3)
OTM-S05/AC-1..4, ERR-1n/a — backendextend POST /teams/bulk create path to persist source cols + reference_id idempotency; trigger client (§2.4, §4.D)

Reverse (RFC → PRD AC):

New FE component / BE endpoint / dependencyPRD composite AC id it serves
teams.source_identifier + reference_id columns + CHECKOTM-S04/AC-1..4
Extended bulk/single create (persist source + reference_id idempotency) + trigger clientOTM-S05/AC-1..4, OTM-S05/ERR-1
teamIds param on SsoInvite + team_users writeOTM-S01/AC-2
Required-state on field-teams + store payload fixOTM-S01/AC-1, OTM-S01/AC-2
Source-column stamping on CreateTeamForCompanyOTM-S02/AC-1
Async retry of General-team creationOTM-S02/ERR-1

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
/user/invite (real: /user_management/users/invite)web (Admin)GET /teams/menu-visibility, GET /teamsPOST /users/sso_invite (now with teamIds)InviteUsers.vue + field-teams (FieldSelectTeams.vue)invite success toast; team_users row
/launchpad/team (OTM-S03)webn/an/an/a — out of repon/a — see §5 OQ-3
Migration (no UI)Chat/CRM squad services → Launchpadn/a — squads pushPOST /teams/bulk (is_migrate,app,app_identifier_id) → teams insertsTEAM_MIGRATED Kafka event + bulk upload-job result
New-CID General team (no UI)systemn/ateams insert via CreateTeamForCompanyn/aslog "Created company" + team event

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
AdminSSO JWT + CRS permission (existing usman permission checks via launchpad_permission_check)POST /users/sso_invite, GET /teams*invite form + team field visibleno (own company)invite events; team events (Kafka TopicTeamEventsV1)
Regular / read-only userCRS permission deniesnone of the aboveteam field not rendered; invite not reachablenon/a
Migration (trigger + source squads)Launchpad trigger client + Chat/CRM service tokens calling /teams/bulktrigger call + POST /teams/bulkn/ayes (squads push their own CIDs)bulk upload-job results + TEAM_MIGRATED Kafka events
System (CID creation)in-process (no actor)CreateTeamForCompanyn/an/a — per new companyslog + team event

PRD Section Coverage

PRD §TitleWhere covered / n/a — reason
3One-liner + Problem§1 Overview
4Target Users + Persona§1.A Role Coverage
5Non-Goals§1 Out of Scope
Scope ChangesBackend/Frontend§2.I Scope Boundaries
6 / 6.1Constraints / Data Lifecycle§3 (SLAs), §3.D (failure-log retention), §4.B (flags)
7Feature Changes (CHG-001 URL)§5 OQ-3 (n/a — out of repo)
8New Features (mandatory dropdown)§2.A, §2.C
9API & Webhook Behavior§2.4
10System Flow + Stories + ACs§1.A, §2.1, §2.2, Detail 1.C
11 / 11.1Rollout / Transition Window§4 Rollout, §4.A
12 / 12.1Observability / Cadence§3 Monitoring
13Success Metrics§1 Success Criteria
14Launch Plan & Stage Gates§4 Rollout
15Dependencies§1 Dependencies, §2.F.1
16Key Decisions§1.B, §2 Technical Decisions
17Open Questions§5

Detail 1.B — Decisions Closed (cross-layer)

DecisionChosen optionAlternatives rejectedWhy rejectedLayer
source_identifier storagetext + CHECK (source_identifier IN ('Chat','CRM','Launchpad'))Postgres ENUM typesqlc + golang-migrate codebase uses plain columns; an enum type adds migration friction to add values later; CHECK is queryable + type-safe enough (PRD §16)BE
Idempotency keyunique partial index (company_id, source_identifier, reference_id) WHERE reference_id IS NOT NULLdedupe by team namenames are prefixed/duplicable; reference_id is the stable source key (PRD §16)BE
Migration modelpush — each squad maps its own data and calls Launchpad's create endpoint; Launchpad triggers thempull — Launchpad reads Chat/CRM data via their read APIskeeps source-data ownership in each squad; reuses the existing is_migrate/app bulk path; avoids coupling Launchpad to Chat/CRM internal schemas (PRD §16, 2026-06-30)both (cross-squad)
Migration write pathextend the existing POST /teams/bulk async path (EnqueueBulkCreateTeam/ProcessBulkCreateTeam) to persist source cols + reference_id idempotencybuild a separate Launchpad-internal migrate commandthe bulk path already validates app, prefixes names, processes async, and emits Kafka — reuse beats reinventingBE
Migration kickoffLaunchpad triggers Chat/CRM via their migration-trigger endpoints (outbound client)each squad self-triggersPRD §9 #5 puts the trigger on Launchpad so kickoff is coordinated; trigger contract owned by the squads (OQ-1)BE (cross-squad)
Source-id field namingrequest/event field app_identifier_id (string), persisted into the teams.reference_id columnone reference_id name end-to-endapp/app_identifier_id is the source-app vocabulary the squads speak on the wire + in the mapping event; source_identifier/reference_id is Launchpad's storage vocabulary (appsource_identifier already established at create.go:84) — a clean, consistent request↔storage mappingboth (cross-squad)
app_identifier_id requirednessrequired when is_migrate=true; optional (ignored) for native createsalways optionalthe idempotency index is partial (WHERE reference_id IS NOT NULL), so a missing app_identifier_id ⇒ NULL reference_id ⇒ no replay dedup (would duplicate on re-push, breaking OTM-S05/AC-3); mirror the existing IsMigrate && App=="" guard at team_request.go:34BE
Migrate-mode member_idsrelax CreateTeam.Validate() to allow empty member_ids when is_migrate=truekeep member_ids required for all createsmigration carries team structures only, not memberships (PRD §5.1); the current validation (team_request.go:20) rejects empty member_ids, which would block every structure-only pushBE
Migration eventemit a separate TEAM_MIGRATED event for migrate-mode creates (instead of TEAM_CREATED), carrying team_id + app + app_identifier_idreuse TEAM_CREATED for migrated teamsmigrated teams need a back-mapping signal so each source squad can link its division/team to the centralized team; a distinct event_type lets Chat/CRM consume only migration mappings without re-handling normal creates; same envelope + topic (TopicTeamEventsV1) as the existing eventsBE (cross-squad)
Invite team cardinalityaccept teamIds: [], enforce ≥ 1 when flag onsingle team_idFE field is already multi-select (selectedTeamIds); accept array, require non-empty (reconciles §5 OQ-2)both
Enforce flag read pointreuse existing GET /teams/menu-visibility (launchpad_show_team_menu) plus new launchpad_one_team_migration gatebrand-new FE flag fetchmenu-visibility already gates the field; layering the migration flag on the same BE check avoids a second FE flag mechanism (see §5 OQ-4)both
General-team failure policykeep non-fatal + add async retry jobmake it fatal (roll back CID)PRD OTM-S02/ERR-1 forbids rollback; retry closes the gap that today's log-only path leavesBE
Team name casingmigrate to "General" (capital) per PRDkeep "general"PRD OTM-S02 specifies "General"; reconcile casing — see §5 OQ-5BE
Soft vs hard deleten/a — no deletes introducedmigration only inserts; no delete path addedBE
Inbound migration callsChat/CRM push into POST /teams/bulk (service-to-service)Launchpad reads Chat/CRM data (pull)push keeps source-data ownership with each squad; reuses existing bulk handlerBE (cross-squad)
FE↔BE casing/error shapeinvite API is camelCase (the ApiUserInviteParam struct is tagless → Go case-insensitive JSON binds fullName/roleId/teamIds); FE sends camelCase as-is, no transform; errors use existing resp_desc.ensnake_case on invitethe existing invite payload already works camelCase end-to-end (verified sso_invite.go:23 + util.go:39); inventing snake_case would break the live contractboth

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
OTM-S01Mandatory team on inviteFE + BEInviteUsers.vue yup teams → required (≥1) when enabled; fix useUsers.ts inviteUser to forward teamIds; error/empty/retry statesSsoInvite accepts teamIds, validates non-empty when flag on, writes team_users via CreateTeamUser; 400 on emptyOTM-S01/AC-1, AC-2, ERR-1, ERR-2, NEG-1vitest form test: submit blocked w/o team; go test SsoInvite writes N team_users rows; 400 body resp_desc.en == "Please select a team to continue"§2.A · §2.4 row 1 · §4.D chunks 5–7
OTM-S02Auto-create General team on CID creationBE-only (mostly built)n/a — BE-onlystamp source_identifier='Launchpad', reference_id=NULL in CreateTeamForCompany (create.go:18); name "General"; add async retry on failureOTM-S02/AC-1, AC-2, ERR-1go test MigrateCompany_GeneralTeam: created team has cols set; retry enqueued on injected failure§2.3 · §2.4 row 4 · §2.F · §4.D chunk 4
OTM-S03Team sidebar URL changeCross-squaddeferred — cross-squad (legacy CRM app)n/aOTM-S03/AC-1..3n/a — not in launchpad repos§5 OQ-3
OTM-S04Track team source identifiersBE-onlyn/amigration …_add_source_tracking_to_teams.up.sql; teams.source_identifier + reference_id + CHECK + unique index; update db/query/team.sql CreateTeam; regenerate team.sql.go; extend Team structOTM-S04/AC-1..4make migrate-up then \d teams shows cols + CHECK; insert of bad source value rejected; make migrate-down clean§2.3 · §4.D chunks 1–2
OTM-S05Process pushed migrationBE-only + Cross-squadn/aextend request.CreateTeam/CreateTeamItem with app_identifier_id (required when is_migrate=true, REV-9) + relax member_ids to optional when is_migrate=true (REV-10, structures-only); createSingleTeam maps appsource_identifier, persists app_identifier_idreference_id; reference_id idempotency (skip on conflict); emit TEAM_MIGRATED (with app+app_identifier_id+team_id) instead of TEAM_CREATED for migrate-mode; new trigger client methods (api/chat,api/crm). Chat/CRM own the mapping + active filter + the create calls + consuming TEAM_MIGRATEDOTM-S05/AC-1..4, ERR-1go test on create path: app=chat+app_identifier_id (no member_ids) ⇒ Chat - Support row with source_identifier='Chat'+reference_id; replay = 0 new rows (idempotent); TEAM_MIGRATED emitted carrying app_identifier_id§2.4 rows (bulk/single + event + trigger) · §4.D chunk 3 · §5 OQ-1

OTM-S03 is intentionally retained (not dropped) as Cross-squad/deferred so traceability stays complete — its absence from the launchpad repos is the finding, recorded in §5 OQ-3.


2. Technical Design

Detail 2.0 — Repo Reading Guide

Repo Map (mermaid, both layers)

flowchart LR
subgraph fe["qontak-launchpad-fe (Nuxt 4)"]
invite["features/user_management/users/invite/views/InviteUsers.vue"]
fieldteams["FieldSelectTeams.vue (field-teams)"]
useUsers["composables/useUsers.ts (inviteUser)"]
useTeams["composables/useTeams.ts (menu-visibility, list)"]
client["composables/useClient.ts ($fetch wrapper)"]
end
subgraph be["qontak-launchpad (Go)"]
router["server/rest_router.go (chi)"]
invsvc["service/users/sso_invite.go"]
teamsvc["service/teams (create.go, bulk_create.go)"]
cosvc["service/companies (migrate.go)"]
repo["repository (sqlc: team.sql.go)"]
bulk["bulk_create.go (EnqueueBulkCreateTeam / ProcessBulkCreateTeam)"]
trig["trigger client (api/chat, api/crm)"]
end
subgraph ext["source squads"]
chat(["Chat (maps own Divisions)"])
crm(["CRM (maps own Teams)"])
end
subgraph infra["infrastructure"]
db[("Postgres: teams, team_users")]
pref[("qontak-preferences")]
kafka[["Kafka TopicTeamEventsV1"]]
end
invite --> fieldteams --> useTeams --> client
invite --> useUsers --> client
client -->|"HTTPS via Kong"| router
router --> invsvc --> repo --> db
router --> teamsvc --> repo
cosvc --> teamsvc
trig -->|"trigger migrate"| chat
trig -->|"trigger migrate"| crm
chat -->|"POST /teams/bulk"| router
crm -->|"POST /teams/bulk"| router
router --> bulk --> teamsvc
invsvc --> pref
teamsvc --> kafka

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEinternal/app/repository/models.go:182Team struct to extendsqlc model shape (no tags-driven ORM)
BEdb/migration/20250410080041_create_teams.up.sqlcurrent teams DDLgolang-migrate up/down, BEGIN…COMMIT, Postgres
BEdb/query/team.sql (-- name: CreateTeam :one)sqlc query to amendsqlc query annotations, RETURNING *
BEinternal/app/repository/team.sql.go:40generated CreateTeamParams/CreateTeamDO NOT EDIT; regenerate via sqlc
BEinternal/app/service/users/sso_invite.go:23invite entrypoint to extendApiUserInviteParam, validation, createLaunchpadUser
BEinternal/app/service/teams/create.go:18CreateTeamForCompany (General team)where to stamp source cols
BEinternal/app/service/companies/migrate.go:148General-team call site (non-fatal)failure policy to extend with retry
BEinternal/app/service/teams/bulk_create.go:25,75EnqueueBulkCreateTeam/ProcessBulkCreateTeam — the migration receive pathasync (202 + upload job) → worker → createSingleTeam; BulkCreateTeamJobPayload
BEinternal/pkg/request/team_request.go:9CreateTeam/CreateTeamItem request to extendalready has IsMigrate/App (validated chat/crm); add AppIdentifierID string json:"app_identifier_id" (optional) here
BEinternal/app/service/teams/create.go:84migrate-mode name prefixprefixedName := item.App + "-" + item.Name; this is where source_identifier/reference_id get persisted
BEinternal/server/rest_router.go:86,117route registrationchi r.Method(...), myHandler wrapper
BEinternal/pkg/http/default_error.go:8error envelopeBaseResponse{resp_code, resp_desc{id,en}, meta}, ErrBadRequest
BEinternal/pkg/constants/preferences.go:4flag constantslaunchpad_* naming; add launchpad_one_team_migration
BEinternal/app/service/teams/get_team_menu_visibility.go:16flag read patternpreference.IsEnabled(ctx, IsEnabledParams{FeatureName, UniqueID})
BEinternal/app/service/teams/events.go:32,87,130team Kafka events to extendTeamCreatedEvent envelope + maybePublishTeamCreatedEvent (EventType:"TEAM_CREATED", topic TopicTeamEventsV1); add a sibling TEAM_MIGRATED event/publisher carrying app+app_identifier_id
BEcmd/seed_search_tokens.go:1cobra command templatepattern for the optional trigger-kickoff command (RootCmd.AddCommand, config.InitConfig()) if Launchpad initiates the trigger via CLI rather than an admin endpoint
BEinternal/app/api/chat/iChatClient.go:18 / api/crm/iCrmClient.gooutbound client pattern for the trigger callheimdall httpclient + httpclientmw.LogMiddleware, timeout
BEinternal/app/api/chat/notify_team_update.goplaceholder client (log-only)where a real trigger method would be added once Chat/CRM expose the endpoint
FEapp/features/user_management/users/invite/views/InviteUsers.vue:188invite form + team fieldPixel MpFormControl, vee-validate yup, onSubmit builds objToSend
FEapp/features/user_management/composables/useUsers.ts:294inviteUser store actiondrops teamIds — the gap to fix
FEapp/features/user_management/composables/useTeams.ts:87,118fetchTeamMenuVisibility, fetchTeamListisTeamMenuVisible, loading-status pattern
FEapp/common/composables/useClient.tsAPI client$fetch wrapper, {data,error}, 401 refresh

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
teams tableextendadd source_identifier, reference_id + CHECK + unique indexBifrost
db/query/team.sql CreateTeamextendadd the two columns to the insert + a source-stamped variantBifrost
POST /iag/v1/users/sso_inviteextendadd optional teamIds; enforce when flag onBifrost
CreateTeamForCompany (teams/create.go)extendstamp source_identifier='Launchpad', reference_id=NULL; name "General"Bifrost
GET /iag/v1/teams, GET /teams/menu-visibilityreuseinvite field already consumes theseBifrost
POST /iag/v1/teams/bulk (+ POST /teams)extendalready supports is_migrate/app + async + Kafka; add app_identifier_id, persist source_identifier+reference_id, add reference_id idempotency, emit TEAM_MIGRATED for migrate-modeBifrost
request.CreateTeam / CreateTeamItemextendadd app_identifier_id (json:"app_identifier_id", optional); app/is_migrate already presentBifrost
events.go team eventsextendadd TEAM_MIGRATED event (sibling of TEAM_CREATED) carrying app+app_identifier_id+team_idBifrost
Chat/CRM migration-trigger clientnew-with-justificationLaunchpad must call each squad to kick off migration; only placeholder notify_team_update.go exists today — a trigger method must be added once the squads expose the endpoint (dependency)Bifrost (calls Chat/CRM)
qontak-preferences launchpad_one_team_migrationnew-with-justificationno existing flag covers this rollout; reuse the existing preference.IsEnabled mechanismBifrost

Patterns to Follow

LayerConcernPattern in repoReference fileDeviation?
BEHTTP handler shapemyHandler(h.X.Method) returning (ResponseBody, error)internal/pkg/http/handler.go:46, rest_router.go:86none
BEDB accesssqlc-generated Queries methodsinternal/app/repository/team.sql.go:50none
BEMigrationsgolang-migrate Postgres up/down, BEGIN…COMMITdb/migration/20250306062703_create_team_users.up.sqlnone
BEError shapeErrBadRequest()resp_desc.eninternal/pkg/http/default_error.go:24none
BELoggingslog.InfoContext(ctx, msg, slog.String(...))internal/app/service/teams/events.go:150none
BEFeature flagpreference.IsEnabledinternal/app/service/teams/get_team_menu_visibility.go:44none
BETrigger kickoff (optional CLI)cobra subcommand + RootCmd.AddCommandcmd/seed_search_tokens.go:12only if Launchpad initiates the trigger via CLI vs an admin endpoint
BEOutbound clientheimdall httpclient + log middleware + timeoutinternal/app/api/chat/iChatClient.go:44none
FEState mgmtPinia defineStorecomposables/useTeams.ts:9none
FEValidationvee-validate + yup useForm({validationSchema})InviteUsers.vue:289extend teams rule to required
FEData fetchuseClient($fetch wrapper); apiBaseUrlKongcomposables/useClient.ts, useUsers.ts:310none
FEToast/errortoast.notify({variant:'error', title})InviteUsers.vue:361none
Crossinvite API camelCase (tagless struct) ↔ camelCase FEdirect pass-through in store action body (no transform)useUsers.ts:296add teamIds to paramsToSend (note: the bulk-migration endpoint uses snake_case app/app_identifier_id — different endpoint, intentional)

Reading Order for the Agent

  1. internal/app/repository/models.go:182 — current Team shape.
  2. db/migration/20250410080041_create_teams.up.sql — DDL to extend.
  3. db/query/team.sql + internal/app/repository/team.sql.go:40 — sqlc query/codegen.
  4. internal/app/service/teams/create.go:18CreateTeamForCompany (General team).
  5. internal/app/service/companies/migrate.go:138-153 — non-fatal General-team call site.
  6. internal/app/service/users/sso_invite.go:23-269 — invite flow to extend.
  7. internal/server/rest_router.go:80-128 — route registration.
  8. internal/pkg/request/team_request.go + service/teams/bulk_create.go + create.go — the existing migration receive path (is_migrate/app) to extend with reference_id + source persistence.
  9. internal/app/service/teams/get_team_menu_visibility.go + internal/pkg/constants/preferences.go — flag mechanism.
  10. FE: InviteUsers.vue:188-410 then useUsers.ts:294-339 — the team-field + the dropped-payload gap.

Source Verification (anti-hallucination)

LayerAnchor / claimVerified byEvidence
BETeam struct has no source colsreadmodels.go:182-189 fields ID, CompanyID, Name, ParentID, CreatedAt, UpdatedAt only
BEteams DDL columnsread20250410080041_create_teams.up.sql:3-18 (id, company_id, name, parent_id, created_at, updated_at)
BEsqlc, not ORMreadteam.sql.go:1 "Code generated by sqlc … v1.31.1"; CreateTeamParams{CompanyID,Name,ParentID} at L44
BEmigrate tool/dialectreadMakefile:161-175 migrate -path db/migration -database "postgresql://…"
BEinvite has no team todaygrepgrep team in sso_invite.go → 0 matches; ApiUserInviteParam lacks team field (L23)
BEGeneral team already createdreadcompanies/migrate.go:148 CreateTeamForCompany(ctx, newCompany.ID, param.CompanySsoID, "general"); impl teams/create.go:18
BEGeneral-team failure is non-fatal, no retryreadmigrate.go:147 comment "(non-fatal)"; on error only slog.ErrorContext then continues (L149-153)
BEteamCreator interfacereadcompanies/main.go:28-31 CreateTeamForCompany(ctx, companyID, companySSOID uuid.UUID, name string)
BEroute POST /sso_invitereadrest_router.go:86 r.Method(http.MethodPost, "/sso_invite", myHandler(h.UserHandler.SsoInvite))
BEteams routes incl. /bulk, /menu-visibilityreadrest_router.go:117-128
BEerror envelopereaddefault_error.go:8-95 BaseResponse{ResponseCode, ResponseDesc{ID,EN}, Meta}
BEflag mechanism + launchpad_* conventionreadget_team_menu_visibility.go:44 preference.IsEnabled; constants/preferences.go:4-27 (ShowTeamMenuKey="launchpad_show_team_menu"); no one_team_migration
BEbulk migration path already existsreadteam_request.go:9 CreateTeam{IsMigrate, App}; create.go:84 prefixedName := item.App + "-" + item.Name; bulk_create.go:25,75 async enqueue/process; createSingleTeammaybePublishTeamCreatedEvent
BEChat/CRM team integration is placeholderreadapi/chat/notify_team_update.go & api/crm/notify_team_update.go log-only return nil
BEbulk-create is async/job-basedreadteams/bulk_create.go:25,75 EnqueueBulkCreateTeam/ProcessBulkCreateTeam
BEtest/build commandsreadMakefile:105 make test (go test -race -coverprofile), :120 make lint (staticcheck), :125 make sec (gosec), :50 make build, :161 make migrate-up
FENuxt 4 / Vue 3 / Pixel3 / Piniareadpackage.json:50 nuxt ^4.4.2, :58 vue ^3.4.33, :64 @mekari/pixel3 1.0.8, :67-68 pinia
FEteam field exists, optionalreadInviteUsers.vue:191 v-if="teamsStore.isTeamMenuVisible"; :318 teams: yup.array().of(yup.string()) (no required)
FEview passes teamIds but store drops themreadInviteUsers.vue:356 teamIds: selectedTeamIds.value; useUsers.ts:296-306 paramsToSend omits teamIds
FEinvite endpoint pathreaduseUsers.ts:310 POST ${apiBaseUrlKong}/users/sso_invite
FEmenu-visibility readreaduseTeams.ts:93 GET ${apiBaseUrlKong}/teams/menu-visibilityis_visible
FEteam URL change not in repogrepgrep "qontakcrm/team|launchpad/team" app/ → 0 matches; real route app/pages/user_management/teams/index.vue
FEtest/build commandsreadpackage.json:16 test: vitest --dom --pool=forks, :12 lint:js eslint ., :19 type-check vue-tsc --noEmit, :7 build nuxt build; CI pnpm run test -- --run

Design ↔ Code Mapping (frontend half)

Figma frame / componentImplementing fileReuse vs newTokens usedBacking API endpoint(s)Deviation
Invite team field (required)InviteUsers.vue:188 + FieldSelectTeams.vuereused (extend to required)Pixel MpFormControl/MpFormLabel/MpFormErrorMessage defaultsGET /teams/menu-visibility, GET /teams, POST /users/sso_invitenone — pixel-faithful; new required * marker only
Empty / error / loading statesInviteUsers.vue + store loading-statusreusedPixel skeleton/toastGET /teamscopy strings pending design (§3.C)

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
admin([Admin]) --> invite["InviteUsers.vue"]
invite --> useUsers["useUsers.inviteUser"]
useUsers --> client["useClient ($fetch)"]
client -->|"POST /users/sso_invite"| router["rest_router.go (chi)"]
router --> invsvc["UserService.SsoInvite"]
invsvc --> pref[("qontak-preferences")]
invsvc --> repo["repository (sqlc)"]
repo --> db[("teams, team_users")]
newcid([New CID]) --> cosvc["CompanyService.MigrateCompany"]
cosvc --> teamsvc["TeamService.CreateTeamForCompany"]
teamsvc --> repo
teamsvc --> kafka[["Kafka TopicTeamEventsV1"]]
trig["trigger client"] -->|"trigger migrate"| chat(["Chat squad (maps own data)"])
trig -->|"trigger migrate"| crm(["CRM squad (maps own data)"])
chat -->|"POST /teams/bulk (is_migrate, app, reference_id)"| router
crm -->|"POST /teams/bulk"| router
router --> bulk["ProcessBulkCreateTeam"] --> teamsvc

Data model (mermaid erDiagram)

erDiagram
COMPANIES ||--o{ TEAMS : has
TEAMS ||--o{ TEAM_USERS : has
USERS ||--o{ TEAM_USERS : has
TEAMS {
uuid id PK
uuid company_id FK
text name
uuid parent_id
text source_identifier "NEW: Chat|CRM|Launchpad"
text reference_id "NEW: nullable source id"
timestamptz created_at
timestamptz updated_at
}
TEAM_USERS {
uuid id PK
uuid user_id FK
uuid team_id FK
timestamptz created_at
}

State machine — teams.source_identifier

stateDiagram-v2
[*] --> Launchpad: native create or General team
[*] --> Chat: pushed migration from Chat Division
[*] --> CRM: pushed migration from CRM Team
Launchpad --> [*]
Chat --> [*]
CRM --> [*]
note right of Launchpad
reference_id is NULL
end note
note right of Chat
reference_id = division_id (immutable)
end note

Branch & skip flow — invite team enforcement

flowchart TD
trigger([Admin submits invite]) --> flag{"one_team_migration_enabled?"}
flag -- "no" --> legacy["team optional - proceed (legacy)"]
flag -- "yes" --> hasteam{"teamIds non-empty?"}
hasteam -- "no" --> reject["400 - Please select a team to continue"]
hasteam -- "yes" --> create["create user + write team_users"]
legacy --> done([invite complete])
create --> done
reject --> done

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

Happy path — invite with mandatory team (flag on)

sequenceDiagram
actor A as Admin (FE)
participant LB as Kong gateway
participant API as launchpad-api (chi)
participant SVC as UserService.SsoInvite
participant PREF as qontak-preferences
participant DBW as Postgres primary
A->>LB: POST /users/sso_invite (email, role, teamIds)
LB->>API: HTTP
API->>SVC: SsoInvite(param incl teamIds)
SVC->>PREF: IsEnabled(launchpad_one_team_migration, cid)
PREF-->>SVC: true
SVC->>SVC: validate email/role + teamIds non-empty
SVC->>DBW: BEGIN - INSERT users - INSERT team_users x N - COMMIT
DBW-->>SVC: COMMIT ok
SVC-->>API: UserInfoResponse
API-->>A: 200 (invited, team assigned)

Failure path — invite with no team (flag on)

sequenceDiagram
actor A as Admin (FE)
participant API as launchpad-api
participant SVC as UserService.SsoInvite
participant PREF as qontak-preferences
A->>API: POST /users/sso_invite (no teamIds)
API->>SVC: SsoInvite(param)
SVC->>PREF: IsEnabled(launchpad_one_team_migration, cid)
PREF-->>SVC: true
SVC-->>API: ErrBadRequest (resp_desc.en)
API-->>A: 400 - Please select a team to continue

Happy path — push migration (Chat/CRM → Launchpad)

sequenceDiagram
participant TRIG as Launchpad trigger client
participant SQUAD as Chat / CRM squad
participant API as launchpad-api (POST /teams/bulk)
participant SVC as ProcessBulkCreateTeam
participant DBW as Postgres primary
participant K as Kafka TopicTeamEventsV1
TRIG->>SQUAD: trigger migrate (HTTPS)
Note right of SQUAD: squad maps its own ACTIVE Divisions/Teams
SQUAD->>API: POST /teams/bulk (is_migrate, app, reference_id, name, company_sso_id)
API->>SVC: enqueue + worker process
loop each item
SVC->>DBW: INSERT team (name prefix, source_identifier, reference_id=app_identifier_id) skip if (company,source,reference_id) exists
DBW-->>SVC: inserted or skipped (idempotent)
SVC->>K: publish TEAM_MIGRATED (team_id, app, app_identifier_id)
end
SVC-->>SQUAD: per-item results (upload job)
Note over K,SQUAD: squad consumes TEAM_MIGRATED to map app_identifier_id to launchpad team_id

Failure path — a source squad's migration fails

sequenceDiagram
participant TRIG as Launchpad trigger client
participant SQUAD as Chat / CRM squad
participant API as launchpad-api
TRIG->>SQUAD: trigger migrate (HTTPS)
alt squad unavailable / errors
SQUAD--xTRIG: error / no response
TRIG->>TRIG: log + record per-squad failure (no Launchpad rollback)
else item-level failure
SQUAD->>API: POST /teams/bulk (some bad items)
API-->>SQUAD: per-item results (failed items flagged, batch continues)
end

Happy/Failure — General team on CID creation

sequenceDiagram
participant CO as CompanyService.MigrateCompany
participant TS as TeamService.CreateTeamForCompany
participant DBW as Postgres primary
participant Q as retry queue (NEW)
CO->>DBW: INSERT company
DBW-->>CO: company row
CO->>TS: CreateTeamForCompany(General, source=Launchpad)
alt success
TS->>DBW: INSERT team (General, Launchpad, null)
DBW-->>TS: ok
else failure (non-fatal)
TS--xCO: error
CO->>CO: slog error (company NOT rolled back)
CO->>Q: enqueue retry (NEW)
end

Detail 2.3 — Database Model (DDL)

Migration db/migration/<YYYYMMDDHHmmss>_add_source_tracking_to_teams.up.sql (golang-migrate, Postgres — matches create_teams style):

BEGIN;

ALTER TABLE teams
ADD COLUMN IF NOT EXISTS source_identifier TEXT NOT NULL DEFAULT 'Launchpad',
ADD COLUMN IF NOT EXISTS reference_id TEXT;

ALTER TABLE teams
ADD CONSTRAINT chk_teams_source_identifier
CHECK (source_identifier IN ('Chat', 'CRM', 'Launchpad'));

-- idempotency key for migrated teams (native teams have NULL reference_id and are exempt)
CREATE UNIQUE INDEX IF NOT EXISTS idx_teams_source_ref_unique
ON teams (company_id, source_identifier, reference_id)
WHERE reference_id IS NOT NULL; -- supports OTM-S05/AC-3 (skip duplicates)
-- NOTE (REV-9): the index is partial on reference_id, so replay-safety requires a
-- non-NULL reference_id for migrated rows. `app_identifier_id` is therefore REQUIRED
-- when is_migrate=true (enforced in request validation), keeping migrated rows in-index.

COMMIT;

…_add_source_tracking_to_teams.down.sql:

BEGIN;
DROP INDEX IF EXISTS idx_teams_source_ref_unique;
ALTER TABLE teams DROP CONSTRAINT IF EXISTS chk_teams_source_identifier;
ALTER TABLE teams DROP COLUMN IF EXISTS reference_id;
ALTER TABLE teams DROP COLUMN IF EXISTS source_identifier;
COMMIT;
  • Cardinality / growth: bounded by existing Divisions+Teams per CID × CIDs; one-time insert burst, then native-rate. No partitioning.
  • Example rows: ('Chat - Support','Chat','4821'), ('CRM - Sales','CRM','193'), ('General','Launchpad',NULL).
  • PII: name may contain org-chosen labels (low sensitivity, already present); source_identifier/reference_id non-PII.
  • Retention: team rows retained for company lifetime (existing policy). Migration failure logs retained 30 days (PRD §6.1) — see §3.D.

Per-status lifecycle (source_identifier):

ValueVisibilityRetentionRestore semanticsTransitions allowed
Launchpadnormal team listcompany lifetimen/aterminal (not reclassified)
Chatnormal team listcompany lifetimen/a — re-migrate is a no-op (idempotent)terminal
CRMnormal team listcompany lifetimen/a — re-migrate is a no-opterminal
  • NoSQL alternative: rejected — teams already in Postgres; cross-table FK to team_users.

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequest schemaResponse schemaStatus codesIdempotencyVersioningReuse?
/iag/v1/users/sso_invitePOSTSSO JWT + CRS usman invite permission{email, fullName, phone, roleId, nik, staffLevel, teamIds: string[]}camelCase (binds to the tagless ApiUserInviteParam via Go case-insensitive JSON, sso_invite.go:23 + BindRequest util.go:39); adds field TeamIds []string (binds teamIds)UserInfoResponse200; 400 (no team when enforced / invalid); 403natural (one SSO invitation per email/company)path unchanged; additive fieldextend
/iag/v1/teamsGETSSO JWT + permquery: pagination{data:[{id,name,...}]}200; 4xxn/a (read)unchangedreuse
/iag/v1/teams/menu-visibilityGETSSO JWT + perm{data:{is_visible:bool}}200n/aunchanged (visibility now also reflects migration flag — see §5 OQ-4)reuse/extend
/iag/v1/teams/bulkPOSTservice-to-service (Chat/CRM squad token){teams:[{company_sso_id, name, member_ids?, is_migrate, app, app_identifier_id}]}adds app_identifier_id (required when is_migrate=true, REV-9); member_ids optional when is_migrate=true (REV-10, structures-only); is_migrate/app already exist{upload_id, status:"pending", total}202; 400 (validation)upload job + (company_id, source_identifier, reference_id) unique indexadditive app_identifier_id + relaxed member_ids for migrateextend
/iag/v1/teamsPOSTSSO JWT (UI) or service token (single migrate)request.CreateTeam + app_identifier_idCreateTeamResponse200; 400same unique indexadditive app_identifier_idextend

POST /sso_invite example (flag on, success):

// request
{ "email": "ana@acme.com", "fullName": "Ana", "roleId": "role-123", "teamIds": ["b1f...","c2e..."] }
// 400 when teamIds empty and flag on
{ "resp_code": "400", "resp_desc": { "id": "Silakan pilih tim untuk melanjutkan", "en": "Please select a team to continue" }, "meta": {} }
  • Rate limits / pagination: unchanged from existing endpoints.

Inbound webhooks (other services call us)

EndpointMethodAuthN/AuthZSourceSchemaCodesIdempotencyVersioning
/iag/v1/teams/bulkPOSTservice-to-service (Chat/CRM token)Chat / CRM squad{teams:[…app_identifier_id…]} (see Outbound row){upload_id,…}202; 400(company_id, source_identifier, reference_id) unique index

In the push model, Chat and CRM call into Launchpad's POST /teams/bulk to submit their mapped teams — so from Launchpad's side this is an inbound service-to-service call, not an outbound read. It is the same handler as the Outbound row above (listed in both places for the cross-squad reader).

Outbound — migration trigger (Launchpad → Chat/CRM)

CallDirectionAuthPayloadBehaviourFailure
Chat/CRM migration-trigger endpointLaunchpad → squad (HTTPS)service token{ ... — contract owned by Chat/CRM, §5 OQ-1 }kicks off the squad's own mapping + push to /teams/bulkheimdall timeout + retry; log + record per-squad failure; Launchpad not blocked

Outbound event — TEAM_MIGRATED (Kafka, consumed by Chat/CRM)

Emitted by createSingleTeam for migrate-mode creates (is_migrate=true), in place of TEAM_CREATED. Same envelope + topic as the existing events (TopicTeamEventsV1, TeamCreatedEvent shape), gated by the existing FeaturePublishTeamUpdate preference. Its purpose is the back-mapping each source squad needs.

FieldValueNotes
event_type"TEAM_MIGRATED"distinct from "TEAM_CREATED" so consumers subscribe only to migration mappings
aggregate_idLaunchpad team.ID (UUID)the centralized team id
payload.team_nameChat - <name> / CRM - <name>as persisted
payload.appchat / crmwhich source app
payload.app_identifier_idthe source division_id / team_idthe join key — lets the squad map its own record → aggregate_id
payload.company_sso_id, members, created_by_sso_idas in TeamCreatedPayloadsame fields as TEAM_CREATED otherwise

Example:

{ "event_type": "TEAM_MIGRATED", "aggregate_id": "b1f2…", "aggregate_type": "team", "version": "1",
"occurred_at": "2026-07-01T03:00:00Z",
"payload": { "team_name": "Chat - Support", "app": "chat", "app_identifier_id": "4821",
"company_sso_id": "c2e…", "members": [], "created_by_sso_id": "" } }

Consumer side (Chat/CRM building their_id ↔ launchpad_team_id) is owned by the source squads. Non-migration creates still emit TEAM_CREATED unchanged.

Detail 2.A — UI Contract

  • Component: InviteUsers.vue (modify) + FieldSelectTeams.vue (field-teams, reuse).
  • Implementation file: app/features/user_management/users/invite/views/InviteUsers.vue.
  • Change: make teams required (≥1) in the yup schema when team enforcement applies; render required *; keep emit @teams-selected="handleTeamsSelected"selectedTeamIds.
  • State shape & ownership: selectedTeamIds = ref<string[]>([]) (InviteUsers.vue:334); team list from useTeamsStore().
  • Validation: yup.array().of(yup.string()).min(1, "Please select a team to continue") (gated; see §5 OQ-4 on how the gate reaches the FE).
  • Payload fix: useUsers.ts inviteUser must add teamIds to paramsToSend (currently dropped at useUsers.ts:296-306).
  • A11y: required field marked with aria-required; error via existing MpFormErrorMessage.

Detail 2.B — Data-Fetching Strategy

  • Library: custom useClient wrapping $fetch (not useFetch) — app/common/composables/useClient.ts.
  • Base URL: useRuntimeConfig().public.apiBaseUrlKong.
  • Team list / visibility: useTeams.fetchTeamList, fetchTeamMenuVisibility (cached on isTeamMenuVisible boolean; force to refetch).
  • No SWR / optimistic update for invite — single POST, toast on result.

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Team field on inviteskeleton/spinner while fetchTeamList pending"No teams available. Create a team first." + submit disabled"Could not load teams. Try again." + retry; submit disabledn/a (single fetch)populated; submit enabled when ≥1 selected (when enforced)

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotency key + TTLConsistencyDuplicate handlingStale read
Invite → user + team_userssingle DB transaction (user + N team_users atomic)rollback all on any failureteam_users ON CONFLICT(user_id,team_id) DO NOTHING (existing CreateTeamUser)strongduplicate team selection deduped by unique index idx_team_user_uniquen/a
Migration insert (pushed via /teams/bulk)per-team insert in createSingleTeamper-item result recorded, batch continuesunique index idx_teams_source_ref_unique; skip on conflictstrong (per row)replay is a no-op (AC-3)n/a
General team insertindependent of company insert (non-fatal)company NOT rolled back; retry enqueued (NEW)dedupe by name+company on retryeventual (retry)retry must check existence before insertn/a

Detail 2.E — Concurrency Collision Map

ResourceWritersCollisionResolutionOn conflict
teams (migrated)Chat/CRM re-push / parallel bulk jobstwo calls insert same (company_id, source, reference_id)unique partial index + skip-on-conflict in createSingleTeamsecond insert no-ops (idempotent)
team_users (invite)concurrent invites of same emailduplicate (user_id, team_id)existing idx_team_user_unique + ON CONFLICT DO NOTHINGno-op
teams GeneralCID-create + retry jobdouble General insertretry checks existence by (company_id, name='General') firstskip if present

Detail 2.F — Async Job / Event Consumer Spec

JobTriggerInputRetryDLQConcurrencyIdempotencyTimeoutPoison handling
General-team retry (NEW)General-team create failure in MigrateCompany{company_id, company_sso_id}3 attempts, exp backoff (align to existing jobEnqueuer defaults — verify in internal/.../queue)existing job DLQlowcheck (company_id,'General') before insertper-job 30safter max attempts → log general_team_create_exhausted + alert
Team Kafka events (existing)team create/update/deleteTeamCreatedEvent{event_id,...}existingexistingexistingevent_idexistingexisting
TEAM_MIGRATED (NEW, producer)migrate-mode create in createSingleTeamTeamCreatedEvent-shaped + app+app_identifier_id, event_type="TEAM_MIGRATED"producer publish (existing producer retry)TopicTeamEventsV1 (no new topic)n/a (per create)aggregate_id (team_id) + app_identifier_idexisting producerpublish failure logged; team create still succeeds (same as TEAM_CREATED today, events.go:146)

The retry job reuses the existing jobEnqueuer/queue wiring already on TeamService (teams/main.go jobEnqueuer); exact backoff constants must be read from the queue package before coding (§4.D chunk 4).

Detail 2.F.1 — Responsibility Boundary Matrix

Step (exec order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
0. Trigger each squad's migrationBifrost (trigger client)kickoff (flag on)HTTPS trigger to Chat/CRMlog + record per-squad failure; not blockedPRD §9 #5
1. Map own active Divisions + pushChat squadLaunchpad triggerPOST /teams/bulk (app=chat, app_identifier_id)squad retries on its sidePRD §15
2. Map own active Teams + pushCRM squadLaunchpad triggerPOST /teams/bulk (app=crm, app_identifier_id)squad retries on its sidePRD §15
3. CRM-side migration done before Phase 2CRM squadrollout gateCRM teams migratedhold Phase 2PRD §11/§15
4. Process pushed teams + persist source cols + emit TEAM_MIGRATEDBifrost (ProcessBulkCreateTeam/createSingleTeam)inbound /teams/bulkteams rows + TEAM_MIGRATED (with app_identifier_id)per-item result; idempotent skipPRD §10 OTM-S05
5. Consume TEAM_MIGRATED + map own division/team → Launchpad teamChat / CRM squadsTEAM_MIGRATED Kafkaeach squad's own id↔team_id mappingsquad-side retrythis case's requirement
5. Enforce team on inviteBifrost (SsoInvite)Admin inviteteam_users row400PRD §10 OTM-S01
6. General team on CID createBifrost (MigrateCompany)new CIDGeneral teamnon-fatal + retryPRD §10 OTM-S02
7. Team sidebar URL changelegacy CRM app squad (NOT launchpad)navigationroute/redirectn/aPRD §7 / §5 OQ-3

Step 7 disagrees with the PRD's implied Bifrost ownership: the routes are not in either launchpad repo. Recorded as §5 OQ-3, not silently absorbed.

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Teamsource_identifier'Launchpad'migration / CreateTeamForCompany / native createGET /teamsnone (write-time)
Team (migrated)TEAM_MIGRATED (Kafka, carries app_identifier_id) + per-item bulk resultProcessBulkCreateTeam/createSingleTeamKafka TopicTeamEventsV1 + bulk upload-job statusn/a
Team (native create)TEAM_CREATED (Kafka)createSingleTeamKafka TopicTeamEventsV1n/a
Inviteuser_invited_with_team eventSsoInvitelogsn/a

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
POST /users/sso_inviteaccepts teamIds: string[] (tagless TeamIds field, camelCase via case-insensitive JSON); returns UserInfoResponse; 400 resp_desc.enFE sends teamIds; reads resp_desc.en for erroryes (contract aligned — both camelCase teamIds)only the FE store currently drops teamIds before POST (useUsers.ts:296) — a wiring bug closed by §4.D chunk 6 (forward the field as-is, no casing transform)
GET /teams/menu-visibility{data:{is_visible:bool}}is_visibleisTeamMenuVisibleyesvisibility must also reflect launchpad_one_team_migration (§5 OQ-4)
GET /teams{data:[{id,name,...}]}team listyesnone

The invite contract is aligned (both sides camelCase teamIds); the only residual is the FE store wiring closed by §4.D chunk 6; tracked as the cross-layer blocker until that chunk lands.

Detail 2.H — End-to-End Data Flow

Invite with team: Admin selects team(s) in field-teamsselectedTeamIdsonSubmit builds objToSend (InviteUsers.vue:351) → inviteUser must forward teamIds (FIX) → useClient POST /users/sso_invite → Kong → chi rest_router.go:86UserService.SsoInvite → flag check (preference.IsEnabled) → validate teamIds non-empty → BEGIN; createLaunchpadUser; CreateTeamUser×N; COMMITUserInfoResponse → FE success toast.

  • Side effects: invite events (user_invited_with_team); existing SSO invitation side-effects unchanged.
  • Ownership: FE (Bifrost), API+service (Bifrost), DB (Bifrost).

Detail 2.I — Scope Boundaries

  • BE create: db/migration/…_add_source_tracking_to_teams.{up,down}.sql; Chat/CRM migration-trigger client methods (under internal/app/api/chat, internal/app/api/crm); launchpad_one_team_migration constant in internal/pkg/constants/preferences.go.
  • BE modify: db/query/team.sql (+ regenerated team.sql.go); repository/models.go Team; internal/pkg/request/team_request.go (CreateTeam/CreateTeamItem + app_identifier_id); service/teams/create.go createSingleTeam (map appsource_identifier, persist app_identifier_idreference_id, reference_id idempotency, emit TEAM_MIGRATED for migrate-mode) + CreateTeamForCompany (stamp cols, "General"); service/teams/events.go (TEAM_MIGRATED event + publisher); service/users/sso_invite.go (ApiUserInviteParam + write); service/companies/migrate.go + migrate_full.go (retry enqueue).
  • BE NOT touched: existing Chat/CRM notify_team_update.go (placeholder, leave as-is); billing/quota; team_users schema.
  • FE modify: InviteUsers.vue (required rule); useUsers.ts (teamIds payload).
  • FE NOT touched: team management pages/routes; sidebar URLs (OTM-S03 out of repo).
  • Shared impact: CreateTeam sqlc signature changes — every caller (teams/create.go, bulk_create.go) must pass the new cols; impact-checked in §4.D chunk 2.

Detail 2.J — Asset Inventory

AssetTypeSourceFormatPath
n/a — no new icons/illustrationsreuse Pixel MpFormControl

3. High-Availability & Security

The runtime change is additive (two columns, an additive invite field, an extended bulk-create path + a trigger). The invite path stays a single synchronous request; the migration is an async push the source squads drive (existing EnqueueBulkCreateTeam → worker). Graceful degradation: if the team-list read fails, the FE shows a retry and disables submit (§2.C); if a source squad's migration fails, its items are flagged in the bulk result / the squad retries, and Launchpad is not blocked.

Performance Requirement

  • Invite team validation adds one preference.IsEnabled call + N team_users inserts in the same transaction — well within PRD's ≤ 500ms (PRD §6).
  • General-team creation already in the CID-create path; PRD ≤ 2s — retry is async, off the critical path.
  • Migration: PRD target all active CIDs within 24h; --batch-size (default 1000, per seed-* precedent) bounds memory.

Monitoring & Alerting

  • Kafka: TEAM_MIGRATED (topic TopicTeamEventsV1) per migrated team, carrying team_id+app+app_identifier_id — the back-mapping signal for Chat/CRM.
  • Events / logs (slog, matching events.go field style): team_migrated{cid,source_identifier,reference_id (=app_identifier_id),team_name}, team_migration_failed{cid,source,reason}, user_invited_with_team{cid,team_id,source_identifier}, invitation_rejected_no_team{cid,admin_id}, general_team_created{cid,team_id} (PRD §12).
  • Alerts (PRD §12): team_migration_failed rate > 1% per run → #bifrost-alerts; general_team_created failure > 0 in 24h → #bifrost-alerts.
  • Tracing: existing DataDog chi middleware (rest_router.go:39) covers the invite endpoint automatically.

Logging

  • Structured slog with slog.String("cid",…) etc.; no raw PII in migration logs (log reference_id, not member data).

Security Implications

  • Threat model: migration writes span all CIDs by design — but now over POST /teams/bulk from Chat/CRM, so it must be a service-to-service authenticated endpoint (not the user SSO scope), not publicly exposed, and the trigger client uses service creds. Invite path keeps existing per-company SSO/CRS authorization (no cross-tenant team assignment: validate selected teamIds belong to the inviter's company).
  • Input validation: teamIds are UUIDs and must resolve to teams in the actor's company_id (enforce in SsoInvite before write).
  • Injection: sqlc parameterized queries (no string-built SQL).
  • Secrets: Chat/CRM client creds via existing config (heimdall clients already take username/password/clientID/clientSecret).
  • Static analysis: make sec (gosec) in CI.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityConstraintAudit
Admin/users/sso_invite, /teams*POST/GETown companyinvite + team fieldteamIds must be own-companyinvite events
Regular/read-onlyhiddendenied by CRSn/a
Migration (Chat/CRM push)POST /teams/bulkn/aall CIDs (squads push their own)n/aservice-to-service tokenbulk results + Kafka events
System (CID create)CreateTeamForCompanyin-processper new companyn/an/aslog + team event

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE responseCode-shape consistent?
Invite, no team (flag on)inline error "Please select a team to continue"; submit blocked400 resp_desc.enyes
Team list load"Could not load teams. Try again." + retry; submit disabledGET /teams 4xx/5xxyes
Source API down (migration)n/a (CLI)CID skipped, logged, continue, exit 0n/a
General-team create failn/acompany kept; retry enqueuedn/a

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
one_team_migration_enabled = falseSsoInvite + FE visibilityteam optional, no enforcementnoneyes (no required *)
Existing user without team (update)invite/update pathsno enforcement, no error (OTM-S01/NEG-1)noneno
Inactive/archived Division/Teammigration source filterrecord skippedlogno
reference_id already presentunique indexinsert no-op (idempotent)optional logno

Detail 3.B — Error Response Catalog (BE)

Envelope: { "resp_code": "...", "resp_desc": {"id":"...","en":"..."}, "meta": {} } (default_error.go).

EndpointCodeHTTPMessage (en)WhenUser-facing?
/users/sso_invite400400Please select a team to continueflag on, teamIds emptyyes
/users/sso_invite400400Invalid requestmalformed teamIds / cross-tenant teamyes

Detail 3.C — Error Message Catalog (FE)

ErrorUser-facing messageSurfaceUser-facing?
no team selected"Please select a team to continue"inline (MpFormErrorMessage)yes
team list load failed"Could not load teams. Try again." + retryinline/fieldyes
no teams exist"No teams available. Create a team first."inline; submit disabledyes

Final copy pending design sign-off (PRD §8); does not block coding.

Detail 3.D — Compliance & Data Governance

FieldClassificationLegal basisRetentionEncryptionAccess auditRight-to-delete
teams.namelow — org labeln/acompany lifetimeat rest (DB), TLS in transitteam eventsfollows company deletion
Migration failure logoperationaln/a30 days (PRD §6.1)at rest, TLSengineer-onlynightly cleanup

No payment/health data. Member-to-team data is not migrated (out of scope), reducing PII exposure.

Detail 3.E — Accessibility

  • Required team field: aria-required="true"; error announced via MpFormErrorMessage; keyboard-navigable Pixel components; WCAG AA via design system defaults.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • DDL: additive columns with safe defaults (source_identifier DEFAULT 'Launchpad', reference_id NULL) — existing rows valid immediately; no backfill needed.
  • Invite API: teamIds additive/optional; old FE without the field still works while flag off.
  • FE saved state: none affected.

Rollout Strategy

  • Migration sequence: (1) ship DDL (make migrate-up); (2) deploy BE (invite accepts teamIds, General-team stamps cols, retry job); (3) deploy FE (required rule + teamIds payload) behind flag; (4) provision launchpad_one_team_migration per the staged audiences; (5) trigger Chat/CRM migration per enabled CID batch — each squad maps + pushes into /teams/bulk (after CRM-side migration completes for Phase 2).
  • Schema during migration: columns exist with defaults before any code reads them — no intermediate broken state.
  • Feature flag: launchpad_one_team_migration (global, default false); kill-switch = set false (reverts to optional team, no redeploy).
  • Stages (audience only; schedule lives in delivery/): internal QA CIDs → staged CID batches (post CRM migration) → GA all unified_app CIDs (PRD §11/§14).
  • Rollback trigger: team_migration_failed > 1%, or invite 400 spike post-enable.

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline (team optional, dropped payload)
Backend firstOldNewyesNew BE accepts but doesn't require teamIds until flag on; old FE unaffected
Frontend firstNewOldpartialNew FE sends teamIds; old BE ignores unknown field (additive) — no enforcement yet. Acceptable; avoid enabling flag until BE deployed
Both deployed, flag offNewNewyesoptional team (legacy behavior)
Both deployed, flag onNewNewyestarget state: enforced
Backend rollbackNewOldpartialFE still sends teamIds (ignored); disable flag first
Frontend rollbackOldNewyesBE only enforces if flag on; turn flag off to be safe

Deploy order: BE before FE; enable flag last. Any rollback disables the flag first.

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BElaunchpad_one_team_migration (qontak-preferences)bool (global)falseyes (provision)Bifrost via preferences serviceno
BEChat/CRM trigger client base URL + credsstringyes (once trigger endpoint exists)config (existing pattern)yes (creds)
FEreads visibility via GET /teams/menu-visibilityno new FE flagno

Detail 4.C — Test Plan (commands sourced from repo)

LayerCommand (source)Must prove
BE unitmake test (Makefile:105go test -race -coverprofile=coverage.out ./internal/app/...)SsoInvite writes team_users; rejects empty when flag on; General-team stamps cols; migrate path idempotent
BE migrationmake migrate-up / make migrate-down (Makefile:161/168)columns + CHECK + index added and cleanly reverted
BE lintmake lint (Makefile:120, staticcheck)clean
BE securitymake sec (Makefile:125, gosec)clean
BE bulk-create migrate pathmake test (covers ProcessBulkCreateTeam/createSingleTeam)app=chat+app_identifier_id ⇒ team Chat - X with source_identifier='Chat'+reference_id; replay = 0 new (idempotent); TEAM_MIGRATED published with app_identifier_id
FE unitpnpm run test -- --run (package.json:16 vitest --dom; CI bitbucket-pipelines.yml)submit blocked w/o team (flag on); teamIds present in posted body
FE lintpnpm run lint (package.json:11-13, eslint+prettier)clean
FE typecheckpnpm run type-check (package.json:19, vue-tsc --noEmit)clean
Cross-layermanual/integration: invite from FE against BE with flag on/off400 vs success; team_users row created

No e2e framework is configured in qontak-launchpad-fe (no playwright/cypress) — cross-layer coverage is unit + manual integration. Recorded so coverage isn't overstated (initiative is QA Lane B).

Detail 4.D — Agent Execution Plan

OrderLayerChunkFilesCommandsAcceptance criteria
1BEAdd source columns migrationdb/migration/<ts>_add_source_tracking_to_teams.{up,down}.sqlmake migrate-up; make migrate-down; make migrate-up\d teams shows source_identifier,reference_id,chk_*,idx_teams_source_ref_unique; down reverts clean
2BEsqlc query + modeldb/query/team.sql (CreateTeam + source-stamped insert), regenerate internal/app/repository/team.sql.go, repository/models.go Teammake mocks (if needed); make testcode compiles; CreateTeamParams carries source cols; existing callers updated
3BEExtend bulk/single create (persist source + idempotency + TEAM_MIGRATED); add trigger clientinternal/pkg/request/team_request.go (+app_identifier_id; require it when is_migrate=true mirroring the IsMigrate && App=="" guard at :34; allow empty member_ids when is_migrate=true at :20/:55); service/teams/create.go (createSingleTeam maps appsource_identifier, persists app_identifier_idreference_id, skip-on-conflict, emits TEAM_MIGRATED); service/teams/events.go (TEAM_MIGRATED event+publisher); trigger methods in internal/app/api/chat,/crmmake test; make lintmigrate item w/o member_ids accepted; migrate item w/o app_identifier_id rejected (400); app=chat+app_identifier_idChat - X row w/ source cols; replay = 0 new; TEAM_MIGRATED emitted carrying app_identifier_id+team_id; trigger client compiles (endpoint contract stubbed pending OQ-1)
4BEGeneral-team source cols + retryservice/teams/create.go, service/companies/migrate.go, migrate_full.gomake testMigrateCompany_GeneralTeam test: team has source='Launchpad',reference_id=NULL, name "General"; injected failure enqueues retry, company not rolled back
5BEInvite team enforcementservice/users/sso_invite.go (ApiUserInviteParam.TeamIds, validate, CreateTeamUser), flag const internal/pkg/constants/preferences.gomake test; make lintflag on + empty ⇒ ErrBadRequest resp_desc.en; flag on + teams ⇒ team_users rows; flag off ⇒ unchanged; cross-tenant team rejected
6FEForward teamIds to APIcomposables/useUsers.ts (inviteUser includes teamIds in paramsToSendno casing transform)pnpm run type-check; pnpm run test -- --runposted body includes teamIds; unit asserts payload
7FERequired team rule + statesInviteUsers.vue (yup .min(1) gated; empty/error/loading states)pnpm run test -- --run; pnpm run lintsubmit blocked w/o team when enforced; error/retry rendered; success toast on resolve

Chunk order respects deps: DDL (1) → codegen (2) → command (3) and General team (4) depend on cols; invite (5) before FE (6,7); enable flag only after 1–7 deployed.

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (BE), in order: make lintmake secmake migrate-upmake testmake build.
  • Pre-merge (FE), in order: pnpm run lintpnpm run type-checkpnpm run test -- --runpnpm run build.
  • Post-deploy signals: #bifrost-alerts clean; invitation_rejected_no_team not spiking after flag-on; team_migration_failed rate < 1% in the run summary; general_team_created failures = 0 in 24h (DataDog logs).
  • Rollback recipe (deploy-order-aware):
    1. Set launchpad_one_team_migration = false (instant; invite reverts to optional).
    2. If FE regression: revert the FE PR (BE tolerates teamIds absence).
    3. If BE regression: revert BE PR; columns are additive and safe to leave (only make migrate-down if the DDL itself is faulty — note: dropping columns is destructive of migrated source_identifier/reference_id, so prefer leaving columns and only reverting code).
    4. Confirm invite success rate recovered and #bifrost-alerts clear.

Detail 4.F — Resource & Cost Notes

  • Compute: negligible (two columns; one extra flag read + N small inserts per invite; offline batch).
  • DB: one-time insert burst at migration; storage growth bounded by existing Division/Team counts.
  • Network: Launchpad makes outbound trigger calls to Chat/CRM; Chat/CRM make inbound POST /teams/bulk calls (rate-limit-aware, heimdall timeouts on the trigger).

5. Concern, Questions, or Known Limitations

Findings tagged REV-n were raised by the rfc-reviewer second pass (R1, 2026-06-30) — see rfc-qontak-one-team-migration-review.md.

#TypeQuestion / limitationOwnerBlocking?
OQ-1 (REV-1)TECHNICALMigration-trigger contract + push agreement. Migration is push (PRD v1.2): Launchpad's receive path (POST /teams/bulk, is_migrate/app) already exists, so the Launchpad side of chunk 3 (persist source cols + reference_id idempotency) is buildable now. What's undefined: (a) the Chat/CRM migration-trigger endpoint Launchpad calls (path, auth, payload), and (b) the agreement that each squad maps active records, sends app_identifier_id, and consumes TEAM_MIGRATED. The trigger client and full end-to-end migration cannot complete until (a)/(b) land. (PRD §15; §17 OQ1 data-volume also lands here.)Chat + CRM squadsYES (trigger + push contract)
OQ-2 (REV-5)PRODUCT/TECHInvite team cardinality: PRD says team_id (singular); FE field is multi-select (selectedTeamIds). This RFC specifies an array requiring ≥1. Confirm single vs multi is intended. (PRD §17 OQ2 dual-team users relates.)Bifrost PMYES (shapes API)
OQ-3 (REV-8)TECHNICALOTM-S03 team sidebar URL /qontakcrm/team/launchpad/team is absent from both launchpad repos (real route /user_management/teams). It belongs to the legacy CRM app. Confirm owning squad/repo; this RFC excludes it. PRD/ANCHOR still list it Must-Have — reconcile scope.Bifrost PM + CRM squadno (out of scope here)
OQ-4 (REV-3)TECHNICALHow does launchpad_one_team_migration reach the FE enforcement? Option A: BE folds it into GET /teams/menu-visibility (is_visible) so FE needs no new flag fetch (preferred). Option B: new FE flag read. Decide before chunk 7.BifrostYES (FE gating)
OQ-5TECHNICALTeam name casing: existing General team is created as "general" (migrate.go:148); PRD specifies "General". Confirm rename + whether existing "general" rows are normalized.Bifrostno
OQ-6 (REV-2)TECHNICALRESOLVED (2026-06-30). Verified: ApiUserInviteParam (sso_invite.go:23) has no JSON tags, and BindRequest (util.go:39) uses standard json.Decode → Go matches field names case-insensitively, so the endpoint binds camelCase (fullName/roleId/teamIds; full_name would not bind). §2.4/§2.G/§2.A corrected to teamIds; the new BE field is TeamIds []string (tagless, binds teamIds). Note: a different struct UserCreate uses json:"team_id" — intentionally not followed here (different endpoint).Bifrostresolved
OQ-7 (REV-4)TECHNICALGeneral-team async retry policy (attempts, backoff, DLQ, exhausted-alert) is deferred to "existing jobEnqueuer defaults — verify" (§2.F). Read the queue package and pin these before chunk 4.Bifrostno
OQ-8 (REV-6)TECHNICAL/sso_invite success response schema is only named (UserInfoResponse), not pinned in §2.4. Inline the fields the FE reads (FE currently reads only resp_desc.en on error).Bifrostno
OQ-9 (REV-9)TECHNICALRESOLVED (idempotency). Decided: app_identifier_id is required when is_migrate=true (§1.B + §2.3 note + §4.D ch3), so migrated rows always have a non-NULL reference_id and stay in the partial unique index → replay-safe. Residual (still open): Chat/CRM event sign-off — (a) same topic TopicTeamEventsV1 with event_type filtering vs a dedicated topic; (b) payload fields (team_id+app+app_identifier_id).Bifrost + Chat/CRMno (idempotency closed; topic sign-off pending)
OQ-10 (REV-10)TECHNICALRESOLVED. Decided: relax CreateTeam.Validate() to allow empty member_ids when is_migrate=true (§1.B + §4.D ch3), consistent with PRD §5.1 (structures only, not memberships). Native (non-migrate) creates still require member_ids.Bifrostresolved
L-1LIMITATIONMigration trigger is engineer-run (ANCHOR OQ3), not auto on flag toggle — operational runbook needed.Bifrostno
L-2 (REV-7)LIMITATIONNo e2e framework in FE repo; cross-layer verification is unit + manual (QA Lane B). FE perf budget / analytics events also unstated (enhancement — low risk).Bifrost QAno

6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-30RFC author (Claude, via rfc-starter)Initial full-stack draft grounded in live qontak-launchpad (main) + qontak-launchpad-fe (master) worktrees. All 10 mermaid blocks validated with mmdc (exit 0, no parse errors). Surfaced 4 PRD-vs-code deltas (OTM-S02 already built; FE payload gap; OTM-S03 out of repo; Chat/CRM read APIs absent).
2026-06-30rfc-reviewer (R1)Reviewed; HOLD @ 6.5. See -review.md. Findings REV-1..8 promoted to §5.
2026-06-30R2 fixes appliedClosed REV-2/REV-9/REV-10 in spec. REV-2: verified ApiUserInviteParam is tagless (sso_invite.go:23) + BindRequest uses case-insensitive json.Decode (util.go:39) ⇒ endpoint binds camelCase; corrected §2.4/§2.G/§2.A/§1.B to teamIds (field TeamIds []string), removed the snake_case mapping. REV-9: app_identifier_id now required when is_migrate=true (replay-safe vs the partial unique index). REV-10: member_ids relaxed to optional when is_migrate=true (structures-only per PRD §5.1). Updated §1.B decisions, §2.3 DDL note, §2.4, OTM-S05, §4.D ch3/ch6, §5 OQ-6/9/10, §7.
2026-06-30rfc-reviewer (R2)Re-reviewed push-model revision; HOLD @ 6.5 (cap = REV-2 casing). REV-1 eased blocker→major; 2 new findings: REV-9 (optional app_identifier_id + partial index ⇒ no replay dedup → OQ-9) and REV-10 (member_ids required vs structure-only non-goal → OQ-10). See -review.md.
2026-06-30Migration-mapping refinementAdded app_identifier_id (optional string) to the bulk/single create request (persists into the teams.reference_id column); migrate-mode now emits a separate TEAM_MIGRATED Kafka event (instead of TEAM_CREATED) carrying team_id+app+app_identifier_id, so each source squad can map its own division/team → the centralized Launchpad team. Touched §1 overview/deltas/deps/PRD-to-schema, §1.B/C, §2.0 anchors/contracts, §2.2 sequence, §2.4 (+event contract), §2.F/F.1/F.2, §2.I, §3, §4.D, §5 OQ-9.
2026-06-30Flow correction (per planning)Migration model changed pull → push to match service planning: each source squad maps its own data and calls Launchpad's existing POST /teams/bulk (is_migrate/app already present); Launchpad triggers them, persists source cols + reference_id idempotency, emits Kafka. Reworked §1 deltas/deps/assumptions/PRD-to-schema, §1.A/B/C, §2.0 anchors/contracts/repo-map, §2.1 component diagram, §2.2 migration sequences, §2.4 APIs, §2.D/E/F.1/F.2, §2.I, §4.C/D, §5 OQ-1, §7. Review R1 is now stale — re-review (R2) recommended.

7. Ready for agent execution

  • no — blocked on cross-squad contracts. The Bifrost-owned chunks (1, 2, 4, 5, 6, 7) are fully specified and executable today; chunk 3 (migration command) is blocked.

Missing execution-readiness gates:

Resolved since R2 (now closed in spec; code lands in §4.D): REV-2 (invite casing → teamIds, verified tagless binding), REV-9 (app_identifier_id required for migrate-mode → replay-safe), REV-10 (relax member_ids for migrate-mode). The §2.G invite row is now Match? = yes.

Remaining gates:

  • §5 OQ-1 / REV-1 (blocking for end-to-end): the push receive path (POST /teams/bulk) already exists and the Launchpad-side extensions (chunks 1–5) are now fully specified and buildable; what remains is the cross-squad migration-trigger contract + the TEAM_MIGRATED topic sign-off (OQ-9 residual) + the squads' own mapping/push/consume work.
  • §5 OQ-2 / REV-5 (decide before chunk 5): confirm single vs multi team on invite (this RFC specifies teamIds: string[]).
  • §5 OQ-4 / REV-3 (decide before chunk 7): how launchpad_one_team_migration reaches the FE gate (Option A recommended).
  • Cross-Layer Contract Verification (§2.G): invite contract now aligned (both camelCase teamIds); only the FE store wiring remains (chunk 6).

All other gates are met: Infra/component/ER/state/branch/sequence diagrams present and parser-validated; DDL with per-status lifecycle traces to §1 PRD-to-Schema; APIs tagged reuse/extend/new; Source Verification complete with file:line evidence; Agent Execution Plan has files + repo-sourced commands + verifiable acceptance criteria; Verification & Rollback Recipe concrete.

Once OQ-1/OQ-2/OQ-4 are resolved, flip §7 to yes and (optional) hand to rfc-reviewer for a second-pass score.