Skip to main content

RFC: Enable Team Owner Field & Team Permission in CDP

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. Sections marked N/A — reason are intentional, not omissions.

It is also agent-execution-ready: §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), §2 Repo Reading Guide (Detail 2.0) for both layers, mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are complete before §7 Ready for agent execution.

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

Metadata

FieldValueNotes
StatusRFC (IDEA)Human label; YAML status: carries the remapped linter enum draft
DRIZhelia AlifaRFC owner (frontmatter dri)
TeamcdpAdvisory squad slug from the initiative README
Author(s)Zhelia AlifaDerived from PRD v2.5
ReviewersCDP Backend Lead, CDP Frontend Lead, Mobile Squad Lead, Launchpad Squad LeadTech reviewers across affected squads (FE + BE + Mobile + Launchpad)
Approver(s)CDP Tech Lead, InfoSec ApproverThis RFC changes an authorization boundary — InfoSec sign-off is required
Submitted Date2026-06-18ISO-8601
Last Updated2026-06-18ISO-8601
Target Release2026-Q3Progressive per-tenant enable (Growth + Enterprise)
Target Quarter2026-Q3From initiative README
Related../prds/prd-team-owner-field-and-team-permission.md (PRD v2.5)Source PRD
Discussion#cdp-team-permission (TBD — confirm channel)Slack thread

Type: full-stack Frontend sub-type: new-feature Backend sub-type: new-feature

Sections at a Glance

  1. Overview (§1 Design References — FE half; §1 PRD-to-Schema Derivation — BE half; traceability; per-story change map)
  2. Technical Design (Repo Reading Guide both layers → architecture → sequence → DDL/index → APIs → integrity / cross-layer contract)
  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

Story ID convention. The source PRD numbers its stories "Story 1 … Story 17" without bracketed ids. For stable downstream traceability this RFC assigns composite ids TEAM-S01 … TEAM-S17 (mapping in Detail 1.C). AC ids are cited as <STORY-ID>/AC-n / ERR-n / NEG-n.


1. Overview

This RFC specifies a full-stack change that gives the CDP team permission level a real, intermediate meaning and introduces the data it scopes on.

Today CDP exposes three permission levels — disabled, own, team, everything (contact-service internal/pkg/consts/const.go:44-47) — but team is unscoped: permission_service.go:86-88 returns true for every company contact, behaving identically to everything. There is also no field recording which team(s) own a contact. Admins are forced to choose between over-restricting (own) or over-exposing (everything).

This change (1) adds a multi-select Team Owner field — team_owner_ids, an array of Launchpad team UUIDs — to CDP contacts; (2) makes TEAM ONLY a real server-side scope for the four customer permission actions (customers_customers_view / _manage / _delete / _searchassoc); and (3) extends TEAM ONLY to Contact Notes (customers_customernotes_view / _manage / _delete), which inherit their parent contact's team scope. The scope rule mirrors the proven legacy CRM design: a contact is visible under TEAM ONLY if its team_owner_ids is empty/Unassigned (visible to all), OR intersects the viewer's teams ∪ descendant teams (downward hierarchy), OR the viewer is its owner_id/assignee_id.

This is a delta on prior work, not a greenfield service: it extends an existing Go/MongoDB service (contact-service), an existing Nuxt 3 app (qontak-customer-fe), an existing Flutter app (mobile-qontak-crm), and depends on two net-new Launchpad capabilities (a teams-for-user(+descendants) API and a team radio option in the permission settings UI).

Success Criteria

Mirrors PRD §6 success metrics:

  1. Correctness — zero cross-team leakage. 0 confirmed cross-team records visible under TEAM ONLY (audited).
  2. Performance. TEAM ONLY contact-list load ≤ 2s P95, ≤ 20% over the captured everything baseline.
  3. Reliability. ≤ 0.5% of permission-check calls return 5xx under TEAM ONLY within 30 days of GA.
  4. Adoption. ≥ 30% of beta tenants set ≥1 action to team within 30 days of GA.
  5. Data quality. ≥ 95% of contacts have non-empty team_owner_ids after migration (the remainder are intentional Unassigned).

Out of Scope

From PRD §3 Non-Goals:

  1. No custom permission groups beyond own / team / everything.
  2. No change to other CDP module permissions (Companies, Deals, Tickets, Conversations) — except searchassoc.
  3. No team-management UI inside CDP (teams/membership are Launchpad-owned).
  4. Downward hierarchy only — a viewer sees their teams and descendant teams; a child-team member does not see parent-team-owned records.
  5. No bulk re-assignment tool for Team Owner in v1 (single-record edit only).
  6. No change to export/audit rules beyond CHG-002.
  7. customers_customers_profileview / _profilemanage are not in TEAM ONLY scope (OQ-6 still open). Contact Notes are in scope (CHG-006).
  8. No net-new visual design. Every CDP-owned FE surface is a pixel-faithful reuse of an existing component/pattern (Team Owner field forks MultipleSelect.vue; team view clones existing sidebar items) — see §1 Design References. No new Figma frame is in scope.
  • PRD v2.5../prds/prd-team-owner-field-and-team-permission.md (the domain spec; this RFC derives schema and contracts from it).
  • Initiative README — ../README.md.
  • PRD Appendix A–E (grounded code references) — re-verified against live repos in §2.0 Source Verification below; corrections applied where PRD line numbers had drifted (see §5 Known Limitations → "Grounding corrections").
  • Legacy reference model: qontak.com CRM search_parameter.rb:2453-2468 (cited via PRD Appendix C — legacy repo not in this RFC's worktree set; treated as design reference, not an execution target).

Assumptions

  1. Launchpad delivers two net-new capabilities before BE enforcement GA: a teams-for-user(+descendants) API and a team radio in the permission settings UI. Both are blocking dependencies (PRD §7); the CDP work can be built and tested behind the flag against a stub, but cannot GA without them.
  2. Enforcement is server-side in contact-service; web and mobile clients are UX-only and are not a security boundary.
  3. team_owner_ids is a snapshot at create time (auto-filled from the creator's teams), not auto-re-derived on later saves (OQ-2 — proposed resolution carried as the design default).
  4. The viewer's expanded team set (teams + descendants) is resolvable in ≤ 1 cached Launchpad call per request.
  5. Legacy team integer IDs map to Launchpad team UUIDs by team name within a company (migration crosswalk, CHG-004).

Dependencies

DependencyOwnerDeliverableAvailabilityBlocking?
Teams-for-user(+descendants) API (NEW)LaunchpadGET …/users/me/teams (or equivalent) returning the user's teams and descendant team ids; empty array (not error) for teamless usersneeds-building — no such endpoint today (/users/me UserInfoResponse has no teams field; only teams→members queries like ListTeamMember exist)YES
team radio in permission settings UI (CHG-001)Launchpad Squad (or Admin Portal — confirm)Expose team as a selectable level for the 4 CDP actions, persisting via the existing level-update endpointneeds-buildingYES
Level-update endpointLaunchpadPUT /private/users/{user_sso_id}/permissions/{permission_name}/level (verified rest_router.go:172, under r.Route("/private") + BasicAuth)exists — PRD's line (172) is right; PRD's path prefix is wrong (/private, not /iag/v1); no CDP impact (CDP only reads crs_permissions; admin UI is Launchpad-owned) — REV-5 closedYES
Team hierarchy traversalLaunchpadteams.parent_id + TraverseTeamLeaf / TraverseTeamRoot / ListTeamHierarchy / TeamIsParent (verified in db/query/team.sql)exists — descendants can be expanded server-sideYES
crs_permissions per-action levelLaunchpadAlready returned to contact-service; carries no team dataexistsYES
Legacy → Launchpad team name crosswalkLaunchpad + CDPMap legacy integer team ids (from crm_team_hierarchy_id CSV) → Launchpad UUIDs by name within companyneeds-building (migration)YES (migration)
Mobile CDP moduleMobile SquadRender Team Owner field + read per-note permission flagsin this RFC (MB-1/MB-2)YES
Feature flag cdp_team_permission_enabledCDP / OpsDedicated per-company flag, default OFFneeds-building (CHG-005)YES

Design References (frontend half — required)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Team Owner multi-select (contact detail/create — web)reuse — no net-new visualn/a (pixel-faithful to FilterMultiselect.vue)@mekari/pixel3@1.0.10-dev.0 (verified package.json:24)not required (no net-new visual)Fork of MultipleSelect.vue on MpInputTag; tokens = MpInputTag defaults; chip UI already in repo
Team Owner multi-select (mobile)reuse — no net-new visualn/aFlutter crm_design packagenot requiredExisting searchable multi-select field pattern
Team Only permission radio (CHG-001)n/a — Launchpad-ownedn/aLaunchpad-owned UILaunchpad/Admin-PortalOut of CDP FE scope — owned by Launchpad squad
/customers/teams sidebar entry (web)reuse — no net-new visualn/a@mekari/pixel3not requiredMirrors existing "Owned by me" / "Assigned to me" sidebar items exactly

No net-new visual treatment required (REV-2 resolved). Every CDP-owned FE surface is a pixel-faithful reuse: the Team Owner field is a fork of the existing MultipleSelect.vue (rendered via MpInputTag, visual contract set by FilterMultiselect.vue), and the /customers/teams entry clones the existing sidebar items. There is no surface that needs a new Figma frame or a design-QA sign-off — so this gate is cleared, not deferred. The only radio UI (Team Only) is Launchpad-owned and out of CDP FE scope. If, during build, a surface diverges from its reused component, that divergence (and only that) would need a frame + design-QA review — none is anticipated.

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

PRD-described entity / attribute / rulePersisted asExposed viaEnforced whereSource (PRD)
A contact may be owned by multiple teams ("Team Owner", multi-select)contacts.team_owner_ids []string (BSON array, Mongo)read in GET /cdp/customers + detail; write in PATCH/synccontact/base.go struct field; validated in handler§10 CHG-003, D-3
Auto-fill = ALL of the creator's teams; teamless → emptywritten value on create (snapshot)create/sync write pathFE/mobile auto-fill from teams-for-user API; BE stores as-is§10 CHG-003, D-4/D-5
Empty team_owner_ids (Unassigned) is visible to all Team Only usersempty array / missing fieldlist filter + single-get guard$or branch (b); EvaluatePermissions empty-check§8 D-13, AC TEAM-S02/AC-7
TEAM ONLY = empty OR intersects {viewer teams ∪ descendants} OR owner/assigneen/a (query-time)list $or filter; per-record guardsearch_contact_request.go $in; EvaluatePermissions(teamOwnerIDs, viewerTeamIDs); 6 handler branches§8 D-2/D-7, CHG-002
Viewer's team set must include descendant teams (downward hierarchy)n/aresolved per requestLaunchpad teams-for-user(+descendants); cached§8 D-7, §7
Every submitted team_owner_id must be one of the actor's teams (write validation)n/awrite path 422handler validation against viewer teams§10 CHG-003, NEG-6
Notes inherit the parent contact's team scopenone on note; parent contacts.team_owner_idsnotes list/get/update/deletenew parent-contact scope lookup + service-layer evaluation§10 CHG-006
Per-note permission.{update,delete} must reflect team scopecomputed, not storednotes responsesresolveNotePermission team branch (new)§10 CHG-006, FE-6, MB-1
Behaviour is gated per-tenant; default OFFfeature flag valueconfigcdp_team_permission_enabled check before applying TEAM ONLY§5, CHG-005
Compound multikey index to keep TEAM ONLY list ≤ 2s P95Mongo index (company_sso_id, is_deleted, team_owner_ids)n/amigration 033_*.up.json§4 Constraints, App.A

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

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

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
TEAM-S01/AC-1..3, ERR-1Launchpad settings UI (cross-squad, CHG-001)Launchpad PUT /private/users/{sso}/permissions/{name}/level
TEAM-S02/AC-1..7, ERR-1ListPage.vue, DetailPage.vue (level read)§2.4 GET /cdp/customers (+detail) filter; permission_service.go
TEAM-S03/AC-1..3, ERR-1mobile list screen (UX only)same server filter as S02
TEAM-S04/AC-1..3, ERR-1edit entry gating (web)PATCH /cdp/customers/{id} guard + write validation
TEAM-S05/AC-1..3mobile edit gatingsame guard
TEAM-S06/AC-1..3delete gating (web)DELETE /cdp/customers/{id} guard
TEAM-S07/AC-1..2mobile delete gatingsame guard
TEAM-S08/AC-1..3assoc search component (web)GET /cdp/customers/search-assoc filter
TEAM-S09/AC-1..2mobile assoc searchsame filter
TEAM-S10/AC-1..6, ERR-1..2TeamOwnerSelect.vue, CustomerDetails.vueteam_owner_ids write + validation
TEAM-S11/AC-1..7mobile team-owner pickersame write + validation
TEAM-S12/AC-1..4, ERR-1CustomerActivityV2.vue level readnotes list/get scope (CHG-006)
TEAM-S13/AC-1..2mobile notes sectionsame notes scope
TEAM-S14/AC-1..3, ERR-1NotesList.vue (reads note.permission.update)resolveNotePermission team branch + update guard
TEAM-S15/AC-1..2mobile note edit (read per-note flag)same
TEAM-S16/AC-1..3NotesList.vue (reads note.permission.delete)delete guard + per-note flag
TEAM-S17/AC-1..2mobile note deletesame

Reverse (RFC → PRD AC):

New FE component / BE endpoint / dependencyPRD composite AC id it serves
contacts.team_owner_ids array field + multikey indexTEAM-S02/AC-1, TEAM-S10/AC-3
EvaluatePermissions(teamOwnerIDs, viewerTeamIDs) (extended)TEAM-S02/AC-4, TEAM-S04/AC-2, TEAM-S06/AC-2
team_owner_ids $in filter in search_contact_request.goTEAM-S02/AC-1, TEAM-S08/AC-1
Teams-for-user(+descendants) Launchpad APITEAM-S02/AC-6 (hierarchy), TEAM-S10/AC-1
resolveNotePermission team branchTEAM-S14/AC-1, TEAM-S16/AC-1
Parent-contact scope lookup for notesTEAM-S12/AC-2, NEG-7
TeamOwnerSelect.vue (fork of MultipleSelect.vue, emits ids)TEAM-S10/AC-1..6
/customers/teams route + filterByRoute branchTEAM-S02/AC-1 (team list view)
cdp_team_permission_enabled flagCHG-005, all enforcement stories

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Customer Index/ListwebGET /cdp/customersListPage.vuefiltered list (team scope applied server-side)
Customer DetailswebGET /cdp/customers/{id} (+ by email/phone)PATCH /cdp/customers/{id}DetailPage.vue, CustomerDetails.vueteam_owner_ids chips / "Unassigned"
Team Owner multi-selectwebteams-for-user APIPATCH /cdp/customers/{id} (team_owner_ids)TeamOwnerSelect.vue (new)selected team chips
/customers/teams viewwebGET /cdp/customers?team_owner_ids=…SidebarChildCustomer.vue, ListPage.vueteam-scoped list
Association search (Deal/Task/Ticket/Company)webGET /cdp/customers/search-assocassoc search componentfiltered typeahead
Notes tabwebGET /v1/contacts/{id}/notesPOST/PUT/DELETE /v1/contacts/notes/{id}[/{noteId}]Notes.vue, NotesList.vueper-note permission.{update,delete}
Customer list/detailmobilesame BE endpointssamecrm_contact screensteam-scoped list
Team Owner pickermobileteams-for-user APIsame writemobile fieldteam chips
Notes sectionmobileGET /contacts/{id}/notesPUT/DELETE /contacts/{id}/notes/{noteId}crm_note screensper-note permission.{update,delete}
Permission settings (Team Only radio)web (Launchpad/Admin Portal)crs permissionsPUT /private/users/{sso}/permissions/{name}/levelLaunchpad-ownedradio = current level

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
Org Admin / OwnerIAG token + role; admin scopelevel-update (Launchpad)permission settings pageno (own company)Launchpad permission change log
CS / Sales Agent (team)IAG token + crs_permissions level=teamGET/PATCH/DELETE /cdp/customers*, notes — scopedlist/detail/notes within team scopenocdp_team_permission_denied event on 403
Agent (own)level=ownsame endpoints, owner/assignee scopeonly own recordsno
Agent/Admin (everything)level=everythingsame endpoints, company scopeall company recordsno
Disabled (disabled)level=disabled403 on all gated endpointsentry points hiddennomiddleware 403

PRD Section Coverage

PRD section #TitleWhere covered
Header / Adjustment Context§1 Overview, Metadata
1One-liner + Problem§1 Overview
2Target Users + Persona§1 Role Coverage
Scope Changesfrontmatter scope_changes + §1
3Non-Goals§1 Out of Scope
4Constraints§2 Technical Decisions, §2.3, §3 Performance
5Rollout§4 Rollout Strategy
6Success Metrics§1 Success Criteria
6.5Observability§3 Monitoring & Alerting
7Dependencies§1 Dependencies, §2.F.1
8Key Decisions + Alternatives§1 Detail 1.B + §2 Technical Decisions
9Open Questions§5
10Feature Changes (CHG-001..006)§2 Architecture, §2.3, §2.4, §4.D
11Frontend Changes (FE-1..6)§2.A UI Contract, Detail 1.C, §4.D
12Mobile Changes (MB-1..2)Detail 1.C, §2.A, §4.D
13System Flow + Stories + AC§2.2 Sequence, Detail 1.A/1.C, §4.D
Appendix A–EGrounded references§2.0 Source Verification
Changelogn/a — PRD-internal history

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

DecisionChosen optionAlternatives rejectedWhy rejectedLayer§2 block
Scope keyStored team_owner_ids arrayLive owner/assignee/creator membership resolution1+N Launchpad calls/request; volatile; contacts vanish on membership changeBEDecision 1
TEAM ONLY ruleempty OR $in {teams ∪ descendants} OR owner/assigneeflat-only (no hierarchy); Unassigned hiddenContradicts legacy descendant_ids + crm_team_ids << nil; orphans UnassignedBEDecision 2
Field shapeMulti-select arraySingle-selectCannot express collaborative cross-team ownershipbothDecision 3
Descendant expansionLaunchpad expands and returns the set (OQ-3a — proposed)contact-service expandsKeeps hierarchy logic in the team-owning service; simpler cache keyBEDecision 4
Filter operatorbson.M{"$in": ids} via bson:"-" DTO fieldGeneric primitive.A$or expansion (existing)$or equality explodes per element; $in is one multikey opBEDecision 5
IndexCompound multikey (company_sso_id, is_deleted, team_owner_ids)No index / separate indexTEAM ONLY list must be ≤ 2s P95; one array field allowed in compound indexBEDecision 6
EvaluatePermissions signatureExtend with teamOwnerIDs []string, viewerTeamIDs []string (BREAKING)New parallel functionOne evaluation path; avoids drift between list filter and per-record guardBEDecision 7
Notes scopingInherit parent contact scope (one gate, no per-note filter)Per-note team ownerNotes have no team owner; fetched by contact_id → single parent gateBEDecision 8
Feature flagDedicated cdp_team_permission_enabled (default OFF)Reuse GetIsPermissionUsmanEnabledReuse is company-wide across 12 perms; changes own/everything for tenants already ONBE/ConfigDecision 9
Multi-select options sourceUser's OWN teams (teams-for-user API)getTeams() (all company teams, capped 10)Wrong source; user may only assign teams they belong toFEDecision 10
Owner field emitsTeam idsTeam names (existing MultipleSelect.vue behavior)Persisted value is UUIDs; name is display onlyFEDecision 3
Fallback on Launchpad outageDegrade to OWNED ONLY + banner + eventFail request (5xx) / fall back to everythingFail-closed-ish: never widen scope; keep usableBEDecision 2
Per-status lifecyclen/a — no status enum introducedteam_owner_ids is a membership array, not a lifecycle state; soft-delete handled by existing is_deletedboth
Soft vs hard deleteReuse existing contact is_deleted soft-delete; notes existing behaviorNo new entity; no change to delete semantics beyond authz scopeBE
Inbound webhook ownershipn/a — no new inbound webhookTEAM ONLY adds no async callback; sync write path reusedBE
Reuse vs new (endpoints)All four customer endpoints + notes endpoints extended (no new routes); Launchpad teams-for-user is newNew CDP endpointsExisting routes already serve these surfaces; only filter/guard logic changesbothDecision 7/8

Detail 1.C — Per-Story Change Map

Layer scope ∈ {FE-only, BE-only, FE + BE, Runtime / behavior, Config, Cross-squad}. RFC anchor cites where the detail lives.

Story idStory titleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
TEAM-S01Admin enables TEAM ONLY in settingsCross-squad (Launchpad)n/a — Launchpad/Admin-Portal ownedn/a — Launchpad owns level-update; CDP only reads the levelTEAM-S01/AC-1..3, ERR-1Level-update call persists team; reload shows Team Only§1 Deps · §2.F.1 · CHG-001
TEAM-S02customers_customers_view — webFE + BEListPage.vue:532 + DetailPage.vue:131 read perm.level; /customers/teams route; team filter injectGET /cdp/customers (+detail) $or filter; permission_service.go team branch; 6 handler guardsTEAM-S02/AC-1..7, ERR-1Go test: team viewer sees own+team+descendant+Unassigned only; out-of-scope direct-get → 403; fallback → OWNED + event§2.2 · §2.4 row 1 · §4.D ch 2–5
TEAM-S03customers_customers_view — mobileFE + BEmobile list reads server-filtered results; disabled hides tabsame filter as S02 (no new BE)TEAM-S03/AC-1..3, ERR-1Mobile list == web for same role; deep link out-of-scope → 403§2.2 · §4.D ch 9
TEAM-S04customers_customers_manage — webFE + BEedit entry gated on level+scopePATCH guard (handler branch); write validationTEAM-S04/AC-1..3, ERR-1Go test: in-scope edit ok; out-of-scope edit → 403; non-member team in body → 422§2.4 · §4.D ch 4–6
TEAM-S05customers_customers_manage — mobileFE + BEmobile (⋮) edit gatingsame guardTEAM-S05/AC-1..3Mobile edit scoped == web§4.D ch 9
TEAM-S06customers_customers_delete — webFE + BEdelete button gatingDELETE guard branchTEAM-S06/AC-1..3Go test: in-scope delete ok; out-of-scope → 403§2.4 · §4.D ch 4
TEAM-S07customers_customers_delete — mobileFE + BEmobile (⋮) delete gatingsame guardTEAM-S07/AC-1..2Mobile delete scoped == web§4.D ch 9
TEAM-S08customers_customers_searchassoc — webFE + BEassoc search component visibilityGET /cdp/customers/search-assoc filter (handler branch)TEAM-S08/AC-1..3Go test: assoc results team-scoped; disabled → component hidden + 403§2.4 · §4.D ch 4
TEAM-S09customers_customers_searchassoc — mobileFE + BEmobile assoc searchsame filterTEAM-S09/AC-1..2Mobile assoc scoped == web§4.D ch 9
TEAM-S10Set Team Owner — webFE + BETeamOwnerSelect.vue (new, emits ids); CustomerDetails.vue field block; CustomerStore.ts Contact typeteam_owner_ids write in sync/patch path; per-element validation 422TEAM-S10/AC-1..6, ERR-1..2Vitest: chips render, clear→Unassigned, auto-fill all teams; BE 422 on non-member team§2.A · §2.3 · §4.D ch 1,7,8
TEAM-S11Set Team Owner — mobileFE + BEmobile picker (searchable multi-select)same write+validationTEAM-S11/AC-1..7Mobile create auto-fills all teams; offline → error; 422 parity§2.A · §4.D ch 9
TEAM-S12customers_customernotes_view — webFE + BECustomerActivityV2.vue:69 read levelnotes list/get scope via parent contact (CHG-006)TEAM-S12/AC-1..4, ERR-1Go test: notes on out-of-scope contact → 403; in-scope → returned§2.4 notes · §4.D ch 7
TEAM-S13customers_customernotes_view — mobileFE + BEmobile notes sectionsame scopeTEAM-S13/AC-1..2Mobile notes scope == web§4.D ch 9
TEAM-S14customers_customernotes_manage — webFE + BENotesList.vue:91 reads note.permission.update; add _manage constresolveNotePermission team branch; update guardTEAM-S14/AC-1..3, ERR-1Go test: permission.update true in-scope/false out; PUT out-of-scope → 403§2.4 · §4.D ch 7,8
TEAM-S15customers_customernotes_manage — mobileFE + BEmobile gates on per-note permission.update (was contact-level hasUpdatePermission)sameTEAM-S15/AC-1..2Mobile edit gated by note.permission.update§4.D ch 9
TEAM-S16customers_customernotes_delete — webFE + BENotesList.vue:91-101 reads note.permission.delete; add _delete constresolveNotePermission team branch; delete guardTEAM-S16/AC-1..3Go test: permission.delete true in-scope/false out; DELETE out-of-scope → 403§2.4 · §4.D ch 7,8
TEAM-S17customers_customernotes_delete — mobileFE + BEmobile gates on per-note permission.delete (already wired note_screen.dart:312)sameTEAM-S17/AC-1..2Mobile delete gated by note.permission.delete§4.D ch 9

Cross-layer rule satisfied: every FE + BE row has both halves filled. TEAM-S01 is the only Cross-squad row (Launchpad-owned settings UI).


2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
web([qontak-customer-fe / web]) -->|HTTPS IAG token| gw[API Gateway / IAG]
mob([mobile-qontak-crm / Flutter]) -->|HTTPS IAG token| gw
gw -->|HTTP| cs["contact-service api pods xN\n(stateless, Go/chi)"]
cs -->|read / write| mongo[("MongoDB\n(contacts, contact_notes)")]
cs -->|cache get/set| redis[("Redis\n(viewer team-set cache, short TTL)")]
cs -->|HTTPS crs_permissions| lp_perm[["Launchpad\n(permissions: crs_permissions)"]]
cs -->|HTTPS teams-for-user +descendants NEW| lp_team[["Launchpad\n(teams API — NEEDS BUILDING)"]]
admin([Admin settings UI]) -->|"PUT /private/.../level"| lp_perm

No async queue/worker is introduced by enforcement — TEAM ONLY is synchronous in the request path. The only batch component is the one-off migration job (CHG-004), shown in §4.

Per-service responsibility

flowchart LR
subgraph cs["contact-service (CDP, Go/Mongo) — owner: CDP Squad"]
uc1["GET /cdp/customers\n(list — TEAM ONLY $or filter)"]
uc2["GET /cdp/customers/{id}\n(single-get guard)"]
uc3["PATCH /cdp/customers/{id}\n(write + team validation)"]
uc4["DELETE /cdp/customers/{id}\n(delete guard)"]
uc5["GET /cdp/customers/search-assoc\n(assoc filter)"]
uc6["GET/PUT/DELETE /v1/contacts/{id}/notes*\n(notes scope via parent)"]
end
uc1 -->|"HTTPS (cached)"| lpt(["Launchpad teams-for-user(+descendants)\n(owner: Launchpad — NEW)"])
uc2 --> lpt
uc6 --> lpt
uc1 -->|"in-process"| mongo[("MongoDB")]
uc6 -->|"in-process"| mongo
cs -->|"HTTPS"| lpp(["Launchpad crs_permissions\n(owner: Launchpad — exists)"])

subgraph lp["qontak-launchpad (Go/Postgres) — owner: Launchpad Squad"]
t1["teams-for-user(+descendants)\n(NEW query+handler+route)"]
t2["PUT /private/.../permissions/{name}/level\n(exists)"]
t3["team radio in settings UI\n(NEW — CHG-001)"]
end
t1 -->|"in-process sqlc"| pg[("Postgres\nteams.parent_id + TraverseTeamLeaf")]

Technical Decisions (ADR-format)

Decision 1: Scope on stored team_owner_ids, not live membership

Context TEAM ONLY needs a stable, indexable key for "which teams own this contact". Two sources are possible: a value stored on the contact, or membership resolved at query time from owner/assignee/creator.

Options considered

  • Option A — stored team_owner_ids array on the contact.
    • Pros: single indexed multikey $in; stable when team membership changes; mirrors proven legacy CRM design (search_parameter.rb:2453-2467).
    • Cons: requires a write path, validation, and migration/backfill.
  • Option B — resolve owner/assignee/creator team membership per request.
    • Pros: no new field, no migration.
    • Cons: 1+N Launchpad calls per request; volatile key; a contact silently disappears when a member's team changes; cannot express co-ownership.

Decision: Option A.

Rationale Latency budget (≤ 2s P95) cannot absorb N membership lookups per contact; the value must be stable and indexable. Legacy CRM already proves this shape works at scale.

Consequences Adds a field, write validation, a multikey index, and a backfill migration. team_owner_ids is a snapshot — it does not auto-update when a contact's owner changes teams (OQ-2 accepted as snapshot semantics).

Reversibility The field is additive; if abandoned, leave it unread and drop the index (one migration). No data loss.

Decision 2: TEAM ONLY = empty OR $in {teams ∪ descendants} OR owner/assignee; fail to OWNED ONLY on Launchpad outage

Context The exact membership test determines correctness (no leakage) and the failure behavior determines safety when Launchpad is unavailable.

Options considered

  • Option A — full rule (empty-visible + downward hierarchy + owner/assignee), degrade to OWNED ONLY on outage.
    • Pros: matches legacy (crm_team_ids << nil + descendant_ids); managers see sub-teams; Unassigned never orphaned; outage never widens scope.
    • Cons: requires descendant expansion + a cached Launchpad call.
  • Option B — flat membership only, hide Unassigned, fail request on outage.
    • Pros: simplest filter.
    • Cons: contradicts production behavior; orphans Unassigned records; a hard failure blocks the whole list on a transient Launchpad blip.

Decision: Option A.

Rationale Correctness is defined by the legacy behavior the PRD re-grounded against. Degrading to OWNED ONLY is the safe failure mode — it narrows, never widens, visibility, and keeps the list usable with a banner.

Consequences Adds a dependency on the (new) teams-for-user(+descendants) API and a short-TTL cache; a fallback event + banner must be implemented.

Reversibility The $or is assembled in ToFilters()/EvaluatePermissions; reverting to return true restores old behavior in one commit (behind the flag anyway).

Decision 3: Multi-select array, emitting team ids

Context A contact can be collaboratively owned by several teams; the FE must persist UUIDs, not names.

Options considered

  • Option A — multi-select array; fork MultipleSelect.vue to emit ids.
    • Pros: expresses co-ownership; reuses an in-repo MpInputTag component.
    • Cons: the existing component emits names (MultipleSelect.vue:147-148) — the fork must change the emit to item.id.
  • Option B — single-select. Pros: simpler. Cons: cannot express co-ownership — the core requirement.

Decision: Option A.

Rationale The product requirement is explicitly multi-team. Forking an existing component keeps UI consistency.

Consequences A new TeamOwnerSelect.vue; the Contact type gains team_owner_ids?: string[].

Reversibility Component is isolated; removing the field block reverts the UI.

Decision 4: Expand descendants in Launchpad (OQ-3a)

Context Downward hierarchy requires expanding the viewer's teams to their descendants. This can happen in Launchpad or in contact-service.

Options considered

  • Option A — Launchpad returns the expanded set (teams ∪ descendants).
    • Pros: hierarchy lives with the team-owning service (TraverseTeamLeaf already exists); contact-service caches one flat set; simple cache key.
    • Cons: new API must accept "include descendants".
  • Option B — contact-service calls a teams API then expands via a hierarchy endpoint. Pros: Launchpad API stays minimal. Cons: ≥2 calls; CDP duplicates traversal logic; harder caching.

Decision: Option A. OQ-3a resolved — Launchpad expands descendants server-side and returns expanded_team_ids (normative contract pinned in §2.4 Dependency endpoint). Launchpad ratifies the exact shape at review; this is a delivery checkpoint, not an open spec choice.

Rationale TraverseTeamLeaf/TeamIsParent already exist in Launchpad; expansion is a server-side recursive CTE. One cached flat set per viewer is the cheapest contract for the hot path.

Consequences The teams-for-user API contract must include descendants. Caching keys on (company, user); short TTL bounds staleness (NEG-4).

Reversibility If expansion must move to CDP later, the cached set just becomes "teams only" + a second expansion call — additive.

Decision 5: Filter via explicit $in with a bson:"-" DTO field

Context search_contact_request.go ToFilters() (119-248) auto-explodes any array field into $or equality (137-157). A naive team_owner_ids DTO field would be exploded into per-element $or, defeating the multikey index.

Options considered

  • Option A — DTO field tagged bson:"-" + explicit $in branch.
    • Pros: one multikey $in op; idiom proven at segmented_filter_request.go:211 and field_properties_search.go:66.
    • Cons: must remember the bson:"-" to bypass the generic expansion.
  • Option B — let the generic $or expansion handle it. Pros: no special case. Cons: N $or equality clauses; index not used as a single multikey scan; worse latency.

Decision: Option A.

Rationale Performance budget + an established in-repo $in idiom.

Consequences The new field is the first $in in this file; a comment should point to the precedent files.

Reversibility Single branch in ToFilters(); trivially removable.

Decision 6: Compound multikey index (company_sso_id, is_deleted, team_owner_ids)

Context TEAM ONLY list queries already filter company_sso_id + is_deleted; adding team_owner_ids $in must stay ≤ 2s P95.

Options considered

  • Option A — compound multikey index declared like a scalar index (Mongo makes it multikey automatically; precedent: phone/name_search arrays indexed plainly in migrations 011/028).
    • Pros: single index serves the hot query; one array field is allowed in a compound index (company_sso_id/is_deleted are scalar — OK).
    • Cons: index write cost on array updates (small arrays).
  • Option B — no index / separate single-field index. Pros: less write cost. Cons: collection scan or index-merge; misses the latency budget.

Decision: Option A, as new migration 033 (JSON migration format).

Rationale Verified: highest existing migration is 032_create_customer_segments — so the next number is 033 (the PRD's "032" is already taken). Index declaration needs no special multikey syntax.

Consequences Slightly higher write amplification on contacts with many team owners (bounded by team count).

Reversibility 033_*.down.json drops the index.

Decision 7: Extend EvaluatePermissions (BREAKING)

Context Per-record TEAM ONLY (single-get, manage, delete, assoc) needs the record's owning teams and the viewer's expanded teams. Current signature (permission_service.go:41-46) has neither.

Options considered

  • Option A — extend to EvaluatePermissions(permissions, userSSOID, ownerID, assigneeID, teamOwnerIDs []string, viewerTeamIDs []string).
    • Pros: one evaluation path shared by list filter assembly and the 6 handler guards; no logic drift.
    • Cons: BREAKING — every caller must pass the new args (compiler-enforced).
  • Option B — new EvaluateTeamPermissions alongside the old. Pros: non-breaking. Cons: two code paths that can diverge; the very bug class TEAM ONLY must avoid.

Decision: Option A.

Rationale A single authority for "is this contact in scope?" prevents the list filter and the per-record guard from disagreeing (a leakage risk).

Consequences All callers (6 handler branches + get_contact.go) update in the same PR; Go compiler enforces completeness.

Reversibility Revert the signature; behavior is flag-gated regardless.

Decision 8: Notes inherit the parent contact's scope (single gate)

Context ContactNote (contact_notes/base.go:26-36) has no team owner — only contact_id, owner_id, note, company_sso_id. Notes are always fetched by a single contact_id.

Options considered

  • Option A — one parent-contact scope gate (project the parent's owner_id/assignee_id/team_owner_ids, evaluate once).
    • Pros: no per-note filtering; no N+1; matches "notes never widen parent scope" (NEG-7).
    • Cons: requires a new repo method (today ValidateContactExists returns only a bool) + service-layer evaluation (today the notes handler never calls EvaluatePermissions).
  • Option B — give notes their own team owner. Pros: independent scoping. Cons: contradicts the data model; double-bookkeeping; drift from parent.

Decision: Option A.

Rationale Notes are a child of one contact; one gate is correct, cheap, and matches the PRD's transitive rule.

Consequences New projection method on ContactNoteInterface (regenerate mocks); scope evaluation added to contact_notes_service.go for list/get/update/delete; resolveNotePermission gains a team branch so the per-note permission.{update,delete} flags (the seam web+mobile read) reflect team scope. This broadens update/delete from owner-only.

OQ-11 resolved — confirmed broadening (REV-3). Under team/everything, a user may edit/delete others' notes on an in-scope contact; the per-note permission.{update,delete} flags are computed from the level + parent-contact scope (not note ownership). Rationale: notes are a child of the contact, so note authority should track the contact's scope — a fragmented "view the contact but not its notes" model is the inconsistency the PRD set out to remove. Under own the existing owner-only behavior is unchanged. This broadening is flag-gated (cdp_team_permission_enabled), so it ships dark and reverts instantly; Stories 14/16 ACs already encode it.

Reversibility New code is additive and flag-gated; revert restores owner-only.

Decision 9: Dedicated flag cdp_team_permission_enabled (default OFF)

Context team currently means full access; enforcement silently narrows existing team-level roles. Must be gated.

Options considered

  • Option A — dedicated per-company flag cdp_team_permission_enabled.
    • Pros: isolates this behavior change; per-tenant staged enable.
    • Cons: a new flag to provision.
  • Option B — reuse GetIsPermissionUsmanEnabled (require_permission_middleware.go:21).
    • Pros: no new flag. Cons: company-wide across all 12 permissions; flipping it changes own/everything behavior for tenants who already have it ON.

Decision: Option A.

Rationale Blast-radius isolation; the existing flag's scope is wrong.

Consequences Ops provisions the flag; enforcement reads it before applying TEAM ONLY. OQ-8 (single boolean vs per-permission-key) remains open — see §5.

Reversibility Flag OFF = exact current behavior.

Decision 10: Source the picker from the user's OWN teams

Context The FE already calls getTeams() (UserStore.ts:62-81, invoked DetailPage.vue:138) — but it hits GET /v1/teams?…&per_page=10 = all company teams, capped at 10. A user may only assign teams they belong to.

Options considered

  • Option A — new getMyTeams() action hitting the teams-for-user API.
    • Pros: correct source; matches the write-validation rule (every element must be the user's team).
    • Cons: depends on the new Launchpad API.
  • Option B — reuse getTeams(). Pros: exists. Cons: wrong set (all company, capped 10); lets users pick teams they aren't in (server rejects → 422 churn).

Decision: Option A.

Rationale The picker must equal the validation set, or every save risks 422.

Consequences New store action + myTeams ref; getTeams() stays for other uses.

Reversibility Additive store action.


Detail 2.0 — Repo Reading Guide

Repo Map (both layers)

flowchart LR
subgraph fe["qontak-customer-fe (Nuxt 3)"]
listpage["features/customers/views/ListPage.vue"]
detail["features/customers/detail/.../DetailPage.vue + CustomerDetails.vue"]
userstore["features/customers/store/UserStore.ts"]
custstore["features/customers/store/CustomerStore.ts"]
notes["features/customers/detail/components/Notes/*"]
end
subgraph cs["contact-service (Go/Mongo)"]
consts["internal/pkg/consts/const.go"]
permsvc["internal/app/service/permission_service.go"]
handler["internal/app/handler/contact_handler.go"]
searchreq["internal/app/payload/search_contact_request.go"]
base["internal/app/repository/contact/base.go"]
notesvc["internal/app/service/contact_notes/*"]
router["internal/server/rest_router.go"]
migr["db/migrations/*.json"]
end
subgraph mob["mobile-qontak-crm (Flutter)"]
notecdp["features/crm_note/.../note_cdp_data_response.dart"]
notescr["features/crm_note/.../note_screen.dart"]
othertab["features/crm_contact/.../tab/other_tab.dart"]
end
custstore --> handler
notes --> notesvc
handler --> permsvc --> base --> migr
notescr --> notesvc

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEinternal/pkg/consts/const.goPermission keys + notes keys live hereconst block style; team/own keys
BEinternal/app/service/permission_service.goteam returns true today; signature to extendPermissionResult evaluation shape
BEinternal/app/handler/contact_handler.go6 OwnerPermissionKey branches to mirror for teamper-record guard pattern (owner/assignee → 403)
BEinternal/app/service/get_contact.goSingle-get company-only guardwhere to add team-intersection check
BEinternal/app/payload/search_contact_request.goToFilters() array→$or explosionwhy team_owner_ids needs bson:"-" + explicit $in
BEinternal/app/payload/segmented_filter_request.goProven $in idiombson.M{"$in": value}
BEinternal/app/repository/contact/base.goTags []string array field declarationbson array field tag mirror for TeamOwnerIDs
BEinternal/app/payload/contact_sync_request.goWrite/sync DTO shapewhere to add the array on the write path
BEinternal/pkg/middleware/require_permission_middleware.goBinary gate + Usman flagflag check; level stored in context
BEinternal/app/repository/contact_notes/base.goContactNote + ContactNoteInterface + ValidateContactExists (bool)add parent-scope projection method
BEinternal/app/handler/contact_notes_handler.goresolveNotePermission (drops team)add team branch
BEinternal/app/service/contact_notes/contact_notes_service.golist/get company+contact only; update/delete owner-onlywhere to add scope evaluation
BEinternal/server/rest_router.gocanonical + deprecated notes routes (both share handlers)one service change covers both
BEdb/migrations/032_create_customer_segments.up.jsonLatest migration; JSON index formatindex migration shape; next number = 033
FEfeatures/customers/store/UserStore.tsgetTeams() (wrong source) + hasAssociatedAccess()add getMyTeams(); return {enabled, level}
FEcommon/components/field/MultipleSelect.vueFork target; emits nameschange emit to item.id
FEfeatures/customers/views/components/segment/FilterMultiselect.vueMpInputTag usagemulti-tag component contract
FEfeatures/customers/views/ListPage.vueis_enabled-only read; filterByRouteadd level read; /customers/teams branch
FEfeatures/customers/detail/components/CustomerDetails.vuedynamic form; multiple_select field_typewire the field block
FEfeatures/customers/store/CustomerStore.tsContact type; notes API clientadd team_owner_ids?: string[]
FEfeatures/customers/detail/components/Notes/{constants.ts,Notes.vue,components/NotesList/NotesList.vue}notes gating; per-note flagsadd _manage/_delete consts; read level
MBfeatures/crm_note/.../note_cdp_data_response/note_cdp_data_response.dartparses per-note permissionNoteCdpPermissionResponse
MBfeatures/crm_note/.../screens/note/note_screen.dartdelete reads per-note; add gates on contact-levelswitch add/edit gating to per-note
MBfeatures/crm_contact/.../tab/other_tab.dartcontact-level hasEditPermissionwhere the contact-level flag leaks into notes

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

ContractStatusJustificationOwner
GET /cdp/customers (list)extendedadd team_owner_ids $or filter; no new routeCDP
GET /cdp/customers/{id} (+ by email/phone)extendedsingle-get team guardCDP
PATCH /cdp/customers/{id}extendedwrite team_owner_ids + validationCDP
DELETE /cdp/customers/{id}extendeddelete guardCDP
GET /cdp/customers/search-assocextendedassoc filterCDP
GET/POST/PUT/DELETE /v1/contacts/{id}/notes* (+ deprecated /notes/...)extendedscope via parent contact; both groups share handlersCDP
EvaluatePermissions(...)extended (breaking signature)add teamOwnerIDs, viewerTeamIDsCDP
crs_permissions readreusedalready returns the levelLaunchpad
PUT /private/users/{sso}/permissions/{name}/levelreusedalready persists the levelLaunchpad
Teams-for-user(+descendants) APInew-with-justificationno user→teams endpoint exists (/users/me has no teams; only teams→members like ListTeamMember); TEAM ONLY needs the viewer's expanded team set per requestLaunchpad

Patterns to Follow (and where to find them)

LayerConcernPattern in repoReference fileDeviation?
BEHTTP handler guardowner/assignee check → ErrForbidden()contact_handler.go:256none — add team branch beside it
BERepository / DB filterbson.M{"$in": value}segmented_filter_request.go:211none — first $in in search_contact_request.go
BEArray field declarationTags []string \bson:"tags,omitempty"``contact/base.go:76none — mirror for TeamOwnerIDs
BEIndex migrationJSON key/name index docdb/migrations/011_*.up.json, 028none — declare plainly; Mongo makes it multikey
BELogging / eventsslog.*Context(ctx, "module_action", slog.Any(...))contact_handler.go:91none — new events follow cdp_team_permission_* naming
BEError shapemyhttp.ErrForbidden() / myhttp.ResponseBodycontact_handler.gonone
FEState managementPinia store + storeToRefsUserStore.ts, CustomerStore.tsnone — add getMyTeams() + myTeams
FEMulti-selectMpInputTagFilterMultiselect.vue:3-13fork MultipleSelect.vue, emit ids
FEPermission readperm?.is_enabledListPage.vue:532, DetailPage.vue:131deviation: also read perm?.level
FENotes per-note gatingnote.permission?.update ?? falseNotesList.vue:91-101none — server fills the flag
MBPer-note gatingnoteItem.permission?.delete ?? falsenote_screen.dart:312deviation: extend to add/edit too
CrossAPI casingsnake_case API ↔ TS interface fields snake_caseCustomerStore.ts Contact typenone — team_owner_ids stays snake_case both sides

Reading Order for the Agent

  1. internal/pkg/consts/const.go — permission + notes keys (44-47, 35-38).
  2. internal/app/service/permission_service.goteam returns true (86-88); signature (41-46).
  3. internal/app/handler/contact_handler.go — the 6 owner branches (256, 466, 543, 648, 760, 956).
  4. internal/app/payload/search_contact_request.goToFilters() array→$or (137-157).
  5. internal/app/repository/contact/base.goTags array (76).
  6. internal/app/repository/contact_notes/{base.go,read.go} + contact_notes_handler.go + contact_notes_service.go — notes gap.
  7. internal/server/rest_router.go — notes routes (151-159 + 162-169).
  8. db/migrations/032_create_customer_segments.up.json — index migration shape.
  9. features/customers/store/{UserStore.ts,CustomerStore.ts} + views/ListPage.vue + detail/.../CustomerDetails.vue.
  10. features/crm_note/.../note_screen.dart + features/crm_contact/.../tab/other_tab.dart (mobile gating).

Source Verification (anti-hallucination)

LayerAnchor / contractVerified byEvidence
BEconst.go permission keysreadOwnerPermissionKey="own", TeamPermissionKey="team", EverythingPermissionKey="everything" at L44-47 (PRD said 40-42)
BEconst.go notes keysreadCustomersCustomerNotes{Add,View,Manage,Delete}Key at L35-38 (PRD said 34-37)
BEteam returns truereadif permission.Level == consts.TeamPermissionKey { return true } at permission_service.go:86-88
BEEvaluatePermissions signaturereadfunc (s *PermissionService) EvaluatePermissions(permissions []api.LaunchpadPermission, userSSOID, ownerID, assigneeID string) PermissionResult at permission_service.go:41-46
BE6 owner branchesreadif authPermissionLevel == consts.OwnerPermissionKey {…ErrForbidden()} at contact_handler.go:256,466,543,648,760,956 (all confirmed)
BEsingle-get guardreadcompany_sso_id mismatch reset at get_contact.go:24-28 (PRD said 22-26)
BEUsman flagreadif !configService.GetIsPermissionUsmanEnabled() {…} at require_permission_middleware.go:21; binary gate Level != disabled at ~78-91
BETags array fieldreadTags []string \json:"tags,omitempty" bson:"tags,omitempty"`atcontact/base.go:76; grep team_owner` → NOT FOUND
BE$in precedentreadfilters[filter.Name] = bson.M{"$in": filter.Value} segmented_filter_request.go:211; field_properties_search.go:66
BEToFilters() array→$orreadcase primitive.A:AppendConditions(&filters,"$or",orConditions) at search_contact_request.go:137-157; no $in in file
BEsync DTOreadcontact_sync_request.go exists; has Tags []string (L~38), no team_owner_ids
BEContactNote fieldsreadcontact_id, company_sso_id, note, owner_id, … contact_notes/base.go:26-36; no team owner; ValidateContactExists returns bool (base.go:76)
BEnotes routesreadcanonical /{contact_id}/notes rest_router.go:151-159; deprecated /notes 162-169; share handlers
BEresolveNotePermission drops teamreadonly EverythingPermissionKey/OwnerPermissionKey checked, contact_notes_handler.go:143-166; no EvaluatePermissions call
BEnotes service owner-onlyreadif existingNote.OwnerID != ownerID {…access denied} contact_notes_service.go:196-199; list/get gated by ValidateContactExists only
BEmigration numberlshighest = 032_create_customer_segments.up.json; next = 033 (PRD said 032 — taken); format JSON key/name
BEtoolingreadgo test -race -tags dynamic … ./internal/... ./config/... Makefile:82; staticcheck ./... Makefile:140; migrate … up Makefile:174; no integration target
FEgetTeams() sourceread'/v1/teams?order_by=name&…&per_page=10' baseURL IAG_LAUNCHPAD_URL UserStore.ts:62-81; called DetailPage.vue:138 (PRD said 139); no my-teams action
FEMpInputTagread<MpInputTag :suggestions="options" …> FilterMultiselect.vue:3-13; no MpSelect in repo
FEMultipleSelect.vue emits namesreadconst newNames = normalizedData.map(i=>i.name); emit('update:value', newNames) MultipleSelect.vue:146-148; reads userStore
FEmultiple_select formreadcase 'multiple_select': CustomerDetails.vue:832-833; arrayFieldTypes=['multiple_select','upload','signature'] :868; Profile.vue absent
FEContact typereadinterface CustomerStore.ts:270-309; assignee_name present; no team_owner_ids
FEis_enabled readsreadreturn perm?.is_enabled === true ListPage.vue:532, DetailPage.vue:131, UserStore.ts:161 (hasAssociatedAccess)
FEfilterByRoutereadroute.path.split('/').pop() ListPage.vue:190; 'owned-by-me'→owner_id … 'assigned-to-me'→assignee_id :367; no /customers/teams
FEnotes constantsreadonly ADD_NOTES_PERMISSION,VIEW_NOTES_PERMISSION Notes/constants.ts:27-28; canViewNotes CustomerActivityV2.vue:56-70; canAddNotes Notes.vue:83-97; canEditNote/canDeleteNote read note.permission NotesList.vue:91-101
FEnotes APIsreadGET /v1/contacts/${id}/notes (:522), POST /v1/contacts/notes/${id} (:627), PUT/DELETE /v1/contacts/notes/${id}/${noteId} (:643/:659)
FEtoolingreadeslint . (:18), vitest (:16), nuxt build (:6); pnpm; @mekari/pixel3@1.0.10-dev.0 (:24)
MBflagsreadcontact360='flag_contact_360' (feature_flag_constant.dart:52), noteCdp='flag_note_cdp' (:115)
MBper-note modelreadNoteCdpPermissionResponse{update,delete} note_cdp_data_response.dart:44-52; entity note_permission.dart:14-22
MBgatingreaddelete reads per-note note_screen.dart:312; add gates contact-level note_screen.dart:182; hasEditPermission=contact.permission?.update other_tab.dart:108; save argument.canUpdate detail_note_screen.dart:666
MBno notes keysgrepgrep customernotes → NOT FOUND; no permission level on mobile
MBendpointsreadcdpNotesByContactId => '/contacts/$contactId/notes' endpoint.dart; /v1 prefix
MBtoolingreadmelos run test (melos.yaml:97-105), melos run analyze (:77-81); make apk/ipa
LPteam_users no is_primaryreadCREATE TABLE … team_users (… user_id, team_id, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL …) db/migration/20250306062703_create_team_users.up.sql; unique (user_id,team_id)
LPlevel-update routereadPUT /users/{user_sso_id}/permissions/{permission_name}/level under r.Route("/private") + BasicAuth, rest_router.go:172 (canonical main 20a3063) — PRD line 172 correct; PRD path prefix /iag/v1 wrong (actual /private)
LPno teams-for-usergrepListTeamsByUser/users/me/teams → NOT FOUND; /users/me UserInfoResponse has no teams; only ListTeamMember (team.sql:128-139)
LPhierarchy existsreadparent_id UUID create_teams.up.sql; TraverseTeamLeaf/TraverseTeamRoot/ListTeamHierarchy/TeamIsParent in db/query/team.sql

Launchpad note: verified against the canonical Launchpad checkout (/Users/pujitriwibowo/Documents/works/qontak-launchpad, main @ 20a3063), not a card worktree — re-confirm on the branch the Launchpad squad ships for GA. The level-update route is rest_router.go:172 under r.Route("/private") + BasicAuth: the PRD's line (172) is correct, but its path prefix is wrong (/private, not /iag/v1) — REV-5 (no CDP impact). No is_primary, no teams-for-user endpoint, and the hierarchy traversal queries all re-confirmed on this checkout.

Design ↔ Code Mapping (frontend half)

Figma frame / componentImplementing fileReuse vs newTokensBacking APIDeviation
Team Owner multi-select (reuse, no frame)common/components/field/TeamOwnerSelect.vue (new)new (fork of MultipleSelect.vue)Pixel MpInputTag defaultsteams-for-user; PATCH /cdp/customers/{id}emit ids not names
Team list view (reuse, no frame)ListPage.vue + SidebarChildCustomer.vueextendedexisting sidebar tokensGET /cdp/customers?team_owner_idsnone — mirrors existing route items
Notes gating (reuse, no frame)Notes.vue, NotesList.vueextendedexistingnotes endpointsserver fills note.permission

No net-new visual treatment (REV-2 resolved) — each surface maps to an existing component/pattern, so no Figma frame is required. Divergence from the reused component would be the only trigger for a frame + design-QA review; none is anticipated.

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
user([Agent]) --> page["ListPage.vue / DetailPage.vue (web)\nor crm_contact screens (mobile)"]
page --> store["UserStore.getMyTeams() + CustomerStore"]
store --> client[IAG api client]
client --> handler["/contact-service handler/"]
handler --> permsvc["PermissionService.EvaluatePermissions\n(teamOwnerIDs, viewerTeamIDs)"]
handler --> searchreq["search_contact_request.ToFilters()\n($or: empty | $in teams∪desc | owner | assignee)"]
searchreq --> repo[("MongoDB contacts\n(multikey idx)")]
permsvc --> lpt(["Launchpad teams-for-user(+descendants)\n(cached, short TTL)"])
handler --> notesvc["ContactNotesService\n(parent-contact scope gate)"]
notesvc --> repo

Data model (erDiagram)

erDiagram
CONTACT ||--o{ CONTACT_NOTE : "has (by contact_id)"
CONTACT {
string id PK
string company_sso_id
string owner_id
string assignee_id
string_array team_owner_ids "NEW — Launchpad team UUIDs"
string_array tags
bool is_deleted
}
CONTACT_NOTE {
objectid _id PK
string contact_id FK
string company_sso_id
string owner_id
string note
bool is_deleted
}

Permission-level decision (the only enum touched)

team is a value of the existing permission level enum (disabled / own / team / everything); this RFC adds no new status enum on any entity. The level is read per request from crs_permissions; it is not a stored lifecycle state, so a stateDiagram-v2 would be degenerate. Decision flow instead:

flowchart TD
start([request to gated endpoint]) --> flag{cdp_team_permission_enabled?}
flag -- no --> legacy["team == everything (current behavior)"]
flag -- yes --> lvl{level?}
lvl -- disabled --> deny[403]
lvl -- own --> own["owner_id==user OR assignee_id==user"]
lvl -- everything --> all["company scope only"]
lvl -- team --> teamr{resolve viewer teams+descendants}
teamr -- "Launchpad ok" --> rule["empty(Unassigned) OR $in{teams∪desc} OR owner/assignee"]
teamr -- "timeout/5xx" --> fb["fallback OWNED ONLY + banner + cdp_team_permission_fallback"]

Branch & skip flow (Launchpad-unavailable fallback)

flowchart TD
trig([team-level request]) --> call{teams-for-user resolves <500ms?}
call -- yes --> apply[apply TEAM ONLY $or filter]
call -- "no (timeout/5xx)" --> skip["degrade to OWNED ONLY\nemit cdp_team_permission_fallback\nFE/mobile show banner"]
apply --> done([response])
skip --> done

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

Happy path — TEAM ONLY list (cache miss then hit)

sequenceDiagram
actor A as Agent
participant GW as API Gateway / IAG
participant CS as contact-service pod
participant Cache as Redis (team-set cache)
participant LPT as Launchpad teams-for-user(+desc)
participant DB as MongoDB

A->>GW: GET /cdp/customers (IAG token)
GW->>CS: HTTP
CS->>CS: read crs_permissions level == "team"; flag ON?
CS->>Cache: GET teamset:{company}:{user}
alt cache miss
Cache-->>CS: nil
CS->>LPT: GET users/me/teams?include_descendants=true
Note right of LPT: budget <500ms
LPT-->>CS: {expanded_team_ids:[...]}
CS->>Cache: SET teamset short TTL
else cache hit
Cache-->>CS: expanded_team_ids
end
CS->>DB: find $or{ team_owner_ids empty | $in ids | owner==u | assignee==u } + company + !is_deleted
DB-->>CS: contacts (multikey index)
CS-->>GW: 200 filtered list
GW-->>A: render team + sub-team + Unassigned

Failure path — Launchpad teams API timeout

sequenceDiagram
participant CS as contact-service pod
participant LPT as Launchpad teams-for-user
participant DB as MongoDB
CS->>LPT: GET users/me/teams (+desc)
Note right of LPT: timeout > 500ms
LPT--xCS: no response
CS->>CS: fallback OWNED ONLY; emit cdp_team_permission_fallback{platform}
CS->>DB: find { owner==u OR assignee==u } + company + !is_deleted
DB-->>CS: own-scoped contacts
CS-->>CS: response header/flag → FE shows "team filter temporarily unavailable"

Single-get / notes guard — out-of-scope contact

sequenceDiagram
actor A as Agent (team)
participant CS as contact-service
participant DB as MongoDB
participant LPT as Launchpad teams-for-user
A->>CS: GET /cdp/customers/{id} (or its /notes)
CS->>LPT: resolve viewer teams+descendants (cached)
LPT-->>CS: expanded_team_ids
CS->>DB: find contact by id (+ parent for notes): owner_id, assignee_id, team_owner_ids
DB-->>CS: record
CS->>CS: EvaluatePermissions(teamOwnerIDs, viewerTeamIDs)
alt empty OR intersect OR owner/assignee
CS-->>A: 200 (contact / notes)
else out of scope
CS->>CS: emit cdp_team_permission_denied
CS-->>A: 403
end

Write — Team Owner validation

sequenceDiagram
actor A as Agent (manage)
participant CS as contact-service
participant LPT as Launchpad teams-for-user
A->>CS: PATCH /cdp/customers/{id} { team_owner_ids:[t1,t2] }
CS->>LPT: viewer teams (cached)
LPT-->>CS: [t1, t3]
alt every submitted id ∈ viewer teams
CS->>CS: write team_owner_ids; emit cdp_team_owner_set
CS-->>A: 200
else any id ∉ viewer teams (e.g. t2)
CS->>CS: emit cdp_team_owner_validation_rejected{rejected:[t2]}
CS-->>A: 422
end

Detail 2.3 — Database Model (DDL / migration)

contact-service uses MongoDB; migrations are JSON index docs (not SQL), applied by migrate. No new collection — one new array field + one index.

Field (declared on the Go struct; Mongo is schemaless):

// internal/app/repository/contact/base.go (mirror Tags at L76)
TeamOwnerIDs []string `json:"team_owner_ids,omitempty" bson:"team_owner_ids,omitempty"`

New migration db/migrations/033_add_team_owner_ids_index.up.json (shape mirrors 011/028/032; Mongo makes it multikey automatically because team_owner_ids is an array):

{
"collection": "contacts",
"indexes": [
{
"key": { "company_sso_id": 1, "is_deleted": 1, "team_owner_ids": 1 },
"name": "company_isdeleted_team_owner_ids"
}
]
}

033_add_team_owner_ids_index.down.json drops company_isdeleted_team_owner_ids.

  • Cardinality / growth: one array per contact; typical length = creator's team count (1–5). No new rows; index size ∝ Σ array lengths.
  • Example rows: team_owner_ids: ["a1b2-…PM", "c3d4-…QontakProduct"]; Unassigned: team_owner_ids: [] or field absent.
  • PII classification: team_owner_ids = team UUIDs → non-PII (org metadata). Parent contact PII (name/email/phone) unchanged.
  • Retention: follows the contact's existing retention/soft-delete (is_deleted). No separate retention.
  • Per-status lifecycle: n/a — no status enum introduced. The permission level is a per-request value from crs_permissions, not a stored entity state.
  • Partition/sharding: none (existing collection sharding unchanged).
  • NoSQL note: already MongoDB; the array + multikey $in is the native fit (no relational join table needed).

Detail 2.4 — APIs

Outbound endpoints (consumers call contact-service)

EndpointMethodAuthN/AuthZRequestResponseStatusIdempotencyVersioningReuse?
/cdp/customersGETIAG token + customers_customers_view (middleware)query incl. internal team_owner_ids filter (route-derived){data:[Contact{…, team_owner_ids}]}200 / 403n/a (read)existing /cdpextended
/cdp/customers/{id} (+ by email/phone)GET+ view; team guardpathContact{…, team_owner_ids}200 / 403 / 404n/aexistingextended
/cdp/customers/{id}PATCH+ customers_customers_manage; team guard + write validation{…, team_owner_ids:[uuid]}updated Contact200 / 403 / 422 (non-member team)natural (PATCH by id)existingextended
/cdp/customers/{id}DELETE+ customers_customers_delete; team guardpath{}200 / 403naturalexistingextended
/cdp/customers/search-assocGET+ customers_customers_searchassoc; team filterqueryfiltered list200 / 403n/aexistingextended
/v1/contacts/{contact_id}/notesGET+ customers_customernotes_view; parent-contact scopepath{data:[Note{…, permission:{update,delete}}]}200 / 403n/aexistingextended
/v1/contacts/{contact_id}/notes/{id}GET+ view; parent scopepathNote200 / 403n/aexistingextended
/v1/contacts/{contact_id}/notes/{id}PUT+ customers_customernotes_manage; parent scope{note}Note200 / 403naturalexistingextended
/v1/contacts/{contact_id}/notes/{id}DELETE+ customers_customernotes_delete; parent scopepath{}200 / 403naturalexistingextended
deprecated /v1/contacts/notes/{contact_id}[/{id}] groupGET/POST/PUT/DELETEsame keyssamesamesamesameexisting (shares handlers)extended

Sync/write path (contact_sync_request.go) also gains team_owner_ids with the same validation (OpenAPI/import path).

Inbound webhooks (other services call us)

N/A — no inbound webhook introduced. TEAM ONLY is synchronous; no async callback is added.

Dependency endpoint (Launchpad — NEW, contact-service calls it)

RFC-defined contract (authoritative; Launchpad to ratify in review — REV-1 / OQ-NEW-3). This is the normative shape both contact-service (consumer) and the FE picker build against — it removes the cross-layer ambiguity that previously capped this RFC. OQ-3a is resolved: descendants are expanded server-side in Launchpad (Decision 4), so the consumer receives one flat set and never traverses the hierarchy itself. Routed under /private with BasicAuth, matching the verified Launchpad service-to-service convention (e.g. the level-update route at rest_router.go:172).

EndpointMethodAuthRequestResponseNotes
GET /private/users/{user_sso_id}/teams?include_descendants=trueGETBasicAuth (service token, same as /private/*)path user_sso_id; query include_descendants (bool, default false)200 body belowempty arrays (not 404) for teamless; CDP enforces a 500ms client timeout → fallback

Response schema (200):

{
"data": {
"user_sso_id": "u-123",
"team_ids": ["uuidPM", "uuidQP"],
"expanded_team_ids": ["uuidPM", "uuidQP", "uuidPM-child1", "uuidQP-child1"],
"teams": [
{ "id": "uuidPM", "name": "Product Management", "parent_id": null },
{ "id": "uuidQP", "name": "Qontak Product", "parent_id": null }
]
}
}
  • team_ids = the user's directly-assigned teams (powers the FE picker, Decision 10).
  • expanded_team_ids = team_ids ∪ all descendant team ids (powers the TEAM ONLY $in, Decision 2/4). When include_descendants=false, expanded_team_ids equals team_ids.
  • teams[] = {id,name,parent_id} for the directly-assigned teams only (drives chip labels in the picker).
  • Teamless user: {"data":{"user_sso_id":"…","team_ids":[],"expanded_team_ids":[],"teams":[]}} with 200 (never 404).

Status / errors: 200 success; 401 bad/expired service token; 404 unknown user_sso_id; 500 hierarchy-resolution failure. CDP treats timeout / 5xx / 401 as the fallback trigger (degrade to OWNED ONLY + cdp_team_permission_fallback); a 200 with empty arrays is a valid "teamless" answer, not a fallback.

  • Idempotency / versioning: GET is naturally idempotent; lives under the existing /private namespace (no new version). Caching: CDP keys the result on (company_sso_id, user_sso_id) in Redis, short TTL (~60s, NEG-4); expanded_team_ids is the cached value.

  • Example list response (GET /cdp/customers): { "data":[ { "id":"…","name":"Acme","team_owner_ids":["uuidPM","uuidQP"] }, { "id":"…","team_owner_ids":[] } ] }

  • 422 write example: { "error":"TEAM_OWNER_INVALID","message":"team is not one of your teams","details":{"rejected_team_ids":["t2"]} }

  • Rate limits / pagination: unchanged from existing /cdp/customers (cursor/offset as today).

  • Backward compatibility: all responses are additive (team_owner_ids added); existing consumers ignore the new field.

  • Example list response: { "data":[ { "id":"…","name":"Acme","team_owner_ids":["uuidPM","uuidQP"] }, { "id":"…","team_owner_ids":[] } ] }

  • 422 write example: { "error":"TEAM_OWNER_INVALID","message":"team is not one of your teams","details":{"rejected_team_ids":["t2"]} }

  • Rate limits / pagination: unchanged from existing /cdp/customers (cursor/offset as today).

  • Backward compatibility: all responses are additive (team_owner_ids added); existing consumers ignore the new field.

Detail 2.A — UI Contract

TeamOwnerSelect.vue (new; fork of MultipleSelect.vue)

  • Figma: n/a — no net-new visual (REV-2); pixel-faithful to MpInputTag per FilterMultiselect.vue.
  • Path: common/components/field/TeamOwnerSelect.vue.
  • Props:
interface TeamOwnerSelectProps {
value: string[] // selected team UUIDs (required; [] = Unassigned)
disabled?: boolean // true when manage == disabled (read-only)
}
// emits: (e:'update:value', ids: string[]) // IDs, NOT names (deviation from MultipleSelect.vue)
  • State: options from UserStore.myTeams (new ref via getMyTeams()).
  • Events: none analytics-critical; optional cdp_team_owner_set is server-side.
  • Conditional render: empty → "Unassigned" chip placeholder; loading spinner; error "Could not load teams. Try again." (saving blocked until teams load).
  • A11y: chips removable via keyboard; combobox aria-multiselectable.

Permission level readUserStore.hasAssociatedAccess() (:161) changes return shape from boolean to { enabled: boolean; level: 'own'|'team'|'everything'|'disabled' }; ListPage.vue:532 / DetailPage.vue:131 consume .level.

Detail 2.B — Data-Fetching Strategy

  • Library: Nuxt $customFetch (IAG client) + Pinia stores (existing).
  • Cache key: viewer team set cached server-side in Redis (teamset:{company}:{user}); FE caches myTeams in the store for the session.
  • TTL/refetch: FE myTeams fetched on detail/create mount (mirrors current getTeams() at DetailPage.vue:138). Server team-set TTL short (e.g. 60s) to honor NEG-4 (membership change reflected on next load).
  • SWR: no — team set is authorization-relevant; prefer fresh-within-TTL.
  • Optimistic updates: Team Owner save is not optimistic — on PATCH 5xx the field reverts and a toast shows (ERR-2).

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Customer list (team)list skeleton"No contacts in your team yet."fallback banner + own-scoped resultsn/ateam-scoped list
Team Owner selectspinner"No teams assigned" → Unassigned"Could not load teams. Try again." (save blocked)n/ateam-name chips
Notes tabnotes skeleton"No notes yet.""Could not load notes."n/anotes list w/ per-note actions
Assoc searchtypeahead spinner"No contacts found in your team.""Could not load contacts."partial typeahead resultsfiltered results

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotencyConsistencyDuplicate handlingStale-read handling
PATCH team_owner_idssingle Mongo doc updateupdate fails → 5xx, FE revertsnatural (by id)strong (single doc)n/asnapshot value; OQ-2
Notes update/deletesingle note docfails → 5xxnatural (by id)strongn/aparent scope re-checked per request
Migration backfillper-contact update (batched)row error → logged, continue; report unmatchedre-runnable (idempotent set)eventual until completere-set is idempotentreads after flag-on

Detail 2.E — Concurrency Collision Map

ResourceWritersCollisionResolutionOn conflict
contact.team_owner_idstwo agents edit same contactlast-write-wins on the arrayexisting contact update semantics (no new lock)last writer's array persists; both validated independently
viewer team-set cacheconcurrent requests, same usercache stampede on missshort TTL; single-flight optionalextra Launchpad call worst case (bounded)

Detail 2.F — Async Job / Event Consumer Spec

Job/ConsumerTriggerInputRetryDLQConcurrencyIdempotencyTimeoutPoison handling
Migration backfill (CHG-004, one-off)manual run per tenant — CDP backfill job (OQ-1 resolved, REV-4)crm_people.crm_team_hierarchy_id CSVre-run safen/a (batch log)batchedidempotent set of team_owner_idsper-batchunmatched team → leave [] (Unassigned) + report

No long-running runtime consumer; enforcement is synchronous.

Detail 2.F.1 — Responsibility Boundary Matrix

Step (exec order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Admin sets team levelLaunchpad (settings UI, CHG-001)admin savecrs_permissions level=teaminline error, prev level keptTEAM-S01
2. Resolve viewer teams+descendantsLaunchpad (teams-for-user API, NEW)CDP requestexpanded team idstimeout→ CDP fallback OWNED§7, TEAM-S02/AC-6
3. Apply TEAM ONLY filter / guardCDP (contact-service)list/get/patch/deletescoped results / 403 / 422emit denied/fallback eventsCHG-002
4. Compute per-note flagsCDP (contact-service)notes requestnote.permission.{update,delete}drop to false out-of-scopeCHG-006
5. Render gatingCDP web + Mobileresponseshow/hide actionsbanner on fallbackFE-3/6, MB-1
6. Backfill team_owner_idsCDP (backfill job, OQ-1 resolved)one-offpopulated arraysunmatched→Unassigned+reportCHG-004

PRD vs verified path (OQ-NEW-1 resolved — REV-5): PRD §7/CHG-001 cite the level-update endpoint at /iag/v1/...; the verified Launchpad route is PUT /private/users/{user_sso_id}/permissions/{permission_name}/level (BasicAuth). This has no CDP/FE/mobile execution impact: contact-service never calls level-update — it only reads crs_permissions (the resolved level) — and CHG-001 (the Team Only radio) is Launchpad/Admin-Portal owned. So the admin UI's exact call path (direct /private vs a gateway rewrite) is a Launchpad-internal detail that does not appear on any chunk in this RFC's Execution Plan. Closed for this RFC; flagged to Launchpad for their own UI work.

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Contactteam_owner_ids[] (Unassigned)PATCH/sync/migrationGET /cdp/customers*snapshot (OQ-2)
Notepermission.{update,delete} (computed)falseresolveNotePermission (team branch)notes responsesper-request (not stored)
Viewerexpanded team setfrom Launchpadteams-for-user APIserver cacheshort TTL (≈60s, NEG-4)

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
GET /cdp/customers*Contact{…, team_owner_ids: []string}Contact{…, team_owner_ids?: string[]} (add to CustomerStore.ts:270-309)yessnake_case both sides; FE type must add field
PATCH /cdp/customers/{id}accepts team_owner_ids, 422 on non-memberFE sends ids, handles 422 revertyesFE must surface 422 message
notes list/getNote{…, permission:{update,delete}}web reads note.permission.* (NotesList.vue:91-101); mobile NoteCdpPermissionResponseyesmobile must extend add/edit gating to per-note
teams-for-user (Launchpad→CDP){data:{team_ids, expanded_team_ids, teams[]}} (RFC-defined, §2.4 Dependency endpoint)CDP consumes expanded_team_ids; FE getMyTeams() reads teams[] for pickeryesBoth layers build against the §2.4 normative contract; OQ-3a resolved (Launchpad expands descendants). Launchpad ratification is a delivery checkpoint, not a spec gap (REV-1)

All rows are now yes. The teams-for-user contract is pinned normatively in §2.4 (Dependency endpoint) — FE and BE build against the same shape, so the cross-layer mismatch that previously capped this RFC is closed. The residual is Launchpad delivering that contract (tracked in §1 Dependencies + §5), which is a build dependency, not an agent-blocking spec ambiguity.

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

View a team-scoped contact (web): Agent opens list → ListPage.vue reads perm.level=='team'GET /cdp/customers → handler resolves viewer teams+descendants (cached Launchpad) → ToFilters() builds $or(empty | $in | owner | assignee) → Mongo multikey scan → list → render. Side effects: cdp_team_permission_fallback only on Launchpad outage. Ownership: FE (gating UX), CDP (filter = security boundary), Launchpad (team set).

Edit Team Owner: TeamOwnerSelect.vue emits ids → PATCH → handler validates every id ∈ viewer teams → write or 422 → emit cdp_team_owner_set / cdp_team_owner_validation_rejected.

View notes: open contact (must pass contact gate) → GET …/notes → service projects parent team_owner_ids → one scope gate → notes + computed permission.{update,delete} → web/mobile render per-note actions.

Detail 2.I — Scope Boundaries

  • BE create: db/migrations/033_add_team_owner_ids_index.{up,down}.json; new repo projection method on ContactNoteInterface.
  • BE modify: contact/base.go (+field), search_contact_request.go (+bson:"-" field + $in branch), contact_sync_request.go (+field+validation), permission_service.go (signature + team branch), contact_handler.go (6 branches), get_contact.go (guard), contact_notes_service.go (scope eval), contact_notes_handler.go (resolveNotePermission team branch), contact_notes/read.go + base.go (projection), config (flag).
  • FE create: common/components/field/TeamOwnerSelect.vue.
  • FE modify: UserStore.ts (getMyTeams()+myTeams; hasAssociatedAccess shape), CustomerStore.ts (Contact type), CustomerDetails.vue (field block), ListPage.vue (level + /customers/teams), DetailPage.vue (level), SidebarChildCustomer.vue, Breadcrumb.vue, Notes/{constants.ts,Notes.vue,CustomerActivityV2.vue,NotesList.vue}, middleware/authenticated.global.ts, pages/customers/add.vue.
  • Mobile modify: note_screen.dart, detail_note_screen.dart, other_tab.dart (gate add/edit on per-note permission).
  • NOT touched: Launchpad code (separate squad); other CDP modules (Companies/Deals/Tickets) except assoc; Profile.vue (legacy, absent); customers_customers_profileview/_profilemanage (OQ-6).
  • Shared component impact: MultipleSelect.vue is forked, not edited (no blast radius on existing multi-selects).

Detail 2.J — Asset Inventory

N/A — no new icon / illustration / image / lottie / font. The Team Owner field reuses existing Pixel MpInputTag chips; sidebar entry reuses existing icons.


3. High-Availability & Security

TEAM ONLY adds a synchronous dependency on the Launchpad teams-for-user API. Graceful degradation (Decision 2) guarantees a transient Launchpad outage narrows to OWNED ONLY rather than failing the request or widening scope — fail-safe by construction. The team set is cached (short TTL) so steady-state load adds ≤1 Launchpad call per viewer per TTL window. contact-service pods remain stateless and horizontally scalable; the cache is shared (Redis).

Performance Requirement

  • Backend: TEAM ONLY list ≤ 2s P95, ≤ 20% over the captured everything baseline; ≤ 0.5% 5xx on permission checks. Multikey $in on (company_sso_id, is_deleted, team_owner_ids) keeps the list a single index scan.
  • Throughput & connection model (REV-6): the gated endpoints add no new RPS — they are the same /cdp/customers* + notes routes already served; assume parity with current production list/detail/search-assoc traffic (capture the everything list RPS + P95 as the baseline before build — the exact numbers are tenant-dependent and must be measured, not guessed). The only added work is one outbound Launchpad call per request on cache miss. With a per-(company,user) short-TTL cache and the strong per-user request locality of a list session, assume a ≥95% cache hit rate in steady state → ≤1 Launchpad call/user/TTL. The Launchpad client reuses contact-service's existing pooled HTTP client (no new pool); client timeout 500ms → fallback. Worst case (cold cache, full miss) is bounded by the connection pool ceiling; a single-flight guard (Detail 2.E) caps duplicate in-flight misses per key.
  • Load test (REV-6): replay the production list + detail + search-assoc + notes request mix at 1× and 2× current peak against a beta-sized tenant with the flag ON; assert list P95 ≤ 2s and 5xx ≤ 0.5%; include a cold-cache ramp to exercise the Launchpad-call path and a Launchpad-degraded run (inject 500ms+ latency) to confirm the fallback holds P95.
  • Frontend: no bundle-budget regression beyond the new component (Pixel MpInputTag is already bundled — fork adds no new dependency); no LCP/INP regression on list/detail (the field is one more form row).
  • Browser support (REV-8): no new constraint — the Team Owner field and /customers/teams view inherit the existing qontak-customer-fe supported browser matrix (current Chrome/Edge/Firefox/Safari + iOS Safari per the app's baseline); MpInputTag already ships within that matrix.

Monitoring & Alerting

Events (emitted by contact-service; names follow PRD §6.5 and the repo's slog structured-log convention, e.g. contact_handler.go:91):

EventTriggerPropertiesAlert
cdp_team_permission_fallbackteams-for-user >500ms or 5xx → OWNED ONLYcompany_sso_id, user_sso_id, platform, permission_name, latency_ms>1% of team requests / 5-min → page CDP on-call
cdp_team_permission_deniedsingle-record action out-of-scope → 403company_sso_id, user_sso_id, contact_id, permission_name, viewer_team_countspike >3× 7-day baseline → notify CDP Squad
cdp_team_owner_validation_rejectedwrite 422 (non-member team)company_sso_id, user_sso_id, rejected_team_idsinfo only
cdp_team_owner_setteam_owner_ids writtencompany_sso_id, contact_id, team_owner_count, source
  • RED metrics: rate/errors/duration on the 4 customer endpoints + notes, tagged permission_level. Dashboard owner: CDP Squad. Cadence: weekly for first month post-GA, then monthly. Coverage metric: % contacts with non-empty team_owner_ids.
  • Frontend analytics (REV-7) — emitted by qontak-customer-fe via the existing analytics client, so adoption/UX is observable alongside the BE events (closes the OBS cross-layer gap):
FE eventTriggerProperties
cdp_team_owner_field_saveduser saves team_owner_ids from TeamOwnerSelect.vueteam_owner_count, was_autofilled (bool), cleared_to_unassigned (bool)
cdp_team_filter_view_loaded/customers/teams list view loadsresult_count, is_empty (bool)
cdp_team_fallback_banner_shownfallback banner rendered (BE fell back to OWNED)surface (list/detail/assoc)
cdp_team_owner_save_rejectedFE receives 422 on Team Owner saverejected_team_count

If the analytics platform cannot register new events in time, these may ship in a fast-follow — but they are specified now so an agent wires them rather than inventing names.

  • Cross-layer trace: IAG token / request id propagated FE→gateway→contact-service (existing); add permission_level + viewer_team_count to the list-handler log line so on-call can follow a user-reported issue from the FE cdp_team_fallback_banner_shown event to the BE cdp_team_permission_fallback.

Logging

  • BE: slog.*Context(ctx, "cdp_team_permission_*", slog.String("user_sso_id",…), slog.String("permission_name",…)). PII: do not log contact name/email/phone; team UUIDs and sso ids only.
  • FE: console error on teams-load failure (existing pattern DetailPage.vue:138); no PII.

Security Implications

  • Threat model: privilege escalation / cross-team data exposure. Entry points: list/detail/assoc/notes endpoints and direct-URL/API access. The defense is server-side filtering + per-record guards; clients are not trusted.
  • Authorization is the core of this RFC. See the matrix below.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityAdditional constraintAudit
team agent/cdp/customers*, /v1/contacts/{id}/notes*GET/PATCH/DELETEown company; team∪descendant∪Unassigned∪owned recordslist/detail/notes within scopewrite team_owner_ids only with own teams (422 else)cdp_team_permission_denied on 403
own agentsamesameowner/assignee records onlyonly own records
everything agentsamesamewhole companyall records
disabledsamenoneentry points hiddendirect URL/API → 403middleware 403
Org AdminLaunchpad level-updatePUTown companysettings pageadmin role requiredLaunchpad change log

Every PRD role has a row here and in Detail 1.A Role Coverage.

  • Ownership validation: TEAM ONLY enforced in EvaluatePermissions + ToFilters() (list) and the 6 handler guards + get_contact.go (single) + contact_notes_service.go (notes). One evaluation authority (Decision 7).
  • Input validation: team_owner_ids — every element must be a UUID that is one of the actor's teams (server-side; 422 otherwise). Empty array allowed.
  • Injection: Mongo filters built with typed bson.M{"$in": ids} (no string concatenation); ids are UUID strings from a trusted Launchpad response.
  • SSRF: the only outbound call is to the internal Launchpad host (fixed config), not a user-supplied URL.
  • Secrets: service token for Launchpad from existing config/Vault; no new hard-coded secret.
  • Multi-tenancy: company_sso_id remains the outermost filter on every query (unchanged) — TEAM ONLY narrows within a company, never across.
  • Static analysis: staticcheck ./... (Makefile:140) on BE; eslint . on FE; flutter analyze (melos) on mobile.
  • ISO 27001/27701: no new PII category; team UUIDs are org metadata. Access changes are audited via the new denial events.

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE response on failureCodes match?
List (Launchpad teams timeout)banner "Showing only your contacts — team filter temporarily unavailable" + own resultsfallback OWNED ONLY, 200, cdp_team_permission_fallbackyes
Single-get out-of-scopedetail not rendered / redirect403 + cdp_team_permission_deniedyes
Team Owner save (non-member team)field reverts, toast422 TEAM_OWNER_INVALIDyes
Team Owner save (5xx)field reverts, "Could not save Team Owner. Try again."5xxyes
Teams list load (picker)"Could not load teams. Try again." (save blocked)5xx / timeout from Launchpadyes
Notes out-of-scopenotes not populated403yes

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
cdp_team_permission_enabled OFFcontact-service (before TEAM ONLY)team behaves as everything (legacy)no
Launchpad teams unavailablecontact-service (per request)degrade to OWNED ONLYcdp_team_permission_fallbackyes (banner)
Empty team_owner_ids (Unassigned)filter / guardvisible to all team usersno
Teamless viewerfilter / guardsees Unassigned + own onlyno

Detail 3.B — Error Response Catalog (BE)

Shape: { "error":"CODE", "message":"…", "details":{} }.

EndpointError codeHTTPMessageWhenUser-facing?
/cdp/customers/{id} GET/DELETEFORBIDDEN403out of your team scopenot in scope, not owner/assigneeyes
/cdp/customers/{id} PATCHTEAM_OWNER_INVALID422team is not one of your teamsnon-member team submittedyes
notes *FORBIDDEN403parent contact out of scopeparent not visibleyes
any gatedFORBIDDEN403permission disabledlevel=disabledyes

Detail 3.C — Error Message Catalog (FE)

Error codeUser-facing messageSurfaceUser-facing?
fallback"Showing only your contacts — team filter temporarily unavailable"banneryes
422 team owner"Could not save Team Owner. Try again." (+ revert)toastyes
teams load"Could not load teams. Try again."inline (picker)yes
notes load"Could not load notes."inlineyes

Detail 3.D — Compliance & Data Governance

N/A — no new compliance trigger. Verified: the only new persisted field is team_owner_ids (team UUIDs = org metadata, non-PII). No payment/health data; existing contact-PII handling and retention are unchanged.

Detail 3.E — Accessibility

  • WCAG AA. TeamOwnerSelect.vue: combobox role, removable chips reachable by keyboard, focus returns to the input after chip removal, aria-multiselectable.
  • Sidebar /customers/teams entry keyboard-navigable like existing items.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: responses are additive (team_owner_ids); existing consumers ignore it. EvaluatePermissions signature is breaking internally only (compiler-enforced; all callers in the same PR). Behavior change (team narrows) is gated by cdp_team_permission_enabled (default OFF) — zero change for tenants until enabled.
  • FE: new optional team_owner_ids?: string[] on the Contact type; reading perm.level is additive to is_enabled.
  • Mobile: reads an already-parsed per-note permission object — no API shape change; gating switch is client-only.
  • Cross-layer: the teams-for-user API is net-new; until it exists, the flag stays OFF (no behavior change).

Rollout Strategy

  • Migration sequence: (Stage 0) add field + index 033 (additive, no behavior change; new records carry team_owner_ids, reads ignore). → (Stage 1) backfill from crm_team_hierarchy_id CSV on a QA tenant; verify coverage + Unassigned counts. → (Stage 2) enable cdp_team_permission_enabled on QA then beta; monitor denial/fallback. → (GA) progressive per-tenant for Growth+Enterprise.
  • Schema during migration: field present but unread while flag OFF; enforcement reads it only when flag ON — no intermediate broken state.
  • Backfill (owner: CDP, OQ-1 resolved — REV-4): a CDP-owned one-off backfill job, not a Bifrost sync. Rationale: keeps the migration under CDP control, re-runnable and idempotent, and decoupled from Bifrost's sync cadence (which would couple enablement timing to a separate pipeline). The job reads crm_people.crm_team_hierarchy_id (read-only), name-maps legacy team ids → Launchpad UUIDs, and writes team_owner_ids. Batched per tenant; report unmatched teams; default unmatched → [] (Unassigned, visible to all per D-13). Reversible: drop the written arrays (they are harmless while the flag is OFF).
  • Deploy order: BE first (field + index + flag-OFF logic), then FE (reads level, renders field), then enable flag. Mobile ships independently (per-note gating works once BE computes the flags). Launchpad APIs must land before flag-ON GA.
  • Feature flag: cdp_team_permission_enabled (per company, default OFF). OQ-8: single boolean vs per-permission-key — see §5.
  • Rollback trigger: cdp_team_permission_fallback >1% sustained 24h, or any confirmed cross-team leak, or 5xx >0.5%.
  • Rollback mechanism: flip cdp_team_permission_enabled OFF (instant return to legacy team==everything); data written (team_owner_ids) is retained and harmless.
  • PIC/timeline: CDP Squad eng on-call; per-stage go/no-go at the dashboard.

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend firstOldNew (flag OFF)yesteam_owner_ids additive; old FE ignores it
Frontend firstNewOldyesFE reads level but BE not enforcing; field hidden if API lacks it
Both + flag OFFNewNewyestarget pre-enable state
Both + flag ONNewNewyestarget state
Backend rollback (flag OFF)NewNew (flag OFF)yesFE field still works; enforcement reverts to legacy
Frontend rollbackOldNewyesBE still enforces server-side; old FE just lacks the picker

No "no" cells — the flag + additive contract make every ordering safe.

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BEcdp_team_permission_enabledper-company boolOFFyesOps / config serviceno
BEteams-for-user base URL/tokenconfigexisting Launchpad configyesconfig / Vaulttoken: yes
BEteam-set cache TTLint (s)~60noconfigno
FE(none new)

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

LayerCommand (source)What it must prove
BE unitgo test -race -tags dynamic ./internal/... ./config/... (Makefile:82)filter $or, EvaluatePermissions team branch, 6 guards, notes scope, 422 validation
BE staticstaticcheck ./... (Makefile:140)no new lint regressions
BE migrationmigrate -database "mongodb://…" -path ./db/migrations/ up (Makefile:174)index company_isdeleted_team_owner_ids created; down drops it
FE unitpnpm vitest (package.json:16)TeamOwnerSelect emits ids; auto-fill all teams; clear→Unassigned; level gating
FE lintpnpm eslint . (package.json:18)clean
FE buildpnpm build (package.json:6)builds
Mobile testmelos run test (melos.yaml:97-105)add/edit/delete gate on per-note permission
Mobile analyzemelos run analyze (melos.yaml:77-81)clean
Cross-layermanual/contract: BE team_owner_ids ↔ FE Contact type; notes permission ↔ web+mobileshapes match (Detail 2.G)

No BE integration-test target exists in the Makefile; integration coverage is the ./internal/... suite against a test Mongo (note in §5).

Detail 4.D — Agent Execution Plan

OrderLayerChunkFilesCommandsAcceptance criteria
1BEAdd team_owner_ids field + index migrationinternal/app/repository/contact/base.go; db/migrations/033_add_team_owner_ids_index.{up,down}.jsonmigrate … up; go build -tags dynamicfield compiles; index company_isdeleted_team_owner_ids exists; down drops it
2BEList filter $in (bypass $or expansion)internal/app/payload/search_contact_request.gogo test -race -tags dynamic ./internal/app/payload/...team_owner_ids produces one $in, not per-element $or; existing tests pass
3BEExtend EvaluatePermissions + team branchinternal/app/service/permission_service.gogo test … ./internal/app/service/...returns true on empty/intersect/owner/assignee; false otherwise; all callers compile
4BE6 handler guards + single-get guard + assoc/deleteinternal/app/handler/contact_handler.go; internal/app/service/get_contact.gogo test … ./internal/app/handler/...out-of-scope get/patch/delete/assoc → 403; in-scope → 200
5BEWrite validation (team_owner_ids ⊆ viewer teams)internal/app/payload/contact_sync_request.go; handlergo test …non-member team → 422 TEAM_OWNER_INVALID; empty allowed
6BEFallback + events + flag gatepermission_service.go; require_permission_middleware.go/configgo test …flag OFF → legacy; Launchpad timeout → OWNED + cdp_team_permission_fallback
7BENotes parent-scope projection + service eval + resolveNotePermission team branchcontact_notes/{base.go,read.go}; contact_notes_service.go; contact_notes_handler.go; regenerate mocksgo test … ./internal/app/service/contact_notes/...notes out-of-scope → 403; permission.{update,delete} reflect team scope
8FEgetMyTeams() + Contact type + TeamOwnerSelect.vue + form wiringUserStore.ts; CustomerStore.ts; common/components/field/TeamOwnerSelect.vue; CustomerDetails.vuepnpm vitest; pnpm eslint .picker shows my teams; emits ids; auto-fill all teams; clear→Unassigned
9FERead level; /customers/teams; notes gating constsListPage.vue; DetailPage.vue; SidebarChildCustomer.vue; Breadcrumb.vue; Notes/{constants.ts,Notes.vue,CustomerActivityV2.vue,NotesList.vue}; middleware/authenticated.global.ts; pages/customers/add.vuepnpm vitest; pnpm buildteam list view filters; notes view/add gate on level; edit/delete follow note.permission
10MobileGate add/edit on per-note permissionnote_screen.dart; detail_note_screen.dart; other_tab.dartmelos run analyze; melos run testadd/edit/delete gate on note.permission.{update,delete}
11BEMigration backfill (CHG-004) — CDP backfill jobnew CDP one-off job reading crm_people.crm_team_hierarchy_id → name-map → team_owner_idsrun on QA tenant; coverage report≥95% coverage; unmatched→Unassigned; report produced; re-run idempotent

Order respects dependencies: field+index (1) before filter (2); evaluation (3) before guards (4); BE (1–7) before FE (8–9) and mobile (10); backfill (11) on a QA tenant before flag-ON.

Detail 4.E — Verification & Rollback Recipe

Pre-merge (per layer, in order):

  • BE: 1. staticcheck ./... 2. go test -race -tags dynamic ./internal/... ./config/... 3. go build -tags dynamic …
  • FE: 1. pnpm eslint . 2. pnpm vitest 3. pnpm build
  • Mobile: 1. melos run analyze 2. melos run test

Post-deploy signals:

  • Dashboard (CDP Squad): cdp_team_permission_fallback rate < 1% (24h); cdp_team_permission_denied not spiking >3× baseline; 5xx < 0.5%; list P95 ≤ 2s; coverage (% non-empty team_owner_ids) ≥ 95% post-backfill.

Rollback recipe (deploy-order aware):

  1. Flip cdp_team_permission_enabled OFF for the affected tenant(s) → instant return to legacy team==everything.
  2. If a bad BE deploy: revert the contact-service PR (FE/mobile remain compatible — additive field).
  3. If the index is problematic: migrate … down to drop 033 (filter falls back to scan; only relevant when flag ON).
  4. Confirm cdp_team_permission_fallback/denied return to baseline and 5xx < 0.5% within 15 min.

Detail 4.F — Resource & Cost Notes

  • Compute: no new pods; one extra cached Launchpad call per viewer per TTL.
  • DB: one multikey index (size ∝ Σ array lengths; small).
  • Network: +1 internal Launchpad call on cache miss; no external egress.
  • Storage: team_owner_ids arrays (few UUIDs/contact).
  • New infra: none (reuse existing Redis + Launchpad).

5. Concern, Questions, or Known Limitations

Grounding corrections (verified against live repos; supersede PRD line refs):

  • Migrations are MongoDB JSON docs; next number is 033 (PRD said 032 — 032_create_customer_segments already exists).
  • Launchpad level-update route is PUT /private/users/{user_sso_id}/permissions/{permission_name}/level (rest_router.go:172, BasicAuth) — PRD's line 172 is correct; PRD's path prefix /iag/v1 is wrong (actual /private). (Re-verified on the canonical checkout Documents/works/qontak-launchpad, main @ 20a3063.)
  • Line-number drift corrected throughout §2.0 (e.g. const.go:44-47/35-38, get_contact.go:24-28, contact/base.go:76, DetailPage.vue:138, UserStore.ts:161, NotesList.vue:91-101).
  • Mobile delete already reads per-note permission.delete (note_screen.dart:312); only add/edit still gate on the contact-level flag — MB-1 scope is narrower than the PRD implied for delete.

Resolved in this revision (R1-fix — all REV findings from review R1 closed):

  • OQ-1 / REV-4 — RESOLVED. Migration = a CDP-owned one-off backfill job (not Bifrost). See CHG-004 / Detail 2.F / §4 Rollout / Detail 4.D chunk 11.
  • OQ-3a / part of REV-1 — RESOLVED. Descendants are expanded server-side in Launchpad; the consumer receives expanded_team_ids. See Decision 4 + §2.4 Dependency endpoint.
  • OQ-11 / REV-3 — RESOLVED. Under team/everything, a user may edit/delete others' notes on an in-scope contact (flag-gated). See Decision 8.
  • REV-1 (blocker) — CLOSED (spec). The teams-for-user(+descendants) contract is now pinned normatively in §2.4; Detail 2.G is all-yes. Residual = Launchpad delivering it (a build dependency below, not a spec gap).
  • OQ-NEW-1 / REV-5 — CLOSED. Level-update path has no CDP impact (CDP reads crs_permissions; admin UI is Launchpad-owned). See Detail 2.F.1.
  • REV-2 — CLOSED. No net-new visual treatment; every CDP FE surface is a pixel-faithful reuse. See §1 Design References.
  • OQ-NEW-4 / REV-6 — ADDRESSED. Throughput/cache/timeout/load-test added to §3 Performance.
  • OQ-NEW-5 / REV-7 — ADDRESSED. FE analytics events added to §3 Monitoring.
  • OQ-NEW-6 / REV-8 — ADDRESSED. Browser matrix stated in §3 Performance.

Still open (carry-over from PRD §9 — not blocking agent execution):

  • OQ-2 Auto-fill freshness: snapshot at create (this RFC's default) vs re-sync. Default taken; revisit only if product wants live re-derivation.
  • OQ-5 Source of truth: CDP customers_customers_view vs legacy crm_view (cross-system reconciliation; out of this RFC's code scope).
  • OQ-6 Do profileview/profilemanage need TEAM ONLY? (explicit non-goal here.)
  • OQ-7/NEG-6 Confirmed default: write validation rejects non-member teams (422) — implemented, not open.
  • OQ-8 cdp_team_permission_enabled: single boolean vs per-permission-key. Default = single per-company boolean; per-key staging is a future enhancement, not a blocker.
  • OQ-9 Max cap on team owners per contact: soft cap = the user's team count; no hard product cap in v1.
  • OQ-10 Note _add governed by the parent-contact visibility gate (sufficient — you cannot open a contact you can't see).

External delivery dependencies (not spec gaps — tracked in §1 Dependencies):

  • Launchpad must implement the teams-for-user(+descendants) endpoint to the §2.4 contract, and add the Team Only radio (CHG-001). Until both land, the feature stays behind cdp_team_permission_enabled = OFF; in-repo CDP/FE/mobile chunks proceed against the pinned contract / a stub.

Known limitations:

  • No BE integration-test target in the Makefile; integration coverage relies on the ./internal/... suite against a test Mongo.
  • Launchpad anchors verified against the canonical checkout (/Users/pujitriwibowo/Documents/works/qontak-launchpad, main @ 20a3063) — re-confirm on the squad's GA branch before enabling the flag.
  • Team-set cache TTL trades freshness for latency; NEG-4 (removed-from-team) resolves within one TTL window, not instantly.

6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-18RFC author (rfc-starter)Initial draft from PRD v2.5; anchors re-verified across contact-service / qontak-customer-fe / mobile-qontak-crm / qontak-launchpad. Corrections logged in §5.
2026-06-18RFC review R1 (rfc-reviewer)Scored 6.5 / HOLD; 8 findings (REV-1..8). Blocker = unfinalized teams-for-user contract; majors = Figma TBD, notes broadening, migration owner.
2026-06-18RFC author (R1-fix)Closed all 8 REV findings: pinned the teams-for-user contract normatively (§2.4) → Detail 2.G all-yes; resolved OQ-3a (Launchpad expands), OQ-11 (notes broadening confirmed), OQ-1 (CDP backfill job); no net-new visuals (REV-2); added perf/cache/load-test, FE analytics, browser matrix. §7 → yes (in-repo). Re-review as R2.

7. Ready for agent execution

  • yes — all spec gaps closed (R1-fix). The only remaining items are external build dependencies owned by Launchpad (the teams-for-user endpoint + the Team Only radio), which gate GA behind cdp_team_permission_enabled = OFF, not agent execution: every in-repo chunk (BE 1–7, FE 8–9, Mobile 10) is buildable now against the pinned §2.4 contract.

Gate status:

  • §1 Design References (FE half) — ✅ no net-new visual treatment; every surface is a pixel-faithful reuse (REV-2 closed).
  • §1 PRD-to-Schema Derivation (BE half) — ✅ complete; every DDL/endpoint traces to a row.
  • Detail 1.C Per-Story Change Map — ✅ 17 stories, one row each; FE + BE rows have both halves; TEAM-S01 marked Cross-squad.
  • Repo Reading Guide (Detail 2.0) — ✅ anchors + reading order + contracts classified.
  • Source Verification table — ✅ complete with concrete evidence per anchor (BE/FE/Mobile/Launchpad).
  • Design ↔ Code Mapping — ✅ all surfaces mapped to existing components; no frame needed.
  • Asset Inventory — ✅ N/A — no new assets.
  • Mermaid diagrams — ✅ topology, component, ER, decision/branch flow, 4 sequence (incl. failure paths).
  • DDL + index — ✅ migration 033 JSON; per-status lifecycle n/a (no enum).
  • APIs — ✅ outbound tagged extended; teams-for-user contract pinned normatively (§2.4) + tagged new-with-justification; no inbound webhook.
  • Cross-Layer Contract Verification — ✅ all rows yes (teams-for-user contract pinned; REV-1 closed at spec level).
  • Integrity / concurrency / async — ✅ filled.
  • Failure / Branch&Skip / Error catalogs — ✅ filled.
  • Rollout + Cross-Layer Rollout matrix — ✅ no unsafe cell; deploy order chosen (BE→FE, mobile independent, flag last); migration owner = CDP (OQ-1 closed).
  • Configuration Contract — ✅ flag named, default OFF.
  • Agent Execution Plan — ✅ 11 ordered chunks with files + repo-sourced commands + verifiable AC.
  • Verification & Rollback Recipe — ✅ commands runnable; signals named; flag kill-switch.

Before GA (not before agent execution): Launchpad ships the teams-for-user endpoint to the §2.4 contract and the Team Only radio (CHG-001); enable cdp_team_permission_enabled per tenant after the CDP backfill (chunk 11) on QA.

Optional next step: hand to rfc-reviewer for a second-pass score once the three OQ-NEW blockers are closed.